feat(llm): search web UI at /ui/search (refs #578)

A minimal, framework-free search page served by the llm service itself,
in the same editorial style as the chat page, so the retrieval endpoints
(#570) are usable from the dashboard and not only via curl.

- src/llm/web/search.html: query box + collection/kind/docket/year
  filters (exactly the values the API validates), results with kind,
  linked label, date, docket/comment id, title and snippet, a "similar"
  toggle per result carrying a bib item_key (renders /similar/{key} in
  place), limit/offset pagination, and the search state mirrored into
  the query string so a search is shareable and survives a reload.
  Loading/error states surface the API's own 400/404 `detail`. All API
  data becomes text nodes — nothing is assigned to innerHTML — and the
  page carries no external script/style URL, so it renders behind
  oauth2-proxy with no outbound CDN access (fonts fall back to the
  local serif stack). An "llm:" tag filter is left as an HTML comment
  until the tagging chain (#575) makes chunks carry those tags.
- api.py: `GET /ui/search` (HTMLResponse, like `/` serves chat.html),
  registered with and without the trailing slash so the headless probe's
  readiness check gets a 200 instead of a 307 redirect.
- chat header → "Search", search header → "Chat".
- tests/llm/test_api.py::TestSearchPage: the route serves the form and
  every allowed filter value, the trailing-slash form works, the
  results/similar/pagination/error code is present, the page has no
  external script/style URL and no innerHTML assignment, and both nav
  links resolve to routes that return 200.

Headless probe, once the image is rolled (do not run before the roll —
it probes the live service):

    python dev/scripts/nb_fe_smoke.py \
        --url http://llm:8000/ui/search --network gateway

nb_fe_smoke's --url mode probes `<base>/` and `<base>/?file=<notebook>`
and fails on any console or page error; with the base above, both are
the search page (the unused ?file= query is ignored by the route), which
is why the route also answers the trailing-slash form.
This commit is contained in:
kert
2026-09-11 18:48:19 -04:00
parent b9349c2958
commit 2ba846b39a
4 changed files with 472 additions and 3 deletions

View File

@@ -78,8 +78,8 @@ class ChatRequest(BaseModel):
mode: str = "auto"
def _page() -> str:
return resources.files("llm").joinpath("web/chat.html").read_text()
def _page(name: str = "chat.html") -> str:
return resources.files("llm").joinpath(f"web/{name}").read_text()
@app.get("/health")
@@ -93,6 +93,20 @@ def index() -> str:
return _page()
@app.get("/ui/search", response_class=HTMLResponse)
@app.get("/ui/search/", response_class=HTMLResponse)
def search_page() -> str:
"""The search UI (refs #578) — a framework-free page like the chat,
calling ``/search`` and ``/similar/{key}`` from the browser.
Registered with and without the trailing slash so
``dev/scripts/nb_fe_smoke.py --url http://llm:8000/ui/search`` gets a
200 from its readiness probe (which appends ``/``) instead of the
307 Starlette would otherwise redirect with.
"""
return _page("search.html")
@app.get("/whoami")
def whoami(x_auth_request_user: str = Header(default="")) -> dict:
"""The Gitea username Traefik forwarded (empty if unset)."""

View File

@@ -35,6 +35,10 @@
font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0;
}
header .sub { font-size: 13px; opacity: .7; }
header a.nav {
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
letter-spacing: .08em; color: var(--primary-fg); text-decoration: underline dotted;
}
header .who {
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
@@ -178,6 +182,7 @@
<header>
<h1>Library Chat</h1>
<span class="sub">grounded in the indexed library — comments, FR rules, references</span>
<a class="nav" href="/ui/search">Search</a>
<span class="who" id="who"></span>
</header>
<div id="log">

379
src/llm/web/search.html Normal file
View File

@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Library Search</title>
<link rel="icon" type="image/png" sizes="32x32" href="//dashboard.fhirworx.io/fav32.png">
<style>
/* HTI-5 editorial palette — same tokens as web/chat.html, so the two
pages read as one product. No webfont <link> and no script src here
on purpose: behind oauth2-proxy the page must render with no
outbound CDN access, so the display/body/mono stacks fall back to
the platform's local serifs (refs #578). */
:root {
--background: #F7F5F0; --foreground: #1A1A18; --card: #FAFAF7;
--primary: #1C2B3A; --primary-fg: #F7F5F0; --secondary: #EDEBE6;
--muted-fg: #6B6B68; --border: #D4D0C8; --accent: #2E3D8F;
--destructive: #C0392B;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Source Serif 4", Georgia, serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; flex-direction: column;
background: var(--background); color: var(--foreground);
font-family: var(--font-body); font-size: 16px; line-height: 1.55;
}
header {
flex: 0 0 auto; background: var(--primary); color: var(--primary-fg);
border-bottom: 3px solid var(--foreground);
padding: 14px 22px; display: flex; align-items: baseline; gap: 14px;
}
header h1 {
font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0;
}
header .sub { font-size: 13px; opacity: .7; }
header a.nav {
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
letter-spacing: .08em; color: var(--primary-fg); text-decoration: underline dotted;
}
header .who {
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
}
main { flex: 1 1 auto; width: 100%; max-width: 900px; margin: 0 auto; padding: 0 22px 40px; }
form#search {
border-bottom: 3px solid var(--foreground); background: var(--secondary);
margin: 0 -22px 22px; padding: 16px 22px;
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
form#search input[type=search], form#search input[type=text], form#search select {
font-family: var(--font-body); font-size: 15px; padding: 8px 10px;
background: var(--card); color: var(--foreground);
border: 1px solid var(--border); border-radius: 3px;
}
form#search input#q { flex: 1 1 320px; font-size: 16px; }
form#search input#docket, form#search input#year { width: 150px; }
form#search label {
display: flex; align-items: center; gap: 6px; font-size: 13px;
color: var(--muted-fg); white-space: nowrap;
}
form#search input:focus, form#search select:focus {
outline: none; border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 22%, transparent);
}
button {
padding: 8px 20px; border: 1px solid var(--primary); border-radius: 3px;
background: var(--primary); color: var(--primary-fg);
font-family: var(--font-body); font-size: 15px; font-weight: 600; cursor: pointer;
transition: background .15s ease;
}
button:hover:not(:disabled) { background: #253748; }
button:disabled { opacity: .45; cursor: default; }
button.ghost {
background: transparent; color: var(--primary); border-color: var(--border);
font-size: 12px; font-family: var(--font-mono); padding: 3px 9px;
}
button.ghost:hover:not(:disabled) { background: var(--secondary); }
.status { color: var(--muted-fg); font-style: italic; margin: 14px 0; }
.status.err { color: var(--destructive); font-style: normal; }
.hint { max-width: 560px; color: var(--muted-fg); font-style: italic; margin: 20px 0; }
.hit {
border: 1px solid var(--border); border-left: 3px solid var(--border);
background: var(--card); border-radius: 3px;
padding: 12px 14px; margin: 0 0 14px;
}
.hit .line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.hit .kind {
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.hit .label, .hit a.label {
font-family: var(--font-mono); font-size: .82em; color: var(--accent);
text-decoration: underline dotted;
}
.hit .date, .hit .meta { color: var(--muted-fg); font-size: 12px; font-family: var(--font-mono); }
.hit .title { margin-top: 3px; font-weight: 600; }
.hit .snippet { margin-top: 6px; }
.hit .score { margin-left: auto; color: var(--muted-fg); font-size: 11px; font-family: var(--font-mono); }
.neighbours { margin: 10px 0 0 14px; border-left: 3px solid var(--border); padding-left: 12px; }
.neighbours .caption {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); margin-bottom: 6px;
}
.neighbours .caption.err { color: var(--destructive); }
.neighbours .hit { background: var(--background); }
.pager { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
/* the class rule above outranks the UA's [hidden] { display: none } */
.pager[hidden] { display: none; }
.pager .range { color: var(--muted-fg); font-size: 12px; font-family: var(--font-mono); }
</style>
</head>
<body>
<header>
<h1>Library Search</h1>
<span class="sub">comments, FR rules and references — metadata-filtered similarity search</span>
<a class="nav" href="/">Chat</a>
<span class="who" id="who"></span>
</header>
<main>
<form id="search">
<input type="search" id="q" name="q" placeholder="e.g. telehealth originating site" autofocus>
<label>collection
<select id="collection">
<option value="all" selected>all</option>
<option value="comments">comments</option>
<option value="rules">rules</option>
<option value="corpus">corpus</option>
</select>
</label>
<label>kind
<select id="kind">
<option value="" selected>any</option>
<option value="comment">comment</option>
<option value="rule">rule</option>
<option value="corpus">corpus</option>
</select>
</label>
<label>docket <input type="text" id="docket" placeholder="CMS-2023-0121"></label>
<label>year <input type="text" id="year" placeholder="2024"></label>
<!-- An "llm:" tag filter belongs here once the tagging chain (#575)
lands and indexed chunks carry llm: tags; there is nothing to
filter on yet, so no control is drawn. -->
<button id="go" type="submit">Search</button>
</form>
<div id="status" class="status"></div>
<div id="results"></div>
<div id="pager" class="pager" hidden>
<button id="prev" class="ghost" type="button">&lsaquo; prev</button>
<span id="range" class="range"></span>
<button id="next" class="ghost" type="button">next &rsaquo;</button>
</div>
<div class="hint" id="hint">Search the indexed library. Results are ranked by
embedding distance; filter by collection, chunk kind, docket or year, and
follow &ldquo;similar&rdquo; to find neighbours of a cited item.</div>
</main>
<script>
var LIMIT = 10;
var form = document.getElementById('search');
var statusEl = document.getElementById('status');
var resultsEl = document.getElementById('results');
var pagerEl = document.getElementById('pager');
var rangeEl = document.getElementById('range');
var prevBtn = document.getElementById('prev');
var nextBtn = document.getElementById('next');
var goBtn = document.getElementById('go');
var hintEl = document.getElementById('hint');
var offset = 0;
fetch('/whoami').then(function (r) { return r.json(); }).then(function (d) {
if (d.user) document.getElementById('who').textContent = d.user;
}).catch(function () {});
function val(id) { return document.getElementById(id).value.trim(); }
// The current form state as query params — also what lands in the
// address bar, so a search is shareable and survives a reload.
function params() {
var p = new URLSearchParams();
p.set('q', val('q'));
var collection = val('collection');
if (collection && collection !== 'all') p.set('collection', collection);
if (val('kind')) p.set('kind', val('kind'));
if (val('docket')) p.set('docket', val('docket'));
if (val('year')) p.set('year', val('year'));
if (offset) p.set('offset', String(offset));
p.set('limit', String(LIMIT));
return p;
}
function fromUrl() {
var p = new URLSearchParams(window.location.search);
document.getElementById('q').value = p.get('q') || '';
document.getElementById('collection').value = p.get('collection') || 'all';
document.getElementById('kind').value = p.get('kind') || '';
document.getElementById('docket').value = p.get('docket') || '';
document.getElementById('year').value = p.get('year') || '';
offset = Math.max(0, parseInt(p.get('offset') || '0', 10) || 0);
return p.get('q') || '';
}
function setStatus(text, isError) {
statusEl.textContent = text;
statusEl.className = isError ? 'status err' : 'status';
}
// Every value below becomes a text node (or an element's .textContent /
// .href) — API data never reaches innerHTML.
function hitEl(r) {
var box = document.createElement('div');
box.className = 'hit';
var line = document.createElement('div');
line.className = 'line';
var kind = document.createElement('span');
kind.className = 'kind';
kind.textContent = r.kind || 'chunk';
line.appendChild(kind);
var label;
if (r.url) {
label = document.createElement('a');
label.href = r.url; label.target = '_blank'; label.rel = 'noopener';
} else {
label = document.createElement('span'); // an empty href reloads the page
}
label.className = 'label';
label.textContent = '[' + (r.label || r.id || '') + ']';
line.appendChild(label);
if (r.date) {
var date = document.createElement('span');
date.className = 'date';
date.textContent = r.date;
line.appendChild(date);
}
var bits = [];
if (r.docket) bits.push('docket ' + r.docket);
if (r.comment_id) bits.push('comment ' + r.comment_id);
if (bits.length) {
var meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = bits.join(' · ');
line.appendChild(meta);
}
if (r.item_key) {
var sim = document.createElement('button');
sim.type = 'button';
sim.className = 'ghost';
sim.textContent = 'similar';
sim.addEventListener('click', function () { showSimilar(box, sim, r.item_key); });
line.appendChild(sim);
}
if (r.distance !== undefined && r.distance !== null) {
var score = document.createElement('span');
score.className = 'score';
score.textContent = 'd=' + r.distance;
line.appendChild(score);
}
box.appendChild(line);
if (r.title) {
var title = document.createElement('div');
title.className = 'title';
title.textContent = r.title;
box.appendChild(title);
}
if (r.snippet) {
var snip = document.createElement('div');
snip.className = 'snippet';
snip.textContent = r.snippet;
box.appendChild(snip);
}
return box;
}
// A 400/404 carries FastAPI's {"detail": "…"} — show that, not just
// the status code.
async function detail(resp) {
try {
var body = await resp.json();
if (body && body.detail) return String(body.detail);
} catch (e) { /* not JSON */ }
return 'request failed (' + resp.status + ')';
}
// The bib item key a result carries is `item_key`; /similar is keyed
// by it. Toggles the neighbours list open/closed in place.
async function showSimilar(box, button, key) {
var shown = box.querySelector('.neighbours');
if (shown) { shown.remove(); button.textContent = 'similar'; return; }
button.disabled = true;
var wrap = document.createElement('div');
wrap.className = 'neighbours';
var cap = document.createElement('div');
cap.className = 'caption';
cap.textContent = 'similar to ' + key + '…';
wrap.appendChild(cap);
box.appendChild(wrap);
try {
var resp = await fetch('/similar/' + encodeURIComponent(key) +
'?collection=' + encodeURIComponent(val('collection')) + '&limit=5');
if (!resp.ok) throw new Error(await detail(resp));
var body = await resp.json();
cap.textContent = body.total + ' similar to ' + key;
(body.results || []).forEach(function (r) { wrap.appendChild(hitEl(r)); });
button.textContent = 'hide similar';
} catch (e) {
cap.className = 'caption err';
cap.textContent = 'similar failed: ' + e.message;
} finally {
button.disabled = false;
}
}
async function run(push) {
var p = params();
if (!p.get('q')) {
setStatus('Enter a search term.', true);
return;
}
if (push) {
window.history.pushState({}, '', window.location.pathname + '?' + p.toString());
}
goBtn.disabled = true;
setStatus('Searching…', false);
resultsEl.textContent = '';
pagerEl.hidden = true;
if (hintEl) { hintEl.remove(); hintEl = null; }
try {
var resp = await fetch('/search?' + p.toString());
if (!resp.ok) throw new Error(await detail(resp));
var body = await resp.json();
var results = body.results || [];
results.forEach(function (r) { resultsEl.appendChild(hitEl(r)); });
if (!results.length) {
setStatus(offset ? 'No more results on this page.' : 'No results.', false);
} else {
setStatus(results.length + ' result' + (results.length > 1 ? 's' : '') +
' for “' + body.query + '”', false);
}
prevBtn.disabled = offset === 0;
// `total` is this page's size, so a full page means "there may be
// more" and a short one means this is the last page.
nextBtn.disabled = results.length < LIMIT;
rangeEl.textContent = results.length
? (offset + 1) + '–' + (offset + results.length)
: 'none';
pagerEl.hidden = offset === 0 && results.length < LIMIT;
} catch (e) {
setStatus('Error: ' + e.message, true);
} finally {
goBtn.disabled = false;
}
}
form.addEventListener('submit', function (e) {
e.preventDefault();
offset = 0;
run(true);
});
prevBtn.addEventListener('click', function () {
offset = Math.max(0, offset - LIMIT);
run(true);
});
nextBtn.addEventListener('click', function () {
offset += LIMIT;
run(true);
});
window.addEventListener('popstate', function () {
if (fromUrl()) { run(false); } else { resultsEl.textContent = ''; setStatus('', false); }
});
if (fromUrl()) run(false);
</script>
</body>
</html>

View File

@@ -1,10 +1,11 @@
"""llm.api — FastAPI chat app."""
import re
from unittest.mock import patch
from fastapi.testclient import TestClient
from llm.api import app
from llm.api import _COLLECTION_CHOICES, _KIND_CHOICES, app
client = TestClient(app)
@@ -74,6 +75,76 @@ class TestIndex:
assert "innerHTML = ''" in html and "innerHTML = text" not in html
class TestSearchPage:
"""``GET /ui/search`` — the framework-free search UI (refs #578)."""
def test_serves_search_page(self):
r = client.get("/ui/search")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
html = r.text
assert "Library Search" in html
# the form: query box + every filter the API accepts
assert 'id="search"' in html
assert 'id="q"' in html
for field in ("collection", "kind", "docket", "year"):
assert 'id="%s"' % field in html
for value in sorted(_COLLECTION_CHOICES):
assert 'value="%s"' % value in html
for value in sorted(_KIND_CHOICES):
assert 'value="%s"' % value in html
# llm: tags aren't indexed yet — a placeholder, not a dead filter
assert "#575" in html and 'id="tag"' not in html
def test_trailing_slash_also_serves_the_page(self):
"""The headless probe's readiness check appends "/"; a 307 there
would read as "service not responding"."""
r = client.get("/ui/search/")
assert r.status_code == 200
assert "Library Search" in r.text
def test_renders_results_similar_and_pagination(self):
html = client.get("/ui/search").text
# results: label link, snippet, docket/comment id, similar
assert "function hitEl(r)" in html
assert "r.snippet" in html and "r.docket" in html and "r.comment_id" in html
assert "if (r.url)" in html # no empty href for an unlinked label
# /similar is keyed by the bib item key the result carries
assert "function showSimilar" in html
assert "r.item_key" in html
assert "'/similar/' + encodeURIComponent(key)" in html
# pagination + shareable URL
assert 'id="prev"' in html and 'id="next"' in html
assert "offset += LIMIT" in html
assert "window.history.pushState" in html
assert "new URLSearchParams(window.location.search)" in html
# loading + error states, with the API's own 400 detail
assert "'Searching…'" in html
assert "body.detail" in html
assert "setStatus('Error: ' + e.message, true)" in html
def test_no_external_script_or_style_urls(self):
"""Framework-free and CDN-free: behind oauth2-proxy the page has
no outbound access, so every byte of CSS/JS is inline."""
html = client.get("/ui/search").text
assert re.search(r"<script[^>]*\ssrc=", html) is None
assert re.search(r"<link[^>]*stylesheet", html) is None
assert "fonts.googleapis.com" not in html
# no absolute off-site fetch at all (the favicon is the platform's
# own, protocol-relative, host)
assert "http://" not in html and "https://" not in html
# API data never reaches innerHTML — nothing is ever assigned to it
assert re.search(r"innerHTML\s*=", html) is None
def test_nav_links_between_the_two_pages_resolve(self):
search_html = client.get("/ui/search").text
chat_html = client.get("/").text
assert '<a class="nav" href="/">Chat</a>' in search_html
assert '<a class="nav" href="/ui/search">Search</a>' in chat_html
assert client.get("/").status_code == 200
assert client.get("/ui/search").status_code == 200
class TestWhoami:
def test_reads_forwarded_header(self):
r = client.get("/whoami", headers={"X-Auth-Request-User": "kert"})