mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(export): @vocab mints into the shipped ns# namespace (#1236)
fix(export): keep caller data out of the shipped ns# namespace Every JSON-LD context set @vocab to https://semantica.dev/vocab/, which 404s, so every bare term in caller data (extracted entity/ relationship types, arbitrary metadata keys) minted under a namespace the package never ships. The obvious fix, pointing @vocab at SEMANTICA_NS instead, turned out to be worse than the dead link: since that namespace is real and populated, every bare term a caller happens to use now expands into something that looks like official Semantica vocabulary. An extracted type "ORG" became ns#ORG, a class the vocabulary never defines. A metadata key "source" attached a plain string value to sem:source, an owl:ObjectProperty that already exists in semantica-ns.ttl with a resource-valued range, silently corrupting its semantics. @vocab is now removed from all five contexts (four in json_exporter.py, one in rdf_exporter.py) rather than repointed. Every document already used explicit semantica: prefixes for its own terms, so nothing else in the output changes; an unscoped bare term now simply fails to expand, which is standard JSON-LD behavior for a context that doesn't know it, instead of being silently claimed by our namespace. Two call sites needed to stop handing caller data to @type/bare terms in the first place: - Entity nodes are always typed semantica:Entity now, with the caller's label carried as a semantica:type string instead of minted into @type. This matches how relationship nodes already carried their type. sem:type's domain in semantica-ns.ttl opens up to cover entities as well as relationships, following the sem:confidence precedent, since the property is now legitimately emitted for both. - semantica:metadata gets an explicit @json term definition, so a caller's metadata dict travels as one rdf:JSON literal instead of having its keys expand as separate predicates. A metadata key can no longer collide with a real ontology term no matter what the caller names it. Both JSONExporter and RDFExporter.serialize_to_jsonld got the same treatment, since they build separate JSON-LD structures for the same underlying data. The regression tests assert the negative space this bug lived in: no context declares @vocab, no caller type label appears as an rdf:type under ns#, and no caller metadata key appears as a predicate under ns# at all, only as content inside the single JSON literal. Closes #1146
This commit is contained in:
@@ -306,10 +306,10 @@ class JSONExporter:
|
|||||||
|
|
||||||
self.logger.debug(f"Exporting {len(entities)} entity(ies) to JSON")
|
self.logger.debug(f"Exporting {len(entities)} entity(ies) to JSON")
|
||||||
|
|
||||||
# Build JSON data with JSON-LD context
|
# Build JSON data with JSON-LD context. No @vocab: it would expand
|
||||||
|
# every bare key in the caller's entity dicts into ns# (#1146).
|
||||||
json_data = {
|
json_data = {
|
||||||
"@context": {
|
"@context": {
|
||||||
"@vocab": "https://semantica.dev/vocab/",
|
|
||||||
"semantica": SEMANTICA_NS,
|
"semantica": SEMANTICA_NS,
|
||||||
"entities": {"@id": "semantica:entities", "@container": "@list"},
|
"entities": {"@id": "semantica:entities", "@container": "@list"},
|
||||||
},
|
},
|
||||||
@@ -339,7 +339,6 @@ class JSONExporter:
|
|||||||
"""
|
"""
|
||||||
json_data = {
|
json_data = {
|
||||||
"@context": {
|
"@context": {
|
||||||
"@vocab": "https://semantica.dev/vocab/",
|
|
||||||
"semantica": SEMANTICA_NS,
|
"semantica": SEMANTICA_NS,
|
||||||
"relationships": {
|
"relationships": {
|
||||||
"@id": "semantica:relationships",
|
"@id": "semantica:relationships",
|
||||||
@@ -434,11 +433,14 @@ class JSONExporter:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary in JSON-LD format with @context, @graph/@value, and metadata
|
Dictionary in JSON-LD format with @context, @graph/@value, and metadata
|
||||||
"""
|
"""
|
||||||
# Initialize JSON-LD structure with context
|
# Initialize JSON-LD structure with context. No @vocab: for a generic
|
||||||
|
# payload it turned whatever bare keys the caller happened to use into
|
||||||
|
# ns# terms (#1146). Undeclared terms now simply expand to nothing,
|
||||||
|
# which is standard JSON-LD behaviour for a context that does not
|
||||||
|
# know them; the raw payload is still in the document.
|
||||||
jsonld = {
|
jsonld = {
|
||||||
"@context": {
|
"@context": {
|
||||||
"@vocab": "https://semantica.dev/vocab/",
|
"semantica": SEMANTICA_NS,
|
||||||
"semantica": "https://semantica.dev/ns#",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,13 +600,21 @@ class JSONExporter:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary in JSON-LD format with @context, @id, @type, and graph data
|
Dictionary in JSON-LD format with @context, @id, @type, and graph data
|
||||||
"""
|
"""
|
||||||
# Initialize JSON-LD structure with RDF context
|
# Initialize JSON-LD structure with RDF context. No @vocab: it applied
|
||||||
|
# to every bare term in caller data, so an extracted type like "ORG"
|
||||||
|
# became ns#ORG and a metadata key like "source" collided with the
|
||||||
|
# real sem:source object property (#1146). Only explicit semantica:
|
||||||
|
# terms resolve now, and the caller's metadata dict is typed @json so
|
||||||
|
# it survives as one rdf:JSON literal instead of expanding its keys.
|
||||||
jsonld = {
|
jsonld = {
|
||||||
"@context": {
|
"@context": {
|
||||||
"@vocab": "https://semantica.dev/vocab/",
|
"semantica": SEMANTICA_NS,
|
||||||
"semantica": "https://semantica.dev/ns#",
|
|
||||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||||
|
"semantica:metadata": {
|
||||||
|
"@id": "semantica:metadata",
|
||||||
|
"@type": "@json",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
# Minted from the graph's own content rather than the wall clock
|
# Minted from the graph's own content rather than the wall clock
|
||||||
# (#1147): re-exporting an unchanged graph must produce the same
|
# (#1147): re-exporting an unchanged graph must produce the same
|
||||||
@@ -664,14 +674,23 @@ class JSONExporter:
|
|||||||
entity_text = entity.get("text") or entity.get("label", "unknown")
|
entity_text = entity.get("text") or entity.get("label", "unknown")
|
||||||
entity_id = entity.get("id") or mint_entity_iri(entity_text)
|
entity_id = entity.get("id") or mint_entity_iri(entity_text)
|
||||||
|
|
||||||
|
# The caller's type label is data, not a class we define: minting it
|
||||||
|
# into @type expanded it through @vocab into ns#ORG and friends, terms
|
||||||
|
# that look official but do not exist (#1146). The node is always a
|
||||||
|
# semantica:Entity and the label travels as semantica:type, exactly
|
||||||
|
# how _relationship_to_jsonld has always carried the relationship type.
|
||||||
jsonld = {
|
jsonld = {
|
||||||
"@id": entity_id,
|
"@id": entity_id,
|
||||||
"@type": entity.get("type") or "semantica:Entity",
|
"@type": "semantica:Entity",
|
||||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||||
"semantica:confidence": entity.get("confidence", 1.0),
|
"semantica:confidence": entity.get("confidence", 1.0),
|
||||||
}
|
}
|
||||||
|
entity_type = entity.get("type")
|
||||||
|
if entity_type:
|
||||||
|
jsonld["semantica:type"] = entity_type
|
||||||
|
|
||||||
# Add metadata if present
|
# Add metadata if present. The @json term definition on
|
||||||
|
# semantica:metadata keeps the whole dict one rdf:JSON literal.
|
||||||
if "metadata" in entity:
|
if "metadata" in entity:
|
||||||
jsonld["semantica:metadata"] = entity["metadata"]
|
jsonld["semantica:metadata"] = entity["metadata"]
|
||||||
|
|
||||||
|
|||||||
@@ -1226,11 +1226,14 @@ class RDFSerializer:
|
|||||||
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
||||||
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
||||||
|
|
||||||
# Initialize JSON-LD structure with context
|
# Initialize JSON-LD structure with context. No @vocab: it applied to
|
||||||
|
# every bare term in caller data, so an extracted type like "ORG"
|
||||||
|
# became ns#ORG and a metadata key like "source" collided with the
|
||||||
|
# real sem:source object property (#1146). Only explicit semantica:
|
||||||
|
# terms resolve now.
|
||||||
jsonld = {
|
jsonld = {
|
||||||
"@context": {
|
"@context": {
|
||||||
"@vocab": "https://semantica.dev/vocab/",
|
"semantica": SEMANTICA_NS,
|
||||||
"semantica": "https://semantica.dev/ns#",
|
|
||||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||||
},
|
},
|
||||||
@@ -1252,11 +1255,20 @@ class RDFSerializer:
|
|||||||
# and was dropped in full by a JSON-LD parser, silently.
|
# and was dropped in full by a JSON-LD parser, silently.
|
||||||
entity_id = entity.get("id") or mint_entity_iri(entity.get("text", ""))
|
entity_id = entity.get("id") or mint_entity_iri(entity.get("text", ""))
|
||||||
|
|
||||||
|
# The caller's type label is data, not a class we define: minting
|
||||||
|
# it into @type expanded it through @vocab into ns#ORG and
|
||||||
|
# friends, terms that look official but do not exist (#1146).
|
||||||
|
# The node is always a semantica:Entity and the label travels as
|
||||||
|
# semantica:type, matching the relationship node below and
|
||||||
|
# JSONExporter._entity_to_jsonld.
|
||||||
node = {
|
node = {
|
||||||
"@id": entity_id,
|
"@id": entity_id,
|
||||||
"@type": entity.get("type", "semantica:Entity"),
|
"@type": "semantica:Entity",
|
||||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||||
}
|
}
|
||||||
|
entity_type = entity.get("type")
|
||||||
|
if entity_type:
|
||||||
|
node["semantica:type"] = entity_type
|
||||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||||
if confidence is None:
|
if confidence is None:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ sem:metadata a owl:AnnotationProperty ;
|
|||||||
rdfs:label "metadata" ;
|
rdfs:label "metadata" ;
|
||||||
rdfs:comment """Free-form metadata carried through from extraction. An
|
rdfs:comment """Free-form metadata carried through from extraction. An
|
||||||
annotation property because its value is an arbitrary structure rather than a
|
annotation property because its value is an arbitrary structure rather than a
|
||||||
modelled one.""" ;
|
modelled one; in the JSON-LD export the whole mapping is written as one
|
||||||
|
rdf:JSON literal so caller keys never expand into this namespace (#1146).""" ;
|
||||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||||
|
|
||||||
# ── Relationship terms (JSON-LD export) ──────────────────────────────────────
|
# ── Relationship terms (JSON-LD export) ──────────────────────────────────────
|
||||||
@@ -96,10 +97,10 @@ sem:target a owl:ObjectProperty ;
|
|||||||
|
|
||||||
sem:type a owl:DatatypeProperty ;
|
sem:type a owl:DatatypeProperty ;
|
||||||
rdfs:label "type" ;
|
rdfs:label "type" ;
|
||||||
rdfs:comment """The relationship type as a label, as emitted in the JSON-LD
|
rdfs:comment """The entity or relationship type as a label, as emitted in
|
||||||
export. Distinct from rdf:type, which relates a node to a class rather than to
|
the JSON-LD export. Distinct from rdf:type, which relates a node to a class
|
||||||
a string.""" ;
|
rather than to a string. Emitted for both entities and relationships, so the
|
||||||
rdfs:domain sem:Relationship ;
|
domain is left open rather than tied to sem:Relationship.""" ;
|
||||||
rdfs:range xsd:string ;
|
rdfs:range xsd:string ;
|
||||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ def test_merging_repeated_exports_yields_one_graph_node(tmp_path):
|
|||||||
assert len(kg_nodes) == 1
|
assert len(kg_nodes) == 1
|
||||||
|
|
||||||
entity_nodes = set(
|
entity_nodes = set(
|
||||||
merged.subjects(RDF.type, URIRef("https://semantica.dev/vocab/ORG"))
|
merged.subjects(RDF.type, URIRef("https://semantica.dev/ns#Entity"))
|
||||||
)
|
)
|
||||||
assert len(entity_nodes) == 1
|
assert len(entity_nodes) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Caller data must never expand into the Semantica namespace (#1146).
|
||||||
|
|
||||||
|
``@vocab`` used to sit in every JSON-LD context pointing at ``ns#``, so every
|
||||||
|
bare term in caller data expanded into it: an extracted type like ``"ORG"``
|
||||||
|
became ``ns#ORG`` (a term the vocabulary does not define), and a metadata key
|
||||||
|
like ``"source"`` collided with the real ``sem:source`` object property,
|
||||||
|
attaching a plain string to a property whose range is a resource. The fix
|
||||||
|
removes ``@vocab`` outright: only explicit ``semantica:``-prefixed terms
|
||||||
|
resolve, caller type labels travel as ``semantica:type`` strings, and caller
|
||||||
|
metadata survives as one ``rdf:JSON`` literal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from rdflib import RDF, Graph, Literal, URIRef
|
||||||
|
|
||||||
|
from semantica.export.json_exporter import JSONExporter
|
||||||
|
from semantica.export.rdf_exporter import RDFExporter, SEMANTICA_NS
|
||||||
|
|
||||||
|
NS = SEMANTICA_NS
|
||||||
|
E1 = "https://example.org/e1"
|
||||||
|
|
||||||
|
KG = {
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": E1,
|
||||||
|
"text": "Acme",
|
||||||
|
"type": "ORG",
|
||||||
|
"metadata": {"source": "crm_export_2024"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relationships": [
|
||||||
|
{
|
||||||
|
"source_id": E1,
|
||||||
|
"target_id": "https://example.org/e2",
|
||||||
|
"type": "employs",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonld_file(exporter, kind, tmp_path, name):
|
||||||
|
path = tmp_path / name
|
||||||
|
if kind == "knowledge_graph":
|
||||||
|
exporter.export_knowledge_graph(KG, path, format="json-ld")
|
||||||
|
elif kind == "entities":
|
||||||
|
exporter.export_entities(KG["entities"], path, format="json-ld")
|
||||||
|
elif kind == "relationships":
|
||||||
|
exporter.export_relationships(KG["relationships"], path, format="json-ld")
|
||||||
|
elif kind == "generic":
|
||||||
|
exporter.export({"note": "plain payload, no @id"}, path, format="json-ld")
|
||||||
|
else:
|
||||||
|
raise AssertionError(kind)
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_jsonld_context_declares_a_vocab(tmp_path):
|
||||||
|
exporter = JSONExporter()
|
||||||
|
for kind in ("knowledge_graph", "entities", "relationships", "generic"):
|
||||||
|
context = _jsonld_file(exporter, kind, tmp_path, f"{kind}.jsonld")[
|
||||||
|
"@context"
|
||||||
|
]
|
||||||
|
assert "@vocab" not in context, f"{kind}: @vocab expands caller data"
|
||||||
|
|
||||||
|
context = json.loads(RDFExporter().export_to_rdf(KG, format="jsonld"))[
|
||||||
|
"@context"
|
||||||
|
]
|
||||||
|
assert "@vocab" not in context
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracted_type_labels_stay_out_of_the_namespace(tmp_path):
|
||||||
|
path = tmp_path / "kg.jsonld"
|
||||||
|
JSONExporter().export_knowledge_graph(KG, path, format="json-ld")
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.parse(str(path), format="json-ld")
|
||||||
|
|
||||||
|
assert (None, RDF.type, URIRef(NS + "ORG")) not in graph, (
|
||||||
|
"the caller's type label was minted as a class in ns#"
|
||||||
|
)
|
||||||
|
assert (URIRef(E1), RDF.type, URIRef(NS + "Entity")) in graph
|
||||||
|
assert (URIRef(E1), URIRef(NS + "type"), Literal("ORG")) in graph, (
|
||||||
|
"the label itself must survive, as a string"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_keys_stay_out_of_the_namespace(tmp_path):
|
||||||
|
path = tmp_path / "kg.jsonld"
|
||||||
|
JSONExporter().export_knowledge_graph(KG, path, format="json-ld")
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.parse(str(path), format="json-ld")
|
||||||
|
|
||||||
|
assert (None, URIRef(NS + "source"), Literal("crm_export_2024")) not in (
|
||||||
|
graph
|
||||||
|
), "caller metadata value attached to the real sem:source object property"
|
||||||
|
for _, _, o in graph.triples((None, URIRef(NS + "source"), None)):
|
||||||
|
assert not isinstance(o, Literal), (
|
||||||
|
"sem:source has a resource range but received a plain literal"
|
||||||
|
)
|
||||||
|
|
||||||
|
literals = [
|
||||||
|
o
|
||||||
|
for o in graph.objects(None, URIRef(NS + "metadata"))
|
||||||
|
if isinstance(o, Literal)
|
||||||
|
]
|
||||||
|
assert literals, "the metadata dict was dropped instead of preserved"
|
||||||
|
assert literals[0].datatype == RDF.JSON
|
||||||
|
assert json.loads(str(literals[0])) == {"source": "crm_export_2024"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdf_exporter_jsonld_keeps_type_labels_out_of_the_namespace():
|
||||||
|
graph = Graph()
|
||||||
|
graph.parse(
|
||||||
|
data=RDFExporter().export_to_rdf(KG, format="jsonld"), format="json-ld"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (None, RDF.type, URIRef(NS + "ORG")) not in graph
|
||||||
|
assert (URIRef(E1), RDF.type, URIRef(NS + "Entity")) in graph
|
||||||
|
assert (URIRef(E1), URIRef(NS + "type"), Literal("ORG")) in graph
|
||||||
Reference in New Issue
Block a user