mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #1145 from fabio-rovai/jsonld-default-graph
Keep JSON-LD payloads in the default graph (#1144)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user