feat: preserve RDF language tags and datatypes through ingestion and query (#1047)

Language tags and XSD datatypes on RDF literals were being silently
dropped at multiple points in the pipeline, making SPARQL FILTER(LANG())
queries and multilingual datasets non-functional.

Ingestion fixes:
- load_knowledge.py and load_turtle.py now extract .language and
  .datatype from rdflib Literal objects instead of discarding them
- Triple dataclass gains optional o_datatype and o_language fields
- _string_to_term() emits "dt" and "ln" in the wire format
- AsyncBulkClient uses the same term serialization as BulkClient

Query fix:
- TriplesClient.query_gen() preserves Term objects directly instead of
  converting through Uri/Literal (which are str subclasses with no room
  for language metadata), enabling SPARQL LANG() filters to work
This commit is contained in:
cybermaggedon 2026-07-15 10:13:19 +01:00 committed by GitHub
parent f9f5a2318f
commit 0c36dda443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 53 additions and 20 deletions

View file

@ -4,6 +4,7 @@ import websockets
from typing import Optional, AsyncIterator, Dict, Any, Iterator from typing import Optional, AsyncIterator, Dict, Any, Iterator
from . types import Triple from . types import Triple
from . bulk_client import _string_to_term
class AsyncBulkClient: class AsyncBulkClient:
@ -45,9 +46,13 @@ class AsyncBulkClient:
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket: async with websockets.connect(ws_url, ping_interval=20, ping_timeout=self.timeout) as websocket:
async for triple in triples: async for triple in triples:
message = { message = {
"s": triple.s, "s": _string_to_term(triple.s),
"p": triple.p, "p": _string_to_term(triple.p),
"o": triple.o "o": _string_to_term(
triple.o,
datatype=triple.o_datatype,
language=triple.o_language,
),
} }
await websocket.send(json.dumps(message)) await websocket.send(json.dumps(message))

View file

@ -15,13 +15,19 @@ from . types import Triple
from . exceptions import ProtocolException from . exceptions import ProtocolException
def _string_to_term(value: str) -> Dict[str, Any]: def _string_to_term(
value: str, datatype: str = "", language: str = ""
) -> Dict[str, Any]:
"""Convert a string value to Term format for the gateway.""" """Convert a string value to Term format for the gateway."""
# Treat URIs as IRI type, otherwise as literal
if value.startswith("http://") or value.startswith("https://") or "://" in value: if value.startswith("http://") or value.startswith("https://") or "://" in value:
return {"t": "i", "i": value} return {"t": "i", "i": value}
else: else:
return {"t": "l", "v": value} result: Dict[str, Any] = {"t": "l", "v": value}
if datatype:
result["dt"] = datatype
if language:
result["ln"] = language
return result
class BulkClient: class BulkClient:
@ -141,7 +147,11 @@ class BulkClient:
batch.append({ batch.append({
"s": _string_to_term(triple.s), "s": _string_to_term(triple.s),
"p": _string_to_term(triple.p), "p": _string_to_term(triple.p),
"o": _string_to_term(triple.o) "o": _string_to_term(
triple.o,
datatype=triple.o_datatype,
language=triple.o_language,
),
}) })
if len(batch) >= batch_size: if len(batch) >= batch_size:
message = { message = {

View file

@ -19,10 +19,14 @@ class Triple:
s: Subject (entity URI or value) s: Subject (entity URI or value)
p: Predicate (relationship URI) p: Predicate (relationship URI)
o: Object (entity URI, literal value, or typed value) o: Object (entity URI, literal value, or typed value)
o_datatype: XSD datatype URI for literal objects (e.g. "xsd:string")
o_language: Language tag for literal objects (e.g. "en", "lt")
""" """
s : str s : str
p : str p : str
o : str o : str
o_datatype : str = ""
o_language : str = ""
@dataclasses.dataclass @dataclasses.dataclass
class ConfigKey: class ConfigKey:

View file

@ -58,7 +58,7 @@ class TriplesClient(RequestResponse):
raise RuntimeError(resp.error.message) raise RuntimeError(resp.error.message)
batch = [ batch = [
Triple(to_value(v.s), to_value(v.p), to_value(v.o)) Triple(v.s, v.p, v.o)
for v in resp.triples for v in resp.triples
] ]
await queue.put(batch) await queue.put(batch)

View file

@ -44,15 +44,21 @@ class KnowledgeLoader:
for e in g: for e in g:
s_value = str(e[0]) s_value = str(e[0])
p_value = str(e[1]) p_value = str(e[1])
o_value = str(e[2])
if isinstance(e[2], rdflib.term.URIRef): o_datatype = ""
o_value = str(e[2]) o_language = ""
o_is_uri = True
else:
o_value = str(e[2])
o_is_uri = False
yield Triple(s=s_value, p=p_value, o=o_value) if isinstance(e[2], rdflib.term.Literal):
if e[2].language:
o_language = str(e[2].language)
if e[2].datatype:
o_datatype = str(e[2].datatype)
yield Triple(
s=s_value, p=p_value, o=o_value,
o_datatype=o_datatype, o_language=o_language,
)
def load_entity_contexts_from_file(self, file) -> Iterator[Tuple[str, str]]: def load_entity_contexts_from_file(self, file) -> Iterator[Tuple[str, str]]:
"""Generator that yields (entity, context) tuples from a Turtle file""" """Generator that yields (entity, context) tuples from a Turtle file"""

View file

@ -44,13 +44,21 @@ class Loader:
for e in g: for e in g:
s_value = str(e[0]) s_value = str(e[0])
p_value = str(e[1]) p_value = str(e[1])
o_value = str(e[2])
if isinstance(e[2], rdflib.term.URIRef): o_datatype = ""
o_value = str(e[2]) o_language = ""
else:
o_value = str(e[2])
yield Triple(s=s_value, p=p_value, o=o_value) if isinstance(e[2], rdflib.term.Literal):
if e[2].language:
o_language = str(e[2].language)
if e[2].datatype:
o_datatype = str(e[2].datatype)
yield Triple(
s=s_value, p=p_value, o=o_value,
o_datatype=o_datatype, o_language=o_language,
)
def run(self): def run(self):
"""Load triples using Python API""" """Load triples using Python API"""