diff --git a/semantica/export/json_exporter.py b/semantica/export/json_exporter.py index e31c39d8..733ae19a 100644 --- a/semantica/export/json_exporter.py +++ b/semantica/export/json_exporter.py @@ -306,10 +306,10 @@ class JSONExporter: 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 = { "@context": { - "@vocab": "https://semantica.dev/vocab/", "semantica": SEMANTICA_NS, "entities": {"@id": "semantica:entities", "@container": "@list"}, }, @@ -339,7 +339,6 @@ class JSONExporter: """ json_data = { "@context": { - "@vocab": "https://semantica.dev/vocab/", "semantica": SEMANTICA_NS, "relationships": { "@id": "semantica:relationships", @@ -434,11 +433,14 @@ class JSONExporter: Returns: 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 = { "@context": { - "@vocab": "https://semantica.dev/vocab/", - "semantica": "https://semantica.dev/ns#", + "semantica": SEMANTICA_NS, } } @@ -598,13 +600,21 @@ class JSONExporter: Returns: 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 = { "@context": { - "@vocab": "https://semantica.dev/vocab/", - "semantica": "https://semantica.dev/ns#", + "semantica": SEMANTICA_NS, "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "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 # (#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_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 = { "@id": entity_id, - "@type": entity.get("type") or "semantica:Entity", + "@type": "semantica:Entity", "semantica:text": entity.get("text") or entity.get("label", ""), "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: jsonld["semantica:metadata"] = entity["metadata"] diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index 318ccd39..b3bee1d9 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -1226,11 +1226,14 @@ class RDFSerializer: metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", 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 = { "@context": { - "@vocab": "https://semantica.dev/vocab/", - "semantica": "https://semantica.dev/ns#", + "semantica": SEMANTICA_NS, "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "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. 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 = { "@id": entity_id, - "@type": entity.get("type", "semantica:Entity"), + "@type": "semantica:Entity", "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)) if confidence is None: self.logger.warning( diff --git a/semantica/ontology/vocabulary/semantica-ns.ttl b/semantica/ontology/vocabulary/semantica-ns.ttl index ffe36df5..4f1b9871 100644 --- a/semantica/ontology/vocabulary/semantica-ns.ttl +++ b/semantica/ontology/vocabulary/semantica-ns.ttl @@ -70,7 +70,8 @@ sem:metadata a owl:AnnotationProperty ; rdfs:label "metadata" ; rdfs:comment """Free-form metadata carried through from extraction. An 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 . # ── Relationship terms (JSON-LD export) ────────────────────────────────────── @@ -96,10 +97,10 @@ sem:target a owl:ObjectProperty ; sem:type a owl:DatatypeProperty ; rdfs:label "type" ; - rdfs:comment """The relationship type as a label, as emitted in the JSON-LD -export. Distinct from rdf:type, which relates a node to a class rather than to -a string.""" ; - rdfs:domain sem:Relationship ; + rdfs:comment """The entity or relationship type as a label, as emitted in +the JSON-LD export. Distinct from rdf:type, which relates a node to a class +rather than to a string. Emitted for both entities and relationships, so the +domain is left open rather than tied to sem:Relationship.""" ; rdfs:range xsd:string ; rdfs:isDefinedBy . diff --git a/tests/export/test_jsonld_document_iri.py b/tests/export/test_jsonld_document_iri.py index 34b2366e..2f6b2c88 100644 --- a/tests/export/test_jsonld_document_iri.py +++ b/tests/export/test_jsonld_document_iri.py @@ -67,7 +67,7 @@ def test_merging_repeated_exports_yields_one_graph_node(tmp_path): assert len(kg_nodes) == 1 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 diff --git a/tests/export/test_vocab_namespace.py b/tests/export/test_vocab_namespace.py new file mode 100644 index 00000000..5fb4bcfc --- /dev/null +++ b/tests/export/test_vocab_namespace.py @@ -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