From 60eb595d6216588dda8d50d9bd0ff2ba821fb90d Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Thu, 20 Aug 2026 10:14:46 +0100 Subject: [PATCH 1/2] fix(export): keep JSON-LD payloads in the default graph A JSON-LD document with a top-level @id and a top-level @graph is a named graph. Its members become quads named by that @id, and the default graph is left empty. rdflib.Graph.parse() keeps the default graph and discards the rest without reporting anything, so every consumer that loads an export the ordinary way saw the document header and none of the data. _convert_to_jsonld wrote the payload into @graph and then stamped a document @id beside it, which named every list export and every generic-dict export. export_knowledge_graph made it worse: it converted the graph to JSON-LD and handed the finished document back to export(), which converted it a second time. The converted document no longer carries entities/relationships keys, so the second pass treated it as opaque and buried the whole knowledge graph inside @graph, under a name that is a wall-clock timestamp. A two-entity, one-relationship graph exported to JSON-LD parsed as 2 triples with Graph() and 21 quads with Dataset(). The 19 missing triples were the entire knowledge graph. The document node now goes inside @graph when the payload lives there, and is the document itself otherwise, so no export names its own graph by accident. An already-converted document is merged rather than nested, which also stops the export carrying two document nodes and two @context blocks. Semantica's reader has the mirror of this bug (#1129), so these exports could not be read back by Semantica either. --- semantica/export/json_exporter.py | 71 ++++++++++- tests/export/test_jsonld_default_graph.py | 143 ++++++++++++++++++++++ 2 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 tests/export/test_jsonld_default_graph.py diff --git a/semantica/export/json_exporter.py b/semantica/export/json_exporter.py index c9c587de..c1906250 100644 --- a/semantica/export/json_exporter.py +++ b/semantica/export/json_exporter.py @@ -34,6 +34,25 @@ from ..utils.progress_tracker import get_progress_tracker from .rdf_exporter import SEMANTICA_NS, mint_entity_iri, mint_relationship_iri +def _is_jsonld_document(data: Dict[str, Any]) -> bool: + """ + Report whether a dictionary is already a JSON-LD document. + + ``export_knowledge_graph`` converts a knowledge graph to JSON-LD and then + hands the finished document to ``export()``, which converted it a second + time. The converted document no longer carries ``entities``/ + ``relationships`` keys, so the second pass treated it as an opaque value and + buried it inside ``@graph``. + + Args: + data: Dictionary to test + + Returns: + True when the dictionary declares a JSON-LD context + """ + return "@context" in data + + class JSONExporter: """ JSON exporter for knowledge graphs and semantic data. @@ -397,7 +416,16 @@ class JSONExporter: # Convert data based on type if isinstance(data, dict): - if "entities" in data or "relationships" in data: + if _is_jsonld_document(data): + # Already JSON-LD: merge it rather than nesting it. Wrapping a + # converted document in @graph re-typed the payload as a named + # graph and doubled the @context, which is what happened when + # export_knowledge_graph handed its own output back to export(). + context = data.get("@context") + if isinstance(context, dict): + jsonld["@context"].update(context) + jsonld.update({k: v for k, v in data.items() if k != "@context"}) + elif "entities" in data or "relationships" in data: # Knowledge graph structure - use specialized conversion jsonld.update(self._convert_kg_to_jsonld(data, **options)) else: @@ -412,13 +440,46 @@ class JSONExporter: # Add metadata and provenance if requested if include_metadata: - jsonld["@id"] = f"https://semantica.dev/data/{utc_now_iso()}" - if include_provenance: - jsonld["semantica:exportedAt"] = utc_now_iso() - jsonld["semantica:format"] = "json-ld" + self._attach_document_metadata(jsonld, include_provenance) return jsonld + @staticmethod + def _attach_document_metadata( + jsonld: Dict[str, Any], include_provenance: bool + ) -> None: + """ + Attach the export's own metadata without naming the graph. + + A top-level ``@id`` alongside a top-level ``@graph`` is a *named graph*: + the members of ``@graph`` become quads named by that ``@id`` and leave + the default graph empty. ``rdflib.Graph.parse()`` keeps only the default + graph, so every statement in the export was discarded without an error + (2 of 21 statements survived a two-entity knowledge graph). When the + payload lives in ``@graph``, the document node goes in beside it as one + more node; otherwise it is the document itself. + + Args: + jsonld: Document being built, modified in place + include_provenance: Whether to record how and when it was exported + """ + document: Dict[str, Any] = {} + # Do not overwrite an identifier the payload already carries: the + # knowledge-graph conversion names its own document node. + if "@id" not in jsonld: + document["@id"] = f"https://semantica.dev/data/{utc_now_iso()}" + if include_provenance: + document["semantica:exportedAt"] = utc_now_iso() + document["semantica:format"] = "json-ld" + + if "@graph" in jsonld: + document.setdefault( + "@id", f"https://semantica.dev/data/{utc_now_iso()}" + ) + jsonld["@graph"] = list(jsonld["@graph"]) + [document] + else: + jsonld.update(document) + def _convert_kg_to_json(self, kg: Dict[str, Any], **options) -> Dict[str, Any]: """ Convert knowledge graph to JSON format. diff --git a/tests/export/test_jsonld_default_graph.py b/tests/export/test_jsonld_default_graph.py new file mode 100644 index 00000000..8d3f08a6 --- /dev/null +++ b/tests/export/test_jsonld_default_graph.py @@ -0,0 +1,143 @@ +"""Every JSON-LD export must put its payload in the default graph. + +A JSON-LD document carrying a top-level ``@id`` *and* a top-level ``@graph`` is +a **named graph**: the contents of ``@graph`` are quads named by that ``@id``, +not triples in the default graph. ``rdflib.Graph.parse()`` — the ordinary way a +Python consumer loads RDF — keeps the default graph and discards the rest, +without an error. ``JSONExporter`` emitted exactly that shape: + +* ``_convert_to_jsonld`` wrote the payload into ``@graph`` and then stamped a + document ``@id`` beside it, so every list export and every generic-dict + export was named; +* ``export_knowledge_graph`` converted the graph to JSON-LD and handed the + finished document back to ``export()``, which converted it a *second* time. + The converted document no longer has ``entities``/``relationships`` keys, so + the second pass treated it as opaque and wrapped it in ``@graph`` — burying + a whole knowledge graph, entities, relationships and all, inside a named + graph whose name is a wall-clock timestamp. + +Measured on v0.6.6: a two-entity, one-relationship graph exported to JSON-LD +parsed as **2 triples** with ``Graph()`` and 21 quads with ``Dataset()``. The 19 +missing triples were the entire knowledge graph, and nothing reported a +problem. Semantica's own reader has the mirror of this bug (#1129), so the +export could not even be read back by Semantica. +""" + +import json + +import pytest +from rdflib import Dataset, Graph + +from semantica.export.json_exporter import JSONExporter + +KG = { + "entities": [ + {"id": "https://example.org/e1", "text": "Acme Corp", "type": "ORG"}, + {"id": "https://example.org/e2", "text": "Jane Roe", "type": "PERSON"}, + ], + "relationships": [ + { + "source_id": "https://example.org/e1", + "target_id": "https://example.org/e2", + "type": "employs", + } + ], + "metadata": {"source_document": "contract.pdf"}, +} + +RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label" + +PAYLOADS = { + "knowledge_graph": KG, + "list": [ + {"@id": "https://example.org/a", RDFS_LABEL: "A"}, + {"@id": "https://example.org/b", RDFS_LABEL: "B"}, + ], + "generic_dict": {"@id": "https://example.org/x", RDFS_LABEL: "X"}, +} + + +def _write(payload, tmp_path, name="out.jsonld", **options): + path = tmp_path / name + JSONExporter().export(payload, path, format="json-ld", **options) + return path + + +def _counts(path): + """Triples a default-graph reader sees, and quads a quad reader sees.""" + graph = Graph() + graph.parse(str(path), format="json-ld") + dataset = Dataset() + dataset.parse(str(path), format="json-ld") + return len(graph), sum(1 for _ in dataset.quads((None, None, None, None))) + + +@pytest.mark.parametrize("name", sorted(PAYLOADS)) +def test_no_export_hides_its_payload_in_a_named_graph(name, tmp_path): + """A top-level @id beside a top-level @graph names the graph.""" + path = _write(PAYLOADS[name], tmp_path, f"{name}.jsonld") + document = json.loads(path.read_text()) + + assert not ("@id" in document and "@graph" in document), ( + f"{name}: @id + @graph at the top level makes a named graph, " + "which a default-graph reader discards in full" + ) + + +@pytest.mark.parametrize("name", sorted(PAYLOADS)) +def test_a_plain_graph_reader_loses_nothing(name, tmp_path): + """Graph() and Dataset() must agree: no triple may live outside the default graph.""" + path = _write(PAYLOADS[name], tmp_path, f"{name}.jsonld") + triples, quads = _counts(path) + + assert triples == quads, ( + f"{name}: Graph() read {triples} of {quads} statements; " + f"{quads - triples} were dropped silently" + ) + + +def test_exported_knowledge_graph_survives_a_default_graph_read(tmp_path): + """The entities and the relationship must be there after a plain parse.""" + path = tmp_path / "kg.jsonld" + JSONExporter().export_knowledge_graph(KG, path, format="json-ld") + + graph = Graph() + graph.parse(str(path), format="json-ld") + subjects = {str(s) for s in graph.subjects()} + objects = {str(o) for o in graph.objects()} + + assert "https://example.org/e1" in subjects + assert "https://example.org/e2" in subjects + assert "Acme Corp" in objects + assert "Jane Roe" in objects + assert "employs" in objects + + +def test_knowledge_graph_is_not_converted_twice(tmp_path): + """A nested @context is the signature of the document being re-converted.""" + path = tmp_path / "kg.jsonld" + JSONExporter().export_knowledge_graph(KG, path, format="json-ld") + document = json.loads(path.read_text()) + + nested = [ + node + for node in document.get("@graph", []) + if isinstance(node, dict) and "@context" in node + ] + assert nested == [], "the knowledge graph was converted, then converted again" + + +def test_document_provenance_still_reaches_the_default_graph(tmp_path): + """Keeping the payload readable must not cost the export its own metadata.""" + path = _write(PAYLOADS["list"], tmp_path) + + graph = Graph() + graph.parse(str(path), format="json-ld") + predicates = {str(p) for p in graph.predicates()} + + assert "https://semantica.dev/ns#exportedAt" in predicates + assert "https://semantica.dev/ns#format" in predicates + assert {"https://example.org/a", "https://example.org/b"} <= { + str(s) for s in graph.subjects() + } + assert {"A", "B"} <= {str(o) for o in graph.objects()} From 8f6948f85dd26cb6fec33900c272bf9c7f6aceec Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Thu, 20 Aug 2026 10:22:18 +0100 Subject: [PATCH 2/2] fix(export): close the four review gaps in the default-graph change All four are in the branch that recognises an already-converted document, which has to survive every shape JSON-LD allows rather than the one shape Semantica happens to produce. A knowledge graph carrying a context of its own took the already-JSON-LD branch and skipped its own conversion, leaving entity ids, relationship endpoints, types and confidences as raw keys. The entities/relationships test now runs first, and a converted document never has those keys, so the double-conversion guard is unaffected. A context that is a URL or an array cannot be merged key by key, and was being dropped in favour of Semantica's defaults, silently changing how every term expands. Both are kept as an array now, the caller's winning, which is the same precedence the dictionary branch already used. An explicit null is left alone on purpose: in an array it resets the active context and would take the semantica prefix with it. @graph may be a single node object as well as an array. list() on a dictionary yields its keys, so an object-valued graph was replaced by a list of strings. A caller may hand us a document that is deliberately a named graph. That name is theirs to keep, so it is no longer flattened; it is nested one level and the export's own provenance goes beside it, in the default graph, where a plain reader can see it. Four tests, one per case, all failing before this commit. --- semantica/export/json_exporter.py | 42 ++++++++++--- tests/export/test_jsonld_default_graph.py | 75 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/semantica/export/json_exporter.py b/semantica/export/json_exporter.py index c1906250..0c3b492c 100644 --- a/semantica/export/json_exporter.py +++ b/semantica/export/json_exporter.py @@ -416,7 +416,13 @@ class JSONExporter: # Convert data based on type if isinstance(data, dict): - if _is_jsonld_document(data): + # A knowledge graph is converted even when it carries a context of + # its own: the specialized conversion is what mints entity ids and + # relationship endpoints, and skipping it leaves them raw keys. + if "entities" in data or "relationships" in data: + # Knowledge graph structure - use specialized conversion + jsonld.update(self._convert_kg_to_jsonld(data, **options)) + elif _is_jsonld_document(data): # Already JSON-LD: merge it rather than nesting it. Wrapping a # converted document in @graph re-typed the payload as a named # graph and doubled the @context, which is what happened when @@ -424,10 +430,15 @@ class JSONExporter: context = data.get("@context") if isinstance(context, dict): jsonld["@context"].update(context) + elif context is not None: + # A context may also be a URL or an array of them, which + # cannot be merged key by key. Keeping both as an array + # preserves the caller's term expansion, which wins over + # ours, while still defining the semantica prefix. An + # explicit null is left alone: in an array it would reset + # the active context and take our own terms with it. + jsonld["@context"] = [jsonld["@context"], context] jsonld.update({k: v for k, v in data.items() if k != "@context"}) - elif "entities" in data or "relationships" in data: - # Knowledge graph structure - use specialized conversion - jsonld.update(self._convert_kg_to_jsonld(data, **options)) else: # Generic dictionary - wrap in @graph jsonld["@graph"] = [data] @@ -463,20 +474,31 @@ class JSONExporter: jsonld: Document being built, modified in place include_provenance: Whether to record how and when it was exported """ + # A caller may hand us a document that is deliberately a named graph. + # That name is theirs to keep, but our own statements must not end up + # inside it, where a default-graph reader would never see them. + payload_is_named_graph = "@id" in jsonld and "@graph" in jsonld + document: Dict[str, Any] = {} # Do not overwrite an identifier the payload already carries: the # knowledge-graph conversion names its own document node. - if "@id" not in jsonld: + if "@id" not in jsonld or payload_is_named_graph: document["@id"] = f"https://semantica.dev/data/{utc_now_iso()}" if include_provenance: document["semantica:exportedAt"] = utc_now_iso() document["semantica:format"] = "json-ld" - if "@graph" in jsonld: - document.setdefault( - "@id", f"https://semantica.dev/data/{utc_now_iso()}" - ) - jsonld["@graph"] = list(jsonld["@graph"]) + [document] + if payload_is_named_graph: + named = {key: value for key, value in jsonld.items() if key != "@context"} + for key in [key for key in jsonld if key != "@context"]: + del jsonld[key] + jsonld["@graph"] = [named, document] + elif "@graph" in jsonld: + # @graph may be a single node object as well as an array. list() on + # a dictionary yields its keys, which would discard the node. + members = jsonld["@graph"] + members = list(members) if isinstance(members, list) else [members] + jsonld["@graph"] = members + [document] else: jsonld.update(document) diff --git a/tests/export/test_jsonld_default_graph.py b/tests/export/test_jsonld_default_graph.py index 8d3f08a6..c66b9a1a 100644 --- a/tests/export/test_jsonld_default_graph.py +++ b/tests/export/test_jsonld_default_graph.py @@ -141,3 +141,78 @@ def test_document_provenance_still_reaches_the_default_graph(tmp_path): str(s) for s in graph.subjects() } assert {"A", "B"} <= {str(o) for o in graph.objects()} + + +# The document a caller hands to export() need not be one Semantica built, and +# the branch that recognises an already-converted document has to survive every +# shape JSON-LD allows. Each of the four cases below regressed when that branch +# was first written. + + +def test_a_url_valued_context_is_not_thrown_away(tmp_path): + """@context may be a URL or an array, not only an object.""" + payload = { + "@context": "https://schema.org/", + "@id": "https://example.org/thing", + "name": "Acme Corp", + } + path = _write(payload, tmp_path) + context = json.loads(path.read_text())["@context"] + + flattened = context if isinstance(context, list) else [context] + assert "https://schema.org/" in flattened, ( + "the caller's context was replaced by Semantica's defaults, " + "which silently changes how every term expands" + ) + + +def test_a_graph_given_as_one_node_object_survives(tmp_path): + """@graph may be a single node object; list() on it yields its keys.""" + payload = { + "@context": {"rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@graph": {"@id": "https://example.org/only", "rdfs:label": "Only"}, + } + path = _write(payload, tmp_path) + + graph = Graph() + graph.parse(str(path), format="json-ld") + assert "Only" in {str(o) for o in graph.objects()} + + +def test_a_caller_supplied_named_graph_keeps_our_provenance_readable(tmp_path): + """A deliberate named graph stays named, but must not swallow the export's own metadata.""" + payload = { + "@context": {"rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@id": "https://example.org/named", + "@graph": [{"@id": "https://example.org/n1", "rdfs:label": "N1"}], + } + path = _write(payload, tmp_path) + document = json.loads(path.read_text()) + + assert "@id" not in document or "@graph" not in document + + graph = Graph() + graph.parse(str(path), format="json-ld") + predicates = {str(p) for p in graph.predicates()} + assert "https://semantica.dev/ns#exportedAt" in predicates, ( + "the export's provenance was written inside the caller's named graph, " + "where a default-graph reader cannot see it" + ) + + dataset = Dataset() + dataset.parse(str(path), format="json-ld") + names = {str(c.identifier) for c in dataset.graphs()} + assert "https://example.org/named" in names, "the caller's graph lost its name" + + +def test_a_knowledge_graph_carrying_a_context_is_still_converted(tmp_path): + """entities/relationships must win over the already-JSON-LD branch.""" + payload = dict(KG, **{"@context": {"ex": "https://example.org/ns#"}}) + path = _write(payload, tmp_path) + document = json.loads(path.read_text()) + + assert "semantica:entities" in document, ( + "the knowledge graph skipped its own conversion, so entity ids, " + "endpoints, types and confidences were left as raw keys" + ) + assert "entities" not in document