mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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..."
|
||||
)
|
||||
|
||||
@@ -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 "<script>" in result
|
||||
assert "&" 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")
|
||||
|
||||
Reference in New Issue
Block a user