Merge pull request #1145 from fabio-rovai/jsonld-default-graph

Keep JSON-LD payloads in the default graph (#1144)
This commit is contained in:
Mohd Kaif
2026-08-21 19:21:11 +05:30
committed by GitHub
2 changed files with 305 additions and 4 deletions
+87 -4
View File
@@ -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.
+218
View File
@@ -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