Merge branch 'main' into fix/mcp-export-graph

This commit is contained in:
Sameer Kadam
2026-08-24 14:45:37 +05:30
committed by GitHub
3 changed files with 879 additions and 8 deletions
+394 -8
View File
@@ -146,6 +146,275 @@ def mint_relationship_iri(index: int, source: Any, target: Any) -> str:
return f"{SEMANTICA_NS}rel_{index}_{digest}"
#: The metadata keys Semantica itself produces, and the terms they are written
#: as. GraphBuilder.build_graph writes the first five, create_snapshot writes
#: snapshot_time, and load_from_neo4j writes source / uri / database. These are
#: Semantica's own vocabulary, so they are minted in the declared namespace and
#: declared in semantica-ns.ttl.
#:
#: A key the caller supplied is a different matter. Which namespace an
#: arbitrary metadata key belongs in is issue #1146, and until that is settled
#: the exporter refuses to guess: it warns and skips, and a caller who already
#: knows the answer passes ``metadata_terms``.
#:
#: The map is key -> term rather than key -> namespace because two of the keys
#: cannot keep their own name. ``source`` on a graph loaded from Neo4j is the
#: system it came from, while sem:source is already the ObjectProperty holding
#: the subject of a reified relationship; reusing it would put a string where
#: an entity belongs.
DEFAULT_METADATA_TERMS: Dict[str, str] = {
"num_entities": f"{SEMANTICA_NS}numEntities",
"num_relationships": f"{SEMANTICA_NS}numRelationships",
"temporal_enabled": f"{SEMANTICA_NS}temporalEnabled",
"entity_resolution_applied": f"{SEMANTICA_NS}entityResolutionApplied",
"timestamp": f"{SEMANTICA_NS}builtAt",
"snapshot_time": f"{SEMANTICA_NS}snapshotAt",
"source": f"{SEMANTICA_NS}sourceSystem",
"uri": f"{SEMANTICA_NS}sourceUri",
"database": f"{SEMANTICA_NS}sourceDatabase",
}
#: Terms whose value is a node rather than a string. Everything else stays a
#: literal: a metadata value that merely looks like a URL is not thereby a
#: reference to one.
IRI_VALUED_METADATA_TERMS: Set[str] = {f"{SEMANTICA_NS}sourceUri"}
_XSD_NS = "http://www.w3.org/2001/XMLSchema#"
def _escape_literal(value: str) -> str:
"""Escape a string for a Turtle or N-Triples quoted literal."""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
#: Turtle/N-Triples IRIREF grammar excludes these unescaped between `<` and
#: `>`: control characters, space, and <>"{}|^`\. An IRI-valued metadata
#: value (currently only sem:sourceUri, from the caller-controlled "uri"
#: metadata key) is written as `<{value}>` with no other quoting, so a value
#: containing one of these characters — a ">" followed by a full triple, for
#: instance — closes the IRIREF early and lets the rest of the string be
#: parsed as further RDF statements. This is the same shape of defect the
#: entity/relationship IRIs were hardened against; that hardening resolves
#: prefixes as well, which a metadata value never needs, so this stays a
#: narrower, dedicated guard rather than reusing _as_turtle_iri.
_IRI_REF_UNSAFE_RE = re.compile(r'[\x00-\x20<>"{}|^`\\]')
def _safe_iri_ref(value: str) -> str:
"""Percent-encode the characters an IRIREF may not contain unescaped."""
return _IRI_REF_UNSAFE_RE.sub(lambda m: quote(m.group(0), safe=""), value)
def _escape_xml(value: str) -> str:
"""Escape a string for either XML element text or an attribute value.
The quotes matter. This helper feeds `rdf:about`, `rdf:resource` and
`xmlns:` attribute values, which are delimited by double quotes, so a value
carrying one would close the attribute early and produce a document that
does not parse. Escaping them in element text as well is harmless and
means one helper cannot be used in the wrong place.
"""
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def _is_ncname(value: str) -> bool:
"""Whether a string can be an XML NCName, which is what RDF/XML requires.
Checked over the ASCII range rather than the full XML production: the
grammar also admits combining characters and extenders, so this is
deliberately conservative. It refuses names it could have accepted, and it
never accepts one that would produce a document a parser rejects. The
earlier check tested only that the first character was not a digit, which
let through every other way a local name can fail to be a name.
"""
if not value:
return False
if not (value[0].isascii() and (value[0].isalpha() or value[0] == "_")):
return False
return all(c.isascii() and (c.isalnum() or c in "._-") for c in value[1:])
def _split_iri(iri: str) -> Optional[tuple]:
"""Split an IRI into (namespace, local name) for RDF/XML's QName syntax.
Returns None when no split yields a usable local name. RDF/XML is the only
serialization here that cannot write an arbitrary predicate IRI, so this is
the one place a term can be unrepresentable, and the caller reports it
rather than dropping it quietly.
"""
for sep in ("#", "/"):
index = iri.rfind(sep)
if index != -1 and index + 1 < len(iri):
local = iri[index + 1 :]
if _is_ncname(local):
return iri[: index + 1], local
return None
def _metadata_statements(
metadata: Any,
terms: Dict[str, str],
logger: Any,
) -> List[tuple]:
"""Resolve a metadata mapping to a list of (term IRI, value) pairs.
A key with no term is skipped and reported. Silence is the defect this
fixes, so an unmapped key must be louder than a mapped one, not quieter.
"""
if not isinstance(metadata, dict):
return []
statements: List[tuple] = []
for key, value in metadata.items():
term = terms.get(key)
if term is None:
logger.warning(
"Metadata key %r has no term and was not exported. Which "
"namespace a caller-supplied key belongs in is issue #1146; "
"pass metadata_terms={%r: '<iri>'} to export it now.",
key,
key,
)
continue
if value is None:
continue
if isinstance(value, (dict, list, tuple, set)):
logger.warning(
"Metadata key %r holds a %s, which has no modelled RDF shape "
"yet, and was not exported.",
key,
type(value).__name__,
)
continue
statements.append((term, value))
return statements
def _resolve_metadata_terms(overrides: Optional[Dict[str, str]]) -> Dict[str, str]:
if not overrides:
return DEFAULT_METADATA_TERMS
return {**DEFAULT_METADATA_TERMS, **overrides}
def _typed_literal_parts(term: str, value: Any) -> tuple:
"""Return (kind, lexical, datatype) for one metadata value.
kind is "iri" or "literal". The lexical form and datatype are chosen once,
here, so that the four serializers cannot disagree about them the way they
disagreed about confidence in #1100.
"""
if term in IRI_VALUED_METADATA_TERMS and isinstance(value, str):
return "iri", value, None
if isinstance(value, bool):
return "literal", "true" if value else "false", f"{_XSD_NS}boolean"
if isinstance(value, int):
return "literal", str(value), f"{_XSD_NS}integer"
if isinstance(value, float):
# xsd:double, not xsd:decimal. `repr(1e-05)` is "1e-05" and
# `repr(float("nan"))` is "nan", and xsd:decimal admits neither the
# exponent form nor the special values, so typing a float as decimal
# produced lexicals a strict parser rejects. A Python float is an IEEE
# 754 double; xsd:double has legal lexicals for all of them, and it is
# also the honest claim, since nothing that arrived as a float was ever
# exact. `normalize_confidence` keeps xsd:decimal for confidence
# deliberately: that is a bounded score where exactness is meaningful
# and NaN is not a confidence at all.
if value != value:
lexical = "NaN"
elif value == float("inf"):
lexical = "INF"
elif value == float("-inf"):
lexical = "-INF"
else:
lexical = repr(value)
return "literal", lexical, f"{_XSD_NS}double"
return "literal", str(value), None
def _turtle_object(term: str, value: Any) -> str:
kind, lexical, datatype = _typed_literal_parts(term, value)
if kind == "iri":
return f"<{_safe_iri_ref(lexical)}>"
if datatype is None:
return f'"{_escape_literal(lexical)}"'
return f'"{lexical}"^^<{datatype}>'
def _turtle_metadata_clauses(statements: List[tuple]) -> List[str]:
return [f"<{term}> {_turtle_object(term, value)}" for term, value in statements]
def _ntriples_metadata_lines(subject: str, statements: List[tuple]) -> List[str]:
return [
f"<{subject}> <{term}> {_turtle_object(term, value)} ."
for term, value in statements
]
def _rdfxml_metadata_lines(
statements: List[tuple], indent: str, logger: Any = None
) -> List[str]:
"""RDF/XML needs a QName, so an unprefixed term declares its own prefix.
A term with no QName form has no RDF/XML representation at all, and this is
the only serialization with that restriction. Skipping it quietly would
reintroduce, in one format, exactly the silent metadata loss this module
was changed to stop, so it is reported and the other three formats still
carry the statement in full.
"""
lines: List[str] = []
for position, (term, value) in enumerate(statements):
split = _split_iri(term)
if split is None:
if logger is not None:
logger.warning(
"Term %r has no QName form, so it cannot be written in "
"RDF/XML and was omitted from that serialization only. "
"Turtle, N-Triples and JSON-LD carry it in full.",
term,
)
continue
namespace, local = split
kind, lexical, datatype = _typed_literal_parts(term, value)
prefix = f"md{position}"
opening = f'{indent}<{prefix}:{local} xmlns:{prefix}="{_escape_xml(namespace)}"'
if kind == "iri":
lines.append(f'{opening} rdf:resource="{_escape_xml(lexical)}"/>')
continue
if datatype is not None:
opening += f' rdf:datatype="{_escape_xml(datatype)}"'
lines.append(f"{opening}>{_escape_xml(lexical)}</{prefix}:{local}>")
return lines
def _jsonld_metadata_entries(statements: List[tuple]) -> Dict[str, Any]:
"""Absolute IRIs as keys, and explicit @value/@type rather than JSON's own
types: JSON's number is xsd:double, which would make the JSON-LD export
disagree with the other three about the datatype of an integer."""
entries: Dict[str, Any] = {}
for term, value in statements:
kind, lexical, datatype = _typed_literal_parts(term, value)
if kind == "iri":
entries[term] = {"@id": lexical}
elif datatype is None:
entries[term] = lexical
else:
entries[term] = {"@value": lexical, "@type": datatype}
return entries
class NamespaceManager:
"""
RDF namespace management engine.
@@ -501,6 +770,8 @@ class RDFSerializer:
"""
include_temporal: bool = options.pop("include_temporal", False)
time_axis: str = options.pop("time_axis", "valid")
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = []
@@ -534,21 +805,32 @@ class RDFSerializer:
text = entity.get("text") or entity.get("label", "")
confidence = normalize_confidence(entity.get("confidence", 1.0))
lines.append(
f"<{self._as_turtle_iri(entity_id, merged_namespaces)}> a "
f"<{self._as_turtle_iri(entity_type, merged_namespaces)}> ;"
)
clauses = [
f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>",
f'semantica:text "{text}"',
]
if confidence is None:
self.logger.warning(
f"Entity {entity_id} has a confidence that is not a number "
f"({entity.get('confidence')!r}), so no confidence is written"
)
lines.append(f' semantica:text "{text}" .')
else:
lines.append(f' semantica:text "{text}" ;')
lines.append(
f' semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
clauses.append(
f'semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}>'
)
clauses.extend(
_turtle_metadata_clauses(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
)
)
)
entity_iri = self._as_turtle_iri(entity_id, merged_namespaces)
lines.append(f"<{entity_iri}> {clauses[0]} ;")
for clause in clauses[1:-1]:
lines.append(f" {clause} ;")
lines.append(f" {clauses[-1]} .")
lines.append("")
# Convert relationships to RDF triplets
@@ -582,6 +864,31 @@ class RDFSerializer:
)
lines.extend(owl_lines)
# Graph-level metadata needs a subject, and this serializer has never
# minted a document node. Rather than invent one here, it is written
# only when the caller names the graph; issue #1147 is where the
# default subject comes from once that lands.
graph_clauses = (
_turtle_metadata_clauses(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
)
)
if graph_uri
else []
)
if graph_clauses:
graph_iri = self._as_turtle_iri(graph_uri, merged_namespaces)
lines.append("")
lines.append(
f"<{graph_iri}> {graph_clauses[0]} "
+ (";" if len(graph_clauses) > 1 else ".")
)
for clause in graph_clauses[1:-1]:
lines.append(f" {clause} ;")
if len(graph_clauses) > 1:
lines.append(f" {graph_clauses[-1]} .")
return "\n".join(lines)
def _reified_relationship_triples(
@@ -730,6 +1037,9 @@ class RDFSerializer:
... }
>>> rdfxml = serializer.serialize_to_rdfxml(rdf_data)
"""
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append('<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"')
lines.append(' xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"')
@@ -752,6 +1062,9 @@ class RDFSerializer:
confidence = normalize_confidence(entity.get("confidence", 1.0))
# 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.
entity_iri = xml_escape(
self._as_turtle_iri(entity_id, namespaces), quote=True
)
@@ -771,6 +1084,15 @@ class RDFSerializer:
f' <semantica:confidence rdf:datatype="{CONFIDENCE_DATATYPE}">'
f"{confidence}</semantica:confidence>"
)
lines.extend(
_rdfxml_metadata_lines(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
),
" ",
self.logger,
)
)
lines.append(" </rdf:Description>")
lines.append("")
@@ -795,6 +1117,26 @@ class RDFSerializer:
lines.append(" </rdf:Description>")
lines.append("")
graph_lines = (
_rdfxml_metadata_lines(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
),
" ",
self.logger,
)
if graph_uri
else []
)
if graph_lines:
graph_iri = xml_escape(
self._as_turtle_iri(graph_uri, namespaces), quote=True
)
lines.append(f' <rdf:Description rdf:about="{graph_iri}">')
lines.extend(graph_lines)
lines.append(" </rdf:Description>")
lines.append("")
lines.append("</rdf:RDF>")
return "\n".join(lines)
@@ -825,6 +1167,9 @@ class RDFSerializer:
"""
import json
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
# Initialize JSON-LD structure with context
jsonld = {
"@context": {
@@ -869,6 +1214,13 @@ class RDFSerializer:
"@value": confidence,
"@type": CONFIDENCE_DATATYPE,
}
node.update(
_jsonld_metadata_entries(
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
)
)
)
jsonld["@graph"].append(node)
# Convert relationships to JSON-LD
@@ -893,6 +1245,18 @@ class RDFSerializer:
}
)
graph_entries = (
_jsonld_metadata_entries(
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
)
)
if graph_uri
else {}
)
if graph_entries:
jsonld["@graph"].append({"@id": graph_uri, **graph_entries})
return json.dumps(jsonld, indent=2, ensure_ascii=False)
def serialize_to_ntriples(self, rdf_data: Dict[str, Any], **options) -> str:
@@ -909,6 +1273,9 @@ class RDFSerializer:
Returns:
String containing N-Triples serialization
"""
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
graph_uri: Optional[str] = options.pop("graph_uri", None)
lines = []
namespaces = self.namespace_manager.extract_namespaces(rdf_data)
@@ -959,6 +1326,15 @@ class RDFSerializer:
f'"{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
)
lines.extend(
_ntriples_metadata_lines(
subject.strip("<>"),
_metadata_statements(
entity.get("metadata"), metadata_terms, self.logger
),
)
)
# Convert relationships
relationships = rdf_data.get("relationships", [])
for rel in relationships:
@@ -971,6 +1347,16 @@ class RDFSerializer:
f"{expand_uri(source_id)} {expand_uri(rel_type)} {expand_uri(target_id)} ."
)
if graph_uri:
lines.extend(
_ntriples_metadata_lines(
graph_uri,
_metadata_statements(
rdf_data.get("metadata"), metadata_terms, self.logger
),
)
)
return "\n".join(lines)
@@ -135,6 +135,90 @@ JSONExporter.export_to_jsonld in export/json_exporter.py.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Metadata carried through from the graph builder ──────────────────────────
#
# The keys GraphBuilder and the Neo4j loader write into "metadata". Declared
# here because the RDF serializers emit them (#1154); a caller-supplied key is
# not declared here and is not emitted, because which namespace it belongs in
# is #1146.
sem:numEntities a owl:DatatypeProperty ;
rdfs:label "number of entities" ;
rdfs:comment """Count of entities in the graph as built, from
GraphBuilder.build_graph. A count of what was built, not a constraint on what
the graph contains: an export filtered after the fact will disagree with it.""" ;
rdfs:range xsd:integer ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:numRelationships a owl:DatatypeProperty ;
rdfs:label "number of relationships" ;
rdfs:comment "Count of relationships in the graph as built." ;
rdfs:range xsd:integer ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:temporalEnabled a owl:DatatypeProperty ;
rdfs:label "temporal enabled" ;
rdfs:comment """True when the builder was configured to track valid time.
False does not mean the graph is untimed; it means no temporal bounds were
recorded for it.""" ;
rdfs:range xsd:boolean ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:entityResolutionApplied a owl:DatatypeProperty ;
rdfs:label "entity resolution applied" ;
rdfs:comment """True when a resolver ran over the extracted entities, so a
consumer knows whether two nodes with the same surface text were ever
considered for merging.""" ;
rdfs:range xsd:boolean ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:builtAt a owl:DatatypeProperty ;
rdfs:label "built at" ;
rdfs:comment """When the graph was built, as GraphBuilder recorded it.
The range is xsd:string, deliberately, and not xsd:dateTime. GraphBuilder
stamps with a timezone-naive datetime.now(), and #1114 is the demonstration of
what typing such a value as xsd:dateTime costs: a timezone-qualified SPARQL
filter over it raises an indeterminate comparison and silently drops the row.
#1121 swept the export and provenance modules to an explicit UTC offset and
deliberately left kg/ alone, because the context and vector-store modules
compare against naive values already on disk. Until that sweep reaches
GraphBuilder this value is a string that looks like a timestamp, and saying so
is more useful than a type that invites arithmetic it cannot support.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:snapshotAt a owl:DatatypeProperty ;
rdfs:label "snapshot at" ;
rdfs:comment """The point in time a snapshot represents, from
GraphBuilder.create_snapshot. A string for the same reason as sem:builtAt.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceSystem a owl:DatatypeProperty ;
rdfs:label "source system" ;
rdfs:comment """The system a graph was loaded from, currently the literal
"neo4j" written by GraphBuilder.load_from_neo4j.
Named sourceSystem rather than source because sem:source is already the
ObjectProperty carrying the subject of a reified relationship. The metadata key
is still "source"; the exporter maps the key to this term.""" ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceUri a owl:ObjectProperty ;
rdfs:label "source URI" ;
rdfs:comment """The address of the system a graph was loaded from. The one
metadata term whose value is a node rather than a literal, because it names a
thing rather than describing one.""" ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
sem:sourceDatabase a owl:DatatypeProperty ;
rdfs:label "source database" ;
rdfs:comment "The database within the source system a graph was loaded from." ;
rdfs:range xsd:string ;
rdfs:isDefinedBy <https://semantica.dev/ns> .
# ── Temporal term (OWL-Time export) ──────────────────────────────────────────
sem:openEndedInterval a owl:DatatypeProperty ;
+401
View File
@@ -0,0 +1,401 @@
"""Metadata must survive serialization (issue #1154).
``convert_kg_to_rdf`` copies ``metadata`` into the RDF-ready dictionary at
rdf_exporter.py:302, and no serializer has ever read it back out. Turtle,
N-Triples, RDF/XML and RDFExporter's JSON-LD all write the entity's id, type,
text and confidence, and none of them writes a single metadata statement, so an
entity keeps its confidence score and loses what produced it: the source
document, the page, the extractor, the reviewer. JSONExporter's json-ld path
keeps all of them, which is how the same knowledge graph exported two ways came
to carry ten triples of user data through one exporter and none through the
other.
The keys Semantica itself produces (GraphBuilder writes num_entities,
num_relationships, temporal_enabled, timestamp and entity_resolution_applied;
the Neo4j loader writes source, uri and database) are Semantica's own
vocabulary, so they are minted in the declared namespace and declared in
semantica-ns.ttl. Keys the caller supplied are not: which namespace those
belong in is issue #1146, and until that is settled the exporter refuses to
guess rather than inventing an IRI, warns, and takes an explicit
``metadata_terms`` mapping from any caller who already knows the answer.
"""
import json
import pytest
from rdflib import Graph, Literal, URIRef
from rdflib.namespace import XSD
from semantica.export.rdf_exporter import (
DEFAULT_METADATA_TERMS,
RDFSerializer,
SEMANTICA_NS,
mint_entity_iri,
)
ENTITY_IRI = "https://example.org/e1"
# The provenance fields the issue names, plus one key Semantica itself writes.
GRAPH_WITH_METADATA = {
"entities": [
{
"id": ENTITY_IRI,
"type": "https://example.org/Org",
"text": "Acme Corp",
"confidence": 0.91,
"metadata": {"num_entities": 1, "temporal_enabled": True},
}
],
"relationships": [],
"metadata": {
"num_entities": 1,
"num_relationships": 0,
"temporal_enabled": False,
"entity_resolution_applied": True,
},
}
NUM_ENTITIES = URIRef(f"{SEMANTICA_NS}numEntities")
TEMPORAL_ENABLED = URIRef(f"{SEMANTICA_NS}temporalEnabled")
def _parse(text: str, fmt: str) -> Graph:
"""Assert on the parsed graph, never on the serialized text."""
g = Graph()
g.parse(data=text, format=fmt)
return g
def _serialize(serializer: RDFSerializer, fmt: str, data, **options) -> Graph:
method, parse_as = {
"turtle": (serializer.serialize_to_turtle, "turtle"),
"ntriples": (serializer.serialize_to_ntriples, "nt"),
"rdfxml": (serializer.serialize_to_rdfxml, "xml"),
"jsonld": (serializer.serialize_to_jsonld, "json-ld"),
}[fmt]
return _parse(method(data, **options), parse_as)
FORMATS = ["turtle", "ntriples", "rdfxml", "jsonld"]
@pytest.mark.parametrize("fmt", FORMATS)
def test_entity_metadata_reaches_every_serialization(fmt):
"""The headline defect: the statement is absent from all four formats."""
g = _serialize(RDFSerializer(), fmt, GRAPH_WITH_METADATA)
assert (URIRef(ENTITY_IRI), NUM_ENTITIES, Literal(1)) in g
@pytest.mark.parametrize("fmt", FORMATS)
def test_entity_metadata_booleans_keep_their_datatype(fmt):
g = _serialize(RDFSerializer(), fmt, GRAPH_WITH_METADATA)
assert (URIRef(ENTITY_IRI), TEMPORAL_ENABLED, Literal(True)) in g
def test_every_format_writes_the_same_metadata_triples():
"""A value must not change datatype with the serializer, as #1100 found."""
per_format = {}
for fmt in FORMATS:
g = _serialize(RDFSerializer(), fmt, GRAPH_WITH_METADATA)
per_format[fmt] = {
(p, o)
for s, p, o in g
if str(p).startswith(SEMANTICA_NS) and "numEntities" in str(p)
}
assert len(set(map(frozenset, per_format.values()))) == 1, per_format
def test_graph_metadata_needs_a_subject_the_caller_named():
"""Graph-level metadata hangs off graph_uri; #1147 owns the default."""
doc = URIRef("https://example.org/graph/1")
g = _serialize(
RDFSerializer(),
"turtle",
GRAPH_WITH_METADATA,
graph_uri=str(doc),
)
assert (doc, NUM_ENTITIES, Literal(1)) in g
assert (doc, URIRef(f"{SEMANTICA_NS}entityResolutionApplied"), Literal(True)) in g
def test_graph_metadata_is_not_invented_without_a_subject():
g = _serialize(RDFSerializer(), "turtle", GRAPH_WITH_METADATA)
assert not list(g.subjects(NUM_ENTITIES, Literal(0)))
# the entity keeps its own metadata; only the graph-level block waits
assert (URIRef(ENTITY_IRI), NUM_ENTITIES, Literal(1)) in g
def test_an_unknown_key_is_refused_out_loud_not_dropped_in_silence(caplog):
"""#1146 owns which namespace a caller's key belongs in. Until then: warn."""
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"reviewed_by": "fabio"}}
],
"relationships": [],
}
with caplog.at_level("WARNING"):
g = _serialize(RDFSerializer(), "turtle", data)
assert not any("reviewed_by" in str(p) for p in g.predicates())
assert any("reviewed_by" in r.getMessage() for r in caplog.records)
assert any("1146" in r.getMessage() for r in caplog.records)
@pytest.mark.parametrize("fmt", FORMATS)
def test_a_caller_who_knows_the_answer_can_supply_the_term(fmt):
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"reviewed_by": "fabio"}}
],
"relationships": [],
}
terms = {"reviewed_by": "http://purl.org/dc/terms/creator"}
g = _serialize(RDFSerializer(), fmt, data, metadata_terms=terms)
assert (
URIRef(ENTITY_IRI),
URIRef("http://purl.org/dc/terms/creator"),
Literal("fabio"),
) in g
def test_a_literal_with_a_quote_or_newline_still_parses():
"""Metadata is user text; #1098 is the same class of defect one field over."""
data = {
"entities": [
{
"id": ENTITY_IRI,
"text": "Acme",
"metadata": {"source": 'the "Q3" report\nsecond line'},
}
],
"relationships": [],
}
for fmt in FORMATS:
g = _serialize(RDFSerializer(), fmt, data)
assert (
URIRef(ENTITY_IRI),
URIRef(f"{SEMANTICA_NS}sourceSystem"),
Literal('the "Q3" report\nsecond line'),
) in g
def test_an_iri_valued_key_is_written_as_a_node_not_a_string():
"""The Neo4j loader's ``uri`` key. Note the term is sem:sourceUri, not
sem:uri: the key names a field, the term names a relation."""
data = {
"entities": [
{
"id": ENTITY_IRI,
"text": "Acme",
"metadata": {"uri": "https://example.org/db"},
}
],
"relationships": [],
}
g = _serialize(RDFSerializer(), "turtle", data)
assert (
URIRef(ENTITY_IRI),
URIRef(f"{SEMANTICA_NS}sourceUri"),
URIRef("https://example.org/db"),
) in g
def test_output_is_unchanged_when_no_metadata_is_present():
plain = {
"entities": [
{"id": ENTITY_IRI, "type": "https://example.org/Org", "text": "Acme"}
],
"relationships": [
{"source_id": ENTITY_IRI, "target_id": "https://example.org/e2"}
],
}
serializer = RDFSerializer()
assert serializer.serialize_to_turtle(plain) == serializer.serialize_to_turtle(
plain
)
g = _parse(serializer.serialize_to_turtle(plain), "turtle")
assert len(g) == 4
def test_every_default_term_is_declared_in_the_shipped_vocabulary():
"""Drift guard: a term the exporter emits and the vocabulary omits is a bug."""
from semantica.ontology.vocabulary import vocabulary_path
vocab = Graph()
vocab.parse(vocabulary_path(), format="turtle")
declared = {str(s) for s in vocab.subjects()}
missing = sorted(set(DEFAULT_METADATA_TERMS.values()) - declared)
assert not missing, f"emitted but undeclared: {missing}"
def test_jsonld_metadata_survives_a_real_jsonld_processor():
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"num_entities": 3}}
],
"relationships": [],
}
raw = RDFSerializer().serialize_to_jsonld(data)
json.loads(raw) # must be valid JSON before it can be valid JSON-LD
g = _parse(raw, "json-ld")
assert (URIRef(ENTITY_IRI), NUM_ENTITIES, Literal(3)) in g
# --- Findings from the Qodo review of PR #1165 -----------------------------
@pytest.mark.parametrize("fmt", FORMATS)
@pytest.mark.parametrize(
"value", [1e-05, 1e300, 0.1, -0.0, float("nan"), float("inf"), float("-inf")]
)
def test_a_float_metadata_value_is_a_double_and_keeps_a_legal_lexical(fmt, value):
"""`repr()` of a float is not an xsd:decimal lexical.
`repr(1e-05)` is "1e-05" and `repr(float("nan"))` is "nan", neither of which
xsd:decimal admits, so typing a float as decimal produced RDF a strict
parser rejects. A Python float is an IEEE 754 double, xsd:double has legal
lexicals for the exponent form and for the three special values, and saying
double is also the honest claim: nothing here was ever exact.
"""
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"num_entities": value}}
],
"relationships": [],
}
g = _serialize(RDFSerializer(), fmt, data)
objects = list(g.objects(URIRef(ENTITY_IRI), NUM_ENTITIES))
assert len(objects) == 1, f"{fmt}: {objects}"
(written,) = objects
assert written.datatype == XSD.double, written.datatype
parsed = written.toPython()
if value != value: # NaN
assert parsed != parsed
else:
assert parsed == value
def test_every_format_agrees_on_a_float_metadata_value():
per_format = {}
for fmt in FORMATS:
g = _serialize(
RDFSerializer(),
fmt,
{
"entities": [
{"id": ENTITY_IRI, "text": "A", "metadata": {"num_entities": 1e-05}}
],
"relationships": [],
},
)
per_format[fmt] = {(p, o) for s, p, o in g if p == NUM_ENTITIES}
assert len(set(map(frozenset, per_format.values()))) == 1, per_format
def test_a_term_rdfxml_cannot_name_is_refused_out_loud(caplog):
"""RDF/XML needs a QName, and the PR's whole point is no silent drops.
A term whose local part is not an XML NCName has no RDF/XML form at all.
Skipping it quietly reintroduces, in one format, exactly the loss this
change exists to stop.
"""
unnameable = "http://example.org/ns/123"
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"reviewed_by": "fabio"}}
],
"relationships": [],
}
with caplog.at_level("WARNING"):
xml = RDFSerializer().serialize_to_rdfxml(
data, metadata_terms={"reviewed_by": unnameable}
)
_parse(xml, "xml") # must still be well-formed
messages = " ".join(r.getMessage() for r in caplog.records)
assert unnameable in messages
assert "RDF/XML" in messages
@pytest.mark.parametrize("fmt", ["turtle", "ntriples", "jsonld"])
def test_the_other_formats_still_carry_a_term_rdfxml_cannot_name(fmt):
"""Only RDF/XML has the QName restriction; the rest write the full IRI."""
unnameable = "http://example.org/ns/123"
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"reviewed_by": "fabio"}}
],
"relationships": [],
}
g = _serialize(
RDFSerializer(), fmt, data, metadata_terms={"reviewed_by": unnameable}
)
assert (URIRef(ENTITY_IRI), URIRef(unnameable), Literal("fabio")) in g
def test_a_quote_in_an_attribute_value_cannot_break_the_document():
"""`_escape_xml` feeds attribute values, which are delimited by quotes.
Escaping only &, < and > leaves a caller-supplied value able to close the
attribute early and produce XML that does not parse.
"""
data = {
"entities": [
{
"id": 'https://example.org/e"1',
"text": "Acme",
"metadata": {"uri": 'https://example.org/db"x'},
}
],
"relationships": [],
}
xml = RDFSerializer().serialize_to_rdfxml(data)
from xml.dom.minidom import parseString
parseString(xml) # well-formedness is the assertion
# --- Finding from review of PR #1165 ----------------------------------------
@pytest.mark.parametrize("fmt", ["turtle", "ntriples"])
def test_an_iri_valued_metadata_value_cannot_inject_a_second_triple(fmt):
"""`sem:sourceUri` (the "uri" key) is the one metadata term written as a
node, ``<{value}>``, with no other quoting. Turtle/N-Triples IRIREFs
exclude '>' (among other characters) unescaped, so a value shaped like
``<goodIRI> . <injected> <p> <o>`` closed the reference early and let the
rest of the string be parsed as an unrelated, attacker-chosen triple.
"""
payload = (
"https://evil.example/x> . <https://evil.example/injected> "
"<https://evil.example/p> <https://evil.example/o"
)
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"uri": payload}}
],
"relationships": [],
}
g = _serialize(RDFSerializer(), fmt, data)
# Exactly the entity's own four statements: type, text, confidence, and
# the one metadata triple. No extra subject/triple was injected — the
# payload survives only as (a mangled but harmless part of) the single
# sourceUri value, never as a standalone subject of its own.
assert len(g) == 4
assert not list(g.subjects(None, URIRef("https://evil.example/injected")))
assert not list(g.subjects(URIRef("https://evil.example/p"), None))
@pytest.mark.parametrize("fmt", ["turtle", "ntriples"])
def test_an_iri_valued_metadata_value_with_control_characters_still_parses(fmt):
"""A newline or tab in an IRI-valued metadata value is just as
unescaped-IRIREF-breaking as '>' — cover the control-character half of
the grammar, not only the delimiter characters.
"""
payload = "https://evil.example/x\ninjected line\ttabbed"
data = {
"entities": [
{"id": ENTITY_IRI, "text": "Acme", "metadata": {"uri": payload}}
],
"relationships": [],
}
g = _serialize(RDFSerializer(), fmt, data)
assert len(g) == 4