| |
| |
| |
|
|
| (function () { |
| 'use strict'; |
|
|
| |
| const state = { |
| index: [], |
| filtered: [], |
| selectedQid: null, |
| datasetFilter: 'all', |
| search: '', |
| cache: new Map(), |
| activeBucket: 'hard_positive', |
| }; |
|
|
| |
| const dom = { |
| list: document.getElementById('qidList'), |
| main: document.getElementById('mainPane'), |
| search: document.getElementById('searchBox'), |
| filter: document.getElementById('datasetFilter'), |
| prev: document.getElementById('prevBtn'), |
| next: document.getElementById('nextBtn'), |
| counter: document.getElementById('counter'), |
| }; |
|
|
| |
| const escapeHtml = (s) => |
| String(s ?? '') |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
|
|
| const debounce = (fn, ms) => { |
| let t; |
| return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; |
| }; |
|
|
| |
| const SQL_KEYWORDS = new Set([ |
| 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'AS', 'ON', 'JOIN', |
| 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'GROUP', 'BY', 'ORDER', 'HAVING', |
| 'LIMIT', 'OFFSET', 'DISTINCT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', |
| 'IS', 'NULL', 'LIKE', 'BETWEEN', 'ASC', 'DESC', 'UNION', 'ALL', 'EXISTS', |
| 'WITH', |
| ]); |
| const SQL_FUNCS = new Set([ |
| 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ROUND', 'ABS', 'COALESCE', |
| 'LOWER', 'UPPER', 'LENGTH', |
| ]); |
|
|
| function highlightSql(sql) { |
| |
| const parts = []; |
| const re = /('[^']*'|"[^"]*"|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z_0-9]*|[\s\S])/g; |
| let m; |
| while ((m = re.exec(sql)) !== null) { |
| const tok = m[0]; |
| if (/^['"]/.test(tok)) { |
| parts.push(`<span class="sql-str">${escapeHtml(tok)}</span>`); |
| } else if (/^\d/.test(tok)) { |
| parts.push(`<span class="sql-num">${escapeHtml(tok)}</span>`); |
| } else if (/^[A-Za-z_]/.test(tok)) { |
| const upper = tok.toUpperCase(); |
| if (SQL_KEYWORDS.has(upper)) { |
| parts.push(`<span class="sql-kw">${escapeHtml(tok)}</span>`); |
| } else if (SQL_FUNCS.has(upper)) { |
| parts.push(`<span class="sql-fn">${escapeHtml(tok)}</span>`); |
| } else { |
| parts.push(escapeHtml(tok)); |
| } |
| } else { |
| parts.push(escapeHtml(tok)); |
| } |
| } |
| return parts.join(''); |
| } |
|
|
| |
| function recomputeFiltered() { |
| const q = state.search.trim().toLowerCase(); |
| const ds = state.datasetFilter; |
| const filtered = []; |
| for (let i = 0; i < state.index.length; i++) { |
| const e = state.index[i]; |
| if (ds !== 'all' && e.dataset !== ds) continue; |
| if (q) { |
| |
| |
| const hay = (e.qid + ' ' + e.question).toLowerCase(); |
| if (!hay.includes(q)) continue; |
| } |
| filtered.push(i); |
| } |
| state.filtered = filtered; |
| } |
|
|
| |
| function renderSidebar() { |
| const html = []; |
| for (const i of state.filtered) { |
| const e = state.index[i]; |
| const sel = e.qid === state.selectedQid ? ' selected' : ''; |
| html.push( |
| `<li class="qid-row${sel}" data-qid="${escapeHtml(e.qid)}">` + |
| `<span class="qid-id"><span class="qid-tag ${e.dataset}">${e.dataset}</span>${escapeHtml(e.qid)}</span>` + |
| `<span class="qid-question">${escapeHtml(e.question)}</span>` + |
| `</li>` |
| ); |
| } |
| dom.list.innerHTML = html.join(''); |
| updateCounter(); |
| } |
|
|
| function updateCounter() { |
| const total = state.index.length; |
| const shown = state.filtered.length; |
| const sel = state.selectedQid; |
| let pos = 0; |
| if (sel) { |
| const idx = state.filtered.findIndex((i) => state.index[i].qid === sel); |
| pos = idx >= 0 ? idx + 1 : 0; |
| } |
| if (pos) { |
| dom.counter.textContent = `${pos} / ${shown}` + (shown !== total ? ` (of ${total})` : ''); |
| } else { |
| dom.counter.textContent = `${shown}` + (shown !== total ? ` of ${total}` : ''); |
| } |
| dom.prev.disabled = !sel || pos <= 1; |
| dom.next.disabled = !sel || pos >= shown; |
| } |
|
|
| function scrollSelectedIntoView() { |
| const node = dom.list.querySelector('li.selected'); |
| if (node && typeof node.scrollIntoView === 'function') { |
| node.scrollIntoView({ block: 'nearest' }); |
| } |
| } |
|
|
| |
| async function loadRecord(qid) { |
| if (state.cache.has(qid)) return state.cache.get(qid); |
| const res = await fetch(`records/${encodeURIComponent(qid)}.json`); |
| if (!res.ok) throw new Error(`fetch records/${qid}.json failed: ${res.status}`); |
| const rec = await res.json(); |
| state.cache.set(qid, rec); |
| return rec; |
| } |
|
|
| |
| function renderEmpty() { |
| dom.main.innerHTML = `<div class="placeholder">Select a question on the left to begin.</div>`; |
| } |
|
|
| function renderLoading() { |
| dom.main.innerHTML = `<div class="placeholder">Loading recordβ¦</div>`; |
| } |
|
|
| function pickInitialBucket(rec) { |
| if (rec.tables.hard_positive.length) return 'hard_positive'; |
| if (rec.tables.positive.length) return 'positive'; |
| if (rec.tables.negative.length) return 'negative'; |
| return 'hard_positive'; |
| } |
|
|
| function renderRecord(rec) { |
| state.activeBucket = pickInitialBucket(rec); |
|
|
| const answers = Array.isArray(rec.answer) ? rec.answer : [rec.answer]; |
| const answersHtml = answers.length |
| ? answers.map((a) => `<span class="answer-pill">${escapeHtml(a)}</span>`).join('') |
| : `<span class="answer-pill" style="background:#f3eedb;color:var(--ink-mute);font-style:italic">(empty)</span>`; |
|
|
| const html = ` |
| <div class="record-header"> |
| <div class="record-pills"> |
| <span class="pill dataset ${escapeHtml(rec.dataset)}">${escapeHtml(rec.dataset)}</span> |
| <span class="pill mono" title="question_id">${escapeHtml(rec.question_id)}</span> |
| <span class="pill mono" title="original_table_id">π ${escapeHtml(rec.original_table_id)}</span> |
| <button class="copy-btn" id="copyQidBtn" title="Copy qid">π copy qid</button> |
| </div> |
| <div class="question-text">${escapeHtml(rec.question)}</div> |
| <div class="answer-row"> |
| <span class="answer-label">Answer${answers.length > 1 ? 's' : ''} (${answers.length})</span> |
| ${answersHtml} |
| </div> |
| </div> |
| |
| <div class="section"> |
| <h3 class="section-title">Reference SQL</h3> |
| <pre class="sql-block"><code>${highlightSql(rec.sql || '')}</code></pre> |
| </div> |
| |
| <div class="section"> |
| <h3 class="section-title">Candidate tables</h3> |
| <div class="tables-tabs" id="bucketTabs"> |
| ${renderTabButton('hard_positive', 'β Hard positive', rec.tables.hard_positive.length)} |
| ${renderTabButton('positive', 'β Positive', rec.tables.positive.length)} |
| ${renderTabButton('negative', 'β Negative', rec.tables.negative.length)} |
| </div> |
| <div id="bucketBody">${renderBucket(rec, state.activeBucket)}</div> |
| </div> |
| `; |
| dom.main.innerHTML = html; |
|
|
| |
| const copyBtn = document.getElementById('copyQidBtn'); |
| if (copyBtn) { |
| copyBtn.addEventListener('click', () => { |
| navigator.clipboard.writeText(rec.question_id).then(() => { |
| copyBtn.textContent = 'β copied'; |
| copyBtn.classList.add('copied'); |
| setTimeout(() => { |
| copyBtn.textContent = 'π copy qid'; |
| copyBtn.classList.remove('copied'); |
| }, 1200); |
| }); |
| }); |
| } |
|
|
| |
| document.querySelectorAll('#bucketTabs .tables-tab').forEach((btn) => { |
| btn.addEventListener('click', () => { |
| const b = btn.getAttribute('data-bucket'); |
| if (btn.disabled || b === state.activeBucket) return; |
| state.activeBucket = b; |
| document.querySelectorAll('#bucketTabs .tables-tab').forEach((x) => x.classList.toggle('active', x === btn)); |
| document.getElementById('bucketBody').innerHTML = renderBucket(rec, b); |
| }); |
| }); |
| } |
|
|
| function renderTabButton(bucket, label, count) { |
| const active = bucket === state.activeBucket ? ' active' : ''; |
| const disabled = count === 0 ? ' disabled' : ''; |
| return ( |
| `<button class="tables-tab${active}" data-bucket="${bucket}"${disabled}>` + |
| `${label}<span class="count">${count}</span>` + |
| `</button>` |
| ); |
| } |
|
|
| function renderBucket(rec, bucket) { |
| const items = rec.tables[bucket] || []; |
| if (!items.length) { |
| const labelMap = { |
| hard_positive: 'No hard-positive chunk recorded for this question.', |
| positive: 'No partial-positive chunk recorded for this question.', |
| negative: 'No BM25-distractor recorded for this question.', |
| }; |
| return `<div class="empty-bucket">${escapeHtml(labelMap[bucket] || 'empty')}</div>`; |
| } |
| return items.map((c) => renderChunk(c, bucket)).join(''); |
| } |
|
|
| function renderChunk(c, bucket) { |
| const breadParts = []; |
| if (c.page_title) breadParts.push(`<span class="crumb-page">${escapeHtml(c.page_title)}</span>`); |
| if (c.section_title) breadParts.push(`<span class="crumb-section">${escapeHtml(c.section_title)}</span>`); |
| const bread = breadParts.join(`<span class="crumb-sep">βΊ</span>`); |
| const caption = c.caption ? `<span class="crumb-caption">${escapeHtml(c.caption)}</span>` : ''; |
|
|
| const head = (c.header || []).map((h) => `<th>${escapeHtml(h)}</th>`).join(''); |
| const body = (c.rows || []).map( |
| (row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>` |
| ).join(''); |
|
|
| return ` |
| <div class="chunk-card ${bucket}"> |
| <div class="chunk-head"> |
| <div class="chunk-bread">${bread}${caption}</div> |
| <div class="chunk-meta"> |
| <span class="chunk-id-label">chunk #${c.chunk_id}</span> |
| <span>${escapeHtml(c.name || '')}</span> |
| </div> |
| </div> |
| <div class="chunk-table-wrap"> |
| <table class="chunk-table"> |
| <thead><tr>${head}</tr></thead> |
| <tbody>${body}</tbody> |
| </table> |
| </div> |
| </div> |
| `; |
| } |
|
|
| |
| async function selectQid(qid, pushHash = true) { |
| state.selectedQid = qid; |
| renderSidebar(); |
| scrollSelectedIntoView(); |
| if (pushHash) { |
| const h = `#${encodeURIComponent(qid)}`; |
| if (window.location.hash !== h) window.history.replaceState(null, '', h); |
| } |
| renderLoading(); |
| try { |
| const rec = await loadRecord(qid); |
| |
| if (state.selectedQid !== qid) return; |
| renderRecord(rec); |
| } catch (e) { |
| dom.main.innerHTML = `<div class="placeholder">Failed to load record: ${escapeHtml(e.message)}</div>`; |
| } |
| } |
|
|
| function step(delta) { |
| if (!state.selectedQid) { |
| if (state.filtered.length) selectQid(state.index[state.filtered[0]].qid); |
| return; |
| } |
| const cur = state.filtered.findIndex((i) => state.index[i].qid === state.selectedQid); |
| if (cur < 0) return; |
| const next = cur + delta; |
| if (next < 0 || next >= state.filtered.length) return; |
| selectQid(state.index[state.filtered[next]].qid); |
| } |
|
|
| |
| function wire() { |
| dom.list.addEventListener('click', (ev) => { |
| const li = ev.target.closest('li.qid-row'); |
| if (li) selectQid(li.getAttribute('data-qid')); |
| }); |
|
|
| dom.search.addEventListener('input', debounce(() => { |
| state.search = dom.search.value; |
| recomputeFiltered(); |
| renderSidebar(); |
| }, 120)); |
|
|
| dom.filter.addEventListener('click', (ev) => { |
| const btn = ev.target.closest('.filter-pill'); |
| if (!btn) return; |
| const ds = btn.getAttribute('data-dataset'); |
| if (ds === state.datasetFilter) return; |
| state.datasetFilter = ds; |
| dom.filter.querySelectorAll('.filter-pill').forEach((b) => b.classList.toggle('active', b === btn)); |
| recomputeFiltered(); |
| renderSidebar(); |
| }); |
|
|
| dom.prev.addEventListener('click', () => step(-1)); |
| dom.next.addEventListener('click', () => step(+1)); |
|
|
| document.addEventListener('keydown', (ev) => { |
| if (ev.target && (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA')) { |
| if (ev.key === 'Escape') ev.target.blur(); |
| return; |
| } |
| if (ev.key === 'ArrowLeft') { ev.preventDefault(); step(-1); } |
| else if (ev.key === 'ArrowRight') { ev.preventDefault(); step(+1); } |
| else if (ev.key === '/') { ev.preventDefault(); dom.search.focus(); dom.search.select(); } |
| else if (ev.key === '1' || ev.key === '2' || ev.key === '3') { |
| const map = { '1': 'hard_positive', '2': 'positive', '3': 'negative' }; |
| const tab = document.querySelector(`#bucketTabs .tables-tab[data-bucket="${map[ev.key]}"]`); |
| if (tab && !tab.disabled) tab.click(); |
| } |
| }); |
|
|
| window.addEventListener('hashchange', () => { |
| const qid = decodeURIComponent(window.location.hash.slice(1)); |
| if (qid && qid !== state.selectedQid) selectQid(qid, false); |
| }); |
| } |
|
|
| |
| async function boot() { |
| wire(); |
| dom.main.innerHTML = `<div class="placeholder">Loading indexβ¦</div>`; |
| try { |
| const res = await fetch('index.json'); |
| if (!res.ok) throw new Error(`index.json ${res.status}`); |
| state.index = await res.json(); |
| } catch (e) { |
| dom.main.innerHTML = `<div class="placeholder">Failed to load index.json: ${escapeHtml(e.message)}</div>`; |
| return; |
| } |
| recomputeFiltered(); |
| renderSidebar(); |
|
|
| |
| const hashQid = decodeURIComponent(window.location.hash.slice(1)); |
| if (hashQid && state.index.some((e) => e.qid === hashQid)) { |
| selectQid(hashQid, false); |
| } else { |
| renderEmpty(); |
| } |
| } |
|
|
| boot(); |
| })(); |
|
|