| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>LexRAG — Indian Case-Law Research</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| |
| --paper: #f6f2e9; |
| --paper-2: #efe9db; |
| --card: #fffdf8; |
| --ink: #1c2230; |
| --ink-2: #3c4458; |
| --muted: #7a7567; |
| --line: #e0d8c6; |
| --line-2: #d3c9b2; |
| --brass: #9a7236; |
| --brass-2: #b88a45; |
| --brass-soft: #f0e6d2; |
| --accent: #2f4a6b; |
| --danger: #9a4a32; |
| --shadow: 0 1px 2px rgba(28,34,48,.04), 0 8px 28px rgba(28,34,48,.06); |
| --shadow-lg: 0 12px 40px rgba(28,34,48,.12); |
| --r: 12px; |
| --maxw: 880px; |
| } |
| |
| * { box-sizing: border-box; } |
| html { scroll-behavior: smooth; } |
| body { |
| margin: 0; |
| background: |
| radial-gradient(1200px 600px at 80% -10%, var(--paper-2), transparent 60%), |
| var(--paper); |
| color: var(--ink); |
| font-family: "IBM Plex Sans", system-ui, sans-serif; |
| font-size: 16px; |
| line-height: 1.6; |
| -webkit-font-smoothing: antialiased; |
| } |
| |
| .wrap { max-width: var(--maxw); margin: 0 auto; padding: 40px 22px 96px; } |
| |
| |
| header { margin-bottom: 30px; } |
| .brand { |
| display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; |
| } |
| .mark { |
| font-family: "Fraunces", serif; |
| font-weight: 600; |
| font-size: clamp(2.4rem, 6vw, 3.4rem); |
| letter-spacing: -.02em; |
| line-height: 1; |
| color: var(--ink); |
| } |
| .mark .lex { color: var(--brass); } |
| .tagline { |
| margin: 14px 0 0; |
| font-size: clamp(.98rem, 2.5vw, 1.12rem); |
| color: var(--ink-2); |
| max-width: 60ch; |
| } |
| .tagline strong { color: var(--ink); font-weight: 600; } |
| .audience { |
| margin-top: 10px; |
| font-family: "IBM Plex Mono", monospace; |
| font-size: .74rem; |
| letter-spacing: .08em; |
| text-transform: uppercase; |
| color: var(--muted); |
| } |
| .rule { |
| height: 2px; margin: 26px 0 28px; |
| background: linear-gradient(90deg, var(--brass) 0%, var(--line-2) 18%, transparent 60%); |
| } |
| |
| |
| .search { position: relative; } |
| .search-shell { |
| display: flex; align-items: stretch; gap: 10px; |
| background: var(--card); |
| border: 1.5px solid var(--line-2); |
| border-radius: var(--r); |
| padding: 6px 6px 6px 16px; |
| box-shadow: var(--shadow); |
| transition: border-color .18s, box-shadow .18s; |
| } |
| .search-shell:focus-within { |
| border-color: var(--brass); |
| box-shadow: 0 0 0 4px var(--brass-soft), var(--shadow); |
| } |
| .search-shell svg { flex: none; align-self: center; color: var(--muted); } |
| #q { |
| flex: 1; border: 0; background: transparent; resize: none; |
| font-family: inherit; font-size: 1.06rem; color: var(--ink); |
| padding: 12px 4px; line-height: 1.5; min-height: 30px; max-height: 160px; |
| } |
| #q::placeholder { color: var(--muted); } |
| #q:focus { outline: none; } |
| #go { |
| flex: none; align-self: stretch; |
| padding: 0 22px; min-width: 96px; |
| background: var(--ink); color: var(--paper); |
| border: 0; border-radius: 9px; |
| font-family: "IBM Plex Sans", sans-serif; |
| font-weight: 600; font-size: .98rem; cursor: pointer; |
| transition: background .15s, transform .05s; |
| } |
| #go:hover { background: var(--brass); } |
| #go:active { transform: translateY(1px); } |
| #go:disabled { background: var(--line-2); color: var(--muted); cursor: default; } |
| |
| .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } |
| .chips .lbl { |
| font-family: "IBM Plex Mono", monospace; font-size: .72rem; |
| text-transform: uppercase; letter-spacing: .08em; |
| color: var(--muted); align-self: center; margin-right: 2px; |
| } |
| .chip { |
| background: var(--card); border: 1px solid var(--line-2); |
| color: var(--ink-2); border-radius: 999px; |
| padding: 7px 14px; font-size: .86rem; cursor: pointer; |
| transition: all .15s; font-family: inherit; |
| } |
| .chip:hover { border-color: var(--brass); color: var(--ink); background: var(--brass-soft); } |
| |
| |
| #results { margin-top: 36px; } |
| .hidden { display: none !important; } |
| |
| .section-head { |
| display: flex; align-items: center; gap: 12px; margin: 0 0 12px; |
| } |
| .section-head h2 { |
| font-family: "Fraunces", serif; font-weight: 600; |
| font-size: 1.18rem; margin: 0; color: var(--ink); letter-spacing: -.01em; |
| } |
| .badge { |
| font-family: "IBM Plex Mono", monospace; font-size: .72rem; |
| background: var(--accent); color: #eef3f8; |
| padding: 3px 9px; border-radius: 6px; letter-spacing: .04em; |
| } |
| .badge.brass { background: var(--brass); color: var(--paper); } |
| |
| |
| .answer-card { |
| background: var(--card); |
| border: 1px solid var(--line); |
| border-left: 4px solid var(--brass); |
| border-radius: var(--r); |
| padding: 22px 24px; |
| box-shadow: var(--shadow); |
| } |
| .answer-body { |
| font-size: 1.05rem; line-height: 1.7; color: var(--ink); |
| white-space: pre-wrap; |
| } |
| .answer-body .cite { |
| color: var(--accent); font-weight: 600; cursor: pointer; |
| text-decoration: none; border-bottom: 1px dotted var(--accent); |
| white-space: normal; |
| } |
| .answer-body .cite:hover { background: var(--brass-soft); } |
| |
| .plain { |
| margin-top: 18px; padding-top: 16px; |
| border-top: 1px dashed var(--line-2); |
| } |
| .plain-head { |
| display: flex; align-items: center; gap: 8px; margin-bottom: 6px; |
| font-family: "IBM Plex Mono", monospace; font-size: .74rem; |
| text-transform: uppercase; letter-spacing: .08em; color: var(--brass); |
| } |
| .plain-head::before { content: "✦"; } |
| .plain-body { color: var(--ink-2); font-size: .98rem; font-style: italic; } |
| .plain-toggle { |
| margin-left: auto; font-style: normal; cursor: pointer; |
| border: 0; background: none; color: var(--muted); |
| font-family: inherit; font-size: .8rem; text-decoration: underline; |
| } |
| |
| .warn { |
| margin-top: 14px; font-size: .84rem; color: var(--danger); |
| background: #f7eae3; |
| border: 1px solid #e6cabb; border-radius: 8px; padding: 8px 12px; |
| } |
| |
| |
| .sources { margin-top: 34px; } |
| .src { |
| background: var(--card); border: 1px solid var(--line); |
| border-radius: var(--r); margin-bottom: 12px; overflow: hidden; |
| box-shadow: var(--shadow); transition: box-shadow .18s, border-color .18s; |
| } |
| .src.flash { border-color: var(--brass); box-shadow: 0 0 0 3px var(--brass-soft), var(--shadow-lg); } |
| .src-head { |
| display: flex; align-items: flex-start; gap: 14px; |
| padding: 16px 18px; cursor: pointer; user-select: none; |
| } |
| .rank { |
| flex: none; width: 30px; height: 30px; border-radius: 8px; |
| display: grid; place-items: center; |
| background: var(--brass-soft); color: var(--brass); |
| font-family: "IBM Plex Mono", monospace; font-weight: 500; font-size: .9rem; |
| border: 1px solid var(--line-2); |
| } |
| .src-main { flex: 1; min-width: 0; } |
| .src-name { |
| font-family: "Fraunces", serif; font-weight: 600; font-size: 1rem; |
| color: var(--ink); line-height: 1.3; |
| } |
| .src-snippet { |
| margin-top: 4px; font-size: .86rem; color: var(--muted); |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| .chev { |
| flex: none; align-self: center; color: var(--muted); |
| transition: transform .2s; |
| } |
| .src.open .chev { transform: rotate(180deg); } |
| .src-full { |
| max-height: 0; overflow: hidden; |
| transition: max-height .3s ease; |
| font-size: .9rem; line-height: 1.65; color: var(--ink-2); |
| } |
| .src.open .src-full { max-height: 1200px; } |
| .src-full-inner { |
| padding: 0 18px 18px 62px; border-top: 1px solid var(--line); |
| padding-top: 14px; margin-top: 0; |
| } |
| |
| |
| .skel { animation: pulse 1.3s ease-in-out infinite; } |
| .skel-line { |
| height: 13px; border-radius: 6px; margin: 10px 0; |
| background: linear-gradient(90deg, var(--paper-2), var(--line), var(--paper-2)); |
| background-size: 200% 100%; animation: shimmer 1.4s infinite; |
| } |
| @keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } |
| @keyframes pulse { 50% { opacity:.7 } } |
| .spinner { |
| width: 16px; height: 16px; border-radius: 50%; |
| border: 2px solid var(--paper); border-top-color: transparent; |
| display: inline-block; animation: spin .7s linear infinite; vertical-align: -2px; |
| } |
| @keyframes spin { to { transform: rotate(360deg) } } |
| |
| |
| .reveal { animation: rise .45s cubic-bezier(.2,.7,.2,1) both; } |
| @keyframes rise { from { opacity:0; transform: translateY(10px) } } |
| |
| footer { |
| margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line); |
| font-size: .8rem; color: var(--muted); line-height: 1.6; |
| } |
| footer code { font-family: "IBM Plex Mono", monospace; color: var(--ink-2); } |
| |
| @media (max-width: 560px) { |
| .wrap { padding: 28px 16px 72px; } |
| #go { padding: 0 14px; min-width: 64px; } |
| .search-shell svg { display: none; } |
| .src-full-inner { padding-left: 18px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
| <header> |
| <div class="brand"> |
| <span class="mark"><span class="lex">Lex</span>RAG</span> |
| </div> |
| <p class="tagline"> |
| Ask a question, get a <strong>cited answer grounded in real Indian court judgments</strong> — |
| hybrid semantic + keyword search with citation validation. |
| </p> |
| <div class="audience">For law students · paralegals · junior lawyers · legal researchers</div> |
| </header> |
|
|
| <div class="rule"></div> |
|
|
| <div class="search"> |
| <div class="search-shell"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg> |
| <textarea id="q" rows="1" placeholder="Ask about a holding, statute, or procedure…"></textarea> |
| <button id="go">Research</button> |
| </div> |
| <div class="chips"> |
| <span class="lbl">Try</span> |
| <button class="chip">What did the court hold about Section 302 maintainability?</button> |
| <button class="chip">When is a revision petition maintainable?</button> |
| <button class="chip">Does a complaint abate on the death of the complainant?</button> |
| <button class="chip">What is the scope of Section 482 inherent powers?</button> |
| </div> |
| </div> |
|
|
| <div id="results" class="hidden"> |
| <div class="section-head"> |
| <h2>Answer</h2> |
| <span id="srcBadge" class="badge brass"></span> |
| </div> |
|
|
| <div id="answerCard" class="answer-card"> |
| <div id="answerBody" class="answer-body"></div> |
| <div id="warn" class="warn hidden"></div> |
| <div id="plain" class="plain hidden"> |
| <div class="plain-head">Plain English |
| <button id="plainToggle" class="plain-toggle">hide</button> |
| </div> |
| <div id="plainBody" class="plain-body"></div> |
| </div> |
| </div> |
|
|
| <div class="sources"> |
| <div class="section-head"> |
| <h2>Sources</h2> |
| <span class="badge">ranked by relevance</span> |
| </div> |
| <div id="sources"></div> |
| </div> |
| </div> |
|
|
| <footer> |
| Retrieval: pgvector cosine + Postgres full-text, fused with Reciprocal Rank Fusion in a single SQL CTE. |
| Generation: Groq <code>llama-3.1-8b-instant</code>. Citations are validated against retrieved sources — |
| unverifiable ones are stripped. Educational use; not legal advice. |
| </footer> |
| </div> |
|
|
| <script> |
| const $ = s => document.querySelector(s); |
| const q = $('#q'), go = $('#go'), results = $('#results'); |
| const answerBody = $('#answerBody'), srcBadge = $('#srcBadge'); |
| const sourcesEl = $('#sources'), warnEl = $('#warn'); |
| const plainWrap = $('#plain'), plainBody = $('#plainBody'), plainToggle = $('#plainToggle'); |
| |
| |
| q.addEventListener('input', () => { |
| q.style.height = 'auto'; |
| q.style.height = Math.min(q.scrollHeight, 160) + 'px'; |
| }); |
| |
| |
| document.querySelectorAll('.chip').forEach(c => |
| c.addEventListener('click', () => { q.value = c.textContent.trim(); q.dispatchEvent(new Event('input')); ask(); }) |
| ); |
| |
| |
| q.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(); } |
| }); |
| go.addEventListener('click', ask); |
| |
| let plainVisible = true; |
| plainToggle.addEventListener('click', () => { |
| plainVisible = !plainVisible; |
| plainBody.style.display = plainVisible ? '' : 'none'; |
| plainToggle.textContent = plainVisible ? 'hide' : 'show'; |
| }); |
| |
| function esc(s){ return s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } |
| |
| |
| |
| function trimPartials(s) { |
| s = s.trim(); |
| const sp = s.indexOf(' '); |
| if (sp > 0 && sp < 25) s = s.slice(sp + 1); |
| const lsp = s.lastIndexOf(' '); |
| if (lsp > s.length - 25 && lsp > 0) s = s.slice(0, lsp); |
| return s.trim(); |
| } |
| |
| |
| function snippet(s, max = 150) { |
| s = trimPartials(s); |
| if (s.length <= max) return s + '…'; |
| let cut = s.slice(0, max); |
| cut = cut.slice(0, cut.lastIndexOf(' ')); |
| return cut + '…'; |
| } |
| |
| |
| function renderAnswer(text, sources) { |
| const out = esc(text).replace(/\[([^\[\]]+)\]/g, (m, name) => { |
| const idx = sources.findIndex(s => { |
| const a = s.case_name.toLowerCase(), b = name.toLowerCase().trim(); |
| return a === b || a.includes(b) || b.includes(a); |
| }); |
| if (idx === -1) return m; |
| return `<a class="cite" data-src="${idx}" title="Jump to source ${idx+1}">[${esc(name)}]</a>`; |
| }); |
| answerBody.innerHTML = out; |
| answerBody.querySelectorAll('.cite').forEach(a => |
| a.addEventListener('click', () => jumpTo(+a.dataset.src)) |
| ); |
| } |
| |
| function jumpTo(i) { |
| const card = document.querySelectorAll('.src')[i]; |
| if (!card) return; |
| card.classList.add('open'); |
| card.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| card.classList.add('flash'); |
| setTimeout(() => card.classList.remove('flash'), 1400); |
| } |
| |
| function showSkeleton() { |
| results.classList.remove('hidden'); |
| srcBadge.textContent = 'searching…'; |
| answerBody.innerHTML = '<div class="skel"><div class="skel-line" style="width:92%"></div><div class="skel-line" style="width:98%"></div><div class="skel-line" style="width:76%"></div></div>'; |
| warnEl.classList.add('hidden'); |
| plainWrap.classList.add('hidden'); |
| sourcesEl.innerHTML = '<div class="src skel"><div class="src-head"><div class="rank"> </div><div class="src-main"><div class="skel-line" style="width:55%;margin:2px 0"></div><div class="skel-line" style="width:85%"></div></div></div></div>'.repeat(3); |
| } |
| |
| function renderSources(sources) { |
| sourcesEl.innerHTML = ''; |
| sources.forEach((s, i) => { |
| const card = document.createElement('div'); |
| card.className = 'src reveal'; |
| card.style.animationDelay = (i * 60) + 'ms'; |
| card.innerHTML = ` |
| <div class="src-head"> |
| <div class="rank">${i + 1}</div> |
| <div class="src-main"> |
| <div class="src-name">${esc(s.case_name)}</div> |
| <div class="src-snippet">${esc(snippet(s.content))}</div> |
| </div> |
| <svg class="chev" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg> |
| </div> |
| <div class="src-full"><div class="src-full-inner">${esc(trimPartials(s.content))}</div></div>`; |
| card.querySelector('.src-head').addEventListener('click', () => card.classList.toggle('open')); |
| sourcesEl.appendChild(card); |
| }); |
| } |
| |
| async function ask() { |
| const question = q.value.trim(); |
| if (!question) { q.focus(); return; } |
| go.disabled = true; go.innerHTML = '<span class="spinner"></span>'; |
| showSkeleton(); |
| |
| try { |
| const res = await fetch('/ask', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ question }) |
| }); |
| const data = await res.json(); |
| const sources = data.sources || []; |
| |
| srcBadge.textContent = `answered from ${sources.length} source${sources.length === 1 ? '' : 's'}`; |
| renderSources(sources); |
| renderAnswer(data.answer || '(no answer)', sources); |
| |
| if (data.plain_english) { |
| plainBody.textContent = data.plain_english; |
| plainWrap.classList.remove('hidden'); |
| plainVisible = true; plainBody.style.display = ''; plainToggle.textContent = 'hide'; |
| } else { |
| plainWrap.classList.add('hidden'); |
| } |
| |
| if (data.removed_citations && data.removed_citations.length) { |
| warnEl.textContent = '⚠ Stripped unverifiable citation(s): ' + data.removed_citations.join('; '); |
| warnEl.classList.remove('hidden'); |
| } |
| } catch (e) { |
| srcBadge.textContent = 'error'; |
| answerBody.textContent = 'Something went wrong: ' + e; |
| sourcesEl.innerHTML = ''; |
| } finally { |
| go.disabled = false; go.textContent = 'Research'; |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|