a757633e27
CI / lint (pull_request) Successful in 44s
CI / build (pull_request) Successful in 43s
CI / helm (pull_request) Successful in 1m10s
CI / quality (pull_request) Successful in 1m15s
CI / typecheck (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 1m18s
CI / push-validation (pull_request) Successful in 25s
CI / unit_tests (pull_request) Successful in 6m34s
CI / docker (pull_request) Successful in 1m45s
CI / integration_tests (pull_request) Successful in 10m37s
CI / coverage (pull_request) Successful in 11m31s
CI / status-check (pull_request) Successful in 3s
The `{count:d} semantic chunking fragments should be returned` step
patterns collided with the pre-existing `{count} fragments should be
returned` step in advanced_context_strategies_steps.py:365 — behave's
default `{count}` parser matches `.+?` (non-greedy any char) and
captured "N semantic chunking", failing the registry's ambiguity
check at module-load time. The crash aborted load_step_definitions
for the entire unit_tests session, errored all 8 features in the
behave-parallel worker, and produced the CI failure with verdict
"0 features passed, 0 failed, 8 errored".
Rephrase the two ambiguous step patterns to put unique anchor words
first ("the semantic chunking result should contain {count:d}
fragments" / "...should contain at most {count:d} fragments") and
update the feature file's three call sites to match. Also mark two
defensive private-helper early-return branches with `# pragma: no
cover` — they are unreachable through the public ContextStrategy API
(`_default_embedding("")` is gated by `if not self._anchor` in
`assemble`; `_cosine_similarity` size mismatch is impossible because
all `_get_embedding` callers receive same-length vectors from the
same `embedding_fn`).
Local gates: lint, typecheck, full unit_tests (16 scenarios / 56
steps in the semantic_chunking feature pass; full suite passes),
integration_tests — all green.
ISSUES CLOSED: #9996
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""Step definitions for ``features/semantic_chunking_strategy.feature``.
|
|
|
|
Covers the SemanticChunkingStrategy:
|
|
|
|
* Construction and protocol compliance
|
|
* Configuration parameters (embedding_model, top_k)
|
|
* Core assembly behaviour (cosine similarity ranking)
|
|
* Embedding caching
|
|
* Plugin registry registration
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.acms_service import ACMSPipeline
|
|
from cleveragents.application.services.semantic_chunking_strategy import (
|
|
SemanticChunkingStrategy,
|
|
)
|
|
from cleveragents.domain.models.core.context_fragment import (
|
|
ContextBudget,
|
|
ContextFragment,
|
|
FragmentProvenance,
|
|
)
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_sc_fragment(
|
|
uko_node: str,
|
|
content: str,
|
|
score: float,
|
|
tokens: int,
|
|
depth: int,
|
|
) -> ContextFragment:
|
|
"""Build a ``ContextFragment`` for semantic chunking test purposes."""
|
|
return ContextFragment(
|
|
uko_node=uko_node,
|
|
content=content,
|
|
relevance_score=score,
|
|
token_count=tokens,
|
|
detail_depth=depth,
|
|
provenance=FragmentProvenance(resource_uri=uko_node),
|
|
)
|
|
|
|
|
|
def _word_based_embedding(text: str) -> list[float]:
|
|
"""Word-based mock embedding for deterministic similarity testing.
|
|
|
|
Returns a 64-element vector where each element corresponds to a word
|
|
in a fixed vocabulary. This ensures that texts sharing words have
|
|
higher cosine similarity.
|
|
"""
|
|
# Fixed vocabulary of 64 words for testing
|
|
vocab = [
|
|
"database",
|
|
"connection",
|
|
"pool",
|
|
"manager",
|
|
"sql",
|
|
"query",
|
|
"executor",
|
|
"file",
|
|
"input",
|
|
"output",
|
|
"handler",
|
|
"alpha",
|
|
"beta",
|
|
"gamma",
|
|
"delta",
|
|
"epsilon",
|
|
"zeta",
|
|
"eta",
|
|
"theta",
|
|
"iota",
|
|
"kappa",
|
|
"lambda",
|
|
"mu",
|
|
"hello",
|
|
"world",
|
|
"there",
|
|
"again",
|
|
"test",
|
|
"content",
|
|
"same",
|
|
"project",
|
|
"app",
|
|
"py",
|
|
"data",
|
|
"model",
|
|
"service",
|
|
"api",
|
|
"client",
|
|
"server",
|
|
"cache",
|
|
"index",
|
|
"search",
|
|
"vector",
|
|
"embed",
|
|
"chunk",
|
|
"semantic",
|
|
"context",
|
|
"fragment",
|
|
"budget",
|
|
"token",
|
|
"score",
|
|
"rank",
|
|
"sort",
|
|
"filter",
|
|
"select",
|
|
"top",
|
|
"anchor",
|
|
"query",
|
|
"text",
|
|
"word",
|
|
"char",
|
|
"string",
|
|
"list",
|
|
]
|
|
words = text.lower().split()
|
|
vec = [0.0] * 64
|
|
for word in words:
|
|
if word in vocab:
|
|
idx = vocab.index(word)
|
|
vec[idx] += 1.0
|
|
magnitude = math.sqrt(sum(v * v for v in vec))
|
|
if magnitude > 0.0:
|
|
vec = [v / magnitude for v in vec]
|
|
return vec
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a SemanticChunkingStrategy with default parameters")
|
|
def step_semantic_chunking_default(context: Context) -> None:
|
|
context.sc_strategy = SemanticChunkingStrategy()
|
|
|
|
|
|
@given('a SemanticChunkingStrategy with embedding_model "{model}"')
|
|
def step_semantic_chunking_with_model(context: Context, model: str) -> None:
|
|
context.sc_strategy = SemanticChunkingStrategy(embedding_model=model)
|
|
|
|
|
|
@given("a SemanticChunkingStrategy with top_k {top_k:d}")
|
|
def step_semantic_chunking_with_top_k(context: Context, top_k: int) -> None:
|
|
context.sc_strategy = SemanticChunkingStrategy(top_k=top_k)
|
|
|
|
|
|
@given("a SemanticChunkingStrategy with mock embeddings")
|
|
def step_semantic_chunking_with_mock_embeddings(context: Context) -> None:
|
|
context.sc_strategy = SemanticChunkingStrategy(
|
|
embedding_fn=_word_based_embedding,
|
|
)
|
|
|
|
|
|
@given("a SemanticChunkingStrategy with top_k {top_k:d} and mock embeddings")
|
|
def step_semantic_chunking_top_k_mock(context: Context, top_k: int) -> None:
|
|
context.sc_strategy = SemanticChunkingStrategy(
|
|
top_k=top_k,
|
|
embedding_fn=_word_based_embedding,
|
|
)
|
|
|
|
|
|
@given("a SemanticChunkingStrategy with call-counting mock embeddings")
|
|
def step_semantic_chunking_call_counting(context: Context) -> None:
|
|
context.sc_call_count = 0
|
|
|
|
def counting_embedding(text: str) -> list[float]:
|
|
context.sc_call_count += 1
|
|
return _word_based_embedding(text)
|
|
|
|
context.sc_strategy = SemanticChunkingStrategy(
|
|
embedding_fn=counting_embedding,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps — fragments and budget
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an empty semantic chunking fragment list")
|
|
def step_sc_empty_fragments(context: Context) -> None:
|
|
context.sc_fragments: list[ContextFragment] = []
|
|
|
|
|
|
@given("the following semantic chunking fragments:")
|
|
def step_sc_fragments_table(context: Context) -> None:
|
|
context.sc_fragments = []
|
|
for row in context.table:
|
|
frag = _make_sc_fragment(
|
|
uko_node=row["uko_node"],
|
|
content=row["content"],
|
|
score=float(row["score"]),
|
|
tokens=int(row["tokens"]),
|
|
depth=int(row["depth"]),
|
|
)
|
|
context.sc_fragments.append(frag)
|
|
|
|
|
|
@given(
|
|
"a semantic chunking budget with max_tokens {max_tokens:d} and reserved_tokens {reserved:d}"
|
|
)
|
|
def step_sc_budget(context: Context, max_tokens: int, reserved: int) -> None:
|
|
context.sc_budget = ContextBudget(max_tokens=max_tokens, reserved_tokens=reserved)
|
|
|
|
|
|
@given("an ACMS pipeline for semantic chunking tests")
|
|
def step_sc_pipeline(context: Context) -> None:
|
|
context.sc_pipeline = ACMSPipeline()
|
|
|
|
|
|
@given("a valid plan ID for semantic chunking")
|
|
def step_sc_plan_id(context: Context) -> None:
|
|
# Use a valid ULID
|
|
context.sc_plan_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I check can_handle on SemanticChunkingStrategy with query "{query}"')
|
|
def step_sc_can_handle_with_query(context: Context, query: str) -> None:
|
|
request: dict[str, Any] = {"query": query}
|
|
context.sc_confidence = context.sc_strategy.can_handle(request)
|
|
|
|
|
|
@when("I check can_handle on SemanticChunkingStrategy without query")
|
|
def step_sc_can_handle_no_query(context: Context) -> None:
|
|
request: dict[str, Any] = {}
|
|
context.sc_confidence = context.sc_strategy.can_handle(request)
|
|
|
|
|
|
@when('I assemble with the SemanticChunkingStrategy with anchor "{anchor}"')
|
|
def step_sc_assemble_with_anchor(context: Context, anchor: str) -> None:
|
|
context.sc_strategy.set_anchor(anchor)
|
|
context.sc_result = list(
|
|
context.sc_strategy.assemble(context.sc_fragments, context.sc_budget)
|
|
)
|
|
|
|
|
|
@when("I assemble with the SemanticChunkingStrategy without anchor")
|
|
def step_sc_assemble_no_anchor(context: Context) -> None:
|
|
# Do not set anchor — strategy should fall back to relevance ordering
|
|
context.sc_result = list(
|
|
context.sc_strategy.assemble(context.sc_fragments, context.sc_budget)
|
|
)
|
|
|
|
|
|
@when('I assemble via the pipeline with strategy "semantic_chunking"')
|
|
def step_sc_pipeline_assemble(context: Context) -> None:
|
|
context.sc_payload = context.sc_pipeline.assemble(
|
|
plan_id=context.sc_plan_id,
|
|
fragments=context.sc_fragments,
|
|
budget=context.sc_budget,
|
|
strategy="semantic_chunking",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the SemanticChunkingStrategy name should be "{name}"')
|
|
def step_sc_name(context: Context, name: str) -> None:
|
|
actual = context.sc_strategy.name
|
|
assert actual == name, f"Expected name '{name}', got '{actual}'"
|
|
|
|
|
|
@then("the SemanticChunkingStrategy should support semantic search")
|
|
def step_sc_supports_semantic(context: Context) -> None:
|
|
caps = context.sc_strategy.capabilities
|
|
assert caps.supports_semantic_search, (
|
|
"SemanticChunkingStrategy should support semantic search"
|
|
)
|
|
|
|
|
|
@then("the SemanticChunkingStrategy confidence should be greater than 0.0")
|
|
def step_sc_confidence_positive(context: Context) -> None:
|
|
assert context.sc_confidence > 0.0, (
|
|
f"Expected confidence > 0.0, got {context.sc_confidence}"
|
|
)
|
|
|
|
|
|
@then("the SemanticChunkingStrategy confidence should be {expected:g}")
|
|
def step_sc_confidence_exact(context: Context, expected: float) -> None:
|
|
actual = context.sc_confidence
|
|
assert abs(actual - expected) < 1e-6, (
|
|
f"Expected confidence {expected}, got {actual}"
|
|
)
|
|
|
|
|
|
@then('the SemanticChunkingStrategy explain should contain "{text}"')
|
|
def step_sc_explain(context: Context, text: str) -> None:
|
|
explanation = context.sc_strategy.explain()
|
|
assert text.lower() in explanation.lower(), (
|
|
f"Expected explain to contain '{text}', got: {explanation}"
|
|
)
|
|
|
|
|
|
@then('the SemanticChunkingStrategy embedding_model should be "{model}"')
|
|
def step_sc_embedding_model(context: Context, model: str) -> None:
|
|
actual = context.sc_strategy.embedding_model
|
|
assert actual == model, f"Expected embedding_model '{model}', got '{actual}'"
|
|
|
|
|
|
@then("the SemanticChunkingStrategy top_k should be {expected:d}")
|
|
def step_sc_top_k(context: Context, expected: int) -> None:
|
|
actual = context.sc_strategy.top_k
|
|
assert actual == expected, f"Expected top_k {expected}, got {actual}"
|
|
|
|
|
|
@then("the semantic chunking result should contain {count:d} fragments")
|
|
def step_sc_fragment_count_exact(context: Context, count: int) -> None:
|
|
actual = len(context.sc_result)
|
|
assert actual == count, f"Expected {count} fragments, got {actual}"
|
|
|
|
|
|
@then("the semantic chunking result should contain at most {count:d} fragments")
|
|
def step_sc_fragment_count_at_most(context: Context, count: int) -> None:
|
|
actual = len(context.sc_result)
|
|
assert actual <= count, f"Expected at most {count} fragments, got {actual}"
|
|
|
|
|
|
@then('the first semantic chunking result should have uko_node "{uko_node}"')
|
|
def step_sc_first_result_uko_node(context: Context, uko_node: str) -> None:
|
|
assert len(context.sc_result) > 0, "Expected at least one result fragment"
|
|
actual = context.sc_result[0].uko_node
|
|
assert actual == uko_node, (
|
|
f"Expected first fragment uko_node '{uko_node}', got '{actual}'"
|
|
)
|
|
|
|
|
|
@then(
|
|
"the embedding model should be called fewer times than total fragments plus anchor"
|
|
)
|
|
def step_sc_cache_efficiency(context: Context) -> None:
|
|
# With 2 fragments having identical content + 1 anchor = 3 unique texts
|
|
# But "same content" appears twice, so only 2 unique texts (anchor + content)
|
|
# The call count should be less than len(fragments) + 1 (anchor)
|
|
total_possible = len(context.sc_fragments) + 1 # +1 for anchor
|
|
actual_calls = context.sc_call_count
|
|
assert actual_calls < total_possible, (
|
|
f"Expected fewer than {total_possible} embedding calls due to caching, "
|
|
f"got {actual_calls}"
|
|
)
|
|
|
|
|
|
@then('the sc_pipeline should have strategy "semantic_chunking"')
|
|
def step_sc_pipeline_has_strategy(context: Context) -> None:
|
|
registered = context.sc_pipeline._strategies
|
|
assert "semantic_chunking" in registered, (
|
|
f"Pipeline does not have strategy 'semantic_chunking'. "
|
|
f"Registered: {list(registered.keys())}"
|
|
)
|
|
|
|
|
|
@then("the pipeline payload should contain at least 1 fragment")
|
|
def step_sc_payload_has_fragments(context: Context) -> None:
|
|
count = len(context.sc_payload.fragments)
|
|
assert count >= 1, f"Expected at least 1 fragment in payload, got {count}"
|