fix(export): normalize turtle resource iris

This commit is contained in:
mikemikimike
2026-08-23 23:55:11 +08:00
parent ea9b1f5d4a
commit 14107e51c3
2 changed files with 91 additions and 2 deletions
+33 -2
View File
@@ -32,6 +32,7 @@ License: MIT
from pathlib import Path
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Set, Union
from urllib.parse import quote, urlsplit
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.helpers import ensure_directory, hash_data
@@ -395,6 +396,29 @@ class RDFSerializer:
# OWL-Time namespace URI
_OWL_TIME_NS = "http://www.w3.org/2006/time#"
_SEMANTICA_NS = "https://semantica.dev/ns#"
def _as_turtle_iri(
self, value: Any, namespaces: Optional[Dict[str, str]] = None
) -> str:
"""Return an absolute, safely encoded IRI for a Turtle resource."""
value = str(value)
try:
parsed = urlsplit(value)
except ValueError:
parsed = urlsplit("")
if parsed.scheme:
prefix, separator, local_name = value.partition(":")
namespace = (namespaces or self.namespace_manager.namespaces).get(prefix)
if namespace and separator:
return quote(namespace + local_name, safe=":/?#[]@!$&'()*+,;=%")
remainder = value[len(prefix) + 1 :]
if len(prefix) >= 2 and (
remainder.startswith(("//", "/"))
or any(token in remainder for token in (":", "/", "@"))
):
return quote(value, safe=":/?#[]@!$&'()*+,;=%")
return self._SEMANTICA_NS + quote(value, safe="")
# Design decision — TemporalBound.OPEN in RDF:
# OWL-Time has no standard predicate for "no known end date." We use
@@ -467,7 +491,10 @@ class RDFSerializer:
text = entity.get("text") or entity.get("label", "")
confidence = normalize_confidence(entity.get("confidence", 1.0))
lines.append(f"<{entity_id}> a <{entity_type}> ;")
lines.append(
f"<{self._as_turtle_iri(entity_id, merged_namespaces)}> a "
f"<{self._as_turtle_iri(entity_type, merged_namespaces)}> ;"
)
if confidence is None:
self.logger.warning(
f"Entity {entity_id} has a confidence that is not a number "
@@ -488,7 +515,11 @@ class RDFSerializer:
target_id = rel.get("target_id") or rel.get("target")
rel_type = rel.get("type", DEFAULT_RELATION_TYPE)
lines.append(f"<{source_id}> <{rel_type}> <{target_id}> .")
lines.append(
f"<{self._as_turtle_iri(source_id)}> "
f"<{self._as_turtle_iri(rel_type)}> "
f"<{self._as_turtle_iri(target_id)}> ."
)
if include_temporal:
owl_lines = self._owl_time_triples_for_rel(rel, idx, time_axis)
@@ -0,0 +1,58 @@
"""Regression tests for valid Turtle IRI generation (issue #1099)."""
from rdflib import RDF, Graph, URIRef
from semantica.export import RDFExporter
from semantica.kg.graph_builder import GraphBuilder
def test_turtle_normalizes_graph_builder_default_identifiers():
"""Default GraphBuilder labels with spaces become stable absolute IRIs."""
source = {
"entities": [
{"id": "Acme Corp", "name": "Acme Corp", "type": "ORG"},
{"id": "Jane Doe", "name": "Jane Doe", "type": "PERSON"},
],
"relationships": [
{"source": "Jane Doe", "target": "Acme Corp", "type": "works_for"},
],
}
graph_data = GraphBuilder(resolve_conflicts=False).build(sources=[source])
turtle = RDFExporter().export_to_rdf(graph_data, format="turtle")
parsed = Graph().parse(data=turtle, format="turtle")
acme = URIRef("https://semantica.dev/ns#Acme%20Corp")
jane = URIRef("https://semantica.dev/ns#Jane%20Doe")
assert (acme, RDF.type, URIRef("https://semantica.dev/ns#ORG")) in parsed
jane_type = URIRef("https://semantica.dev/ns#PERSON")
assert (jane, RDF.type, jane_type) in parsed
assert (
jane,
URIRef("https://semantica.dev/ns#works_for"),
acme,
) in parsed
def test_turtle_preserves_absolute_iris():
"""Already-valid absolute resource IRIs remain unchanged."""
turtle = RDFExporter().export_to_rdf(
{
"entities": [
{
"id": "https://example.org/entities/jane",
"text": "Jane",
"type": "urn:example:Person",
}
],
"relationships": [],
},
format="turtle",
)
parsed = Graph().parse(data=turtle, format="turtle")
assert (
URIRef("https://example.org/entities/jane"),
RDF.type,
URIRef("urn:example:Person"),
) in parsed