Spaces:
Sleeping
Sleeping
Download web/static/app.js from DevSchretl/UBCHelper: direct link, hf CLI and curl.
- Browser
- Download file 41.4 kB
-
https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/web/static/app.js
- Command line
-
hf download hf://spaces/DevSchretl/UBCHelper/web/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/web/static/app.js
41.4 kB
| /* 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. | |
| */ | |
| ; | |
| // 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: '<path d="M12 20v-7"/><path d="M12 13 6.5 7.5"/><path d="m12 13 5.5-5.5"/><path d="M6 11V7h4"/><path d="M18 11V7h-4"/>', | |
| search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/>', | |
| rank: '<path d="M4 5h16l-6 7v6l-4 2v-8z"/>', | |
| answer: '<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-7l-4 3.5V16H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M8 9.5h8M8 12.5h5"/>', | |
| check: '<path d="m5 12.5 4.2 4.2L19 7"/>', | |
| left: '<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>', | |
| right: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>', | |
| }; | |
| const icon = (name, cls = '') => `<svg class="st-i ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` | |
| + `stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${PATHS[name]}</svg>`; | |
| const fmt = (value, places = 3) => Number(value).toFixed(places); | |
| const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; | |
| const badge = (part) => `<span class="part-badge p${part + 1}" title="Part ${part + 1}">${part + 1}</span>`; | |
| 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 '<div class="stage-top"><h2 class="stage-title">How this answer was found</h2>' | |
| + '<p class="stage-hint"><span class="hint-touch">Tap a stage to look inside</span>' | |
| + '<span class="hint-mouse">Click a stage to look inside, or use the arrow keys</span></p></div>' | |
| + `<div class="stage-bar" role="tablist" aria-label="Pipeline stages" data-run="${this.runId}" ` | |
| + `data-state="${runState}">${cards.join('')}</div>`; | |
| } | |
| card(stage) { | |
| const selected = stage === 'answer'; | |
| return `<button type="button" class="stage-card is-${this.state[stage]}" role="tab" ` | |
| + `id="stage-tab-${stage}" data-stage="${stage}" aria-controls="panel-${stage}" ` | |
| + `aria-selected="${selected}" tabindex="${selected ? 0 : -1}">` | |
| + `<span class="stage-icon">${icon(stage)}</span>` | |
| + `<span class="stage-label">${NAMES[stage]}</span>` | |
| + `<span class="stage-count">${icon('check', 'stage-tick')}${esc(this.count(stage))}</span>` | |
| + `<span class="stage-blurb">${BLURBS[stage]}</span>` | |
| + `<span class="stage-mini mini-${stage}" aria-hidden="true">${this.mini(stage)}</span>` | |
| + '<span class="stage-unseen" aria-hidden="true"></span></button>'; | |
| } | |
| 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) => `<i class="p${k + 1}"></i>`).join(''); | |
| if (this.route === 'simple') return '<i class="one"></i>'; | |
| return '<i class="wait"></i>'; | |
| } | |
| 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 `<i class="md" style="--v:${dense.toFixed(2)}"></i><i class="mk" style="--v:${bm25.toFixed(2)}"></i>`; | |
| } | |
| 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 `<i class="${value ? 'on' : ''}${keep}" style="--w:${width.toFixed(3)}"></i>`; | |
| }).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 `<span class="${classes}" aria-hidden="true">${icon('right')}</span>`; | |
| } | |
| // ------------------------------------------------------------ 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 = [`<div class="sp-quote">${esc(this.question)}</div>`]; | |
| 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(`<div class="sp-decision"><span class="sp-pill">${isComplex ? 'Complex' : 'Simple'}</span><span>${why}</span></div>`); | |
| } | |
| if (this.calls.route) body.push(promptHtml(this.calls.route, 'Router prompt and reply')); | |
| if (this.parts.length) { | |
| body.push(`<ol class="sp-parts">${this.parts.map((part, k) => `<li>${badge(k)}<span>${esc(part)}</span></li>`).join('')}</ol>`); | |
| } | |
| 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 `<div class="sp-lane-head">${badge(i)}<span>${esc(query)}</span></div>`; | |
| } | |
| 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(`<section class="sp-lane">${head}${waitHtml(`Part ${i + 1} is searched after part ${i} has been ranked.`)}</section>`); | |
| } else { | |
| lanes.push(`<section class="sp-lane" data-lane="${i}">${head}${searchListsHtml(this.hops[i])}</section>`); | |
| } | |
| } | |
| 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 <b>both</b>, ` | |
| + 'and the ones that made the final cut are marked <b>kept</b>.'; | |
| } 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 ? `<p class="sp-tech">${tech}</p>` : '') + 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(`<section class="sp-lane">${head}${waitHtml(`Part ${i + 1} is ranked after it has been searched.`)}</section>`); | |
| continue; | |
| } | |
| let cards = hop.finals.map((doc) => docCardHtml(doc, hop, many ? i : null)).join(''); | |
| if (cards) cards = `<div class="sp-cards">${cards}</div>`; | |
| lanes.push(`<section class="sp-lane">${head}${funnelHtml(hop)}${cards}${fusedHtml(hop)}</section>`); | |
| } | |
| 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(`<li class="is-drop">${badge(part)}<span class="sp-strike">${title}</span> (already in)</li>`); | |
| } else { | |
| seen.add(id); | |
| steps.push(`<li>${badge(part)}<span>${title}</span></li>`); | |
| } | |
| }); | |
| } | |
| const dropped = Number(this.merge.dropped || 0); | |
| return `<section class="sp-merge"><h4>Merging the ${plural(perHop.length, 'part')}</h4>` | |
| + "<p class=\"sp-tech\">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.</p>` | |
| + `<ol>${steps.join('')}</ol></section>`; | |
| } | |
| 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 = `<div class="notice">${esc(this.error)}</div>`; | |
| else if (this.answerText) body = `<p class="sp-answer-meta"><span class="sp-badge">${this.route === 'complex' ? 'multi-step' : 'single-shot'}</span></p>`; | |
| 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 '<div class="stage-panel" role="tabpanel" aria-labelledby="stage-tab-answer">' | |
| + `${headHtml('answer', what)}<div class="sp-body">${body}</div></div>`; | |
| } | |
| 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) => `<li><span class="sp-badges">${s.parts.map(badge).join('')}</span>` | |
| + `<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.title)}</a></li>`).join(''); | |
| side += `<section class="sp-sources"><h4>Based on ${plural(sources.length, 'calendar page')}</h4><ul>${items}</ul></section>`; | |
| } | |
| if (this.calls.answer) side += promptHtml(this.calls.answer, 'Answer prompt and reply'); | |
| return `<div class="sp-answer-side">${side}</div>`; | |
| } | |
| notesHtml(stage) { | |
| const lines = this.notes[stage]; | |
| if (!lines.length) return ''; | |
| return `<details class="prompt sp-notes"><summary>Raw trace, ${plural(lines.length, 'line')}</summary>` | |
| + `<div class="prompt-body"><pre>${esc(lines.join('\n'))}</pre></div></details>`; | |
| } | |
| } | |
| // ---------------------------------------------------------------- panel pieces | |
| function panelHtml(stage, what, body) { | |
| return `<div class="stage-panel" role="tabpanel" aria-labelledby="stage-tab-${stage}">` | |
| + `${headHtml(stage, what)}<div class="sp-body">${body}</div>${navHtml(stage)}</div>`; | |
| } | |
| function headHtml(stage, what) { | |
| return `<div class="sp-head"><span class="sp-icon">${icon(stage)}</span><div>` | |
| + `<p class="sp-eyebrow">Stage ${STAGES.indexOf(stage) + 1} of 4</p>` | |
| + `<h3 class="sp-title">${NAMES[stage]}</h3></div></div><p class="sp-what">${what}</p>`; | |
| } | |
| function navHtml(stage) { | |
| const i = STAGES.indexOf(stage); | |
| const back = i ? `<button type="button" class="sp-btn" data-go="${STAGES[i - 1]}">${icon('left')}${NAMES[STAGES[i - 1]]}</button>` : ''; | |
| const next = i < STAGES.length - 1 | |
| ? `<button type="button" class="sp-btn sp-next" data-go="${STAGES[i + 1]}">Next: ${NAMES[STAGES[i + 1]]}${icon('right')}</button>` | |
| : '<button type="button" class="sp-btn sp-next" data-go="plan">See how it was found: start at Plan</button>'; | |
| return `<div class="sp-nav">${back}${next}</div>`; | |
| } | |
| const statusHtml = (text) => `<div class="sp-status"><span class="sp-dot" aria-hidden="true"></span><span>${esc(text)}</span></div>`; | |
| const waitHtml = (text) => `<div class="sp-wait">${text}</div>`; | |
| function promptHtml(call, label) { | |
| return '<details class="prompt">' | |
| + `<summary>${esc(label)}<span class="prompt-meta">${esc(call.model || '')} · ${call.ms || 0} ms</span></summary>` | |
| + `<div class="prompt-body"><h4>System</h4><pre>${esc(call.system)}</pre>` | |
| + `<h4>User</h4><pre>${esc(call.user)}</pre><h4>Reply</h4><pre>${esc(call.reply)}</pre></div></details>`; | |
| } | |
| function searchListsHtml(hop) { | |
| if (!hop.dense.length) return waitHtml('Searching…'); | |
| const vectorOnly = hop.mode === 'dense'; | |
| let both = new Set(); | |
| if (hop.bm25.length) { | |
| const denseIds = new Set(hop.dense.map((c) => c.id)); | |
| both = new Set(hop.bm25.map((c) => c.id).filter((id) => denseIds.has(id))); | |
| } | |
| const kept = new Set(hop.finals.map((doc) => doc.id)); | |
| const lists = [candListHtml('dense', 'Vector search', hop.dense, both, kept, 4)]; | |
| if (vectorOnly) { | |
| lists.push('<div class="sp-list bm25"><div class="sp-list-head">Keyword search</div><div class="sp-wait">Off in this search mode.</div></div>'); | |
| } else { | |
| lists.push(candListHtml('bm25', 'Keyword search', hop.bm25, both, kept, 2)); | |
| } | |
| const keyword = hop.bm25.length ? `Keyword ${hop.bm25.length}` : 'Keyword off'; | |
| const toggle = '<div class="sp-toggle" role="group" aria-label="Which list to show">' | |
| + `<button type="button" data-pick="dense" aria-pressed="true">Vector ${hop.dense.length}</button>` | |
| + `<button type="button" data-pick="bm25" aria-pressed="false">${keyword}</button></div>`; | |
| return `${toggle}<div class="sp-lists">${lists.join('')}</div>`; | |
| } | |
| function candRowHtml(cand, both, kept, places, cut = false) { | |
| let flags = ''; | |
| if (both.has(cand.id)) flags += '<span class="sp-flag both" title="Found by both searches">both</span>'; | |
| if (kept.has(cand.id)) flags += '<span class="sp-flag kept" title="Kept for the answer">kept</span>'; | |
| if (cut) flags += '<span class="sp-flag cut" title="Not sent to the reranker">cut</span>'; | |
| const title = esc(cand.title || ''); | |
| const classes = `sp-row${kept.has(cand.id) ? ' is-kept' : ''}${cut ? ' is-cut' : ''}`; | |
| return `<li class="${classes}"><span class="sp-rid">${cand.id}</span>` | |
| + `<span class="sp-rtitle" title="${title}">${title}</span><span class="sp-flags">${flags}</span>` | |
| + `<span class="sp-rscore">${fmt(cand.score, places)}</span></li>`; | |
| } | |
| function candListHtml(kind, name, items, both, kept, places) { | |
| const rows = items.map((c) => candRowHtml(c, both, kept, places)); | |
| const more = rows.length > 5 | |
| ? `<details class="sp-more"><summary>Show all ${rows.length}</summary><ol class="sp-rows">${rows.slice(5).join('')}</ol></details>` | |
| : ''; | |
| return `<div class="sp-list ${kind}"><div class="sp-list-head">${name}<span class="sp-n">top ${items.length}</span></div>` | |
| + `<ol class="sp-rows">${rows.slice(0, 5).join('')}</ol>${more}</div>`; | |
| } | |
| function funnelHtml(hop) { | |
| const found = hop.dense.length + hop.bm25.length; | |
| if (!found) return ''; | |
| const split = `<i class="d" style="flex-grow:${hop.dense.length}"></i><i class="k" style="flex-grow:${hop.bm25.length}"></i>`; | |
| const rows = [['Candidates', found, 'split', split]]; | |
| if (hop.rrf.length) rows.push(['After fusing', hop.rrf.length, '', '']); | |
| if (hop.shortlist.length) rows.push(['Sent to reranker', hop.shortlist.length, '', '']); | |
| if (hop.finals.length) rows.push(['Kept', hop.finals.length, 'keep', '']); | |
| return '<div class="sp-funnel">' + rows.map(([label, value, cls, inner]) => | |
| `<div class="fn-row"><span class="fn-label">${label}</span><span class="fn-track">` | |
| + `<span class="fn-bar ${cls}" style="--w:${(value / found).toFixed(4)}">${inner}</span></span>` | |
| + `<span class="fn-val">${value}</span></div>`).join('') + '</div>'; | |
| } | |
| function fusedHtml(hop) { | |
| if (!hop.rrf.length) return ''; | |
| const kept = new Set(hop.finals.map((doc) => doc.id)); | |
| const short = new Set(hop.shortlist); | |
| const rows = hop.rrf.map((c) => candRowHtml(c, new Set(), kept, 4, short.size > 0 && !short.has(c.id))).join(''); | |
| return `<details class="prompt sp-fused"><summary>The fused ranking, all ${hop.rrf.length}` | |
| + '<span class="prompt-meta">RRF</span></summary>' | |
| + `<div class="sp-list fused"><ol class="sp-rows sp-scroll">${rows}</ol></div></details>`; | |
| } | |
| function docCardHtml(doc, hop, part) { | |
| const chips = []; | |
| if (doc.edition_year) chips.push(`<span class="tag edition">${esc(doc.edition_year)}</span>`); | |
| if (doc.cohort_qualifier) chips.push('<span class="tag cohort">cohort-specific</span>'); | |
| CHIP_FIELDS.forEach((field) => { if (doc[field]) chips.push(`<span class="tag">${esc(doc[field])}</span>`); }); | |
| const foundBy = []; | |
| [['dense', 'vector', 'dv'], ['bm25', 'keyword', 'kw']].forEach(([kind, label, css]) => { | |
| if (!hop[kind].length) return; | |
| const rank = hop[kind].findIndex((c) => c.id === doc.id) + 1; | |
| foundBy.push(rank ? `<span class="${css}">${label} #${rank}</span>` : `not in the ${label} top ${hop[kind].length}`); | |
| }); | |
| const section = doc.section ? `<p class="doc-section">${esc(doc.section)}</p>` : ''; | |
| const chipRow = chips.length ? `<div class="doc-chips">${chips.join('')}</div>` : ''; | |
| const provenance = foundBy.length ? `<p class="doc-prov">Found by ${foundBy.join(' · ')}</p>` : ''; | |
| const link = doc.url | |
| ? `<div class="doc-actions"><a href="${esc(doc.url)}" target="_blank" rel="noopener noreferrer">Official page ↗</a></div>` | |
| : ''; | |
| return '<article class="doc">' | |
| + `<div class="doc-head">${part !== null ? badge(part) : ''}<span class="doc-title">${esc(doc.title)}</span>` | |
| + `<span class="doc-score" title="Relevance score">id ${doc.id} · ${fmt(doc.score)}</span></div>` | |
| + `${section}${chipRow}${provenance}${link}` | |
| + `<details class="excerpt"><summary>Excerpt</summary><div class="doc-text">${esc(doc.text)}</div></details></article>`; | |
| } | |
| // ---------------------------------------------------------------- painting | |
| let renderer = new Renderer(''); | |
| let paintQueued = false; | |
| /* Repaint every output from the renderer. Unchanged outputs are left alone, so a stage | |
| * someone is reading (an open "Show all" list, a scrolled excerpt) is not reset by an update | |
| * to a different stage. */ | |
| const painted = {}; | |
| function paint(isRunning) { | |
| const out = renderer.panels(isRunning); | |
| const targets = { bar: el.bar, plan: el.plan, search: el.search, rank: el.rank, head: el.head, side: el.side, nav: el.nav }; | |
| Object.entries(targets).forEach(([key, node]) => { | |
| if (painted[key] !== out[key]) { node.innerHTML = out[key]; painted[key] = out[key]; } | |
| }); | |
| if (painted.answer !== out.answer) { el.answer.textContent = out.answer; painted.answer = out.answer; } | |
| } | |
| // Events can arrive dozens at a time; paint at most once a frame while a run is going. | |
| function schedulePaint() { | |
| if (paintQueued) return; | |
| paintQueued = true; | |
| requestAnimationFrame(() => { paintQueued = false; paint(running); }); | |
| } | |
| // ---------------------------------------------------------------- SSE plumbing | |
| async function streamSSE(url, onEvent) { | |
| abort = new AbortController(); | |
| const response = await fetch(url, { signal: abort.signal, headers: { Accept: 'text/event-stream' } }); | |
| if (!response.ok) { | |
| let body = {}; | |
| try { body = await response.json(); } catch (_) { /* non-JSON error page */ } | |
| const err = new Error(body.error || `Request failed (${response.status})`); | |
| err.status = response.status; | |
| err.budgetExhausted = !!body.budget_exhausted; | |
| throw err; | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| let split; | |
| while ((split = buffer.indexOf('\n\n')) !== -1) { | |
| const block = buffer.slice(0, split); | |
| buffer = buffer.slice(split + 2); | |
| if (!block.trim() || block.startsWith(':')) continue; // heartbeat comment | |
| let name = 'message', data = ''; | |
| for (const line of block.split('\n')) { | |
| if (line.startsWith('event:')) name = line.slice(6).trim(); | |
| else if (line.startsWith('data:')) data += line.slice(5).trim(); | |
| } | |
| if (data) { | |
| try { onEvent(name, JSON.parse(data)); } | |
| catch (_) { /* a malformed frame must not kill the stream */ } | |
| } | |
| } | |
| } | |
| } | |
| // ---------------------------------------------------------------- dispatch | |
| function handle(name, data) { | |
| switch (name) { | |
| case 'accepted': | |
| renderer = new Renderer(data.question, site.chunks, site.model); | |
| el.question.value = data.question; | |
| el.notice.hidden = true; | |
| // In static mode the standing note above the chips already says everything is a | |
| // recording, so flashing a banner on every click would just be noise. | |
| if (data.replay && !STATIC) { | |
| note('Playing back a saved run. No API calls, nothing metered.'); | |
| } | |
| break; | |
| case 'answer': | |
| renderer.answerText = String(data.text || ''); | |
| renderer.route = data.route || renderer.route; | |
| break; | |
| case 'done': | |
| if (!data.replay && data.api_calls) { | |
| const c = data.api_calls; | |
| note(`Done in ${(data.ms / 1000).toFixed(1)}s using ${c.llm} model call${c.llm === 1 ? '' : 's'}, ` | |
| + `${c.embed} embedding${c.embed === 1 ? '' : 's'}, ${c.rerank} rerank${c.rerank === 1 ? '' : 's'}.`); | |
| } | |
| refreshUsage(); | |
| break; | |
| case 'error': | |
| renderer.error = data.message; | |
| break; | |
| default: | |
| // Every trace event the pipeline emits: step, route, candidates, and the rest. | |
| renderer.feed({ ...data, kind: data.kind || name }); | |
| } | |
| schedulePaint(); | |
| } | |
| function note(message) { | |
| el.notice.textContent = message; | |
| el.notice.hidden = false; | |
| } | |
| // ---------------------------------------------------------------- static replay | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function staticRun(slug) { | |
| return (window.DEMO_RUNS.runs || []).find((r) => r.slug === slug); | |
| } | |
| /* Walk a cached run's frames with the same pacing the server uses, so the stage cards animate | |
| * rather than appearing all at once. Emits the identical event shapes `handle()` expects. */ | |
| async function replayLocal(slug) { | |
| const runData = staticRun(slug); | |
| if (!runData) { | |
| handle('error', { message: 'That saved run is not available.' }); | |
| return; | |
| } | |
| handle('accepted', { | |
| question: runData.question, | |
| mode: runData.mode || 'hybrid_rerank', | |
| route: runData.forced_route || 'auto', | |
| replay: true, | |
| }); | |
| for (const event of runData.events) { | |
| await sleep(event.kind === 'step' ? STAGE_DELAY_MS : FRAME_DELAY_MS); | |
| handle(event.kind, event); | |
| } | |
| handle('answer', { text: runData.answer, route: runData.route }); | |
| handle('done', { | |
| route: runData.route, | |
| excerpts: runData.excerpts || 0, | |
| ms: runData.ms || 0, | |
| replay: true, | |
| }); | |
| } | |
| // ---------------------------------------------------------------- driving | |
| async function run(target, question) { | |
| if (running) return; | |
| running = true; | |
| el.submit.disabled = true; | |
| el.submit.textContent = 'Working…'; | |
| renderer = new Renderer(question, site.chunks, site.model); | |
| schedulePaint(); | |
| try { | |
| if (target.slug) await replayLocal(target.slug); | |
| else await streamSSE(target.url, handle); | |
| } catch (err) { | |
| if (err.name !== 'AbortError') { | |
| renderer.error = err.budgetExhausted | |
| ? err.message + ' Pick one of the saved runs above to see the whole thing work.' | |
| : err.message; | |
| } | |
| } finally { | |
| running = false; | |
| el.submit.disabled = false; | |
| el.submit.textContent = STATIC ? 'Filter' : 'Ask'; | |
| paint(false); | |
| refreshUsage(); | |
| } | |
| } | |
| /* In static mode there is no backend to ask, so the input filters the cached questions | |
| * instead of pretending to run one. Filtering as you type is honest about what the page can | |
| * do, and still lets someone hunt for the question they had in mind. */ | |
| function filterExamples(term) { | |
| const needle = term.trim().toLowerCase(); | |
| let visible = 0; | |
| el.examples.querySelectorAll('.chip').forEach((chip) => { | |
| const match = !needle || chip.dataset.question.toLowerCase().includes(needle) | |
| || (chip.dataset.category || '').toLowerCase().includes(needle); | |
| chip.hidden = !match; | |
| if (match) visible += 1; | |
| }); | |
| el.noMatch.hidden = visible > 0; | |
| el.examples.dispatchEvent(new Event('scroll')); // let the row's arrows catch up | |
| } | |
| el.form.addEventListener('submit', (ev) => { | |
| ev.preventDefault(); | |
| const question = el.question.value.trim(); | |
| if (!question) return; | |
| if (STATIC) { | |
| filterExamples(question); | |
| return; | |
| } | |
| const params = new URLSearchParams({ q: question, mode: el.mode.value, route: el.route.value }); | |
| run({ url: `/api/ask/stream?${params}` }, question); | |
| }); | |
| if (STATIC) el.question.addEventListener('input', () => filterExamples(el.question.value)); | |
| // ---------------------------------------------------------------- bootstrap | |
| function renderExamples(examples) { | |
| // One row that scrolls sideways, so a long question is cut short on its chip; the full | |
| // text is in the tooltip and fills the box when the run starts. | |
| el.examples.innerHTML = examples.map((ex) => | |
| `<button class="chip" type="button" data-slug="${esc(ex.slug)}" | |
| data-question="${esc(ex.question)}" data-category="${esc(ex.category || '')}" | |
| title="${esc(ex.question)}">${ex.category ? `<span class="chip-cat">${esc(ex.category)}</span>` : ''}<span class="chip-q">${esc(ex.question)}</span></button>`).join(''); | |
| el.examples.querySelectorAll('.chip').forEach((chip) => { | |
| chip.addEventListener('click', () => { | |
| const question = chip.dataset.question; | |
| run(STATIC ? { slug: chip.dataset.slug } | |
| : { url: `/api/replay/${encodeURIComponent(chip.dataset.slug)}` }, question); | |
| }); | |
| }); | |
| } | |
| function renderUsage(usage) { | |
| if (!usage) return; | |
| el.budget.textContent = usage.budget_exhausted | |
| ? 'out of model calls for today, saved runs still work' | |
| : `${usage.llm_calls_remaining}/${usage.llm_calls_limit} model calls left today · ` + | |
| `${usage.per_ip_hour}/hour per visitor`; | |
| } | |
| async function refreshUsage() { | |
| if (STATIC) return; // nothing is metered when nothing is spent | |
| try { | |
| const health = await (await fetch('/api/health')).json(); | |
| renderUsage(health.usage); | |
| site = { chunks: health.chunks, model: health.model }; | |
| const ok = health.status === 'ok'; | |
| el.statusDot.className = 'dot ' + (ok ? (health.usage.budget_exhausted ? 'warn' : 'ok') : 'err'); | |
| el.statusText.textContent = ok | |
| ? `${health.chunks.toLocaleString()} chunks · ${health.retrieval_mode} · ${health.model}` | |
| : 'index unavailable'; | |
| } catch (_) { | |
| el.statusDot.className = 'dot err'; | |
| el.statusText.textContent = 'offline'; | |
| } | |
| } | |
| function initStatic() { | |
| const meta = window.DEMO_RUNS; | |
| document.body.classList.add('static-mode'); | |
| site = { chunks: meta.chunks || null, model: meta.model || '' }; | |
| el.question.placeholder = `Filter ${meta.runs.length} saved runs…`; | |
| el.submit.textContent = 'Filter'; | |
| el.budget.textContent = 'no API keys, nothing metered'; | |
| el.statusDot.className = 'dot ok'; | |
| el.statusText.textContent = | |
| `${(meta.chunks || 0).toLocaleString()} chunks · ${meta.retrieval_mode || 'hybrid_rerank'} · ${meta.model || ''}`; | |
| renderExamples(meta.runs.map((r) => ({ | |
| slug: r.slug, question: r.question, category: r.category, cached: true, | |
| }))); | |
| } | |
| async function initServer() { | |
| refreshUsage(); | |
| try { | |
| const data = await (await fetch('/api/examples')).json(); | |
| if (data.examples.length) renderExamples(data.examples); | |
| else el.examples.closest('.examples-wrap').hidden = true; | |
| } catch (_) { | |
| el.examples.closest('.examples-wrap').hidden = true; | |
| } | |
| } | |
| (function init() { | |
| if (STATIC) initStatic(); | |
| else initServer(); | |
| renderer = new Renderer('', site.chunks, site.model); | |
| paint(false); | |
| })(); | |