"""Step definitions for context vector search integration.""" from __future__ import annotations import shutil import tempfile from pathlib import Path from typing import Any from behave import given, then, when from cleveragents.application.services.context_service import ContextService from cleveragents.config.settings import Settings from cleveragents.core.exceptions import ConfigurationError from cleveragents.domain.models.core import Context, Plan, Project from features.steps.service_steps import add_cleanup class RecordingContextRepository: """In-memory context repository used for vector integration tests.""" def __init__(self) -> None: self._store: dict[int, list[Context]] = {} self._next_id = 1 def get_for_plan(self, plan_id: int) -> list[Context]: return [*self._store.get(plan_id, [])] def add(self, context: Context) -> None: context.id = context.id or self._next_id self._next_id += 1 self._store.setdefault(context.plan_id, []).append(context) def remove(self, context_id: int) -> None: for plan_id, contexts in self._store.items(): self._store[plan_id] = [c for c in contexts if c.id != context_id] def clear_for_plan(self, plan_id: int) -> None: self._store.pop(plan_id, None) class RecordingPlansRepository: def __init__(self, plan: Plan) -> None: self._plan = plan def get_current_for_project(self, project_id: int | None) -> Plan | None: return self._plan if project_id else None class RecordingTransaction: def __init__( self, plan_repo: RecordingPlansRepository, context_repo: RecordingContextRepository, ) -> None: self.plans = plan_repo self.contexts = context_repo def __enter__(self) -> RecordingTransaction: return self def __exit__(self, exc_type, exc, tb) -> bool: return False class RecordingUnitOfWork: def __init__(self, plan: Plan) -> None: self._plans = RecordingPlansRepository(plan) self._contexts = RecordingContextRepository() def transaction(self) -> RecordingTransaction: return RecordingTransaction(self._plans, self._contexts) class RecordingVectorStoreStub: """Test double that records vector store usage.""" def __init__(self, *, enabled: bool) -> None: self.enabled = enabled self.refresh_calls: list[int] = [] self.search_calls: list[tuple[int, str, int, bool]] = [] self.results: list[dict[str, Any]] = [] self.refresh_error: Exception | None = None self.search_error: Exception | None = None def is_enabled(self) -> bool: return self.enabled def refresh_for_plan(self, plan_id: int) -> int: self.refresh_calls.append(plan_id) if self.refresh_error: raise self.refresh_error return len(self.results) def search( self, plan_id: int, query: str, *, top_k: int = 5, refresh_if_missing: bool = True, ) -> list[dict[str, Any]]: self.search_calls.append((plan_id, query, top_k, refresh_if_missing)) if self.search_error: raise self.search_error return [*self.results] def _setup_vector_context_service(context, *, enabled: bool) -> None: temp_dir = Path(tempfile.mkdtemp(prefix="context-vector-")) add_cleanup(context, lambda: shutil.rmtree(temp_dir, ignore_errors=True)) plan = Plan( id=101, project_id=1, name="vector-plan", prompt="Vector integration", current=True, ) project = Project(id=1, name="vector-project", path=temp_dir) context.temp_dir = temp_dir context.plan = plan context.project = project context.unit_of_work = RecordingUnitOfWork(plan) context.vector_stub = RecordingVectorStoreStub(enabled=enabled) context.settings = Settings() context.context_service = ContextService( settings=context.settings, unit_of_work=context.unit_of_work, vector_store_service=context.vector_stub, ) context.created_files: dict[str, Path] = {} @given("a context service with vector search enabled") def step_context_service_vector_enabled(context) -> None: _setup_vector_context_service(context, enabled=True) @given("a context service with vector search disabled") def step_context_service_vector_disabled(context) -> None: _setup_vector_context_service(context, enabled=False) @given('the vector store stub will return "{path}" with score {score:f}') def step_stub_vector_results(context, path: str, score: float) -> None: context.vector_stub.results = [ {"path": path, "score": score, "snippet": f"match:{path}"} ] @given("the vector test project loses its identifier") def step_vector_project_loses_identifier(context) -> None: assert hasattr(context, "project"), "Vector test project is not initialized" context.project.id = None @given("the vector store refresh fails with configuration error") def step_vector_refresh_config_error(context) -> None: context.vector_stub.refresh_error = ConfigurationError( "Vector refresh configuration is invalid" ) @given("the vector store search fails with configuration error") def step_vector_search_config_error(context) -> None: context.vector_stub.search_error = ConfigurationError( "Vector search configuration is invalid" ) @given('I have a vector context file "{filename}" with content:') def step_create_vector_context_file(context, filename: str) -> None: file_path = context.temp_dir / filename file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(context.text.strip("\n"), encoding="utf-8") context.created_files[filename] = file_path @when('I add the file "{filename}" to the vector-aware context') def step_add_file_vector_context(context, filename: str) -> None: file_path = context.created_files[filename] context.context_service.add_to_context(context.project, file_path) @when('I remove the file "{filename}" from the vector-aware context') def step_remove_file_vector_context(context, filename: str) -> None: file_path = context.created_files[filename] context.context_service.remove_from_context(context.project, file_path) @when("I clear the vector-aware context") def step_clear_vector_context(context) -> None: context.context_service.clear_context(context.project) @when('I search the context for "{query}" with limit {limit:d}') def step_search_context(context, query: str, limit: int) -> None: context.search_results = context.context_service.search_context( context.project, query, limit=limit, ) @then('the semantic search results should include "{path}" with score {score:f}') def step_assert_semantic_result(context, path: str, score: float) -> None: assert context.search_results, "No semantic search results recorded" first = context.search_results[0] assert first["path"] == path, first assert abs(first["score"] - score) < 1e-9, first @then("the vector store query count should be {expected:d}") def step_assert_query_count(context, expected: int) -> None: assert len(context.vector_stub.search_calls) == expected, ( context.vector_stub.search_calls ) @then("the semantic search results should be empty") def step_semantic_results_empty(context) -> None: assert context.search_results == [] @then("the vector index should have refreshed {expected:d} time") @then("the vector index should have refreshed {expected:d} times") def step_assert_refresh_count(context, expected: int) -> None: assert len(context.vector_stub.refresh_calls) == expected, ( context.vector_stub.refresh_calls )