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.
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""llm.api — FastAPI chat app."""
|
|
|
|
import re
|
|
from unittest.mock import patch
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from llm.api import _COLLECTION_CHOICES, _KIND_CHOICES, app
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
class TestHealth:
|
|
def test_ok(self):
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"status": "ok"}
|
|
|
|
|
|
class TestIndex:
|
|
def test_serves_chat_page(self):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert "text/html" in r.headers["content-type"]
|
|
assert "Library Chat" in r.text
|
|
assert 'id="form"' in r.text
|
|
|
|
def test_serves_chat_page_with_valuation_renderer(self):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
html = r.text
|
|
assert "function renderValuation" in html
|
|
assert "ev.type === 'valuation'" in html
|
|
assert "table.valuation" in html
|
|
# the money on screen is national and unadjusted — say so
|
|
assert "no geographic (GPCI) adjustment" in html
|
|
assert "createCaption" in html
|
|
# notes explain an unpaid status and which CF is shown
|
|
assert "r.status_note" in html and "r.cf_note" in html
|
|
assert "'cf', 'CF (non-APM)'" in html
|
|
# a citation without a URL is not an empty <a href="">
|
|
assert "if (p.url)" in html
|
|
|
|
def test_serves_chat_page_with_lineage_renderer(self):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
html = r.text
|
|
assert "function renderLineage" in html
|
|
assert "ev.type === 'lineage'" in html
|
|
assert "table.lineage" in html
|
|
assert 'id="mode"' in html
|
|
assert "tr.unanchored" in html
|
|
assert "ul.className = 'elements'" in html
|
|
assert "ul.className = 'guidance'" in html
|
|
# mode is sent to the API and echoed back on the sources meta line
|
|
assert "mode" in html and "ev.mode === 'timeline'" in html
|
|
|
|
def test_serves_chat_page_with_markdown_and_cite_links(self):
|
|
"""The answer is painted as markdown (headings/lists/bold, never
|
|
raw "## …"), and a cited [label] whose URL arrived on the
|
|
lineage/valuation/sources events is an <a class="cite"> jump
|
|
link to the FR paragraph / eCFR section; the answer is repainted
|
|
when the sources event completes the label→url map."""
|
|
html = client.get("/").text
|
|
assert "function paintInline" in html
|
|
assert "function paint(el, text, links)" in html
|
|
assert "function collectLinks" in html
|
|
assert "a.cite" in html
|
|
assert (
|
|
"collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links)"
|
|
in html
|
|
)
|
|
assert "document.createElement('h' + level)" in html
|
|
# model text never reaches innerHTML
|
|
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"})
|
|
assert r.json() == {"user": "kert"}
|
|
|
|
def test_empty_when_absent(self):
|
|
r = client.get("/whoami")
|
|
assert r.json() == {"user": ""}
|
|
|
|
|
|
class TestChat:
|
|
def test_empty_question_400(self):
|
|
r = client.post("/chat", json={"question": " "})
|
|
assert r.status_code == 400
|
|
|
|
@patch("llm.rag.stream_answer")
|
|
def test_streams_sse_events(self, mock_stream):
|
|
mock_stream.return_value = iter(
|
|
[
|
|
{"type": "token", "text": "Hi"},
|
|
{"type": "sources", "sources": []},
|
|
{"type": "done"},
|
|
]
|
|
)
|
|
r = client.post("/chat", json={"question": "hello"})
|
|
assert r.status_code == 200
|
|
assert "text/event-stream" in r.headers["content-type"]
|
|
body = r.text
|
|
assert 'data: {"type": "token", "text": "Hi"}' in body
|
|
assert '"type": "done"' in body
|
|
|
|
@patch("llm.rag.stream_answer")
|
|
def test_error_becomes_event_not_500(self, mock_stream):
|
|
mock_stream.side_effect = RuntimeError("ollama down")
|
|
r = client.post("/chat", json={"question": "hello"})
|
|
assert r.status_code == 200
|
|
assert '"type": "error"' in r.text
|
|
assert "ollama down" in r.text
|
|
|
|
|
|
class TestChatSince:
|
|
@patch("llm.rag.stream_answer")
|
|
def test_since_forwarded(self, mock_stream):
|
|
mock_stream.return_value = iter([{"type": "done"}])
|
|
r = client.post("/chat", json={"question": "q", "since": "2025-09-01"})
|
|
assert r.status_code == 200
|
|
assert mock_stream.call_args.kwargs["since"] == "2025-09-01"
|
|
|
|
def test_bad_since_400(self):
|
|
r = client.post("/chat", json={"question": "q", "since": "last year"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
class TestChatMode:
|
|
@patch("llm.rag.stream_answer")
|
|
def test_mode_forwarded(self, mock_stream):
|
|
mock_stream.return_value = iter([{"type": "done"}])
|
|
r = client.post("/chat", json={"question": "q", "mode": "timeline"})
|
|
assert r.status_code == 200
|
|
assert mock_stream.call_args.kwargs["mode"] == "timeline"
|
|
|
|
@patch("llm.rag.stream_answer")
|
|
def test_mode_defaults_to_auto(self, mock_stream):
|
|
mock_stream.return_value = iter([{"type": "done"}])
|
|
r = client.post("/chat", json={"question": "q"})
|
|
assert r.status_code == 200
|
|
assert mock_stream.call_args.kwargs["mode"] == "auto"
|
|
|
|
def test_bad_mode_400(self):
|
|
r = client.post("/chat", json={"question": "q", "mode": "x"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def _cfg(**kw):
|
|
from llm.config import LlmConfig
|
|
|
|
base = dict(
|
|
ollama_hosts=("http://h1:11434", "http://h2:11434"),
|
|
host_vram={"http://h1:11434": 12, "http://h2:11434": 24},
|
|
embed_model="e",
|
|
instruct_model="chat",
|
|
embed_dim=768,
|
|
build_ann_index=False,
|
|
pg_host="x",
|
|
pg_port=5432,
|
|
pg_db="llm",
|
|
pg_user="llm",
|
|
)
|
|
base.update(kw)
|
|
return LlmConfig(**base)
|
|
|
|
|
|
class TestStartup:
|
|
"""#699 ruling B3: the replica is warmed at boot, not on the chat's
|
|
first valuation lookup. ``with TestClient(app) as c:`` is required to
|
|
actually trigger FastAPI's startup event — a bare ``TestClient(app)``
|
|
(as ``client`` above, module-level) never sends the ASGI lifespan
|
|
``startup`` message."""
|
|
|
|
@patch("llm.config.load")
|
|
def test_startup_event_warms_the_replica(self, mock_load):
|
|
mock_load.return_value = _cfg()
|
|
with patch("llm.evidence.warm") as mock_warm:
|
|
with TestClient(app):
|
|
pass
|
|
mock_warm.assert_called_once_with(mock_load.return_value)
|
|
|
|
|
|
class TestHosts:
|
|
@patch("llm.pool.pick_model", return_value="big")
|
|
@patch("llm.pool.HostPool.check", return_value=["http://h2:11434"])
|
|
@patch("llm.pool.HostPool.status")
|
|
@patch("llm.config.load")
|
|
def test_reports_fleet_and_pick(self, mock_load, mock_status, _check, _pm):
|
|
mock_load.return_value = _cfg()
|
|
mock_status.return_value = [
|
|
{"host": "http://h2:11434", "vram_gb": 24.0, "models": ["chat:latest"]}
|
|
]
|
|
r = client.get("/hosts")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["generation"] == {"host": "http://h2:11434", "model": "big"}
|
|
assert [(h["host"], h["live"]) for h in body["hosts"]] == [
|
|
("http://h1:11434", False),
|
|
("http://h2:11434", True),
|
|
]
|
|
|
|
@patch("llm.pool.HostPool.check", side_effect=RuntimeError("no Ollama host"))
|
|
@patch("llm.config.load")
|
|
def test_no_live_hosts(self, mock_load, _check):
|
|
mock_load.return_value = _cfg(ollama_hosts=("http://h1:11434",))
|
|
r = client.get("/hosts")
|
|
assert r.json()["generation"] is None
|
|
assert "no Ollama host" in r.json()["error"]
|
|
|
|
|
|
class TestSearchEndpoint:
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_empty_q_400(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/search", params={"q": " "})
|
|
assert r.status_code == 400
|
|
mock_search.assert_not_called()
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_unknown_collection_400(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/search", params={"q": "ccm", "collection": "bogus"})
|
|
assert r.status_code == 400
|
|
mock_search.assert_not_called()
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_unknown_kind_400(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/search", params={"q": "ccm", "kind": "bogus"})
|
|
assert r.status_code == 400
|
|
mock_search.assert_not_called()
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_limit_out_of_range_400(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
assert client.get("/search", params={"q": "ccm", "limit": 0}).status_code == 400
|
|
assert (
|
|
client.get("/search", params={"q": "ccm", "limit": 51}).status_code == 400
|
|
)
|
|
mock_search.assert_not_called()
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_negative_offset_400(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/search", params={"q": "ccm", "offset": -1})
|
|
assert r.status_code == 400
|
|
mock_search.assert_not_called()
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_filtered_search_scopes_by_docket_and_returns_shape(
|
|
self, mock_load, mock_search
|
|
):
|
|
mock_load.return_value = _cfg()
|
|
src = {
|
|
"id": "CMS-2023-0121-1",
|
|
"label": "CMS-2023-0121-1",
|
|
"kind": "comment",
|
|
"url": "u",
|
|
"title": "t",
|
|
"date": "2024-01-01",
|
|
"docket": "CMS-2023-0121",
|
|
"comment_id": "CMS-2023-0121-1",
|
|
"snippet": "s",
|
|
"score": 0.9,
|
|
"item_key": "K1",
|
|
"p_id": "",
|
|
"seq": "1",
|
|
"section": "",
|
|
"distance": 0.1,
|
|
}
|
|
mock_search.return_value = [src]
|
|
r = client.get(
|
|
"/search",
|
|
params={
|
|
"q": "chronic care management",
|
|
"docket": "CMS-2023-0121",
|
|
"collection": "comments",
|
|
"limit": 10,
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["query"] == "chronic care management"
|
|
assert body["filters"] == {"docket": "CMS-2023-0121"}
|
|
assert body["total"] == 1
|
|
assert body["results"] == [src]
|
|
assert all(s["docket"] == "CMS-2023-0121" for s in body["results"])
|
|
kwargs = mock_search.call_args.kwargs
|
|
assert kwargs["collection"] == "comments"
|
|
assert kwargs["filters"] == {"docket": "CMS-2023-0121"}
|
|
assert kwargs["limit"] == 10
|
|
assert kwargs["offset"] == 0
|
|
|
|
@patch("llm.search.search")
|
|
@patch("llm.config.load")
|
|
def test_defaults(self, mock_load, mock_search):
|
|
mock_load.return_value = _cfg()
|
|
mock_search.return_value = []
|
|
r = client.get("/search", params={"q": "q"})
|
|
assert r.status_code == 200
|
|
kwargs = mock_search.call_args.kwargs
|
|
assert kwargs["collection"] == "all"
|
|
assert kwargs["filters"] == {}
|
|
assert kwargs["limit"] == 10
|
|
assert kwargs["offset"] == 0
|
|
|
|
|
|
class TestSimilarEndpoint:
|
|
@patch("llm.search.similar")
|
|
@patch("llm.config.load")
|
|
def test_unknown_collection_400(self, mock_load, mock_similar):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/similar/K1", params={"collection": "bogus"})
|
|
assert r.status_code == 400
|
|
mock_similar.assert_not_called()
|
|
|
|
@patch("llm.search.similar")
|
|
@patch("llm.config.load")
|
|
def test_limit_out_of_range_400(self, mock_load, mock_similar):
|
|
mock_load.return_value = _cfg()
|
|
assert client.get("/similar/K1", params={"limit": 0}).status_code == 400
|
|
assert client.get("/similar/K1", params={"limit": 51}).status_code == 400
|
|
mock_similar.assert_not_called()
|
|
|
|
@patch("llm.search.similar", return_value=None)
|
|
@patch("llm.config.load")
|
|
def test_unknown_key_404(self, mock_load, mock_similar):
|
|
mock_load.return_value = _cfg()
|
|
r = client.get("/similar/NOPE")
|
|
assert r.status_code == 404
|
|
|
|
@patch("llm.search.similar")
|
|
@patch("llm.config.load")
|
|
def test_returns_neighbours_shape(self, mock_load, mock_similar):
|
|
mock_load.return_value = _cfg()
|
|
src = {
|
|
"id": "91 FR 43949 ¶1",
|
|
"label": "91 FR 43949 ¶1",
|
|
"kind": "rule",
|
|
"url": "u",
|
|
"title": "t",
|
|
"date": "2024-01-01",
|
|
"docket": "",
|
|
"comment_id": "",
|
|
"snippet": "s",
|
|
"score": 0.8,
|
|
"item_key": "R1",
|
|
"p_id": "1",
|
|
"seq": "",
|
|
"section": "",
|
|
"distance": 0.2,
|
|
}
|
|
mock_similar.return_value = [src]
|
|
r = client.get("/similar/K1", params={"limit": 5})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["key"] == "K1"
|
|
assert body["total"] == 1
|
|
assert body["results"] == [src]
|
|
kwargs = mock_similar.call_args.kwargs
|
|
assert kwargs["collection"] == "all"
|
|
assert kwargs["limit"] == 5
|