Merge pull request #1113 from cxzg007/fix/rdf-name-label-normalization

fix(export): normalize entity name to label on all RDF paths
This commit is contained in:
Mohd Kaif
2026-08-27 16:08:53 +05:30
committed by GitHub
2 changed files with 200 additions and 6 deletions
+48 -5
View File
@@ -642,6 +642,37 @@ class RDFSerializer:
self.logger.debug("RDF serializer initialized")
@staticmethod
def _local_name_from_id(identifier: str) -> str:
"""Derive a human-readable local name from an entity identifier.
Handles HTTP(S)/IRI identifiers (path segments and fragments, tolerating
trailing slashes) as well as compact/CURIE and URN-style identifiers.
"""
raw = str(identifier).strip()
if not raw:
return ""
# Prefer a fragment if present (e.g. http://ex.org/onto#acme -> acme).
if "#" in raw:
candidate = raw.rsplit("#", 1)[-1]
if candidate:
return candidate
# For IRIs/paths, take the last non-empty path segment.
if "/" in raw:
segment = raw.rstrip("/").rsplit("/", 1)[-1]
if segment:
return segment
# Fall back to the tail of a CURIE/URN (e.g. urn:x:acme, semantica:acme).
if ":" in raw:
candidate = raw.rsplit(":", 1)[-1]
if candidate:
return candidate
return raw
def convert_kg_to_rdf(self, knowledge_graph: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert knowledge graph to RDF data structure.
@@ -681,8 +712,11 @@ class RDFSerializer:
if "name" in norm_entity:
norm_entity["label"] = norm_entity["name"]
elif "id" in norm_entity:
# Use ID part as label if no name/text
norm_entity["label"] = str(norm_entity["id"]).split(":")[-1]
# Derive a readable label from the identifier's local name
# (fragment/last path segment/CURIE tail). See #1097.
local_name = self._local_name_from_id(norm_entity["id"])
if local_name:
norm_entity["label"] = local_name
rdf_data["entities"].append(norm_entity)
@@ -1082,8 +1116,9 @@ class RDFSerializer:
# RDF/XML syntax: rdf:Description with rdf:about
# Attribute values are delimited by quotes, and both of these
# are caller input. Element text is left alone deliberately: that
# is #1098, and it is being fixed on its own path.
# are caller input. Element text (semantica:text) is caller input
# too, so it needs the same escaping to avoid injecting markup
# or breaking out of the element (#1097 / #1113).
entity_iri = xml_escape(
self._as_turtle_iri(entity_id, namespaces), quote=True
)
@@ -1092,7 +1127,9 @@ class RDFSerializer:
)
lines.append(f' <rdf:Description rdf:about="{entity_iri}">')
lines.append(f' <rdf:type rdf:resource="{entity_type_iri}"/>')
lines.append(f" <semantica:text>{text}</semantica:text>")
lines.append(
f" <semantica:text>{xml_escape(text)}</semantica:text>"
)
if confidence is None:
self.logger.warning(
f"Entity {entity_id} has a confidence that is not a number "
@@ -1714,6 +1751,12 @@ class RDFExporter:
self.logger.debug(f"Exporting to RDF format: {format}")
# Normalize the graph before serialization so every format benefits
# from field normalization (e.g. mapping 'name' -> 'label'/'text').
# Without this, graphs produced by GraphBuilder (which emit 'name')
# export with an empty semantica:text on all RDF paths. See #1097.
data = self.serializer.convert_kg_to_rdf(data)
self.progress_tracker.update_tracking(
tracking_id, message="Validating RDF data..."
)
+152 -1
View File
@@ -1,8 +1,8 @@
"""Tests for RDFExporter format alias resolution (issue #355)."""
import pytest
from semantica.export import RDFExporter
from semantica.export import RDFExporter
RDF_DATA = {
"entities": [
@@ -89,3 +89,154 @@ def test_validate_rdf_returns_overall_valid_key(exporter):
result = exporter.validate_rdf(RDF_DATA)
assert "overall_valid" in result
assert isinstance(result["overall_valid"], bool)
# --- Regression tests for #1097 -------------------------------------------
# convert_kg_to_rdf() normalizes an entity's 'name' into 'label'/'text' but was
# never called from the export path, so GraphBuilder graphs (which emit 'name')
# exported with an empty semantica:text on every RDF format. These tests assert
# the human-readable label survives export on all four serializers.
NAME_ONLY_DATA = {
"entities": [
{
"id": "https://example.org/acme",
"name": "Acme Corp",
"type": "https://example.org/Org",
"confidence": 0.91,
},
],
"relationships": [],
}
@pytest.mark.parametrize("fmt", ["turtle", "ntriples", "rdfxml", "jsonld"])
def test_name_only_entity_exports_nonempty_label(exporter, fmt):
"""A GraphBuilder-style 'name'-only entity must export a non-empty label.
Regression for #1097: previously every RDF path dropped the label because
convert_kg_to_rdf() was never invoked from export_to_rdf().
"""
result = exporter.export_to_rdf(NAME_ONLY_DATA, format=fmt)
assert "Acme Corp" in result
# The empty-text pattern that the bug produced must not appear.
assert 'semantica:text ""' not in result
def test_name_only_export_to_file_contains_label(exporter, tmp_path):
"""The file-writing entry point must also normalize name -> label (#1097)."""
out = tmp_path / "acme.ttl"
exporter.export(NAME_ONLY_DATA, str(out), format="turtle")
content = out.read_text()
assert "Acme Corp" in content
assert 'semantica:text ""' not in content
def test_existing_text_not_overwritten_by_name(exporter):
"""An entity that already has 'text' must keep it, not be clobbered by 'name' (#1097)."""
data = {
"entities": [
{
"id": "https://example.org/acme",
"name": "Acme Corp",
"text": "Explicit Text",
"type": "https://example.org/Org",
}
],
"relationships": [],
}
result = exporter.export_to_rdf(data, format="turtle")
assert "Explicit Text" in result
assert "Acme Corp" not in result
def test_id_fallback_label_when_no_name(exporter):
"""With neither 'name' nor 'text', the id local-name is used as label (#1097)."""
data = {
"entities": [
{"id": "https://example.org/acme", "type": "https://example.org/Org"}
],
"relationships": [],
}
result = exporter.export_to_rdf(data, format="turtle")
assert 'semantica:text ""' not in result
# The label must be the URI local name 'acme', not '//example.org/acme'.
assert 'semantica:text "acme"' in result
@pytest.mark.parametrize(
"identifier,expected",
[
("https://example.org/acme", "acme"),
("https://example.org/path/acme", "acme"),
("https://example.org/onto#acme", "acme"),
("https://example.org/acme/", "acme"),
("urn:example:acme", "acme"),
("semantica:acme", "acme"),
("acme", "acme"),
],
)
def test_id_fallback_local_name_extraction(exporter, identifier, expected):
"""The id fallback must extract a URI-aware local name, not a colon split (#1113)."""
data = {
"entities": [{"id": identifier, "type": "https://example.org/Org"}],
"relationships": [],
}
result = exporter.export_to_rdf(data, format="turtle")
assert f'semantica:text "{expected}"' in result
def _export(exporter, name, fmt):
data = {
"entities": [
{"id": "https://example.org/e1", "name": name, "type": "ORG"}
],
"relationships": [],
}
return exporter.export_to_rdf(data, format=fmt)
def test_turtle_escapes_quotes_and_control_chars(exporter):
"""Turtle literals must escape quotes/backslashes/newlines (#1113 security)."""
result = _export(exporter, 'Acme "Best" \\ Corp\nLine2\tTab\rCR', "turtle")
# The raw closing-quote breakout must not appear inside the literal.
assert '"Acme "Best"' not in result
assert '\\"Best\\"' in result
assert "\\\\ Corp" in result
assert "\\n" in result and "\\t" in result and "\\r" in result
# No unescaped newline leaked into the literal value.
assert "Line2" in result and "\nLine2" not in result.split("semantica:text")[1]
def test_ntriples_escapes_quotes_and_control_chars(exporter):
"""N-Triples literals must escape backslash first, then quotes/controls (#1113)."""
result = _export(exporter, 'Quote " Back \\ New\nTab\t', "ntriples")
assert '\\"' in result
assert "\\\\" in result
assert "\\n" in result and "\\t" in result
# Each triple must be a single physical line: the literal value carrying the
# escaped text must not have leaked a bare newline that splits it in two.
text_lines = [ln for ln in result.splitlines() if "Quote" in ln]
assert len(text_lines) == 1
assert text_lines[0].rstrip().endswith(" .")
def test_rdfxml_escapes_markup(exporter):
"""RDF/XML character data must escape &, <, > so names cannot inject markup (#1113)."""
result = _export(exporter, 'Acme <script>&"x"', "rdfxml")
assert "<script>" not in result
assert "&lt;script&gt;" in result
assert "&amp;" in result
# The document must still parse as well-formed XML.
import xml.dom.minidom
xml.dom.minidom.parseString(result)
def test_turtle_output_is_parseable_with_special_name(exporter):
"""A name full of metacharacters must still yield parseable Turtle (#1113)."""
rdflib = pytest.importorskip("rdflib")
result = _export(exporter, 'Tricky "quote" \\ and <angle> & amp', "turtle")
graph = rdflib.Graph()
# Should not raise a parser error.
graph.parse(data=result, format="turtle")