Files
Chris CoutinhoandClaude Opus 4.8 973f80e7b9 feat(usage): rename metrics → tokens_embedded/pages_embedded + export token cost to Prometheus
Billing product model finalized (Deck #281): bill pages externally, record
tokens internally. Rename the data-plane metric literals to match the now-
canonical contract (Deck #284) — the control plane's METRIC_EVENT_NAMES is
already renamed, so the old names would be unmapped and never sync to Stripe.

Rename (values unchanged):
- embeddings_queries → tokens_embedded (value = real token count, already
  emitted by this PR; the unit upstream providers bill on).
- pages_chunks → pages_embedded (value kept as len(chunk_texts) interim;
  TODO(#282): real normalized "pages indexed" count — real pages for paginated
  types, chars/tokens-per-page constant otherwise — is deferred to the
  instrumentation card, this only lands the name/contract).
- All literals, log strings, docstrings, comments, the migration comment, and
  tests renamed; grep confirms zero old strings remain.

Observability (new): export embedding token cost to Prometheus as
astrolabe_embedding_tokens_total{provider,operation} (operation = index|query)
so the billed cost unit is visible in Grafana, not just the per-tenant billing
DB. Dedicated counter (doesn't inflate the existing chunk/request metrics) and
always-on (independent of USAGE_METERING_ENABLED, so OSS/self-host gets it).
Wired on both the indexing batch embed and the search query embed (query inside
the per-request cache-miss branch, so reused embeddings aren't double-counted).

Note: the rename orphans any pre-existing embeddings_queries/pages_chunks rows
in tenant app DBs (CP no longer maps them) — acceptable; pipeline is inert with
throwaway dev/sandbox data.

Deck #284 (folded into PR #875).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:17:53 +02:00

127 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for BM25 hybrid search algorithm."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from qdrant_client import models
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
@pytest.mark.unit
def test_bm25_hybrid_initialization_default():
"""Test BM25HybridSearchAlgorithm initializes with default RRF fusion."""
algo = BM25HybridSearchAlgorithm()
assert algo.score_threshold == 0.0
assert algo.fusion == models.Fusion.RRF
assert algo.fusion_name == "rrf"
assert algo.name == "bm25_hybrid"
@pytest.mark.unit
def test_bm25_hybrid_initialization_with_rrf():
"""Test BM25HybridSearchAlgorithm initializes with explicit RRF fusion."""
algo = BM25HybridSearchAlgorithm(score_threshold=0.5, fusion="rrf")
assert algo.score_threshold == 0.5
assert algo.fusion == models.Fusion.RRF
assert algo.fusion_name == "rrf"
@pytest.mark.unit
def test_bm25_hybrid_initialization_with_dbsf():
"""Test BM25HybridSearchAlgorithm initializes with DBSF fusion."""
algo = BM25HybridSearchAlgorithm(score_threshold=0.7, fusion="dbsf")
assert algo.score_threshold == 0.7
assert algo.fusion == models.Fusion.DBSF
assert algo.fusion_name == "dbsf"
@pytest.mark.unit
def test_bm25_hybrid_invalid_fusion_raises_error():
"""Test BM25HybridSearchAlgorithm raises ValueError for invalid fusion."""
with pytest.raises(ValueError) as exc_info:
BM25HybridSearchAlgorithm(fusion="invalid")
assert "Invalid fusion algorithm 'invalid'" in str(exc_info.value)
assert "Must be 'rrf' or 'dbsf'" in str(exc_info.value)
@pytest.mark.unit
def test_bm25_hybrid_requires_vector_db():
"""Test BM25HybridSearchAlgorithm reports it requires vector database."""
algo = BM25HybridSearchAlgorithm()
assert algo.requires_vector_db is True
@pytest.fixture
def patched_search(monkeypatch):
"""Stub the embedding / BM25 / Qdrant deps of search() and return the
embed_with_usage mock so tests can assert how often the query was embedded."""
embed = AsyncMock(return_value=([0.1, 0.2, 0.3], 7))
svc = MagicMock()
svc.embed_with_usage = embed
monkeypatch.setattr(
"nextcloud_mcp_server.search.bm25_hybrid.get_embedding_service", lambda: svc
)
bm25 = MagicMock()
bm25.encode_async = AsyncMock(return_value={"indices": [1], "values": [0.5]})
monkeypatch.setattr(
"nextcloud_mcp_server.search.bm25_hybrid.get_bm25_service",
AsyncMock(return_value=bm25),
)
qdrant = MagicMock()
empty = MagicMock()
empty.points = []
qdrant.query_points = AsyncMock(return_value=empty)
monkeypatch.setattr(
"nextcloud_mcp_server.search.bm25_hybrid.get_qdrant_client",
AsyncMock(return_value=qdrant),
)
settings = MagicMock()
settings.get_collection_name.return_value = "test_collection"
settings.get_embedding_provider_family.return_value = "mistral"
monkeypatch.setattr(
"nextcloud_mcp_server.search.bm25_hybrid.get_settings", lambda: settings
)
monkeypatch.setattr(
"nextcloud_mcp_server.search.bm25_hybrid.build_base_filter_conditions",
lambda **kwargs: [],
)
return embed
@pytest.mark.unit
async def test_query_embedded_and_metered_once_across_doc_types(patched_search):
"""nc_semantic_search calls search() once per doc_type on one instance with
the same query; the dense embedding (and its billed token count) must be
computed exactly once, not once per type."""
embed = patched_search
algo = BM25HybridSearchAlgorithm()
for dtype in ("note", "file", "deck_card"):
await algo.search(query="hello", user_id="alice", doc_type=dtype)
assert embed.await_count == 1 # embedded once, not 3×
assert (
algo.query_token_count == 7
) # single query's token count, not summed/overwritten
assert algo.query_embedding == [0.1, 0.2, 0.3]
@pytest.mark.unit
async def test_different_query_invalidates_cache(patched_search):
"""A different query string re-embeds (and re-meters)."""
embed = patched_search
algo = BM25HybridSearchAlgorithm()
await algo.search(query="hello", user_id="alice")
await algo.search(query="world", user_id="alice")
assert embed.await_count == 2