Files
cleveragents-core/features/steps/acms_backends_steps.py
T
freemo 025d379946
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 17s
CI / build (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 33s
CI / security (pull_request) Successful in 42s
CI / unit_tests (pull_request) Successful in 2m36s
CI / docker (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 3m17s
CI / coverage (pull_request) Successful in 4m21s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 33s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m36s
CI / docker (push) Successful in 40s
CI / integration_tests (push) Successful in 3m57s
CI / coverage (push) Successful in 5m22s
CI / benchmark-publish (push) Has been cancelled
CI / benchmark-regression (pull_request) Successful in 25m12s
feat(acms): add text, vector, and graph backend protocol implementations
Implemented the Backend Abstraction Layer (BAL) for the Advanced Context
Management System, following the specification in docs/specification.md
Section ACMS > Backend Abstraction Layer and ADR-014.

Key additions:

- TextBackend protocol with search(query, scope, max_results) returning
  list[TextResult], and TextResult frozen dataclass (uko_uri, content,
  score, metadata fields)
- VectorBackend protocol with similarity_search(embedding, scope, top_k)
  returning list[VectorResult], and VectorResult frozen dataclass
- GraphBackend protocol with sparql_query(query, scope),
  get_triples(subject), and traverse(start, depth) methods returning
  GraphResult frozen dataclass (triples, metadata fields)
- In-memory stub backends (InMemoryTextBackend, InMemoryVectorBackend,
  InMemoryGraphBackend) that validate arguments and return empty results,
  serving as development placeholders and test doubles
- DI container registration as configurable Singletons with provider
  selection via override_providers()
- Behave BDD feature (35 scenarios / 83 steps) covering protocol
  compliance, argument validation, result immutability, and DI resolution
- Robot Framework smoke tests (6 tests) for integration verification
- ASV benchmarks for stub query overhead and instantiation time
- Reference documentation at docs/reference/acms_backends.md

Design decisions:
- Used @runtime_checkable Protocol for structural subtyping, consistent
  with existing ResourceHandler pattern
- Used frozen dataclasses (not Pydantic) for result types to minimize
  overhead in the hot path of context assembly
- scope parameter typed as frozenset[str] for immutability and hashability
- Stubs registered as default Singletons; production backends swap via DI

ISSUES CLOSED: #498
2026-03-03 03:29:31 +00:00

427 lines
14 KiB
Python

"""Step definitions for the ACMS Backend Abstraction Layer feature."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from typing import Any
from behave import given, then, when
from cleveragents.application.container import get_container, reset_container
from cleveragents.domain.models.acms.backends import (
GraphBackend,
GraphResult,
TextBackend,
TextResult,
VectorBackend,
VectorResult,
)
from cleveragents.domain.models.acms.stubs import (
InMemoryGraphBackend,
InMemoryTextBackend,
InMemoryVectorBackend,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# TextResult steps
# ---------------------------------------------------------------------------
@given('a TextResult with uko_uri "{uko_uri}" and score {score:g}')
def step_given_text_result(context: Any, uko_uri: str, score: float) -> None:
context.text_result = TextResult(
uko_uri=uko_uri,
content="class Auth: pass",
score=score,
)
@then('the text result uko_uri should be "{expected}"')
def step_then_text_result_uko_uri(context: Any, expected: str) -> None:
assert context.text_result.uko_uri == expected
@then('the text result content should be "{expected}"')
def step_then_text_result_content(context: Any, expected: str) -> None:
assert context.text_result.content == expected
@then("the text result score should be {expected:g}")
def step_then_text_result_score(context: Any, expected: float) -> None:
assert context.text_result.score == expected
@then("the text result metadata should be empty")
def step_then_text_result_metadata_empty(context: Any) -> None:
assert context.text_result.metadata == {}
@then("creating a TextResult with empty uko_uri should raise ValueError")
def step_then_text_result_empty_uko_uri(context: Any) -> None:
try:
TextResult(uko_uri="", content="x", score=0.5)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a TextResult with score {score:g} should raise ValueError")
def step_then_text_result_bad_score(context: Any, score: float) -> None:
try:
TextResult(uko_uri="uko:x", content="x", score=score)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("modifying the text result score should raise an error")
def step_then_text_result_immutable(context: Any) -> None:
try:
context.text_result.score = 0.1 # type: ignore[misc]
raise AssertionError("Expected FrozenInstanceError")
except FrozenInstanceError:
pass
# ---------------------------------------------------------------------------
# VectorResult steps
# ---------------------------------------------------------------------------
@given('a VectorResult with uko_uri "{uko_uri}" and score {score:g}')
def step_given_vector_result(context: Any, uko_uri: str, score: float) -> None:
context.vector_result = VectorResult(
uko_uri=uko_uri,
content="vector store content",
score=score,
)
@then('the vector result uko_uri should be "{expected}"')
def step_then_vector_result_uko_uri(context: Any, expected: str) -> None:
assert context.vector_result.uko_uri == expected
@then('the vector result content should be "{expected}"')
def step_then_vector_result_content(context: Any, expected: str) -> None:
assert context.vector_result.content == expected
@then("the vector result score should be {expected:g}")
def step_then_vector_result_score(context: Any, expected: float) -> None:
assert context.vector_result.score == expected
@then("the vector result metadata should be empty")
def step_then_vector_result_metadata_empty(context: Any) -> None:
assert context.vector_result.metadata == {}
@then("creating a VectorResult with empty uko_uri should raise ValueError")
def step_then_vector_result_empty_uko_uri(context: Any) -> None:
try:
VectorResult(uko_uri="", content="x", score=0.5)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a VectorResult with score {score:g} should raise ValueError")
def step_then_vector_result_bad_score(context: Any, score: float) -> None:
try:
VectorResult(uko_uri="uko:x", content="x", score=score)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("modifying the vector result score should raise an error")
def step_then_vector_result_immutable(context: Any) -> None:
try:
context.vector_result.score = 0.1 # type: ignore[misc]
raise AssertionError("Expected FrozenInstanceError")
except FrozenInstanceError:
pass
# ---------------------------------------------------------------------------
# GraphResult steps
# ---------------------------------------------------------------------------
@given("a GraphResult with no triples")
def step_given_graph_result_empty(context: Any) -> None:
context.graph_result = GraphResult()
@given('a GraphResult with triple "{s}" "{p}" "{o}"')
def step_given_graph_result_triple(context: Any, s: str, p: str, o: str) -> None:
context.graph_result = GraphResult(triples=[(s, p, o)])
@then("the graph result triples should be empty")
def step_then_graph_result_triples_empty(context: Any) -> None:
assert context.graph_result.triples == []
@then("the graph result metadata should be empty")
def step_then_graph_result_metadata_empty(context: Any) -> None:
assert context.graph_result.metadata == {}
@then("the graph result should have {count:d} triple")
def step_then_graph_result_triple_count(context: Any, count: int) -> None:
assert len(context.graph_result.triples) == count
@then("modifying the graph result metadata should raise an error")
def step_then_graph_result_immutable(context: Any) -> None:
try:
context.graph_result.metadata = {"x": "y"} # type: ignore[misc]
raise AssertionError("Expected FrozenInstanceError")
except FrozenInstanceError:
pass
# ---------------------------------------------------------------------------
# InMemoryTextBackend steps
# ---------------------------------------------------------------------------
@given("an InMemoryTextBackend instance")
def step_given_text_backend(context: Any) -> None:
context.backend = InMemoryTextBackend()
@then("it should be a TextBackend")
def step_then_is_text_backend(context: Any) -> None:
assert isinstance(context.backend, TextBackend)
@when('I search for "{query}" with scope "{scope}"')
def step_when_text_search(context: Any, query: str, scope: str) -> None:
context.search_results = context.backend.search(query, scope=frozenset({scope}))
@when('I search for "{query}" with scope "{scope}" and max_results {n:d}')
def step_when_text_search_max(context: Any, query: str, scope: str, n: int) -> None:
context.search_results = context.backend.search(
query, scope=frozenset({scope}), max_results=n
)
@then("the text search results should be empty")
def step_then_text_search_empty(context: Any) -> None:
assert context.search_results == []
@then("searching with empty query should raise ValueError")
def step_then_text_search_empty_query(context: Any) -> None:
try:
context.backend.search("", scope=frozenset({"RES01"}))
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("searching with max_results {n:d} should raise ValueError")
def step_then_text_search_bad_max(context: Any, n: int) -> None:
try:
context.backend.search("q", scope=frozenset({"RES01"}), max_results=n)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# InMemoryVectorBackend steps
# ---------------------------------------------------------------------------
@given("an InMemoryVectorBackend instance")
def step_given_vector_backend(context: Any) -> None:
context.backend = InMemoryVectorBackend()
@then("it should be a VectorBackend")
def step_then_is_vector_backend(context: Any) -> None:
assert isinstance(context.backend, VectorBackend)
@when('I similarity search with embedding and scope "{scope}"')
def step_when_vector_search(context: Any, scope: str) -> None:
context.search_results = context.backend.similarity_search(
[0.1, 0.2, 0.3], scope=frozenset({scope})
)
@when('I similarity search with embedding and scope "{scope}" and top_k {k:d}')
def step_when_vector_search_topk(context: Any, scope: str, k: int) -> None:
context.search_results = context.backend.similarity_search(
[0.1, 0.2, 0.3], scope=frozenset({scope}), top_k=k
)
@then("the vector search results should be empty")
def step_then_vector_search_empty(context: Any) -> None:
assert context.search_results == []
@then("similarity searching with empty embedding should raise ValueError")
def step_then_vector_search_empty_embed(context: Any) -> None:
try:
context.backend.similarity_search([], scope=frozenset({"RES01"}))
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("similarity searching with top_k {k:d} should raise ValueError")
def step_then_vector_search_bad_topk(context: Any, k: int) -> None:
try:
context.backend.similarity_search([0.1], scope=frozenset({"RES01"}), top_k=k)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# InMemoryGraphBackend steps
# ---------------------------------------------------------------------------
@given("an InMemoryGraphBackend instance")
def step_given_graph_backend(context: Any) -> None:
context.backend = InMemoryGraphBackend()
@then("it should be a GraphBackend")
def step_then_is_graph_backend(context: Any) -> None:
assert isinstance(context.backend, GraphBackend)
@when('I run sparql query "{query}" with scope "{scope}"')
def step_when_sparql_query(context: Any, query: str, scope: str) -> None:
context.graph_query_result = context.backend.sparql_query(
query, scope=frozenset({scope})
)
@then("the graph query result should have no triples")
def step_then_graph_query_empty(context: Any) -> None:
assert context.graph_query_result.triples == []
@then("sparql querying with empty query should raise ValueError")
def step_then_sparql_empty_query(context: Any) -> None:
try:
context.backend.sparql_query("", scope=frozenset({"RES01"}))
raise AssertionError("Expected ValueError")
except ValueError:
pass
@when('I get triples for subject "{subject}"')
def step_when_get_triples(context: Any, subject: str) -> None:
context.graph_query_result = context.backend.get_triples(subject)
@then("getting triples with empty subject should raise ValueError")
def step_then_get_triples_empty_subject(context: Any) -> None:
try:
context.backend.get_triples("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@when('I traverse from "{start}" with depth {depth:d}')
def step_when_traverse(context: Any, start: str, depth: int) -> None:
context.graph_query_result = context.backend.traverse(start, depth=depth)
@then("traversing with empty start should raise ValueError")
def step_then_traverse_empty_start(context: Any) -> None:
try:
context.backend.traverse("")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("traversing with depth {depth:d} should raise ValueError")
def step_then_traverse_bad_depth(context: Any, depth: int) -> None:
try:
context.backend.traverse("uko:x", depth=depth)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# DI Container steps
# ---------------------------------------------------------------------------
@given("the DI container")
def step_given_container(context: Any) -> None:
reset_container()
context.container = get_container()
@then("the container should provide a text_backend")
def step_then_container_text(context: Any) -> None:
context.resolved_text = context.container.text_backend()
assert context.resolved_text is not None
@then("the text_backend should be a TextBackend")
def step_then_text_is_protocol(context: Any) -> None:
assert isinstance(context.resolved_text, TextBackend)
@then("the container should provide a vector_backend")
def step_then_container_vector(context: Any) -> None:
context.resolved_vector = context.container.vector_backend()
assert context.resolved_vector is not None
@then("the vector_backend should be a VectorBackend")
def step_then_vector_is_protocol(context: Any) -> None:
assert isinstance(context.resolved_vector, VectorBackend)
@then("the container should provide a graph_backend")
def step_then_container_graph(context: Any) -> None:
context.resolved_graph = context.container.graph_backend()
assert context.resolved_graph is not None
@then("the graph_backend should be a GraphBackend")
def step_then_graph_is_protocol(context: Any) -> None:
assert isinstance(context.resolved_graph, GraphBackend)
@then("resolving text_backend twice should return the same instance")
def step_then_text_singleton(context: Any) -> None:
a = context.container.text_backend()
b = context.container.text_backend()
assert a is b
@then("resolving vector_backend twice should return the same instance")
def step_then_vector_singleton(context: Any) -> None:
a = context.container.vector_backend()
b = context.container.vector_backend()
assert a is b
@then("resolving graph_backend twice should return the same instance")
def step_then_graph_singleton(context: Any) -> None:
a = context.container.graph_backend()
b = context.container.graph_backend()
assert a is b