lexrag / app /static /index.html
RV302001's picture
Initial commit: LexRAG hybrid legal RAG
cc0207f
Raw
History Blame Contribute Delete
18.7 kB
<!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 {
/* Editorial legal-research palette — ivory paper + ink + brass */
--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; /* deep slate-blue for links/badges */
--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 ---------- */
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 ---------- */
.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 ---------- */
#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 */
.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 */
.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;
}
/* Skeleton / loading */
.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) } }
/* entrance */
.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');
// auto-grow textarea
q.addEventListener('input', () => {
q.style.height = 'auto';
q.style.height = Math.min(q.scrollHeight, 160) + 'px';
});
// example chips fill + submit
document.querySelectorAll('.chip').forEach(c =>
c.addEventListener('click', () => { q.value = c.textContent.trim(); q.dispatchEvent(new Event('input')); ask(); })
);
// Enter submits, Shift+Enter newline
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// Display-only: drop leading/trailing partial words from a chunk.
// Chunks are cut mid-word at fixed widths, so the first/last token is junk.
function trimPartials(s) {
s = s.trim();
const sp = s.indexOf(' ');
if (sp > 0 && sp < 25) s = s.slice(sp + 1); // drop leading fragment
const lsp = s.lastIndexOf(' ');
if (lsp > s.length - 25 && lsp > 0) s = s.slice(0, lsp); // drop trailing fragment
return s.trim();
}
// Collapsed snippet: clean partials, cap at word boundary, ellipsis if cut.
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 + '…';
}
// Link [citations] in the answer to their source card by case-name match.
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>