diff --git a/docs/architecture/local-models/catalog.md b/docs/architecture/local-models/catalog.md index 4c6ef149ea..b6b7b93f8d 100644 --- a/docs/architecture/local-models/catalog.md +++ b/docs/architecture/local-models/catalog.md @@ -69,7 +69,7 @@ catalog/local/ audiocpp/ engine.py, manifest_fields.py, evidence.py, builds/, rows/, audio_folder/ onnxruntime/ engine.py, manifest_fields.py, rows.py, spec.py - service.py router.py schemas.py dependencies.py + service.py router.py schemas.py dependencies.py search_cache.py ``` [`engine.py`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/engine.py) is the one seam the service and the routes @@ -419,7 +419,8 @@ is gated, and **Vision** when its file names include a projector, by the same ru `builds/in_repo.py` uses. `full=true` returns every repo's file names, so this costs no request of its own. Each hit also carries `quantized_from`, from its `base_model:quantized:` tag, which the API returns and the row does not show. The -screen searches once a query has two characters and keeps results for 300 s. +screen searches 300 ms after a query has two characters, and the API keeps each +answer for 300 s. **Opening a repo reads its listing and no file**, about a second: the summary and the file tree, fetched together. Every build is listed smallest first with its diff --git a/surfsense_local/backend/modules/llm/catalog/local/search_cache.py b/surfsense_local/backend/modules/llm/catalog/local/search_cache.py index 50b380d4eb..0b5ba94419 100644 --- a/surfsense_local/backend/modules/llm/catalog/local/search_cache.py +++ b/surfsense_local/backend/modules/llm/catalog/local/search_cache.py @@ -2,7 +2,7 @@ Hugging Face allows 500 requests per 5 minutes; a burst of typing must not spend them. In memory and forgetful: a cached answer is a convenience, never -a record. Shaped like the install TicketStore, keyed on query and limit. +a record. Shaped like the install TicketStore. """ import time @@ -13,13 +13,22 @@ # The same window the renderer's search cache uses. TTL_SECONDS = 300.0 +# The API caps limit at 50, so the key does too: a larger limit asks for the +# same answer. +MAX_LIMIT = 50 + @dataclass(frozen=True) class _Entry: - hits: list[SearchHit] + hits: tuple[SearchHit, ...] stored_at: float +def _key(query: str, limit: int) -> tuple[str, int]: + # Hugging Face search ignores case, so the key does as well. + return (query.strip().casefold(), min(limit, MAX_LIMIT)) + + class SearchCache: def __init__(self, *, ttl_seconds: float = TTL_SECONDS) -> None: self._ttl = ttl_seconds @@ -29,8 +38,9 @@ def get( self, query: str, limit: int, *, now: float | None = None ) -> list[SearchHit] | None: self._expire(now) - entry = self._entries.get((query, limit)) - return entry.hits if entry is not None else None + entry = self._entries.get(_key(query, limit)) + # A fresh list per caller: nobody mutates another caller's answer. + return list(entry.hits) if entry is not None else None def put( self, @@ -41,8 +51,8 @@ def put( now: float | None = None, ) -> None: self._expire(now) - self._entries[(query, limit)] = _Entry( - hits, now if now is not None else time.monotonic() + self._entries[_key(query, limit)] = _Entry( + tuple(hits), now if now is not None else time.monotonic() ) def _expire(self, now: float | None) -> None: diff --git a/surfsense_local/backend/tests/unit/llm/catalog/local/test_search_cache.py b/surfsense_local/backend/tests/unit/llm/catalog/local/test_search_cache.py index 8672c81f11..d5b0def691 100644 --- a/surfsense_local/backend/tests/unit/llm/catalog/local/test_search_cache.py +++ b/surfsense_local/backend/tests/unit/llm/catalog/local/test_search_cache.py @@ -33,6 +33,36 @@ def test_a_different_query_is_not_served_from_the_cache() -> None: assert cache.get("qwen", 30, now=1.0) is None +def test_the_cache_key_ignores_case_and_surrounding_whitespace() -> None: + """Hugging Face search ignores case, so the cache does too.""" + cache = SearchCache(ttl_seconds=300) + cache.put("qwen", 30, [hit()], now=0.0) + + assert cache.get("QWEN", 30, now=1.0) == [hit()] + assert cache.get(" qwen ", 30, now=1.0) == [hit()] + + +def test_the_cache_key_clamps_the_limit_like_the_api() -> None: + """The API caps limit at 50, so a larger limit shares that entry.""" + cache = SearchCache(ttl_seconds=300) + cache.put("qwen", 100, [hit()], now=0.0) + + assert cache.get("qwen", 50, now=1.0) == [hit()] + assert cache.get("qwen", 30, now=1.0) is None + + +def test_a_caller_cannot_mutate_another_callers_cached_list() -> None: + """The cache stores an immutable copy; each get returns a fresh list.""" + cache = SearchCache(ttl_seconds=300) + cache.put("qwen", 30, [hit()], now=0.0) + + first = cache.get("qwen", 30, now=1.0) + assert first is not None + first.clear() + + assert cache.get("qwen", 30, now=2.0) == [hit()] + + def test_a_different_limit_is_not_served_from_the_cache() -> None: """The cache is keyed on the query and the limit.""" cache = SearchCache(ttl_seconds=300) diff --git a/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx b/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx index a03ae708bd..bf9a328303 100644 --- a/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx +++ b/surfsense_local/frontend/src/features/models/local/chat/download-chat-models.test.tsx @@ -691,6 +691,33 @@ describe("model catalog", () => { expect(searches[0]).toContain("q=qwen") }) + it("clears the results the moment the box is cleared", async () => { + // The old list must not linger under an empty box for the debounce window. + vi.stubGlobal( + "fetch", + serving(catalog(), (path) => { + if (path.startsWith("/llm/catalog/local/search?")) { + return Response.json({ results: [] }) + } + return null + }) + ) + const user = userEvent.setup() + + render() + const search = await screen.findByRole("searchbox", { + name: "Search all models", + }) + await user.type(search, "qwen") + // Let the debounce settle and the empty results render. + await screen.findByText(/no models match/i) + await user.click(screen.getByRole("button", { name: "Clear search" })) + + // The prompt returns at once; the stale list does not outlive the click. + expect(screen.getByText(/type to find a model/i)).toBeTruthy() + expect(screen.queryByText(/no models match/i)).toBeNull() + }) + it("explains that search is unavailable rather than erroring", async () => { // With egress off, curated and installed still work. That is the airgapped // product, not a degraded one. diff --git a/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx b/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx index 70db7d45e1..6a1426aa3d 100644 --- a/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx +++ b/surfsense_local/frontend/src/features/models/local/chat/model-search.tsx @@ -348,7 +348,7 @@ export function ModelSearch({ prompt to a spinner to a list and back. Results scroll inside it rather than stretching the page, as a server's models do. */}
- {debounced.length <= 1 ? ( + {trimmed.length <= 1 ? (

{intl.formatMessage({ id: "models_search_prompt_empty",