mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-08 04:00:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0646601219 | ||
|
|
ad0fbcf235 |
@@ -160,7 +160,7 @@ No installation or API key required. FAISS requires `pip install faiss-cpu`.
|
||||
<Tab title="Pinecone">
|
||||
|
||||
```bash
|
||||
pip install "semantica[vectorstore-pinecone]"
|
||||
pip install "semantica[pinecone]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -178,7 +178,7 @@ store = VectorStore(
|
||||
<Tab title="Weaviate">
|
||||
|
||||
```bash
|
||||
pip install "semantica[vectorstore-weaviate]"
|
||||
pip install "semantica[weaviate]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -194,7 +194,7 @@ store = VectorStore(
|
||||
<Tab title="Qdrant">
|
||||
|
||||
```bash
|
||||
pip install "semantica[vectorstore-qdrant]"
|
||||
pip install "semantica[qdrant]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -210,7 +210,7 @@ store = VectorStore(
|
||||
<Tab title="PgVector">
|
||||
|
||||
```bash
|
||||
pip install "semantica[vectorstore-pgvector]"
|
||||
pip install "semantica[pgvector]"
|
||||
```
|
||||
|
||||
```python
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@ graph-all = [
|
||||
tripletstore-oxigraph = ["pyoxigraph>=0.5.0"]
|
||||
|
||||
# ---- Vector Store Backends ----
|
||||
vectorstore-qdrant = ["qdrant-client>=1.0.0"]
|
||||
vectorstore-qdrant = ["qdrant-client>=1.10.0"]
|
||||
vectorstore-weaviate = ["weaviate-client>=4.0.0"]
|
||||
vectorstore-pinecone = ["pinecone>=3.0.0"]
|
||||
vectorstore-milvus = ["pymilvus>=2.0.0"]
|
||||
|
||||
+14
-12
@@ -4764,10 +4764,15 @@ def mcp_list_tools(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
# Same catalog the server exposes via tools/list, so `list-tools`
|
||||
# and `mcp start` can't drift (issue #1355).
|
||||
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
|
||||
tools = [t["name"] for t in TOOL_DEFINITIONS]
|
||||
try:
|
||||
from semantica_mcp.mcp.tools import __all__ as tools
|
||||
except ImportError:
|
||||
tools = [
|
||||
"extract_entities", "extract_relations", "build_graph",
|
||||
"query_graph", "get_graph_analytics", "run_reasoning",
|
||||
"record_decision", "get_decisions", "export_graph",
|
||||
"validate_shacl", "get_provenance", "embed_and_search",
|
||||
]
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho({"tools": list(tools)})
|
||||
else:
|
||||
@@ -4800,15 +4805,12 @@ def mcp_call(cli_ctx: CLIContext, tool_name: str, args: str, local_json: bool) -
|
||||
tool_args = json.loads(args)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise click.ClickException(f"Invalid JSON in --args: {exc}") from exc
|
||||
if not isinstance(tool_args, dict):
|
||||
raise click.ClickException("--args must be a JSON object")
|
||||
# Dispatch through the same server `mcp start` spawns; its session
|
||||
# module never defined MCPSession (issue #1355).
|
||||
from semantica_mcp.mcp.server import UnknownToolError, call_tool
|
||||
try:
|
||||
result = call_tool(tool_name, tool_args)
|
||||
except UnknownToolError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
from semantica_mcp.mcp.session import MCPSession
|
||||
session = MCPSession(config=cli_ctx.config.to_dict())
|
||||
result = session.call_tool(tool_name, **tool_args)
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"MCP module not available: {exc}") from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho(result if isinstance(result, (dict, list)) else {"result": str(result)})
|
||||
else:
|
||||
|
||||
@@ -153,9 +153,12 @@ class QdrantCollection:
|
||||
raise ProcessingError("Qdrant not available")
|
||||
|
||||
try:
|
||||
search_results = self.client.search(
|
||||
# qdrant-client >=1.10.0: query_points() supersedes the removed search().
|
||||
# It returns a QueryResponse whose .points attribute is a list of
|
||||
# ScoredPoint objects (id, score, payload, …).
|
||||
response = self.client.query_points(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_vector.tolist(),
|
||||
query=query_vector.tolist(),
|
||||
limit=limit,
|
||||
query_filter=query_filter,
|
||||
with_payload=True,
|
||||
@@ -164,19 +167,19 @@ class QdrantCollection:
|
||||
)
|
||||
|
||||
results = []
|
||||
for result in search_results:
|
||||
for point in response.points:
|
||||
results.append(
|
||||
{
|
||||
"id": result.id,
|
||||
"id": point.id,
|
||||
# See pinecone_store.py PineconeIndex.search_vectors for why
|
||||
# this uses x/(1+|x|) rather than clamping distance-to-zero:
|
||||
# Qdrant's Dot distance metric is unbounded, and the old
|
||||
# clamped formula collapsed every score >= 1.0 to 1.0.
|
||||
"score": (
|
||||
float(result.score) / (1.0 + abs(float(result.score))) + 1.0
|
||||
float(point.score) / (1.0 + abs(float(point.score))) + 1.0
|
||||
)
|
||||
/ 2.0,
|
||||
"metadata": result.payload or {},
|
||||
"metadata": point.payload or {},
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
}
|
||||
@@ -695,9 +698,31 @@ class QdrantStore:
|
||||
collection_info = self.client.get_collection(
|
||||
self.collection.collection_name
|
||||
)
|
||||
# vectors_count was removed in qdrant-client 1.16.0.
|
||||
# When it is absent, only infer the total from points_count if we
|
||||
# can confirm the collection uses a single unnamed vector per point
|
||||
# (VectorParams). Named/multi-vector collections (dict of VectorParams)
|
||||
# have an unknown multiplier, so return None rather than a wrong value.
|
||||
# get_collection() accepts externally-created collections without schema
|
||||
# validation, so the schema must be inspected at stats time.
|
||||
vectors_count_fallback: Optional[int]
|
||||
try:
|
||||
vectors_cfg = collection_info.config.params.vectors
|
||||
vectors_count_fallback = (
|
||||
collection_info.points_count
|
||||
if QDRANT_AVAILABLE and isinstance(vectors_cfg, VectorParams)
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
vectors_count_fallback = None
|
||||
|
||||
return {
|
||||
"points_count": collection_info.points_count,
|
||||
"vectors_count": collection_info.vectors_count,
|
||||
"vectors_count": getattr(
|
||||
collection_info,
|
||||
"vectors_count",
|
||||
vectors_count_fallback,
|
||||
),
|
||||
"status": str(collection_info.status)
|
||||
if hasattr(collection_info, "status")
|
||||
else "unknown",
|
||||
|
||||
@@ -51,27 +51,6 @@ _INTERNAL_ERROR = -32603
|
||||
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
|
||||
|
||||
|
||||
class UnknownToolError(Exception):
|
||||
"""Raised by :func:`call_tool` when the tool name is not in the catalog.
|
||||
|
||||
A dedicated type (rather than ``KeyError``) so callers can distinguish
|
||||
a bad tool name from a ``KeyError`` raised inside a handler indexing a
|
||||
required argument (e.g. ``args["category"]``).
|
||||
"""
|
||||
|
||||
|
||||
def call_tool(name: str, arguments: dict) -> dict:
|
||||
"""Invoke a tool in-process by name and return its raw result dict.
|
||||
|
||||
Shared by the JSON-RPC ``tools/call`` handler and ``semantica mcp call``
|
||||
(issue #1355), so both expose exactly the same tool set.
|
||||
"""
|
||||
tool = _TOOL_INDEX.get(name)
|
||||
if tool is None:
|
||||
raise UnknownToolError(f"Unknown tool: {name}")
|
||||
return tool["_handler"](arguments)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,10 +85,12 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict:
|
||||
name = params.get("name", "")
|
||||
args = params.get("arguments", {}) or {}
|
||||
|
||||
tool = _TOOL_INDEX.get(name)
|
||||
if tool is None:
|
||||
return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {name}")
|
||||
|
||||
try:
|
||||
result = call_tool(name, args)
|
||||
except UnknownToolError as exc:
|
||||
return _err(req_id, _METHOD_NOT_FOUND, str(exc))
|
||||
result = tool["_handler"](args)
|
||||
except Exception as exc:
|
||||
log.exception("Tool %s raised an exception", name)
|
||||
# The exception's class name (e.g. "ValidationError", "TimeoutError")
|
||||
|
||||
+10
-39
@@ -2090,16 +2090,12 @@ class TestMCP:
|
||||
# Table renders correctly — at minimum the column header is present
|
||||
assert "Tool" in result.output or "tool" in result.output.lower()
|
||||
|
||||
def test_list_tools_reads_server_catalog(self, runner, monkeypatch):
|
||||
"""list-tools must read TOOL_DEFINITIONS (what the server serves via
|
||||
tools/list), not the module's ``__all__`` (issue #1355)."""
|
||||
import semantica_mcp.mcp.tools as tools_mod
|
||||
fake = [{"name": "fake_tool_from_catalog", "description": "", "inputSchema": {},
|
||||
"_handler": lambda a: {}}]
|
||||
monkeypatch.setattr(tools_mod, "TOOL_DEFINITIONS", fake)
|
||||
def test_list_tools_with_mock_shows_known_tools(self, runner, monkeypatch):
|
||||
fake_tools = _fake_module(__all__=["extract_entities", "query_graph"])
|
||||
monkeypatch.setitem(__import__("sys").modules, "semantica_mcp.mcp.tools", fake_tools)
|
||||
result = runner.invoke(cli_module.main, ["mcp", "list-tools"])
|
||||
_ok(result)
|
||||
assert "fake_tool_from_catalog" in result.output
|
||||
assert "extract_entities" in result.output
|
||||
|
||||
def test_list_tools_json(self, runner):
|
||||
result = runner.invoke(cli_module.main, ["mcp", "list-tools", "--json"])
|
||||
@@ -2142,39 +2138,14 @@ class TestMCP:
|
||||
err = json.loads(result.stderr)
|
||||
assert err["error"].startswith("Invalid JSON in --args")
|
||||
|
||||
def test_call_dispatches_through_packaged_server(self, runner):
|
||||
"""Regression for issue #1355: ``mcp call`` dispatches in-process through
|
||||
``semantica_mcp.mcp.server`` (the server ``mcp start`` spawns) instead
|
||||
of importing the nonexistent ``MCPSession``."""
|
||||
result = runner.invoke(
|
||||
cli_module.main, ["--json", "mcp", "call", "extract_entities"]
|
||||
)
|
||||
_ok(result)
|
||||
# Empty args short-circuit before heavy imports; reaching the
|
||||
# handler's own validation proves the dispatch path works.
|
||||
assert "text is required" in result.output
|
||||
|
||||
def test_call_unknown_tool_fails_cleanly(self, runner):
|
||||
result = runner.invoke(cli_module.main, ["mcp", "call", "no_such_tool"])
|
||||
def test_call_import_error_is_clean(self, runner):
|
||||
with patch("builtins.__import__", side_effect=lambda n, *a, **k: (
|
||||
(_ for _ in ()).throw(ImportError(n))
|
||||
if n.startswith("mcp") else __import__(n, *a, **k)
|
||||
)):
|
||||
result = runner.invoke(cli_module.main, ["mcp", "call", "extract_entities"])
|
||||
assert result.exit_code != 0
|
||||
assert "Traceback" not in result.output
|
||||
assert "Unknown tool" in result.output
|
||||
|
||||
def test_call_non_object_args_rejected(self, runner):
|
||||
result = runner.invoke(
|
||||
cli_module.main, ["mcp", "call", "extract_entities", "--args", "[1, 2]"]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Traceback" not in result.output
|
||||
assert "--args must be a JSON object" in result.output
|
||||
|
||||
def test_list_tools_json_matches_server_catalog(self, runner):
|
||||
"""The CLI catalog and the MCP server catalog must be the same list."""
|
||||
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
|
||||
result = runner.invoke(cli_module.main, ["mcp", "list-tools", "--json"])
|
||||
_ok(result)
|
||||
data = _json_output(result)
|
||||
assert data["tools"] == [t["name"] for t in TOOL_DEFINITIONS]
|
||||
|
||||
|
||||
# ─── services group (backward-compat wrapper) ─────────────────────────────────
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the shared in-process tool entry point (issue #1355).
|
||||
|
||||
``semantica_mcp.mcp.server.call_tool`` is the dispatch used by both the
|
||||
JSON-RPC ``tools/call`` handler and the ``semantica mcp call`` CLI command,
|
||||
so the two surfaces cannot expose different tool sets.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from semantica_mcp.mcp import server
|
||||
from semantica_mcp.mcp.server import UnknownToolError, _handle_tools_call, call_tool
|
||||
|
||||
|
||||
class TestCallTool(unittest.TestCase):
|
||||
|
||||
def test_known_tool_dispatches_to_handler(self):
|
||||
# Empty args hit extract_entities' own validation before any heavy
|
||||
# imports, which is enough to prove dispatch reached the handler.
|
||||
result = call_tool("extract_entities", {})
|
||||
self.assertEqual(result["error"], "text is required")
|
||||
|
||||
def test_unknown_tool_raises_unknown_tool_error(self):
|
||||
with self.assertRaises(UnknownToolError):
|
||||
call_tool("no_such_tool", {})
|
||||
|
||||
def test_unknown_tool_error_is_not_a_key_error(self):
|
||||
"""A handler's own KeyError (missing required arg) must remain
|
||||
distinguishable from an unknown tool name."""
|
||||
self.assertFalse(issubclass(UnknownToolError, KeyError))
|
||||
|
||||
|
||||
class TestToolsCallDispatch(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _tools_call(name, arguments):
|
||||
return _handle_tools_call(1, {"name": name, "arguments": arguments})
|
||||
|
||||
def test_unknown_tool_returns_method_not_found(self):
|
||||
response = self._tools_call("no_such_tool", {})
|
||||
self.assertEqual(response["error"]["code"], -32601)
|
||||
self.assertEqual(response["error"]["message"], "Unknown tool: no_such_tool")
|
||||
|
||||
def test_handler_key_error_is_internal_error_not_unknown_tool(self):
|
||||
def _boom(args):
|
||||
raise KeyError("category")
|
||||
|
||||
fake = {"name": "boom", "description": "", "inputSchema": {}, "_handler": _boom}
|
||||
with patch.dict(server._TOOL_INDEX, {"boom": fake}):
|
||||
response = self._tools_call("boom", {})
|
||||
self.assertEqual(response["error"]["code"], -32603)
|
||||
|
||||
def test_known_tool_returns_result_content(self):
|
||||
response = self._tools_call("extract_entities", {})
|
||||
self.assertIn("content", response["result"])
|
||||
self.assertTrue(response["result"]["isError"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -91,7 +91,7 @@ _INIT_REQUEST = _jsonrpc("initialize", 1, {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMCPStdioFramingContract(unittest.TestCase):
|
||||
"""Run 'python -m semantica_mcp.mcp' exactly as an MCP client would, over a real pipe.
|
||||
"""Run 'python -m mcp' exactly as an MCP client would, over a real pipe.
|
||||
|
||||
Each test sends a complete JSON-RPC session through stdin and asserts that
|
||||
every byte on stdout is valid JSON — catching the exact failure mode from
|
||||
@@ -102,7 +102,7 @@ class TestMCPStdioFramingContract(unittest.TestCase):
|
||||
|
||||
def _run(self, *requests: bytes) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "semantica_mcp.mcp"],
|
||||
[sys.executable, "-m", "mcp"],
|
||||
input=b"".join(requests),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Tests for QdrantCollection.search_points and QdrantStore.get_stats.
|
||||
|
||||
These cover the qdrant-client >=1.16.0 compatibility fixes:
|
||||
|
||||
1. search_points() must call client.query_points() (not the removed .search()),
|
||||
read ScoredPoints from response.points, and map them to the documented
|
||||
Semantica result shape.
|
||||
|
||||
2. get_stats() must not access vectors_count unconditionally; when the field
|
||||
is absent (qdrant-client >=1.16), it falls back to points_count for
|
||||
single-vector collections, and to None for named/multi-vector collections
|
||||
where the per-point vector count is unknown.
|
||||
|
||||
All tests drive the real implementation against a MagicMock client, following
|
||||
the established pattern in test_qdrant_store.py.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from semantica.utils.exceptions import ProcessingError
|
||||
from semantica.vector_store.qdrant_store import QdrantCollection, QdrantStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scored_point(point_id, score, payload=None):
|
||||
"""Build a stand-in for a qdrant_client ScoredPoint."""
|
||||
sp = MagicMock()
|
||||
sp.id = point_id
|
||||
sp.score = score
|
||||
sp.payload = payload
|
||||
return sp
|
||||
|
||||
|
||||
def _query_response(*scored_points):
|
||||
"""Build a stand-in for a qdrant_client QueryResponse."""
|
||||
qr = MagicMock()
|
||||
qr.points = list(scored_points)
|
||||
return qr
|
||||
|
||||
|
||||
def _collection_with_query_response(*scored_points):
|
||||
"""QdrantCollection whose client.query_points() returns the given points."""
|
||||
client = MagicMock()
|
||||
client.query_points.return_value = _query_response(*scored_points)
|
||||
return QdrantCollection(client, "test_collection")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QdrantCollection.search_points — API call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_calls_query_points_not_search():
|
||||
"""search_points() must call .query_points(), NOT the removed .search()."""
|
||||
collection = _collection_with_query_response()
|
||||
query = np.array([0.1, 0.2, 0.3, 0.4])
|
||||
|
||||
collection.search_points(query, limit=5)
|
||||
|
||||
collection.client.query_points.assert_called_once()
|
||||
collection.client.search.assert_not_called()
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_passes_correct_arguments():
|
||||
"""query_points() must receive collection_name, query list, limit, and payload flag."""
|
||||
collection = _collection_with_query_response()
|
||||
query = np.array([0.1, 0.2, 0.3, 0.4])
|
||||
|
||||
collection.search_points(query, limit=7)
|
||||
|
||||
_, kwargs = collection.client.query_points.call_args
|
||||
assert kwargs["collection_name"] == "test_collection"
|
||||
assert kwargs["query"] == [0.1, 0.2, 0.3, 0.4]
|
||||
assert kwargs["limit"] == 7
|
||||
assert kwargs["with_payload"] is True
|
||||
assert kwargs["with_vectors"] is False
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_passes_query_filter_through():
|
||||
"""The query_filter argument must be forwarded verbatim to query_points()."""
|
||||
collection = _collection_with_query_response()
|
||||
mock_filter = MagicMock()
|
||||
query = np.array([0.5, 0.6])
|
||||
|
||||
collection.search_points(query, limit=3, query_filter=mock_filter)
|
||||
|
||||
_, kwargs = collection.client.query_points.call_args
|
||||
assert kwargs["query_filter"] is mock_filter
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_passes_none_filter_when_unfiltered():
|
||||
"""query_filter=None must be passed through (not omitted) so the server
|
||||
returns all matching vectors rather than raising a missing-argument error."""
|
||||
collection = _collection_with_query_response()
|
||||
query = np.array([0.1, 0.2])
|
||||
|
||||
collection.search_points(query, limit=5, query_filter=None)
|
||||
|
||||
_, kwargs = collection.client.query_points.call_args
|
||||
assert kwargs["query_filter"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QdrantCollection.search_points — result shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_result_shape():
|
||||
"""Each result dict must contain id, score, metadata, vector, distance."""
|
||||
sp = _scored_point(42, 0.8, payload={"tag": "ml"})
|
||||
collection = _collection_with_query_response(sp)
|
||||
query = np.array([0.1, 0.2, 0.3])
|
||||
|
||||
results = collection.search_points(query, limit=1)
|
||||
|
||||
assert len(results) == 1
|
||||
r = results[0]
|
||||
assert set(r.keys()) == {"id", "score", "metadata", "vector", "distance"}
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_maps_id_and_payload():
|
||||
"""id and metadata must come from ScoredPoint.id and ScoredPoint.payload."""
|
||||
sp = _scored_point(99, 0.5, payload={"source": "wiki", "year": 2024})
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
assert results[0]["id"] == 99
|
||||
assert results[0]["metadata"] == {"source": "wiki", "year": 2024}
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_null_payload_becomes_empty_dict():
|
||||
"""A ScoredPoint with payload=None must produce metadata={}."""
|
||||
sp = _scored_point(7, 0.9, payload=None)
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
assert results[0]["metadata"] == {}
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_vector_and_distance_are_none():
|
||||
"""vector and distance fields must always be None (vectors are not fetched)."""
|
||||
sp = _scored_point(1, 0.7, payload={})
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
assert results[0]["vector"] is None
|
||||
assert results[0]["distance"] is None
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_score_normalization_midrange():
|
||||
"""Score=0 must map to exactly 0.5 under the normalization formula."""
|
||||
sp = _scored_point(1, 0.0)
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
assert results[0]["score"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_score_normalization_positive():
|
||||
"""Positive raw scores must map to (0.5, 1.0) under the normalization formula."""
|
||||
sp = _scored_point(1, 1.0)
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
# (1.0/(1+1.0) + 1.0) / 2.0 = (0.5 + 1.0) / 2.0 = 0.75
|
||||
assert results[0]["score"] == pytest.approx(0.75)
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_score_normalization_negative():
|
||||
"""Negative raw scores must map to (0.0, 0.5) under the normalization formula."""
|
||||
sp = _scored_point(1, -1.0)
|
||||
collection = _collection_with_query_response(sp)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=1)
|
||||
|
||||
# (-1.0/(1+1.0) + 1.0) / 2.0 = (−0.5 + 1.0) / 2.0 = 0.25
|
||||
assert results[0]["score"] == pytest.approx(0.25)
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_multiple_results_preserve_order():
|
||||
"""All ScoredPoints in response.points must appear in the output, in order."""
|
||||
points = [_scored_point(i, 1.0 - i * 0.1) for i in range(5)]
|
||||
collection = _collection_with_query_response(*points)
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=5)
|
||||
|
||||
assert len(results) == 5
|
||||
assert [r["id"] for r in results] == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_empty_response():
|
||||
"""An empty response.points list must produce an empty result list."""
|
||||
collection = _collection_with_query_response() # zero points
|
||||
|
||||
results = collection.search_points(np.array([0.1, 0.2]), limit=10)
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QdrantCollection.search_points — error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", False)
|
||||
def test_search_points_raises_when_qdrant_unavailable():
|
||||
client = MagicMock()
|
||||
collection = QdrantCollection(client, "test_collection")
|
||||
|
||||
with pytest.raises(ProcessingError):
|
||||
collection.search_points(np.array([0.1, 0.2]), limit=5)
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_search_points_wraps_client_errors_as_processing_error():
|
||||
client = MagicMock()
|
||||
client.query_points.side_effect = RuntimeError("network failure")
|
||||
collection = QdrantCollection(client, "test_collection")
|
||||
|
||||
with pytest.raises(ProcessingError, match="network failure"):
|
||||
collection.search_points(np.array([0.1, 0.2]), limit=5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QdrantStore.get_stats — vectors_count compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _store_with_collection_info(**info_attrs):
|
||||
"""QdrantStore with a mocked client.get_collection() response."""
|
||||
store = QdrantStore()
|
||||
store.client = MagicMock()
|
||||
store.collection = MagicMock()
|
||||
store.collection.collection_name = "test_coll"
|
||||
|
||||
info = MagicMock(spec=list(info_attrs.keys()))
|
||||
for attr, val in info_attrs.items():
|
||||
setattr(info, attr, val)
|
||||
store.client.get_collection.return_value = info
|
||||
return store
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_get_stats_uses_vectors_count_when_present():
|
||||
"""On qdrant-client <1.16, vectors_count exists and must be returned."""
|
||||
store = _store_with_collection_info(
|
||||
points_count=10, vectors_count=10, status="green"
|
||||
)
|
||||
|
||||
stats = store.get_stats()
|
||||
|
||||
assert stats["points_count"] == 10
|
||||
assert stats["vectors_count"] == 10
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_get_stats_uses_points_count_when_vectors_count_absent():
|
||||
"""On qdrant-client >=1.16, vectors_count is absent.
|
||||
For a single unnamed-vector collection (config.params.vectors is a
|
||||
VectorParams instance), points_count is the correct substitute.
|
||||
indexed_vectors_count must NOT be used: it counts only vectors
|
||||
in optimised segments and is 0 for freshly-inserted data."""
|
||||
from qdrant_client.models import VectorParams, Distance
|
||||
store = QdrantStore()
|
||||
store.client = MagicMock()
|
||||
store.collection = MagicMock()
|
||||
store.collection.collection_name = "test_coll"
|
||||
|
||||
info = MagicMock(spec=["points_count", "indexed_vectors_count", "config", "status"])
|
||||
info.points_count = 5
|
||||
info.indexed_vectors_count = 0 # typical for freshly-inserted, unoptimised data
|
||||
info.config.params.vectors = VectorParams(size=4, distance=Distance.COSINE)
|
||||
info.status = "green"
|
||||
store.client.get_collection.return_value = info
|
||||
|
||||
stats = store.get_stats()
|
||||
|
||||
assert stats["points_count"] == 5
|
||||
# Must equal points_count (5), NOT indexed_vectors_count (0)
|
||||
assert stats["vectors_count"] == 5
|
||||
assert stats["vectors_count"] != info.indexed_vectors_count
|
||||
assert stats["status"] == "green"
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_get_stats_vectors_count_equals_points_count_when_vectors_count_absent():
|
||||
"""On qdrant-client >=1.16, vectors_count is absent. For a single unnamed-
|
||||
vector collection the fallback is points_count, so both keys are equal.
|
||||
indexed_vectors_count is intentionally absent from this mock to confirm
|
||||
it is not required by the fallback path."""
|
||||
from qdrant_client.models import VectorParams, Distance
|
||||
store = QdrantStore()
|
||||
store.client = MagicMock()
|
||||
store.collection = MagicMock()
|
||||
store.collection.collection_name = "test_coll"
|
||||
|
||||
info = MagicMock(spec=["points_count", "config", "status"])
|
||||
info.points_count = 7
|
||||
info.config.params.vectors = VectorParams(size=8, distance=Distance.COSINE)
|
||||
info.status = "green"
|
||||
store.client.get_collection.return_value = info
|
||||
|
||||
stats = store.get_stats()
|
||||
|
||||
assert stats["points_count"] == 7
|
||||
assert stats["vectors_count"] == 7
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_get_stats_vectors_count_is_none_for_named_multi_vector_collection():
|
||||
"""When vectors_count is absent and the collection uses named/multi vectors
|
||||
(config.params.vectors is a dict), the total cannot be inferred and
|
||||
vectors_count must be None rather than a misleading points_count value."""
|
||||
from qdrant_client.models import VectorParams, Distance
|
||||
store = QdrantStore()
|
||||
store.client = MagicMock()
|
||||
store.collection = MagicMock()
|
||||
store.collection.collection_name = "test_coll"
|
||||
|
||||
info = MagicMock(spec=["points_count", "config", "status"])
|
||||
info.points_count = 4
|
||||
# Named multi-vector: qdrant-client returns a dict of VectorParams
|
||||
info.config.params.vectors = {
|
||||
"text": VectorParams(size=4, distance=Distance.COSINE),
|
||||
"image": VectorParams(size=8, distance=Distance.DOT),
|
||||
}
|
||||
info.status = "green"
|
||||
store.client.get_collection.return_value = info
|
||||
|
||||
stats = store.get_stats()
|
||||
|
||||
assert stats["points_count"] == 4
|
||||
# vectors_count must be None: total vectors = points * num_named_vectors,
|
||||
# and that multiplier is unknown to the caller.
|
||||
assert stats["vectors_count"] is None
|
||||
|
||||
|
||||
@patch("semantica.vector_store.qdrant_store.QDRANT_AVAILABLE", True)
|
||||
def test_get_stats_vectors_count_is_none_when_config_inaccessible():
|
||||
"""If the collection config cannot be read (e.g. an older schema or
|
||||
unexpected server response), vectors_count must fall back to None safely
|
||||
without raising."""
|
||||
store = QdrantStore()
|
||||
store.client = MagicMock()
|
||||
store.collection = MagicMock()
|
||||
store.collection.collection_name = "test_coll"
|
||||
|
||||
# Simulate a CollectionInfo that has no config attribute at all
|
||||
info = MagicMock(spec=["points_count", "status"])
|
||||
info.points_count = 3
|
||||
info.status = "green"
|
||||
store.client.get_collection.return_value = info
|
||||
|
||||
stats = store.get_stats()
|
||||
|
||||
assert stats["points_count"] == 3
|
||||
assert stats["vectors_count"] is None
|
||||
Reference in New Issue
Block a user