diff --git a/nextcloud_mcp_server/providers/ollama.py b/nextcloud_mcp_server/providers/ollama.py index 57f8388c..b541750d 100644 --- a/nextcloud_mcp_server/providers/ollama.py +++ b/nextcloud_mcp_server/providers/ollama.py @@ -159,6 +159,11 @@ class OllamaProvider(Provider): data = response.json() all_embeddings.extend(data["embeddings"]) + # Cache the dimension inline (mirrors OpenAI/Mistral) so it is set + # via any embed path, not only an explicit _detect_dimension() call. + if self._dimension is None and data["embeddings"]: + self._dimension = len(data["embeddings"][0]) + prompt_eval = data.get("prompt_eval_count") total_tokens += ( round(prompt_eval) diff --git a/nextcloud_mcp_server/search/bm25_hybrid.py b/nextcloud_mcp_server/search/bm25_hybrid.py index 08fb64ba..7c6f8c51 100644 --- a/nextcloud_mcp_server/search/bm25_hybrid.py +++ b/nextcloud_mcp_server/search/bm25_hybrid.py @@ -56,9 +56,13 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): self.score_threshold = score_threshold self.fusion = models.Fusion.RRF if fusion == "rrf" else models.Fusion.DBSF self.fusion_name = fusion - # The query string whose dense embedding is cached in - # ``self.query_embedding`` — lets repeated search() calls on this - # per-request instance (the doc_types loop) reuse one embedding. + # Per-request query-embedding cache. ``_embedded_query`` is the query + # string whose dense embedding is held in ``query_embedding`` — repeated + # search() calls on this instance (the doc_types loop) reuse it. These + # shadow the class-level defaults on SearchAlgorithm; set here so all + # three cache fields are instance attributes from construction. + self.query_embedding: list[float] | None = None + self.query_token_count: int | None = None self._embedded_query: str | None = None @property diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index 952a46e2..c7524833 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -70,7 +70,9 @@ async def record_search_usage( estimated) — the unit upstream providers bill on, and the same metric the indexing path records for chunk embeddings (Deck #67). ``nc_semantic_search`` and ``nc_semantic_search_answer`` (which reuses it) both flow through here — - do not add a second hook. + do not add a second hook. ``nc_semantic_search_answer`` exposes no + ``doc_types`` parameter, so its searches always meter with + ``doc_types=None``. Best-effort and flag-gated: a metering failure is logged and never breaks the search. Unlike the indexing path's chunk-count guard, a 0-token query is diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index a44efbef..adeeddbb 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -153,6 +153,10 @@ async def record_indexing_usage( store = await UsageEventStore.shared() # enabled=True: the guard above already confirmed the flag, so the store # skips a second uncached Settings build per record (ADR-024). + # record_usage_event swallows its own write failures, so the two records + # are independent; if pages_chunks somehow raised mid-way, embeddings_- + # queries would be skipped, leaving an unmatched pages_chunks row — + # acceptable under the (day, metric) SUM-aggregation billing model. await store.record_usage_event( metric="pages_chunks", value=chunk_count, metadata=metadata, enabled=True ) diff --git a/tests/unit/server/test_semantic_metering.py b/tests/unit/server/test_semantic_metering.py index e93a79a0..0b581767 100644 --- a/tests/unit/server/test_semantic_metering.py +++ b/tests/unit/server/test_semantic_metering.py @@ -75,6 +75,21 @@ async def test_none_token_count_records_zero(store_spy): assert kwargs["metadata"]["doc_types"] is None +@pytest.mark.unit +async def test_empty_doc_types_normalizes_to_null(store_spy): + """An empty doc_types list normalizes to None, same as a None input, so a + metadata->'doc_types' IS NULL query counts the all-types case consistently.""" + await semantic.record_search_usage( + enabled=True, + user_id="alice", + fusion="rrf", + doc_types=[], + token_count=5, + ) + kwargs = store_spy.record_usage_event.await_args.kwargs + assert kwargs["metadata"]["doc_types"] is None + + @pytest.mark.unit async def test_doc_types_metadata_is_bounded(store_spy): """A large doc_types list is truncated to the metadata cap."""