Files
cleveragents-core/features/steps/uko_indexer_backend_steps.py
hamza.khyari 1e606553d4
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m32s
CI / integration_tests (pull_request) Successful in 3m18s
CI / docker (pull_request) Successful in 49s
CI / coverage (pull_request) Successful in 5m29s
CI / benchmark-regression (pull_request) Successful in 33m48s
feat(acms): implement Real-time Index Sync / UKOIndexer with pluggable analyzers
Implement the UKOIndexer service that produces UKO triples from resources
using pluggable domain-specific analyzers, wraps each triple with provenance
metadata, and simultaneously indexes into text, vector, and graph backends.

Key design decisions and components:

- UKOIndexer orchestrates the full index lifecycle: add_resource,
  update_resource (remove-then-add), remove_resource, and maintenance
  triggers. Each operation fires lifecycle hooks (on_indexed, on_removed,
  on_error) so callers can observe progress.

- Analyzer selection is pluggable via ContentAnalyzer protocol. The indexer
  accepts a registry mapping resource types to analyzers. PythonAnalyzer
  and MarkdownAnalyzer are provided as built-in implementations.

- LocationContentReader protocol abstracts file I/O with a base_dir
  parameter for path-traversal prevention (post-resolve validation rejects
  paths escaping the base directory and non-regular files).

- UKOTriple model includes a @model_validator ensuring at least one of
  object_uri or object_value is populated, preventing empty triples at
  construction time.

- Triple removal uses scoped deletion via uko:sourceResource predicate to
  avoid shared-subject collision — only triples originating from the
  specific resource are removed, not all triples for a shared subject.

- _resource_subjects.pop is deferred until after all backend removal
  operations succeed, preventing inconsistent state on partial failure.

- analyzer.analyze() is wrapped in try/except so that analyzer errors
  produce an IndexResult with error details rather than propagating
  exceptions to callers.

- All lifecycle hook calls are guarded via _fire_on_indexed,
  _fire_on_removed, and _fire_on_error helpers that catch and log hook
  exceptions without disrupting the indexing pipeline.

- max_triples parameter (default 50,000) bounds analyzer output size to
  prevent runaway resource consumption.

- ResourceFileWatcher monitors filesystem paths via watchdog and triggers
  re-indexing callbacks on file changes with configurable debouncing.
  Emits RESOURCE_MODIFIED domain events via EventBus when file changes
  are detected. Debounce timers coalesce rapid edits into a single
  callback invocation. Thread-safe design with daemon threads for clean
  shutdown.

- SearchResult.__post_init__ validates score is in [0.0, 1.0], correctly
  rejecting NaN values.

- Placeholder embedding uses [1.0] instead of [float(len(content))] to
  avoid leaking content size information.

- isinstance check on graph_backend ensures GraphIndexBackend protocol
  compliance at runtime.

- Test doubles extracted to features/mocks/uko_indexer_mocks.py for reuse
  across BDD steps and Robot helpers.

Spec reference: Architecture > ACMS > Real-time Index Synchronization
(specification.md lines ~43205-43300).

ISSUES CLOSED: #578
2026-03-11 00:40:07 +00:00

496 lines
17 KiB
Python

"""Text/Vector/Graph backend steps + Provenance + IndexResult steps."""
from __future__ import annotations
from typing import Any
from behave import then, when # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.domain.models.acms.analyzers import UKOTriple
from cleveragents.domain.models.acms.index_backends import (
IndexedDocument,
SearchResult,
)
from cleveragents.domain.models.acms.provenance import (
IndexResult,
ProvenancedTriple,
ProvenanceMetadata,
)
from features.steps.uko_indexer_common import ULID_1
__all__: list[str] = []
@when(
'idx I index a text document "{doc_id}" with content "{content}" in project "{project}"'
)
def step_index_text_doc(context: Any, doc_id: str, content: str, project: str) -> None:
context.idx_text_backend.index_document(project, doc_id, content, {})
@then('idx searching "{query}" in project "{project}" should return {count:d} result')
def step_search_text_singular(
context: Any, query: str, project: str, count: int
) -> None:
results = context.idx_text_backend.search(project, query)
assert len(results) == count, f"Expected {count} result, got {len(results)}"
context.idx_search_results = results
@then('idx searching "{query}" in project "{project}" should return {count:d} results')
def step_search_text_plural(context: Any, query: str, project: str, count: int) -> None:
results = context.idx_text_backend.search(project, query)
assert len(results) == count, f"Expected {count} results, got {len(results)}"
context.idx_search_results = results
@then('idx the search result doc_id should be "{doc_id}"')
def step_check_search_doc_id(context: Any, doc_id: str) -> None:
assert context.idx_search_results[0].doc_id == doc_id
@when('idx I remove text document "{doc_id}" from project "{project}"')
def step_remove_text_doc(context: Any, doc_id: str, project: str) -> None:
context.idx_text_backend.remove_document(project, doc_id)
@when('idx I rebuild text index for project "{project}"')
def step_rebuild_text_index(context: Any, project: str) -> None:
context.idx_text_backend.rebuild_index(project)
@then("idx indexing a text document with empty project should raise ValueError")
def step_text_empty_project(context: Any) -> None:
try:
context.idx_text_backend.index_document("", "doc", "content", {})
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx indexing a text document with empty content should raise ValueError")
def step_text_empty_content(context: Any) -> None:
try:
context.idx_text_backend.index_document("proj", "doc", "", {})
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx searching with empty query should raise ValueError")
def step_text_empty_query(context: Any) -> None:
try:
context.idx_text_backend.search("proj", "")
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx searching with limit 0 should raise ValueError")
def step_text_zero_limit(context: Any) -> None:
try:
context.idx_text_backend.search("proj", "q", limit=0)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@when(
'idx I index an embedding "{doc_id}" with vector "{vector_str}" in project "{project}"'
)
def step_index_embedding(
context: Any, doc_id: str, vector_str: str, project: str
) -> None:
embedding = [float(v) for v in vector_str.split(",")]
context.idx_vector_backend.index_embedding(project, doc_id, embedding, {})
@then('idx searching similar in project "{project}" should return {count:d} result')
def step_search_similar_singular(context: Any, project: str, count: int) -> None:
results = context.idx_vector_backend.search_similar(project, [1.0])
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then('idx searching similar in project "{project}" should return {count:d} results')
def step_search_similar_plural(context: Any, project: str, count: int) -> None:
results = context.idx_vector_backend.search_similar(project, [1.0])
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then(
"idx searching similar in project"
' "{project}" with min_relevance {threshold} should return {count:d} results'
)
def step_search_similar_min_relevance(
context: Any,
project: str,
threshold: str,
count: int,
) -> None:
results = context.idx_vector_backend.search_similar(
project,
[1.0],
min_relevance=float(threshold),
)
assert len(results) == count, f"Expected {count}, got {len(results)}"
@then("idx searching similar with min_relevance {threshold} should raise ValueError")
def step_search_similar_invalid_min_relevance(
context: Any,
threshold: str,
) -> None:
try:
context.idx_vector_backend.search_similar(
"local/app",
[1.0],
min_relevance=float(threshold),
)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@when('idx I remove embedding "{doc_id}" from project "{project}"')
def step_remove_embedding(context: Any, doc_id: str, project: str) -> None:
context.idx_vector_backend.remove_embedding(project, doc_id)
@then("idx indexing an embedding with empty vector should raise ValueError")
def step_vector_empty_embedding(context: Any) -> None:
try:
context.idx_vector_backend.index_embedding("proj", "doc", [], {})
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@when('idx I add a triple "{subj}" "{pred}" "{obj}" in project "{project}"')
def step_add_triple(context: Any, subj: str, pred: str, obj: str, project: str) -> None:
context.idx_graph_backend.add_triple(project, subj, pred, obj)
@then('idx querying project "{project}" should return {count:d} binding')
def step_query_graph_singular(context: Any, project: str, count: int) -> None:
results = context.idx_graph_backend.query(project, "SELECT * WHERE { ?s ?p ?o }")
assert len(results) == count, f"Expected {count}, got {len(results)}"
context.idx_graph_results = results
@then('idx querying project "{project}" should return {count:d} bindings')
def step_query_graph_plural(context: Any, project: str, count: int) -> None:
results = context.idx_graph_backend.query(project, "SELECT * WHERE { ?s ?p ?o }")
assert len(results) == count, f"Expected {count}, got {len(results)}"
context.idx_graph_results = results
@then('idx the binding should have subject "{subject}"')
def step_check_binding_subject(context: Any, subject: str) -> None:
assert context.idx_graph_results[0]["s"] == subject
@when('idx I remove triples with subject "{subject}" from project "{project}"')
def step_remove_triples(context: Any, subject: str, project: str) -> None:
context.idx_graph_backend.remove_triples(
project, subject=subject, predicate=None, obj=None
)
@then("idx removing triples with all-None pattern should raise ValueError")
def step_graph_all_none(context: Any) -> None:
try:
context.idx_graph_backend.remove_triples("proj", None, None, None)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx adding a triple with empty project should raise ValueError")
def step_graph_empty_project(context: Any) -> None:
try:
context.idx_graph_backend.add_triple("", "s", "p", "o")
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@when('idx I create provenance for resource "{resource_id}"')
def step_create_provenance(context: Any, resource_id: str) -> None:
context.idx_provenance = ProvenanceMetadata(source_resource=resource_id)
@then('idx the provenance should have source_resource "{resource_id}"')
def step_check_provenance_resource(context: Any, resource_id: str) -> None:
assert context.idx_provenance.source_resource == resource_id
@then("idx the provenance should be current")
def step_check_provenance_current(context: Any) -> None:
assert context.idx_provenance.is_current is True
@then("idx the provenance should have a valid_from timestamp")
def step_check_provenance_timestamp(context: Any) -> None:
assert context.idx_provenance.valid_from is not None
@then("idx creating provenance with empty source_resource should raise ValidationError")
def step_provenance_empty_resource(context: Any) -> None:
try:
ProvenanceMetadata(source_resource="")
assert False, "Expected ValidationError" # noqa: B011
except ValidationError:
pass
@when('idx I create a provenanced triple for resource "{resource_id}"')
def step_create_provenanced_triple(context: Any, resource_id: str) -> None:
triple = UKOTriple(
subject_uri="uko://s", predicate="uko:type", object_uri="uko://C"
)
provenance = ProvenanceMetadata(source_resource=resource_id)
context.idx_pt = ProvenancedTriple(triple=triple, provenance=provenance)
@then("idx the provenanced triple should have the triple")
def step_check_pt_triple(context: Any) -> None:
assert context.idx_pt.triple.subject_uri == "uko://s"
@then("idx the provenanced triple should have the provenance")
def step_check_pt_provenance(context: Any) -> None:
assert context.idx_pt.provenance.source_resource == ULID_1
@then("idx mutating the provenance should raise ValidationError")
def step_provenance_frozen(context: Any) -> None:
try:
context.idx_provenance.is_current = False # type: ignore[misc]
assert False, "Expected error" # noqa: B011
except ValidationError:
# Pydantic frozen models raise ValidationError on mutation
pass
# Verify the object wasn't actually mutated
assert context.idx_provenance.is_current is True
@when("idx I create an IndexResult with {triples:d} triples and {text:d} text doc")
def step_create_index_result(context: Any, triples: int, text: int) -> None:
context.idx_result = IndexResult(
resource_id=ULID_1,
triple_count=triples,
text_docs_indexed=text,
analyzer_domain="python",
)
@then("idx the IndexResult should have triple_count {count:d}")
def step_check_ir_triples(context: Any, count: int) -> None:
assert context.idx_result.triple_count == count
@then("idx the IndexResult should have text_docs_indexed {count:d}")
def step_check_ir_text(context: Any, count: int) -> None:
assert context.idx_result.text_docs_indexed == count
@then('idx the IndexResult should have analyzer_domain "{domain}"')
def step_check_ir_domain(context: Any, domain: str) -> None:
assert context.idx_result.analyzer_domain == domain
@then(
"idx creating IndexResult with negative triple_count should raise ValidationError"
)
def step_ir_negative(context: Any) -> None:
try:
IndexResult(resource_id=ULID_1, triple_count=-1)
assert False, "Expected ValidationError" # noqa: B011
except ValidationError:
pass
@then("idx creating SearchResult with score 1.5 should raise ValueError")
def step_search_result_bad_score(context: Any) -> None:
try:
SearchResult(doc_id="d", content="c", score=1.5)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx creating SearchResult with empty doc_id should raise ValueError")
def step_search_result_empty_id(context: Any) -> None:
try:
SearchResult(doc_id="", content="c", score=0.5)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx creating IndexedDocument with negative char_count should raise ValueError")
def step_indexed_doc_negative(context: Any) -> None:
try:
IndexedDocument(project="p", doc_id="d", char_count=-1)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx creating IndexedDocument with empty project should raise ValueError")
def step_indexed_doc_empty_project(context: Any) -> None:
try:
IndexedDocument(project="", doc_id="d")
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then(
"idx the graph backend should contain triple"
' with predicate "{predicate}" and object "{obj}"'
)
def step_graph_contains_triple(context: Any, predicate: str, obj: str) -> None:
backend = context.idx_graph_backend
project = getattr(context, "idx_project", "local/test")
bindings = backend.query(project, "SELECT *")
matched = [b for b in bindings if b["p"] == predicate and b["o"] == obj]
assert len(matched) > 0, (
f"No triple with predicate={predicate!r} object={obj!r} found in {bindings}"
)
@then('idx the graph backend should not contain triple with object "{obj}"')
def step_graph_not_contains_object(context: Any, obj: str) -> None:
backend = context.idx_graph_backend
project = getattr(context, "idx_project", "local/test")
bindings = backend.query(project, "SELECT *")
matched = [b for b in bindings if b["o"] == obj]
assert len(matched) == 0, (
f"Found unexpected triple(s) with object={obj!r}: {matched}"
)
@then('idx the provenance source_resource should be "{expected}"')
def step_provenance_source_resource(context: Any, expected: str) -> None:
pt = context.idx_pt
assert pt.provenance.source_resource == expected, (
f"Expected source_resource={expected!r}, got {pt.provenance.source_resource!r}"
)
@then('idx the provenance source_path should be "{expected}"')
def step_provenance_source_path(context: Any, expected: str) -> None:
pt = context.idx_pt
assert pt.provenance.source_path == expected, (
f"Expected source_path={expected!r}, got {pt.provenance.source_path!r}"
)
@then(
"idx indexing a text document with whitespace-only project should raise ValueError"
)
def step_text_whitespace_project(context: Any) -> None:
try:
context.idx_text_backend.index_document(" ", "doc", "content", {})
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then(
"idx indexing a text document with whitespace-only content should raise ValueError"
)
def step_text_whitespace_content(context: Any) -> None:
try:
context.idx_text_backend.index_document("proj", "doc", " ", {})
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx adding a triple with whitespace-only subject should raise ValueError")
def step_graph_whitespace_subject(context: Any) -> None:
try:
context.idx_graph_backend.add_triple("proj", " ", "p", "o")
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then('idx the text backend should contain doc_id "{doc_id}" in project "{project}"')
def step_text_contains_doc(context: Any, doc_id: str, project: str) -> None:
key = (project, doc_id)
assert key in context.idx_text_backend._docs, (
f"doc_id {doc_id!r} not found in text backend for project {project!r}"
)
@then('idx the vector backend should contain doc_id "{doc_id}" in project "{project}"')
def step_vector_contains_doc(context: Any, doc_id: str, project: str) -> None:
key = (project, doc_id)
assert key in context.idx_vector_backend._embeddings, (
f"doc_id {doc_id!r} not found in vector backend for project {project!r}"
)
@then("idx creating IndexResult with empty resource_id should raise ValidationError")
def step_ir_empty_resource_id(context: Any) -> None:
try:
IndexResult(resource_id="")
assert False, "Expected ValidationError" # noqa: B011
except ValidationError:
pass
@then("idx creating SearchResult with score -0.1 should raise ValueError")
def step_search_result_negative_score(context: Any) -> None:
try:
SearchResult(doc_id="d", content="c", score=-0.1)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx creating SearchResult with NaN score should raise ValueError")
def step_search_result_nan_score(context: Any) -> None:
try:
SearchResult(doc_id="d", content="c", score=float("nan"))
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx removing triples with empty-string subject should raise ValueError")
def step_graph_empty_string_subject(context: Any) -> None:
try:
context.idx_graph_backend.remove_triples(
"proj", subject="", predicate=None, obj=None
)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx removing triples with empty-string predicate should raise ValueError")
def step_graph_empty_string_predicate(context: Any) -> None:
try:
context.idx_graph_backend.remove_triples(
"proj", subject="s", predicate="", obj=None
)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass
@then("idx removing triples with empty-string obj should raise ValueError")
def step_graph_empty_string_obj(context: Any) -> None:
try:
context.idx_graph_backend.remove_triples(
"proj", subject="s", predicate=None, obj=""
)
assert False, "Expected ValueError" # noqa: B011
except ValueError:
pass