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
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
496 lines
17 KiB
Python
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
|