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"})