forked from HAL9000/cleveragents-core
7ef5ebb695
This should automatically check for problems on build.
430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""Step definitions for vector store service coverage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.vector_store_service import VectorStoreService
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.core.exceptions import ConfigurationError
|
|
from cleveragents.domain.models.core.context import Context as ContextModel
|
|
from features.steps.service_steps import add_cleanup
|
|
|
|
|
|
class _StubContextRepository:
|
|
def __init__(self, backing_store: dict[int, list[ContextModel]]) -> None:
|
|
self._backing_store = backing_store
|
|
|
|
def get_for_plan(self, plan_id: int) -> Iterable[ContextModel]:
|
|
return list(self._backing_store.get(plan_id, []))
|
|
|
|
|
|
class _StubTransaction:
|
|
def __init__(self, unit: _StubUnitOfWork) -> None:
|
|
self._unit = unit
|
|
|
|
def __enter__(self) -> _StubUnitOfWork:
|
|
return self._unit
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
|
|
class _StubUnitOfWork:
|
|
def __init__(self) -> None:
|
|
self._contexts_map: dict[int, list[ContextModel]] = {}
|
|
self.contexts = _StubContextRepository(self._contexts_map)
|
|
|
|
def transaction(self) -> _StubTransaction:
|
|
return _StubTransaction(self)
|
|
|
|
def set_contexts(self, plan_id: int, contexts: list[ContextModel]) -> None:
|
|
self._contexts_map[plan_id] = contexts
|
|
|
|
|
|
class _StubDocument:
|
|
def __init__(self, path: str, content: str) -> None:
|
|
self.metadata = {"path": path}
|
|
self.page_content = content
|
|
|
|
|
|
class RecordingFAISS:
|
|
"""Test double that records FAISS usage."""
|
|
|
|
last_from_texts_args: ClassVar[dict[str, Any] | None] = None
|
|
last_built_instance: ClassVar[RecordingFAISS | None] = None
|
|
load_local_should_raise: ClassVar[bool] = False
|
|
load_local_calls: ClassVar[list[str]] = []
|
|
default_similarity_payload: ClassVar[list[tuple[_StubDocument, float]]] = []
|
|
|
|
def __init__(self) -> None:
|
|
self.documents: list[str] = []
|
|
self.metadatas: list[dict[str, Any]] = []
|
|
self.saved_directory: str | None = None
|
|
self.similarity_payload: list[tuple[_StubDocument, float]] = []
|
|
self.last_limit: int | None = None
|
|
self.last_query: str | None = None
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
cls.last_from_texts_args = None
|
|
cls.last_built_instance = None
|
|
cls.load_local_should_raise = False
|
|
cls.load_local_calls = []
|
|
cls.default_similarity_payload = []
|
|
|
|
@classmethod
|
|
def from_texts(
|
|
cls,
|
|
documents: list[str],
|
|
*,
|
|
embedding: Any,
|
|
metadatas: list[dict[str, Any]],
|
|
) -> RecordingFAISS:
|
|
instance = cls()
|
|
instance.documents = list(documents)
|
|
instance.metadatas = list(metadatas)
|
|
instance.similarity_payload = list(cls.default_similarity_payload)
|
|
cls.last_from_texts_args = {
|
|
"documents": list(documents),
|
|
"metadatas": list(metadatas),
|
|
"embedding": embedding,
|
|
}
|
|
cls.last_built_instance = instance
|
|
return instance
|
|
|
|
def save_local(self, directory: str) -> None:
|
|
self.saved_directory = directory
|
|
|
|
def similarity_search_with_score(
|
|
self, query: str, k: int
|
|
) -> list[tuple[_StubDocument, float]]:
|
|
self.last_query = query
|
|
self.last_limit = k
|
|
return self.similarity_payload[:k]
|
|
|
|
@classmethod
|
|
def load_local(
|
|
cls,
|
|
directory: str,
|
|
embeddings: Any,
|
|
allow_dangerous_deserialization: bool = True,
|
|
) -> RecordingFAISS:
|
|
cls.load_local_calls.append(directory)
|
|
if cls.load_local_should_raise:
|
|
raise ValueError("Failed to load index")
|
|
instance = cls()
|
|
instance.saved_directory = directory
|
|
cls.last_built_instance = instance
|
|
return instance
|
|
|
|
|
|
class StubOpenAIEmbeddings:
|
|
"""Minimal stub to capture requested OpenAI embedding model."""
|
|
|
|
last_model: str | None = None
|
|
|
|
def __init__(self, *, model: str | None = None, **_) -> None:
|
|
StubOpenAIEmbeddings.last_model = model
|
|
|
|
|
|
def _create_vector_service(context: Context, *, enabled: bool) -> None:
|
|
temp_dir = tempfile.mkdtemp(prefix="vector-store-service-")
|
|
add_cleanup(context, lambda: shutil.rmtree(temp_dir, ignore_errors=True))
|
|
settings = Settings()
|
|
settings.vector_store_enabled = enabled
|
|
settings.vector_store_path = Path(temp_dir) / "vector_store"
|
|
settings.vector_embeddings_provider = "fake"
|
|
settings.vector_embeddings_dimension = 8
|
|
unit = _StubUnitOfWork()
|
|
context.vector_store_service = VectorStoreService(settings, unit)
|
|
context.stub_unit_of_work = unit
|
|
context.vector_store_root = temp_dir
|
|
context.error = None
|
|
context.refresh_result = None
|
|
context.search_results = None
|
|
context.stub_openai_class = None
|
|
|
|
|
|
def _set_plan_contexts(
|
|
context: Context,
|
|
plan_id: int,
|
|
entries: list[tuple[str, str | None]],
|
|
) -> None:
|
|
base = Path(context.vector_store_root)
|
|
contexts = [
|
|
ContextModel(plan_id=plan_id, path=str(base / rel_path), content=content)
|
|
for rel_path, content in entries
|
|
]
|
|
context.stub_unit_of_work.set_contexts(plan_id, contexts)
|
|
|
|
|
|
@given("a vector store service with search enabled")
|
|
def step_vector_service_enabled(context: Context) -> None:
|
|
_create_vector_service(context, enabled=True)
|
|
|
|
|
|
@given("a vector store service with search disabled")
|
|
def step_vector_service_disabled(context: Context) -> None:
|
|
_create_vector_service(context, enabled=False)
|
|
|
|
|
|
@given("plan {plan_id:d} has no context documents")
|
|
def step_plan_no_contexts(context: Context, plan_id: int) -> None:
|
|
context.stub_unit_of_work.set_contexts(plan_id, [])
|
|
|
|
|
|
@given("plan {plan_id:d} has contexts with stored content and blanks")
|
|
def step_plan_mixed_contexts(context: Context, plan_id: int) -> None:
|
|
_set_plan_contexts(
|
|
context,
|
|
plan_id,
|
|
[
|
|
(f"plan_{plan_id}_doc.md", "Context body"),
|
|
(f"plan_{plan_id}_empty.md", None),
|
|
],
|
|
)
|
|
|
|
|
|
@given("plan {plan_id:d} has contexts with stored content")
|
|
def step_plan_filled_contexts(context: Context, plan_id: int) -> None:
|
|
_set_plan_contexts(
|
|
context,
|
|
plan_id,
|
|
[
|
|
(f"plan_{plan_id}_alpha.md", "Alpha document"),
|
|
(f"plan_{plan_id}_beta.md", "Beta document"),
|
|
],
|
|
)
|
|
|
|
|
|
@given("plan {plan_id:d} already has persisted FAISS files")
|
|
def step_plan_has_files(context: Context, plan_id: int) -> None:
|
|
plan_dir = context.vector_store_service._plan_store_dir(plan_id)
|
|
for filename in ("index.faiss", "index.pkl"):
|
|
(plan_dir / filename).write_bytes(b"stub")
|
|
|
|
|
|
@given("FAISS interactions are recorded")
|
|
def step_patch_faiss(context: Context) -> None:
|
|
RecordingFAISS.reset()
|
|
patcher = patch(
|
|
"cleveragents.application.services.vector_store_service.FAISS",
|
|
RecordingFAISS,
|
|
)
|
|
patcher.start()
|
|
add_cleanup(context, patcher.stop)
|
|
context.faiss_class = RecordingFAISS
|
|
|
|
|
|
@given("future FAISS builds will return similarity hits")
|
|
def step_future_faiss_hits(context: Context) -> None:
|
|
assert hasattr(context, "faiss_class"), "FAISS interactions were not recorded"
|
|
payload = [(_StubDocument("doc.md", "C" * 600), 0.25)]
|
|
context.faiss_class.default_similarity_payload = payload
|
|
|
|
def cleanup() -> None:
|
|
context.faiss_class.default_similarity_payload = []
|
|
|
|
add_cleanup(context, cleanup)
|
|
|
|
|
|
@given("plan {plan_id:d} cache contains similarity results")
|
|
def step_plan_cache_with_results(context: Context, plan_id: int) -> None:
|
|
store = context.faiss_class()
|
|
store.similarity_payload = [
|
|
(_StubDocument("doc.md", "A" * 600), 0.25),
|
|
(_StubDocument("doc-two.md", "B" * 50), 0.9),
|
|
]
|
|
context.vector_store_service._cache[plan_id] = store
|
|
context.cached_store = store
|
|
|
|
|
|
@given("loading the plan {plan_id:d} index will fail")
|
|
def step_fail_load(context: Context, plan_id: int) -> None:
|
|
context.faiss_class.load_local_should_raise = True
|
|
|
|
|
|
@given('the embeddings provider flag is "{provider}"')
|
|
def step_set_embeddings_provider(context: Context, provider: str) -> None:
|
|
settings = context.vector_store_service.settings.model_copy(
|
|
update={"vector_embeddings_provider": provider}
|
|
)
|
|
context.vector_store_service.settings = settings
|
|
|
|
|
|
@given('the embeddings provider is "openai" using model "{model}"')
|
|
def step_set_openai_provider(context: Context, model: str) -> None:
|
|
settings = context.vector_store_service.settings.model_copy(
|
|
update={
|
|
"vector_embeddings_provider": "openai",
|
|
"vector_embeddings_model": model,
|
|
}
|
|
)
|
|
context.vector_store_service.settings = settings
|
|
|
|
|
|
@given("a stub OpenAI embeddings backend is available")
|
|
def step_stub_openai_backend(context: Context) -> None:
|
|
StubOpenAIEmbeddings.last_model = None
|
|
module = types.SimpleNamespace(OpenAIEmbeddings=StubOpenAIEmbeddings)
|
|
previous = sys.modules.get("langchain_openai")
|
|
sys.modules["langchain_openai"] = module
|
|
|
|
def cleanup() -> None:
|
|
if previous is None:
|
|
sys.modules.pop("langchain_openai", None)
|
|
else:
|
|
sys.modules["langchain_openai"] = previous
|
|
|
|
add_cleanup(context, cleanup)
|
|
context.stub_openai_class = StubOpenAIEmbeddings
|
|
|
|
|
|
@when("I attempt to refresh the vector store for plan {plan_id:d}")
|
|
@when("I refresh the vector store for plan {plan_id:d}")
|
|
def step_refresh_plan(context: Context, plan_id: int) -> None:
|
|
try:
|
|
context.refresh_result = context.vector_store_service.refresh_for_plan(plan_id)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
context.refresh_result = None
|
|
|
|
|
|
@when('I search plan {plan_id:d} with the query "{query}" and limit {limit:d}')
|
|
def step_search_with_limit(
|
|
context: Context, plan_id: int, query: str, limit: int
|
|
) -> None:
|
|
try:
|
|
context.search_results = context.vector_store_service.search(
|
|
plan_id,
|
|
query,
|
|
top_k=limit,
|
|
refresh_if_missing=False,
|
|
)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
context.search_results = None
|
|
|
|
|
|
@when('I search plan {plan_id:d} with the query "{query}"')
|
|
def step_search_auto_refresh(context: Context, plan_id: int, query: str) -> None:
|
|
context.search_results = context.vector_store_service.search(plan_id, query)
|
|
|
|
|
|
@when('I search plan {plan_id:d} with the query ""')
|
|
def step_search_empty_query(context: Context, plan_id: int) -> None:
|
|
step_search_auto_refresh(context, plan_id, "")
|
|
|
|
|
|
@when('I search plan {plan_id:d} with the query "{query}" and refresh disabled')
|
|
def step_search_no_refresh(context: Context, plan_id: int, query: str) -> None:
|
|
context.search_results = context.vector_store_service.search(
|
|
plan_id,
|
|
query,
|
|
refresh_if_missing=False,
|
|
)
|
|
|
|
|
|
@when("I invalidate the vector store without specifying a plan")
|
|
def step_invalidate_without_plan(context: Context) -> None:
|
|
context.vector_store_service.invalidate(None)
|
|
|
|
|
|
@then("the refresh result should be {expected:d} documents")
|
|
@then("the refresh result should be {expected:d} document")
|
|
def step_verify_refresh_result(context: Context, expected: int) -> None:
|
|
assert context.refresh_result == expected, context.refresh_result
|
|
|
|
|
|
@then("the plan {plan_id:d} cache should be empty")
|
|
def step_verify_cache_empty(context: Context, plan_id: int) -> None:
|
|
assert plan_id not in context.vector_store_service._cache
|
|
|
|
|
|
@then("the plan {plan_id:d} cache should still contain the cached store")
|
|
def step_verify_cache_still_present(context: Context, plan_id: int) -> None:
|
|
cached = context.vector_store_service._cache.get(plan_id)
|
|
assert cached is context.cached_store
|
|
|
|
|
|
@then("the plan {plan_id:d} persisted files should be removed")
|
|
def step_verify_files_removed(context: Context, plan_id: int) -> None:
|
|
plan_dir = context.vector_store_service._plan_store_dir(plan_id)
|
|
for filename in ("index.faiss", "index.pkl"):
|
|
assert not (plan_dir / filename).exists()
|
|
|
|
|
|
@then("FAISS should be built with {expected:d} cleaned document")
|
|
def step_verify_faiss_documents(context: Context, expected: int) -> None:
|
|
args = context.faiss_class.last_from_texts_args
|
|
assert args is not None
|
|
assert len(args["documents"]) == expected
|
|
assert len(args["metadatas"]) == expected
|
|
|
|
|
|
@then("the plan {plan_id:d} cache should hold the FAISS instance")
|
|
def step_verify_cache_holds_instance(context: Context, plan_id: int) -> None:
|
|
cached = context.vector_store_service._cache.get(plan_id)
|
|
assert cached is context.faiss_class.last_built_instance
|
|
|
|
|
|
@then("FAISS should be loaded for plan {plan_id:d}")
|
|
def step_verify_faiss_loaded(context: Context, plan_id: int) -> None:
|
|
assert hasattr(context, "faiss_class"), "FAISS interactions were not recorded"
|
|
expected_dir = str(context.vector_store_service._plan_store_dir(plan_id))
|
|
assert context.faiss_class.load_local_calls, "No FAISS load_local calls recorded"
|
|
assert expected_dir in context.faiss_class.load_local_calls
|
|
|
|
|
|
@then(
|
|
'the search results should include one formatted hit with path "{path}" and score {score:f}'
|
|
)
|
|
def step_verify_formatted_result(context: Context, path: str, score: float) -> None:
|
|
assert isinstance(context.search_results, list)
|
|
assert len(context.search_results) == 1
|
|
hit = context.search_results[0]
|
|
assert hit["path"] == path
|
|
assert abs(hit["score"] - score) < 1e-9
|
|
assert len(hit["snippet"]) == 500
|
|
|
|
|
|
@then("the FAISS similarity search limit should be {expected:d}")
|
|
def step_verify_limit(context: Context, expected: int) -> None:
|
|
assert context.cached_store.last_limit == expected
|
|
|
|
|
|
@then("the search results should be empty")
|
|
def step_verify_empty_results(context: Context) -> None:
|
|
assert context.search_results == []
|
|
|
|
|
|
@then("a configuration error should mention disabled vector store support")
|
|
def step_verify_disabled_error(context: Context) -> None:
|
|
assert isinstance(context.error, ConfigurationError)
|
|
assert "disabled" in str(context.error).lower()
|
|
|
|
|
|
@then("a configuration error should mention unsupported embeddings provider")
|
|
def step_verify_unsupported_provider(context: Context) -> None:
|
|
assert isinstance(context.error, ConfigurationError)
|
|
assert "unsupported" in str(context.error).lower()
|
|
|
|
|
|
@then('the last OpenAI embeddings model should be "{model}"')
|
|
def step_verify_openai_model(context: Context, model: str) -> None:
|
|
assert context.stub_openai_class is not None
|
|
assert context.stub_openai_class.last_model == model
|