Files
temp/features/steps/faiss_vector_backend_steps.py
aditya eff446f5e8 feat(acms): integrate FAISS into ACMS vector backend protocol
Add FAISS-backed ACMS read and write adapters on top of the shared VectorStoreService, wire them through the DI container, and cover indexing, scoped search, removal, and benchmark behavior with Behave and ASV.

Address review feedback by keeping the commit scoped to the FAISS backend work and replacing the loose vector-store cache typing with explicit FAISS store protocols instead of Any.

ISSUES CLOSED: #871
2026-03-30 13:19:08 +00:00

339 lines
12 KiB
Python

"""Step definitions for the FAISS ACMS vector backend feature."""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any, ClassVar, cast
from unittest.mock import patch
from behave import given, then, when
from langchain_community.embeddings import FakeEmbeddings
from cleveragents.application.container import get_container, reset_container
from cleveragents.application.services.config_service import ConfigService
from cleveragents.application.services.faiss_vector_backend import (
FAISSVectorBackend,
FAISSVectorIndexBackend,
)
from cleveragents.application.services.vector_store_service import VectorStoreService
from cleveragents.config.settings import Settings
from cleveragents.domain.models.acms.index_stubs import InMemoryVectorIndexBackend
from cleveragents.domain.models.acms.stubs import InMemoryVectorBackend
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from features.steps.service_steps import add_cleanup
class _StubDocument:
def __init__(self, page_content: str, metadata: dict[str, str]) -> None:
self.page_content = page_content
self.metadata = dict(metadata)
class _StubDocStore:
def __init__(self) -> None:
self._docs: dict[str, _StubDocument] = {}
def search(self, store_id: str) -> _StubDocument:
return self._docs[store_id]
class RecordingFAISS:
"""Deterministic FAISS stand-in for ACMS backend tests."""
saved_snapshots: ClassVar[
dict[str, list[tuple[str, str, list[float], dict[str, str]]]]
] = {}
last_embedding_backend: ClassVar[Any | None] = None
def __init__(self) -> None:
self.docstore = _StubDocStore()
self.index_to_docstore_id: dict[int, str] = {}
self._vectors: dict[str, list[float]] = {}
@classmethod
def reset(cls) -> None:
cls.saved_snapshots = {}
cls.last_embedding_backend = None
@classmethod
def from_embeddings(
cls,
text_embeddings: list[tuple[str, list[float]]],
embedding: Any,
metadatas: list[dict[str, str]] | None = None,
ids: list[str] | None = None,
**_: Any,
) -> RecordingFAISS:
instance = cls()
cls.last_embedding_backend = embedding
instance.add_embeddings(text_embeddings, metadatas=metadatas, ids=ids)
return instance
def add_embeddings(
self,
text_embeddings: list[tuple[str, list[float]]],
metadatas: list[dict[str, str]] | None = None,
ids: list[str] | None = None,
**_: Any,
) -> list[str]:
stored_ids: list[str] = []
metadata_list = metadatas or [{} for _ in text_embeddings]
for index, (text, vector) in enumerate(text_embeddings):
store_id = ids[index] if ids is not None else f"doc-{len(self._vectors)}"
self.docstore._docs[store_id] = _StubDocument(text, metadata_list[index])
self._vectors[store_id] = list(vector)
stored_ids.append(store_id)
self._rebuild_index_map()
return stored_ids
def similarity_search_with_score_by_vector(
self,
embedding: list[float],
k: int = 4,
filter: Any | None = None,
fetch_k: int = 20,
**_: Any,
) -> list[tuple[_StubDocument, float]]:
matches: list[tuple[_StubDocument, float]] = []
for store_id, vector in self._vectors.items():
document = self.docstore.search(store_id)
if filter is not None and not filter(document.metadata):
continue
distance = sum(
abs(left - right)
for left, right in zip(embedding, vector, strict=False)
)
matches.append((document, float(distance)))
matches.sort(key=lambda item: item[1])
return matches[: min(fetch_k, k)]
def save_local(self, directory: str, index_name: str = "index") -> None:
_ = index_name
RecordingFAISS.saved_snapshots[directory] = [
(
store_id,
self.docstore.search(store_id).page_content,
list(self._vectors[store_id]),
dict(self.docstore.search(store_id).metadata),
)
for store_id in self.index_to_docstore_id.values()
]
target = Path(directory)
target.mkdir(parents=True, exist_ok=True)
(target / "index.faiss").write_bytes(b"stub")
(target / "index.pkl").write_bytes(b"stub")
@classmethod
def load_local(
cls,
directory: str,
embeddings: Any,
index_name: str = "index",
*,
allow_dangerous_deserialization: bool = False,
**_: Any,
) -> RecordingFAISS:
_unused = (index_name, allow_dangerous_deserialization)
cls.last_embedding_backend = embeddings
if directory not in cls.saved_snapshots:
raise FileNotFoundError(directory)
instance = cls()
for store_id, text, vector, metadata in cls.saved_snapshots[directory]:
instance.docstore._docs[store_id] = _StubDocument(text, metadata)
instance._vectors[store_id] = list(vector)
instance._rebuild_index_map()
return instance
def delete(self, ids: list[str] | None = None, **_: Any) -> bool:
for store_id in ids or []:
self.docstore._docs.pop(store_id, None)
self._vectors.pop(store_id, None)
self._rebuild_index_map()
return True
def _rebuild_index_map(self) -> None:
ordered_ids = sorted(self._vectors)
self.index_to_docstore_id = {
position: store_id for position, store_id in enumerate(ordered_ids)
}
def _set_env(context: Any, key: str, value: str | None) -> None:
original = os.environ.get(key)
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def cleanup() -> None:
if original is None:
os.environ.pop(key, None)
else:
os.environ[key] = original
add_cleanup(context, cleanup)
def _parse_vector(raw: str) -> list[float]:
return [float(part.strip()) for part in raw.split(",") if part.strip()]
@given("the ACMS FAISS backend is configured to use fake embeddings")
def step_configure_acms_faiss(context: Any) -> None:
index_dir = tempfile.mkdtemp(prefix="acms-faiss-index-")
add_cleanup(context, lambda: shutil.rmtree(index_dir, ignore_errors=True))
_set_env(context, "CLEVERAGENTS_INDEX_VECTOR_BACKEND", "faiss")
_set_env(context, "CLEVERAGENTS_INDEX_VECTOR_DIR", index_dir)
_set_env(context, "CLEVERAGENTS_EMBEDDING_PROVIDER", "fake")
_set_env(context, "CLEVERAGENTS_EMBEDDING_DIMENSIONS", "8")
reset_container()
@given("FAISS interactions are recorded for ACMS backends")
def step_patch_acms_faiss(context: Any) -> None:
RecordingFAISS.reset()
patchers = [
patch(
"cleveragents.application.services.vector_store_service.FAISS",
RecordingFAISS,
),
patch(
"cleveragents.application.services.faiss_vector_backend.FAISS",
RecordingFAISS,
),
]
for patcher in patchers:
patcher.start()
add_cleanup(context, patcher.stop)
@given("FAISS is unavailable for ACMS backends")
def step_disable_acms_faiss(context: Any) -> None:
patchers = [
patch("cleveragents.application.services.vector_store_service.FAISS", None),
patch("cleveragents.application.services.faiss_vector_backend.FAISS", None),
]
for patcher in patchers:
patcher.start()
add_cleanup(context, patcher.stop)
reset_container()
@given("an ACMS FAISS backend pair")
def step_acms_backend_pair(context: Any) -> None:
settings = Settings()
unit_of_work = UnitOfWork("sqlite:///:memory:", require_confirmation=False)
service = VectorStoreService(settings, unit_of_work, ConfigService())
context.acms_service = service
context.acms_vector_backend = FAISSVectorBackend(service)
context.acms_index_backend = FAISSVectorIndexBackend(service)
@given(
'I index ACMS embedding "{doc_id}" in project "{project}" for resource "{resource_id}" with vector "{vector}"'
)
@when(
'I index ACMS embedding "{doc_id}" in project "{project}" for resource "{resource_id}" with vector "{vector}"'
)
def step_index_acms_embedding(
context: Any,
doc_id: str,
project: str,
resource_id: str,
vector: str,
) -> None:
context.acms_index_backend.index_embedding(
project,
doc_id,
_parse_vector(vector),
{
"resource_id": resource_id,
"location": f"src/{resource_id.lower()}.py",
"resource_type": "python",
},
)
@when('I search ACMS vectors in project "{project}" for vector "{vector}"')
def step_search_project_vectors(context: Any, project: str, vector: str) -> None:
context.index_results = context.acms_index_backend.search_similar(
project,
_parse_vector(vector),
limit=5,
)
@when('I search ACMS vectors scoped to resource "{resource_id}" for vector "{vector}"')
def step_search_scoped_vectors(context: Any, resource_id: str, vector: str) -> None:
context.vector_results = context.acms_vector_backend.similarity_search(
_parse_vector(vector),
scope=frozenset({resource_id}),
top_k=5,
)
@when('I remove ACMS embedding "{doc_id}" from project "{project}"')
def step_remove_acms_embedding(context: Any, doc_id: str, project: str) -> None:
context.acms_index_backend.remove_embedding(project, doc_id)
@when("I resolve ACMS vector backends from the DI container")
def step_resolve_container_backends(context: Any) -> None:
reset_container()
container = get_container()
context.container_vector_backend = container.vector_backend()
context.container_index_vector_backend = container.index_vector_backend()
@then('the project vector search should include doc_id "{doc_id}"')
def step_assert_project_result(context: Any, doc_id: str) -> None:
assert any(result.doc_id == doc_id for result in context.index_results)
@then('the scoped vector search should include uko_uri "{uko_uri}"')
def step_assert_scoped_result(context: Any, uko_uri: str) -> None:
assert any(result.uko_uri == uko_uri for result in context.vector_results)
@then("the scoped vector search should be empty")
def step_assert_scoped_empty(context: Any) -> None:
assert context.vector_results == []
@then('project "{project}" should have {count:d} ACMS embeddings')
def step_assert_embedding_count(context: Any, project: str, count: int) -> None:
assert context.acms_service.acms_count(project=project) == count
@then("the ACMS embeddings provider should be FakeEmbeddings sized {size:d}")
def step_assert_fake_embeddings(context: Any, size: int) -> None:
backend = RecordingFAISS.last_embedding_backend
assert isinstance(backend, FakeEmbeddings)
typed_backend = cast(Any, backend)
assert typed_backend.size == size
@then("the container vector backend should be a FAISSVectorBackend")
def step_assert_container_vector_backend(context: Any) -> None:
assert isinstance(context.container_vector_backend, FAISSVectorBackend)
@then("the container index vector backend should be a FAISSVectorIndexBackend")
def step_assert_container_index_backend(context: Any) -> None:
assert isinstance(context.container_index_vector_backend, FAISSVectorIndexBackend)
@then("the container vector backend should be an InMemoryVectorBackend")
def step_assert_container_vector_fallback(context: Any) -> None:
assert isinstance(context.container_vector_backend, InMemoryVectorBackend)
@then("the container index vector backend should be an InMemoryVectorIndexBackend")
def step_assert_container_index_fallback(context: Any) -> None:
assert isinstance(
context.container_index_vector_backend, InMemoryVectorIndexBackend
)