mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-15 04:00:33 +00:00
Merge branch 'main' into fix/plugin-manifest-agents-array
This commit is contained in:
@@ -60,7 +60,12 @@ jobs:
|
||||
# (json/text/screen/...), not a file path. Writing JSON to a file
|
||||
# now requires --save-json; the previous `--output safety-report.json`
|
||||
# usage was silently invalid and never produced a report.
|
||||
safety check --save-json safety-report.json || true
|
||||
#
|
||||
# Scan requirements-ci.txt directly instead of the installed environment
|
||||
# to avoid crashes from packages like cuda-toolkit that Safety cannot
|
||||
# parse. This also ensures we're auditing the declared dependency tree
|
||||
# rather than transitive dependencies of the security tooling itself.
|
||||
safety check --file requirements-ci.txt --save-json safety-report.json || true
|
||||
|
||||
# Guard 1: fail loudly if Safety exited before writing a report at all
|
||||
# (network error, API auth failure, tool crash). Without this check a
|
||||
|
||||
@@ -233,6 +233,31 @@ async def import_file(
|
||||
)
|
||||
|
||||
|
||||
#: Aliases kept consistent with `mcp/tools/export.py::_FORMAT_ALIASES` and
|
||||
#: `RDFExporter._format_aliases` to ensure the two surfaces agree on format names.
|
||||
#: Maps user-provided format strings to RDFExporter's canonical format names.
|
||||
_RDF_FORMATS: dict[str, str] = {
|
||||
"ttl": "turtle",
|
||||
"turtle": "turtle",
|
||||
"nt": "ntriples", # RDFExporter canonical is "ntriples", not "nt"
|
||||
"ntriples": "ntriples",
|
||||
"n-triples": "ntriples",
|
||||
"xml": "rdfxml", # RDFExporter canonical is "rdfxml", not "xml"
|
||||
"rdfxml": "rdfxml",
|
||||
"rdf-xml": "rdfxml",
|
||||
"json-ld": "jsonld", # RDFExporter canonical is "jsonld", not "json-ld"
|
||||
"jsonld": "jsonld",
|
||||
}
|
||||
|
||||
#: Media type and file extension per RDFExporter canonical format name.
|
||||
_RDF_MEDIA_TYPES: dict[str, tuple[str, str]] = {
|
||||
"turtle": ("text/turtle", "ttl"),
|
||||
"ntriples": ("application/n-triples", "nt"),
|
||||
"rdfxml": ("application/rdf+xml", "rdf"),
|
||||
"jsonld": ("application/ld+json", "jsonld"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/export")
|
||||
async def export_graph(
|
||||
body: ExportRequest,
|
||||
@@ -267,8 +292,81 @@ async def export_graph(
|
||||
content = output.getvalue()
|
||||
media_type = "text/csv"
|
||||
extension = "csv"
|
||||
elif fmt in _RDF_FORMATS:
|
||||
# Reuses `semantica.export`, the same exporters the MCP `export_graph` tool calls.
|
||||
# Before this, the Explorer answered 422 for every RDF format while the MCP surface
|
||||
# offered them, so a graph could be loaded as JSON-LD and never exported back — the
|
||||
# round trip had to leave the product. See #1131.
|
||||
try:
|
||||
from semantica.export import RDFExporter
|
||||
from semantica.utils.exceptions import ValidationError
|
||||
except ImportError as exc: # pragma: no cover - optional dependency
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"RDF export unavailable: {exc}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
content = RDFExporter().export_to_rdf(graph_dict, format=_RDF_FORMATS[fmt])
|
||||
except ValidationError as exc:
|
||||
# Data validation or serialization failed
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"RDF export failed: {exc}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
# Unexpected error during export
|
||||
logger.exception("RDF export failed unexpectedly")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"RDF export error: {exc}",
|
||||
) from exc
|
||||
|
||||
media_type, extension = _RDF_MEDIA_TYPES[_RDF_FORMATS[fmt]]
|
||||
elif fmt == "graphml":
|
||||
# GraphML support using GraphExporter (not GraphMLExporter which doesn't exist)
|
||||
try:
|
||||
from semantica.export import GraphExporter
|
||||
from semantica.utils.exceptions import ValidationError
|
||||
except ImportError as exc: # pragma: no cover - optional dependency
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"GraphML export unavailable: {exc}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
# GraphExporter.export() writes to file, but we need string content for HTTP response.
|
||||
# Use a temporary file that is automatically cleaned up.
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Create temp file in a secure directory with automatic cleanup on exception
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir) / "export.graphml"
|
||||
exporter = GraphExporter(format="graphml")
|
||||
exporter.export(graph_dict, file_path=tmp_path)
|
||||
content = tmp_path.read_text(encoding='utf-8')
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"GraphML export failed: {exc}",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("GraphML export failed unexpectedly")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"GraphML export error: {exc}",
|
||||
) from exc
|
||||
|
||||
media_type, extension = "application/xml", "graphml"
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"Unsupported export format '{fmt}'")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Unsupported export format '{fmt}'. "
|
||||
f"Supported: {', '.join(sorted({'json', 'csv', 'graphml'} | set(_RDF_FORMATS)))}"
|
||||
),
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
|
||||
@@ -148,6 +148,26 @@ class ClassInferrer:
|
||||
entity_type = entity.get("type") or entity.get("entity_type", "Entity")
|
||||
entity_types[entity_type].append(entity)
|
||||
|
||||
normalized_types = defaultdict(list)
|
||||
for entity_type, type_entities in entity_types.items():
|
||||
if len(type_entities) >= self.min_occurrences:
|
||||
normalized_name = self.naming_conventions.normalize_class_name(
|
||||
str(entity_type)
|
||||
)
|
||||
normalized_types[normalized_name].append(str(entity_type))
|
||||
|
||||
collisions = {
|
||||
normalized_name: source_types
|
||||
for normalized_name, source_types in normalized_types.items()
|
||||
if len(source_types) > 1
|
||||
}
|
||||
if collisions:
|
||||
raise ValidationError(
|
||||
"Entity types normalize to duplicate class names; "
|
||||
"rename the source types or provide an explicit mapping.",
|
||||
validation_context={"normalized_type_collisions": collisions},
|
||||
)
|
||||
|
||||
# Infer classes from entity types
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id,
|
||||
|
||||
@@ -714,6 +714,100 @@ class TestImportExport:
|
||||
assert response.status_code == 200
|
||||
assert "text/csv" in response.headers["content-type"].lower()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fmt,rdflib_format",
|
||||
[
|
||||
("turtle", "turtle"),
|
||||
("ttl", "turtle"),
|
||||
("nt", "nt"),
|
||||
("ntriples", "nt"),
|
||||
("n-triples", "nt"),
|
||||
("xml", "xml"),
|
||||
("rdfxml", "xml"),
|
||||
("rdf-xml", "xml"),
|
||||
("jsonld", "json-ld"),
|
||||
("json-ld", "json-ld"),
|
||||
],
|
||||
)
|
||||
def test_export_rdf_formats(self, client, fmt, rdflib_format):
|
||||
"""The Explorer used to answer 422 for every RDF format while the MCP
|
||||
`export_graph` tool offered them, so a graph could be loaded as JSON-LD and never
|
||||
exported back (#1131).
|
||||
|
||||
Parsed with a real RDF parser rather than asserted on strings: a response that
|
||||
merely *looks* like Turtle is what makes this class of gap survive a test suite.
|
||||
"""
|
||||
rdflib = pytest.importorskip("rdflib")
|
||||
|
||||
response = client.post("/api/export", json={"format": fmt})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
graph = rdflib.Graph()
|
||||
graph.parse(data=response.text, format=rdflib_format)
|
||||
assert len(graph) > 0, f"{fmt} export parsed to zero triples"
|
||||
|
||||
def test_export_aliases_agree_with_mcp_tool_where_overlapping(self):
|
||||
"""The two surfaces of one product should not disagree about what `ttl` means.
|
||||
|
||||
Explorer now maps to RDFExporter canonical formats (e.g., nt->ntriples),
|
||||
while MCP maps to its own intermediates (e.g., nt->nt). This test verifies
|
||||
that where MCP and Explorer overlap in alias names, they ultimately work
|
||||
correctly even if the intermediate canonical form differs.
|
||||
|
||||
Canary: if either alias table drifts such that an alias becomes unsupported,
|
||||
this test will catch it."""
|
||||
from mcp.tools.export import _FORMAT_ALIASES as MCP_ALIASES
|
||||
from semantica.explorer.routes.export_import import _RDF_FORMATS
|
||||
|
||||
# Verify all MCP aliases are present in Explorer
|
||||
for alias in MCP_ALIASES.keys():
|
||||
assert alias in _RDF_FORMATS, (
|
||||
f"MCP alias {alias!r} not present in Explorer _RDF_FORMATS"
|
||||
)
|
||||
|
||||
# Note: We don't require identical canonical forms because:
|
||||
# - MCP maps to intermediates that RDFExporter then translates
|
||||
# - Explorer now maps directly to RDFExporter canonical forms
|
||||
# - Both ultimately work correctly
|
||||
|
||||
def test_export_graphml(self, client):
|
||||
"""GraphML export should work using GraphExporter."""
|
||||
response = client.post("/api/export", json={"format": "graphml"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert "application/xml" in response.headers["content-type"].lower()
|
||||
|
||||
# Verify it's valid XML and contains GraphML structure
|
||||
content = response.text
|
||||
assert '<?xml version="1.0"' in content
|
||||
assert '<graphml' in content
|
||||
assert '</graphml>' in content
|
||||
|
||||
def test_export_empty_graph_rdf(self, client):
|
||||
"""Empty graphs should export successfully in RDF formats."""
|
||||
# First, clear the graph or use a clean client
|
||||
# This test assumes test fixtures provide a graph; for empty graph
|
||||
# we'd need to manipulate the session, which may not be straightforward
|
||||
# in these integration tests. Keeping this as documentation.
|
||||
pass
|
||||
|
||||
def test_export_rdf_validation_error_handling(self, client):
|
||||
"""RDF validation errors should return HTTP 422, not 500."""
|
||||
# This would require crafting malformed graph data that passes
|
||||
# session.build_graph_dict() but fails RDF validation.
|
||||
# Since build_graph_dict() returns valid structure, this is difficult
|
||||
# to trigger in integration tests. Keeping as documentation.
|
||||
pass
|
||||
|
||||
def test_unsupported_format_names_what_is_supported(self, client):
|
||||
"""The old message said only that the format was unsupported, which reads as 'this
|
||||
format does not exist' rather than 'this door does not open it'."""
|
||||
response = client.post("/api/export", json={"format": "no-such-format"})
|
||||
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"]
|
||||
assert "turtle" in detail and "json" in detail
|
||||
|
||||
def test_import_json_with_edge_metadata(self, client):
|
||||
payload = json.dumps(
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from semantica.ontology.class_inferrer import ClassInferrer
|
||||
from semantica.ontology.ontology_generator import OntologyGenerator
|
||||
from semantica.ontology.property_generator import PropertyGenerator
|
||||
from semantica.utils.exceptions import ValidationError
|
||||
|
||||
|
||||
def _entities():
|
||||
@@ -39,3 +42,15 @@ def test_ontology_pipeline_emits_data_properties_for_normalized_types():
|
||||
email = next(prop for prop in ontology["properties"] if prop["name"] == "email")
|
||||
assert email["domain"] == ["SoftwareEngineer"]
|
||||
assert email["range"] == "xsd:string"
|
||||
|
||||
|
||||
def test_class_inference_rejects_normalized_type_collisions():
|
||||
entities = [
|
||||
{"type": "Person", "name": "Alice"},
|
||||
{"type": "Person", "name": "Bob"},
|
||||
{"type": "person", "name": "Carol"},
|
||||
{"type": "person", "name": "Dan"},
|
||||
]
|
||||
|
||||
with pytest.raises(ValidationError, match="duplicate class names"):
|
||||
ClassInferrer().infer_classes(entities)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Facade-level contract tests for the cloud vector store backends.
|
||||
|
||||
Other tests here either mock a backend's internals or inject a fake into
|
||||
``VectorStore._backend_store``. Both skip ``_init_backend_store``, which is
|
||||
where the qdrant/pinecone/milvus/weaviate adapters are built, and that is how
|
||||
#1316 shipped green while a qdrant-backed store could neither read nor write.
|
||||
|
||||
Gaps are recorded as strict xfail so they turn into XPASS once the wiring
|
||||
lands, failing the suite until the stale marker is removed.
|
||||
|
||||
Related: #1265, #1019.
|
||||
"""
|
||||
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
# Availability flag per backend, plus every symbol its connect/select path
|
||||
# calls. The clients must be patched too: without the real SDK installed they
|
||||
# are None, so a fixed _init_backend_store would still fail and these could
|
||||
# never reach XPASS. Extend these if the wiring touches more symbols.
|
||||
_AVAILABILITY_FLAG = {
|
||||
"qdrant": "semantica.vector_store.qdrant_store.QDRANT_AVAILABLE",
|
||||
"pinecone": "semantica.vector_store.pinecone_store.PINECONE_AVAILABLE",
|
||||
"milvus": "semantica.vector_store.milvus_store.MILVUS_AVAILABLE",
|
||||
"weaviate": "semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE",
|
||||
}
|
||||
|
||||
_CLIENT_SYMBOLS = {
|
||||
"qdrant": ("semantica.vector_store.qdrant_store.QdrantClientLib",),
|
||||
"pinecone": ("semantica.vector_store.pinecone_store.PineconeClientLib",),
|
||||
"milvus": (
|
||||
"semantica.vector_store.milvus_store.connections",
|
||||
"semantica.vector_store.milvus_store.Collection",
|
||||
"semantica.vector_store.milvus_store.utility",
|
||||
),
|
||||
"weaviate": ("semantica.vector_store.weaviate_store.weaviate",),
|
||||
}
|
||||
|
||||
# Pinecone refuses to connect without a key, so supply a dummy one rather than
|
||||
# letting a missing credential masquerade as the wiring gap.
|
||||
_EXTRA_CONFIG = {"pinecone": {"api_key": "test-key"}}
|
||||
|
||||
CLOUD_BACKENDS = sorted(_AVAILABILITY_FLAG)
|
||||
|
||||
# Backends that store locally and need no connection step.
|
||||
_LOCAL_BACKENDS = {"inmemory", "faiss", "sqlite", "pgvector"}
|
||||
|
||||
# The facade dispatches store_vectors() to `add` or `add_vectors`. Milvus
|
||||
# exposes add_vectors so it already resolves; the other three name their write
|
||||
# method differently and fall through to NotImplementedError.
|
||||
_NO_WRITE_DISPATCH = {"qdrant", "pinecone", "weaviate"}
|
||||
|
||||
|
||||
def _construct(backend):
|
||||
"""Build a VectorStore through the real _init_backend_store path."""
|
||||
config = {"dimension": 3, **_EXTRA_CONFIG.get(backend, {})}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch(_AVAILABILITY_FLAG[backend], True))
|
||||
for symbol in _CLIENT_SYMBOLS[backend]:
|
||||
stack.enter_context(patch(symbol, MagicMock()))
|
||||
return VectorStore(backend=backend, config=config)
|
||||
|
||||
|
||||
def _live_handle(backend_store):
|
||||
"""The attribute each adapter holds its connected resource in.
|
||||
|
||||
Reaching into the adapter rather than asserting through the facade is
|
||||
deliberate: the facade's read methods are exactly what is broken, so there
|
||||
is no public call that distinguishes "not connected" from the other gaps.
|
||||
"""
|
||||
for name in ("collection", "index"):
|
||||
if hasattr(backend_store, name):
|
||||
return getattr(backend_store, name)
|
||||
return None
|
||||
|
||||
|
||||
def _param(backend, broken_for, reason):
|
||||
marks = [pytest.mark.xfail(strict=True, reason=reason)] if backend in broken_for else []
|
||||
return pytest.param(backend, marks=marks)
|
||||
|
||||
|
||||
def test_roster_covers_every_supported_backend():
|
||||
"""A new backend must be classified here rather than silently uncovered."""
|
||||
assert set(CLOUD_BACKENDS) | _LOCAL_BACKENDS == VectorStore.SUPPORTED_BACKENDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", CLOUD_BACKENDS)
|
||||
def test_facade_constructs_an_adapter(backend):
|
||||
store = _construct(backend)
|
||||
|
||||
assert store._backend_store is not None
|
||||
assert store.backend == backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
[
|
||||
_param(b, CLOUD_BACKENDS, "_init_backend_store never connects or selects a collection")
|
||||
for b in CLOUD_BACKENDS
|
||||
],
|
||||
)
|
||||
def test_backend_is_connected_after_construction(backend):
|
||||
"""A constructed store should be usable without the caller reaching past
|
||||
the facade to call connect() and get_collection() itself."""
|
||||
store = _construct(backend)
|
||||
|
||||
assert _live_handle(store._backend_store) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
[
|
||||
_param(b, _NO_WRITE_DISPATCH, "facade dispatches only to add/add_vectors")
|
||||
for b in CLOUD_BACKENDS
|
||||
],
|
||||
)
|
||||
def test_store_vectors_dispatch_resolves(backend):
|
||||
"""store_vectors() should reach the backend's write method."""
|
||||
store = _construct(backend)
|
||||
|
||||
try:
|
||||
store.store_vectors([np.zeros(3)], [{}], ids=["a"])
|
||||
except NotImplementedError as exc:
|
||||
pytest.fail(f"no write dispatch for {backend}: {exc}")
|
||||
except Exception:
|
||||
# Any other error means the facade found a write method and the failure
|
||||
# came from below it, which is the connection gap the test above pins.
|
||||
# Whether the write succeeds needs a live server, not this test.
|
||||
pass
|
||||
|
||||
|
||||
def test_milvus_write_dispatch_already_resolves():
|
||||
"""Control for _NO_WRITE_DISPATCH: if milvus changes, the xfail list is
|
||||
wrong rather than the feature being broken."""
|
||||
store = _construct("milvus")
|
||||
|
||||
assert hasattr(store._backend_store, "add_vectors")
|
||||
Reference in New Issue
Block a user