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(
{intl.formatMessage({ id: "models_search_prompt_empty",