/* UBC Calendar RAG demo: event-stream renderer.
*
* The page is driven entirely by the pipeline's own trace events, which arrive in the order
* they happen. Every mode below emits the same frames, so there is exactly one rendering
* path: `handle()` does not know or care where its events came from.
*
* A run is drawn as four connected stages (Plan, Search, Rank, Answer): a row of stage cards
* and one panel per stage. `Renderer` below is a port of web/render.py, which draws the same
* markup server-side for the Gradio Space, so panels.css styles both builds and stages.js
* (which keeps track of the open stage) serves both. Keep the two renderers in step.
*
* Two delivery modes share this file:
*
* server FastAPI is present (`uvicorn web.app:app`). Live questions stream over SSE and
* cached runs replay from /api/replay. This is the full pipeline, using your keys.
* static No backend at all (the Hugging Face static Space). `runs.js` defines
* window.DEMO_RUNS and every run replays straight from memory. No keys exist, so
* arbitrary questions are impossible; the ask box filters the cached set instead.
*
* SSE is consumed via fetch + a hand-rolled parser rather than EventSource, because
* EventSource cannot read the body of a non-200 response, and the rate-limit and
* budget-exhausted messages are precisely the ones a visitor needs to see.
*/
'use strict';
// Set by runs.js, which only the static build emits.
const STATIC = typeof window.DEMO_RUNS !== 'undefined';
// Replay pacing, mirrored from web/replay.py so both modes animate identically.
const FRAME_DELAY_MS = 60;
const STAGE_DELAY_MS = 250;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const el = {
form: $('ask-form'), question: $('question'), submit: $('submit-btn'),
mode: $('mode'), route: $('route'), budget: $('budget'), notice: $('notice'),
examples: $('example-chips'), noMatch: $('no-match'),
bar: $('stage-bar'), plan: $('panel-plan'), search: $('panel-search'), rank: $('panel-rank'),
head: $('answer-head'), answer: $('answer-md'), side: $('answer-side'), nav: $('answer-nav'),
statusDot: $('status-dot'), statusText: $('status-text'),
};
let running = false;
let abort = null;
let site = { chunks: null, model: '' }; // filled in from /api/health or runs.js
// ---------------------------------------------------------------- the stage renderer
const STAGES = ['plan', 'search', 'rank', 'answer'];
const NAMES = { plan: 'Plan', search: 'Search', rank: 'Rank', answer: 'Answer' };
const BLURBS = {
plan: 'The router decides how to search',
search: 'Vector and keyword search',
rank: 'Fuse, rerank, keep the best',
answer: 'Claude writes it up',
};
// Chips worth surfacing on a result card, after the edition and cohort chips.
const CHIP_FIELDS = ['page_type', 'faculty', 'program', 'subject_code'];
const PATHS = {
plan: '',
search: '',
rank: '',
answer: '',
check: '',
left: '',
right: '',
};
const icon = (name, cls = '') => ``;
const fmt = (value, places = 3) => Number(value).toFixed(places);
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
const badge = (part) => `${part + 1}`;
function firstLine(markdown) {
for (const line of String(markdown).split('\n')) {
const text = line.replace(/[#>*_`]+/g, '').trim();
if (text) return text;
}
return '';
}
class Renderer {
constructor(question, chunks = null, model = '') {
this.question = question;
this.chunks = chunks;
this.model = model;
this.runId = Math.random().toString(16).slice(2, 10);
this.started = false;
this.state = Object.fromEntries(STAGES.map((s) => [s, 'idle']));
this.status = '';
this.route = '';
this.forced = false;
this.merging = false;
this.parts = [];
this.calls = {};
this.hops = [];
this.merge = null;
this.docs = new Map();
this.order = [];
this.notes = Object.fromEntries(STAGES.map((s) => [s, []]));
this.flow = null;
this.answerText = '';
this.error = '';
}
// ------------------------------------------------------------ ingest
feed(ev) {
const handler = this[`on_${ev.kind}`];
if (handler) handler.call(this, ev);
}
partNote(i) {
return this.parts.length > 1 ? ` for part ${i + 1} of ${this.parts.length}` : '';
}
current() {
const active = STAGES.find((s) => this.state[s] === 'active');
if (active) return active;
const reached = STAGES.filter((s) => this.state[s] !== 'idle');
return reached.length ? reached[reached.length - 1] : 'plan';
}
planDone() {
if (this.state.plan !== 'done') {
this.state.plan = 'done';
this.flow = ['plan', 0];
}
}
searchDone() {
const i = this.hops.length - 1;
const more = this.parts.length > 1 && i < this.parts.length - 1;
this.state.search = more ? 'waiting' : 'done';
this.flow = ['search', i];
}
on_step(ev) {
const title = String(ev.title || '');
if (title.startsWith('PIPELINE - answer')) {
this.started = true;
this.state.plan = 'active';
this.status = 'Reading your question';
} else if (title.startsWith('ROUTE')) {
this.state.plan = 'active';
this.status = 'Deciding whether this needs one search or several';
} else if (title.startsWith('AGENT - decompose')) {
if (!this.route) { this.route = 'complex'; this.forced = true; }
this.state.plan = 'active';
this.status = 'Splitting your question into parts';
} else if (title.startsWith('RETRIEVE - simple route')) {
if (!this.route) { this.route = 'simple'; this.forced = true; }
this.planDone();
} else if (title.startsWith('AGENT - merge')) {
this.merging = true;
this.state.rank = 'active';
this.status = 'Combining what the searches found';
} else if (title.startsWith('GENERATE') || title.startsWith('AGENT - synthesize')) {
['plan', 'search', 'rank'].forEach((s) => { this.state[s] = 'done'; });
this.merging = false;
this.state.answer = 'active';
this.status = 'Writing the answer';
this.flow = ['rank', -1];
}
}
on_route(ev) {
this.route = String(ev.decision || 'simple');
if (this.route !== 'complex') this.planDone();
}
on_subquestions(ev) {
this.parts = (ev.items || []).map(String);
this.planDone();
}
on_llm_call(ev) {
this.calls[String(ev.purpose || 'complete')] = ev;
}
on_detail(ev) {
const label = String(ev.label || '');
const value = String(ev.value ?? '');
if (label === 'decision' || label.startsWith('sub-question')) return;
this.notes[this.current()].push(value ? `${label}: ${value}` : label);
}
on_retrieval_start(ev) {
const i = this.hops.length;
this.hops.push({
query: String(ev.query || ''), mode: String(ev.mode || ''),
dense: [], bm25: [], rrf: [], shortlist: [], finals: [],
});
this.planDone();
if (this.state.rank === 'active') this.state.rank = 'waiting';
this.state.search = 'active';
this.status = `Searching the calendar${this.partNote(i)}`;
if (i > 0) this.flow = ['plan', i];
}
on_candidates(ev) {
const stage = String(ev.stage || '');
if (!this.hops.length || !['dense', 'bm25', 'rrf'].includes(stage)) return;
this.hops[this.hops.length - 1][stage] = ev.items || [];
if (stage === 'rrf') {
this.searchDone();
this.state.rank = 'active';
this.status = `Ranking the results${this.partNote(this.hops.length - 1)}`;
}
}
on_shortlist(ev) {
if (!this.hops.length) return;
const ids = ev.ids || [];
this.hops[this.hops.length - 1].shortlist = ids;
this.state.rank = 'active';
this.status = `Reranking the top ${ids.length}${this.partNote(this.hops.length - 1)}`;
}
on_retrieval_final(ev) {
const items = ev.items || [];
items.forEach((doc) => this.docs.set(doc.id, doc));
if (!this.hops.length) return;
this.hops[this.hops.length - 1].finals = items;
if (this.state.search === 'active') this.searchDone();
if (this.route === 'complex') {
this.state.rank = 'waiting';
} else {
this.state.rank = 'done';
this.order = items.map((doc) => doc.id);
}
}
on_merge(ev) {
this.merge = ev;
this.order = ev.order || [];
this.state.rank = 'done';
}
settle() {
if (this.answerText && !this.error) {
STAGES.forEach((s) => { this.state[s] = 'done'; });
} else if (this.started) {
// The run ended early: nothing still to come is "waiting" any more.
STAGES.forEach((s) => { if (this.state[s] !== 'done') this.state[s] = 'stopped'; });
}
this.status = '';
this.merging = false;
}
// ------------------------------------------------------------ render: all outputs
panels(isRunning = false) {
if (!isRunning) this.settle();
const out = {
bar: this.barHtml(isRunning), plan: this.planHtml(), search: this.searchHtml(),
rank: this.rankHtml(), head: this.answerHeadHtml(isRunning), answer: this.answerText.trim(),
side: this.answerSideHtml(), nav: navHtml('answer'),
};
if (this.error) out.answer = '';
this.flow = null;
return out;
}
// ------------------------------------------------------------ render: the bar
barHtml(isRunning) {
let runState = this.answerText ? 'done' : 'idle';
if (this.error) runState = 'error';
else if (isRunning) runState = 'running';
const cards = [];
STAGES.forEach((stage, i) => {
cards.push(this.card(stage));
if (i < STAGES.length - 1) cards.push(this.arrow(stage));
});
return '
How this answer was found
'
+ '
Tap a stage to look inside'
+ 'Click a stage to look inside, or use the arrow keys
'
+ `
${cards.join('')}
`;
}
card(stage) {
const selected = stage === 'answer';
return `';
}
found() {
return this.hops.reduce((n, hop) => n + hop.dense.length + hop.bm25.length, 0);
}
kept() {
return this.order.length ? this.order.length : this.hops.reduce((n, hop) => n + hop.finals.length, 0);
}
count(stage) {
const state = this.state[stage];
if (!this.started) return '';
if (state === 'idle') return 'waiting';
if (state === 'stopped') return 'stopped';
const many = this.parts.length > 1;
const part = many ? `part ${this.hops.length} of ${this.parts.length}…` : '';
if (stage === 'plan') {
if (state === 'active') return this.route === 'complex' ? 'splitting…' : 'thinking…';
return many ? plural(this.parts.length, 'part') : '1 search';
}
if (stage === 'search') {
if (state === 'active') return part || 'searching…';
return state === 'waiting' ? `${this.found()} so far` : `${this.found()} found`;
}
if (stage === 'rank') {
if (state === 'active') return this.merging ? 'merging…' : (part || 'ranking…');
return state === 'waiting' ? `${this.kept()} kept so far` : `${this.kept()} kept`;
}
return state === 'active' ? 'writing…' : 'ready';
}
mini(stage) {
if (stage === 'plan') {
if (this.route === 'complex' && this.parts.length) return this.parts.map((_, k) => ``).join('');
if (this.route === 'simple') return '';
return '';
}
if (stage === 'search') {
const n = Math.max(this.parts.length, 1);
const dense = Math.min(this.hops.filter((hop) => hop.dense.length).length / n, 1);
const bm25 = Math.min(this.hops.filter((hop) => hop.bm25.length).length / n, 1);
return ``;
}
if (stage === 'rank') {
const found = this.found();
const fused = this.hops.reduce((n, hop) => n + hop.rrf.length, 0);
const short = this.hops.reduce((n, hop) => n + hop.shortlist.length, 0);
const kept = this.hops.some((hop) => hop.finals.length) ? this.kept() : 0;
return [[found, 1, ''], [fused, 0.87, ''], [short, 0.5, ''], [kept, 0.04, ' k']].map(([value, nominal, keep]) => {
const width = found && value ? value / found : nominal;
return ``;
}).join('');
}
return this.answerText && !this.error ? esc(firstLine(this.answerText)) : '';
}
arrow(stage) {
const filled = {
plan: this.state.plan === 'done',
search: this.hops.some((hop) => hop.rrf.length || hop.finals.length),
rank: this.state.rank === 'done',
}[stage];
let classes = `stage-arrow${filled ? ' is-filled' : ''}`;
if (this.flow && this.flow[0] === stage) {
classes += ' is-flowing';
if (this.flow[1] >= 0 && this.parts.length > 1) classes += ` part${this.flow[1] + 1}`;
}
return `${icon('right')}`;
}
// ------------------------------------------------------------ render: the panels
planHtml() {
const what = 'A quick model call reads the question and decides how much searching it needs. '
+ 'Simple questions get one search. Comparisons and prerequisite chains are split '
+ 'into parts, and each part is searched on its own.';
if (!this.started) return panelHtml('plan', what, waitHtml("Ask a question and the router's decision shows up here."));
const body = [`
${esc(this.question)}
`];
if (this.state.plan === 'active') body.push(statusHtml(this.status));
if (this.route) {
const isComplex = this.route === 'complex';
let why = isComplex
? 'It needs more than one calendar page, so it is split into parts and each part gets its own search.'
: 'One calendar page should answer it, so it gets a single search.';
if (this.forced) {
why = `Search settings set the route to always ${isComplex ? 'multi-step' : 'single-shot'}, so the router was skipped.`;
}
body.push(`
${isComplex ? 'Complex' : 'Simple'}${why}
`);
}
if (this.calls.route) body.push(promptHtml(this.calls.route, 'Router prompt and reply'));
if (this.parts.length) {
body.push(`${this.parts.map((part, k) => `
${badge(k)}${esc(part)}
`).join('')}`);
}
if (this.calls.decompose) body.push(promptHtml(this.calls.decompose, 'Splitting prompt and reply'));
body.push(this.notesHtml('plan'));
return panelHtml('plan', what, body.join(''));
}
laneHead(i) {
const query = i < this.parts.length ? this.parts[i] : this.hops[i].query;
return `
${badge(i)}${esc(query)}
`;
}
searchHtml() {
const corpus = this.chunks ? `all ${Number(this.chunks).toLocaleString('en-US')} calendar excerpts` : 'every calendar excerpt';
const what = `Two searches run side by side. Vector search compares the meaning of the question with ${corpus}. `
+ 'Keyword search (BM25) looks for the exact words, which is how a course code like CPSC 210 gets found.';
if (!this.hops.length) {
return panelHtml('search', what, waitHtml(this.started ? 'Waiting for Plan.' : 'Ask a question and the two searches show up here.'));
}
const many = this.parts.length > 1;
const lanes = [];
for (let i = 0; i < Math.max(this.parts.length, this.hops.length); i += 1) {
const head = many ? this.laneHead(i) : '';
if (i >= this.hops.length) {
lanes.push(`${head}${waitHtml(`Part ${i + 1} is searched after part ${i} has been ranked.`)}`);
} else {
lanes.push(`${head}${searchListsHtml(this.hops[i])}`);
}
}
const status = this.state.search === 'active' ? statusHtml(this.status) : '';
const first = this.hops[0];
let tech = '';
if (first.bm25.length) {
tech = `Each search returns its top ${first.dense.length}. Pages that both searches found are marked both, `
+ 'and the ones that made the final cut are marked kept.';
} else if (first.dense.length) {
tech = `Vector search returns its top ${first.dense.length}, and those are what Rank keeps.`;
}
return panelHtml('search', what, status + lanes.join('') + (tech ? `
${tech}
` : '') + this.notesHtml('search'));
}
rankHtml() {
const first = this.hops[0] || { mode: '', shortlist: [], finals: [] };
const kept = first.finals.length || 4;
const fuse = 'The two lists are fused into one ranking (reciprocal rank fusion), so a page that both searches found rises to the top.';
let what;
if (first.mode === 'dense') what = `Vector search only, so there is nothing to fuse: the ${kept} closest excerpts are kept.`;
else if (first.mode === 'hybrid') what = `${fuse} The best ${kept} are kept. This search mode skips the reranker.`;
else {
what = `${fuse} The top ${first.shortlist.length || 50} go to a reranker (Cohere) that reads each excerpt `
+ `against the question, and the best ${kept} are kept.`;
}
const many = this.parts.length > 1;
if (many) what += ' Each part is ranked on its own, then the parts are merged.';
if (!this.hops.some((hop) => hop.rrf.length || hop.finals.length)) {
const status = this.state.rank === 'active' ? statusHtml(this.status) : '';
return panelHtml('rank', what, status + waitHtml(this.started ? 'Waiting for Search.' : 'Ask a question and the ranking shows up here.'));
}
const lanes = [];
for (let i = 0; i < Math.max(this.parts.length, this.hops.length); i += 1) {
const head = many ? this.laneHead(i) : '';
const hop = this.hops[i];
if (!hop || !(hop.rrf.length || hop.finals.length)) {
lanes.push(`${head}${waitHtml(`Part ${i + 1} is ranked after it has been searched.`)}`);
continue;
}
let cards = hop.finals.map((doc) => docCardHtml(doc, hop, many ? i : null)).join('');
if (cards) cards = `
${cards}
`;
lanes.push(`${head}${funnelHtml(hop)}${cards}${fusedHtml(hop)}`);
}
const status = this.state.rank === 'active' ? statusHtml(this.status) : '';
return panelHtml('rank', what, status + lanes.join('') + (this.merge ? this.mergeHtml() : '') + this.notesHtml('rank'));
}
mergeHtml() {
const perHop = this.merge.per_hop || [];
const seen = new Set();
const steps = [];
const depth = Math.max(0, ...perHop.map((ids) => ids.length));
for (let rank = 0; rank < depth; rank += 1) {
perHop.forEach((ids, part) => {
if (rank >= ids.length) return;
const id = ids[rank];
const title = esc((this.docs.get(id) || {}).title || `Excerpt ${id}`);
if (seen.has(id)) {
steps.push(`
Each part's best excerpt goes in first, then each part's second best, and so on. "
+ `Repeats are dropped. That leaves ${plural(this.order.length, 'excerpt')}, with ${plural(dropped, 'repeat')} dropped.
`
+ `${steps.join('')}`;
}
answerHeadHtml(isRunning) {
const model = String((this.calls.answer || {}).model || this.model || '');
let who = esc(model) || 'The model';
if (model.includes('claude')) who = `Claude (${esc(model)})`;
const what = `${who} writes the answer using only the kept excerpts, and points you to the official calendar pages.`;
let body;
if (this.error) body = `
`;
else if (isRunning || this.started) body = statusHtml(this.status || 'Reading your question');
else body = waitHtml('Your answer will show up here, with links to the calendar pages it came from.');
return '
'
+ `${headHtml('answer', what)}
${body}
`;
}
sources() {
const perHop = ((this.merge || {}).per_hop || []).map((ids) => new Set(ids));
const many = this.parts.length > 1;
const pages = new Map();
for (const id of (this.order.length ? this.order : [...this.docs.keys()])) {
const doc = this.docs.get(id);
if (!doc || !doc.url) continue;
const parts = many ? perHop.map((ids, k) => (ids.has(id) ? k : -1)).filter((k) => k >= 0) : [];
if (pages.has(doc.url)) parts.forEach((k) => pages.get(doc.url).parts.add(k));
else pages.set(doc.url, { title: String(doc.title || doc.url), parts: new Set(parts) });
}
return [...pages].map(([url, page]) => ({ url, title: page.title, parts: [...page.parts].sort() }));
}
answerSideHtml() {
if (this.error || !this.answerText) return '';
const sources = this.sources();
let side = '';
if (sources.length) {
const items = sources.map((s) => `