From 2ba846b39afa64bf8b3f80ea8f9c6e74e6c69050 Mon Sep 17 00:00:00 2001 From: kert Date: Fri, 11 Sep 2026 18:48:19 -0400 Subject: [PATCH] feat(llm): search web UI at /ui/search (refs #578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/` and `/?file=` 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. --- src/llm/api.py | 18 +- src/llm/web/chat.html | 5 + src/llm/web/search.html | 379 ++++++++++++++++++++++++++++++++++++++++ tests/llm/test_api.py | 73 +++++++- 4 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 src/llm/web/search.html diff --git a/src/llm/api.py b/src/llm/api.py index f0eea0c..f4edb89 100644 --- a/src/llm/api.py +++ b/src/llm/api.py @@ -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).""" diff --git a/src/llm/web/chat.html b/src/llm/web/chat.html index 9b528bc..c08ba53 100644 --- a/src/llm/web/chat.html +++ b/src/llm/web/chat.html @@ -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 @@

Library Chat

grounded in the indexed library — comments, FR rules, references + Search
diff --git a/src/llm/web/search.html b/src/llm/web/search.html new file mode 100644 index 0000000..284dbcc --- /dev/null +++ b/src/llm/web/search.html @@ -0,0 +1,379 @@ + + + + + + Library Search + + + + +
+

Library Search

+ comments, FR rules and references — metadata-filtered similarity search + Chat + +
+
+ +
+
+ +
Search the indexed library. Results are ranked by + embedding distance; filter by collection, chunk kind, docket or year, and + follow “similar” to find neighbours of a cited item.
+
+ + + + diff --git a/tests/llm/test_api.py b/tests/llm/test_api.py index 26c0aba..1b220c7 100644 --- a/tests/llm/test_api.py +++ b/tests/llm/test_api.py @@ -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"]*\ssrc=", html) is None + assert re.search(r"]*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 'Chat' in search_html + assert 'Search' 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"})