diff --git a/semantica/export/json_exporter.py b/semantica/export/json_exporter.py index c9c587de..0c3b492c 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,9 +416,29 @@ class JSONExporter: # Convert data based on type if isinstance(data, dict): + # 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 + # export_knowledge_graph handed its own output back to export(). + 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"}) else: # Generic dictionary - wrap in @graph jsonld["@graph"] = [data] @@ -412,13 +451,57 @@ 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 + """ + # 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 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 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) + 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..c66b9a1a --- /dev/null +++ b/tests/export/test_jsonld_default_graph.py @@ -0,0 +1,218 @@ +"""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()} + + +# 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