Files
temp/features/steps/vector_store_service_steps.py
brent.edwards 7ef5ebb695 feat: Add Q0: Pre-commit hooks setup.
This should automatically check for problems on build.
2026-02-10 16:04:17 +00:00

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