From d6d74fb2ef87a57b450e2056b3cb8b37d778a303 Mon Sep 17 00:00:00 2001 From: Cyber MacGeddon Date: Thu, 5 Mar 2026 21:17:07 +0000 Subject: [PATCH] Reified triples support --- trustgraph-base/trustgraph/api/flow.py | 34 ++++++++++-- trustgraph-base/trustgraph/api/knowledge.py | 22 ++++++-- trustgraph-base/trustgraph/api/library.py | 34 ++++++++++-- trustgraph-base/trustgraph/knowledge/defs.py | 32 ++++++++++++ .../trustgraph/cli/graph_to_turtle.py | 42 ++++++++++----- .../query/triples/cassandra/service.py | 52 +++++++++++++++++-- 6 files changed, 190 insertions(+), 26 deletions(-) diff --git a/trustgraph-base/trustgraph/api/flow.py b/trustgraph-base/trustgraph/api/flow.py index cc07f794..7c5d5c9c 100644 --- a/trustgraph-base/trustgraph/api/flow.py +++ b/trustgraph-base/trustgraph/api/flow.py @@ -9,26 +9,52 @@ including LLM operations, RAG queries, knowledge graph management, and more. import json import base64 -from .. knowledge import hash, Uri, Literal -from .. schema import IRI, LITERAL +from .. knowledge import hash, Uri, Literal, QuotedTriple +from .. schema import IRI, LITERAL, TRIPLE from . types import Triple from . exceptions import ProtocolException def to_value(x): - """Convert wire format to Uri or Literal.""" + """Convert wire format to Uri, Literal, or QuotedTriple.""" if x.get("t") == IRI: return Uri(x.get("i", "")) elif x.get("t") == LITERAL: return Literal(x.get("v", "")) + elif x.get("t") == TRIPLE: + # Parse the nested triple from JSON or structured data + triple_data = x.get("v", "") + if isinstance(triple_data, str): + import json + try: + triple_data = json.loads(triple_data) + except json.JSONDecodeError: + return Literal(triple_data) + if isinstance(triple_data, dict): + return QuotedTriple( + s=to_value(triple_data.get("s", {})), + p=to_value(triple_data.get("p", {})), + o=to_value(triple_data.get("o", {})), + ) + return Literal(str(triple_data)) # Fallback for any other type return Literal(x.get("v", x.get("i", ""))) def from_value(v): - """Convert Uri or Literal to wire format.""" + """Convert Uri, Literal, or QuotedTriple to wire format.""" if isinstance(v, Uri): return {"t": IRI, "i": str(v)} + elif isinstance(v, QuotedTriple): + import json + return { + "t": TRIPLE, + "v": json.dumps({ + "s": from_value(v.s), + "p": from_value(v.p), + "o": from_value(v.o), + }) + } else: return {"t": LITERAL, "v": str(v)} diff --git a/trustgraph-base/trustgraph/api/knowledge.py b/trustgraph-base/trustgraph/api/knowledge.py index 1fae350c..84f83d21 100644 --- a/trustgraph-base/trustgraph/api/knowledge.py +++ b/trustgraph-base/trustgraph/api/knowledge.py @@ -9,17 +9,33 @@ into flows for use in queries and RAG operations. import json import base64 -from .. knowledge import hash, Uri, Literal -from .. schema import IRI, LITERAL +from .. knowledge import hash, Uri, Literal, QuotedTriple +from .. schema import IRI, LITERAL, TRIPLE from . types import Triple def to_value(x): - """Convert wire format to Uri or Literal.""" + """Convert wire format to Uri, Literal, or QuotedTriple.""" if x.get("t") == IRI: return Uri(x.get("i", "")) elif x.get("t") == LITERAL: return Literal(x.get("v", "")) + elif x.get("t") == TRIPLE: + # Parse the nested triple from JSON or structured data + triple_data = x.get("v", "") + if isinstance(triple_data, str): + import json + try: + triple_data = json.loads(triple_data) + except json.JSONDecodeError: + return Literal(triple_data) + if isinstance(triple_data, dict): + return QuotedTriple( + s=to_value(triple_data.get("s", {})), + p=to_value(triple_data.get("p", {})), + o=to_value(triple_data.get("o", {})), + ) + return Literal(str(triple_data)) # Fallback for any other type return Literal(x.get("v", x.get("i", ""))) diff --git a/trustgraph-base/trustgraph/api/library.py b/trustgraph-base/trustgraph/api/library.py index b61348fc..5c31ef4a 100644 --- a/trustgraph-base/trustgraph/api/library.py +++ b/trustgraph-base/trustgraph/api/library.py @@ -12,8 +12,8 @@ import base64 import logging from . types import DocumentMetadata, ProcessingMetadata, Triple -from .. knowledge import hash, Uri, Literal -from .. schema import IRI, LITERAL +from .. knowledge import hash, Uri, Literal, QuotedTriple +from .. schema import IRI, LITERAL, TRIPLE from . exceptions import * logger = logging.getLogger(__name__) @@ -27,19 +27,45 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 def to_value(x): - """Convert wire format to Uri or Literal.""" + """Convert wire format to Uri, Literal, or QuotedTriple.""" if x.get("t") == IRI: return Uri(x.get("i", "")) elif x.get("t") == LITERAL: return Literal(x.get("v", "")) + elif x.get("t") == TRIPLE: + # Parse the nested triple from JSON or structured data + triple_data = x.get("v", "") + if isinstance(triple_data, str): + import json + try: + triple_data = json.loads(triple_data) + except json.JSONDecodeError: + return Literal(triple_data) + if isinstance(triple_data, dict): + return QuotedTriple( + s=to_value(triple_data.get("s", {})), + p=to_value(triple_data.get("p", {})), + o=to_value(triple_data.get("o", {})), + ) + return Literal(str(triple_data)) # Fallback for any other type return Literal(x.get("v", x.get("i", ""))) def from_value(v): - """Convert Uri or Literal to wire format.""" + """Convert Uri, Literal, or QuotedTriple to wire format.""" if isinstance(v, Uri): return {"t": IRI, "i": str(v)} + elif isinstance(v, QuotedTriple): + import json + return { + "t": TRIPLE, + "v": json.dumps({ + "s": from_value(v.s), + "p": from_value(v.p), + "o": from_value(v.o), + }) + } else: return {"t": LITERAL, "v": str(v)} diff --git a/trustgraph-base/trustgraph/knowledge/defs.py b/trustgraph-base/trustgraph/knowledge/defs.py index d6290930..e8157586 100644 --- a/trustgraph-base/trustgraph/knowledge/defs.py +++ b/trustgraph-base/trustgraph/knowledge/defs.py @@ -26,8 +26,40 @@ KEYWORD = 'https://schema.org/keywords' class Uri(str): def is_uri(self): return True def is_literal(self): return False + def is_triple(self): return False class Literal(str): def is_uri(self): return False def is_literal(self): return True + def is_triple(self): return False + +class QuotedTriple: + """ + RDF-star quoted triple (reification). + + Represents a triple that can be used as the object of another triple, + enabling statements about statements. + + Example: + # stmt:123 tg:reifies <<:Hope skos:definition "A feeling...">> + qt = QuotedTriple( + s=Uri("https://example.org/Hope"), + p=Uri("http://www.w3.org/2004/02/skos/core#definition"), + o=Literal("A feeling of expectation") + ) + """ + def __init__(self, s, p, o): + self.s = s # Uri, Literal, or QuotedTriple + self.p = p # Uri + self.o = o # Uri, Literal, or QuotedTriple + + def is_uri(self): return False + def is_literal(self): return False + def is_triple(self): return True + + def __repr__(self): + return f"<<{self.s} {self.p} {self.o}>>" + + def __str__(self): + return f"<<{self.s} {self.p} {self.o}>>" diff --git a/trustgraph-cli/trustgraph/cli/graph_to_turtle.py b/trustgraph-cli/trustgraph/cli/graph_to_turtle.py index 1d34e39f..f42fe140 100644 --- a/trustgraph-cli/trustgraph/cli/graph_to_turtle.py +++ b/trustgraph-cli/trustgraph/cli/graph_to_turtle.py @@ -1,6 +1,6 @@ """ Connects to the graph query service and dumps all graph edges in Turtle -format. +format with RDF-star support for quoted triples. """ import rdflib @@ -10,11 +10,37 @@ import argparse import os from trustgraph.api import Api, Uri +from trustgraph.knowledge import QuotedTriple default_url = os.getenv("TRUSTGRAPH_URL", 'http://localhost:8088/') default_user = 'trustgraph' default_collection = 'default' + +def value_to_rdflib(val): + """Convert a TrustGraph value to an rdflib term.""" + if isinstance(val, Uri): + # Skip malformed URLs with spaces + if " " in val: + return None + return rdflib.term.URIRef(val) + elif isinstance(val, QuotedTriple): + # RDF-star quoted triple + s_term = value_to_rdflib(val.s) + p_term = value_to_rdflib(val.p) + o_term = value_to_rdflib(val.o) + if s_term is None or p_term is None or o_term is None: + return None + # rdflib 6.x+ supports Triple as a term type + try: + return rdflib.term.Triple((s_term, p_term, o_term)) + except AttributeError: + # Fallback for older rdflib versions - represent as string + return rdflib.term.Literal(f"<<{val.s} {val.p} {val.o}>>") + else: + return rdflib.term.Literal(str(val)) + + def show_graph(url, flow_id, user, collection): api = Api(url).flow().id(flow_id) @@ -30,18 +56,10 @@ def show_graph(url, flow_id, user, collection): sv = rdflib.term.URIRef(row.s) pv = rdflib.term.URIRef(row.p) + ov = value_to_rdflib(row.o) - if isinstance(row.o, Uri): - - # Skip malformed URLs with spaces in - if " " in row.o: - continue - - ov = rdflib.term.URIRef(row.o) - - else: - - ov = rdflib.term.Literal(row.o) + if ov is None: + continue g.add((sv, pv, ov)) diff --git a/trustgraph-flow/trustgraph/query/triples/cassandra/service.py b/trustgraph-flow/trustgraph/query/triples/cassandra/service.py index eac33dde..4d4290b1 100755 --- a/trustgraph-flow/trustgraph/query/triples/cassandra/service.py +++ b/trustgraph-flow/trustgraph/query/triples/cassandra/service.py @@ -6,11 +6,13 @@ null. Output is a list of quads. import logging +import json + from .... direct.cassandra_kg import ( EntityCentricKnowledgeGraph, GRAPH_WILDCARD, DEFAULT_GRAPH ) from .... schema import TriplesQueryRequest, TriplesQueryResponse, Error -from .... schema import Term, Triple, IRI, LITERAL +from .... schema import Term, Triple, IRI, LITERAL, TRIPLE from .... base import TriplesQueryService from .... base.cassandra_config import add_cassandra_args, resolve_cassandra_config @@ -33,6 +35,36 @@ def get_term_value(term): return term.id or term.value +def deserialize_term(term_dict): + """Deserialize a term from JSON structure.""" + if term_dict is None: + return None + term_type = term_dict.get("type", "") + if term_type == IRI: + return Term(type=IRI, iri=term_dict.get("iri", "")) + elif term_type == LITERAL: + return Term( + type=LITERAL, + value=term_dict.get("value", ""), + datatype=term_dict.get("datatype", ""), + language=term_dict.get("language", "") + ) + elif term_type == TRIPLE: + # Recursive for nested triples + nested = term_dict.get("triple") + if nested: + return Term( + type=TRIPLE, + triple=Triple( + s=deserialize_term(nested.get("s")), + p=deserialize_term(nested.get("p")), + o=deserialize_term(nested.get("o")), + ) + ) + # Fallback + return Term(type=LITERAL, value=str(term_dict)) + + def create_term(value, otype=None, dtype=None, lang=None): """ Create a Term from a string value, optionally using type metadata. @@ -57,8 +89,22 @@ def create_term(value, otype=None, dtype=None, lang=None): language=lang or "" ) elif otype == 't': - # Triple/reification - treat as IRI for now - return Term(type=IRI, iri=value) + # Triple/reification - parse JSON and create nested Triple + try: + triple_data = json.loads(value) if isinstance(value, str) else value + if isinstance(triple_data, dict): + return Term( + type=TRIPLE, + triple=Triple( + s=deserialize_term(triple_data.get("s")), + p=deserialize_term(triple_data.get("p")), + o=deserialize_term(triple_data.get("o")), + ) + ) + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Failed to parse triple JSON: {e}") + # Fallback if parsing fails + return Term(type=LITERAL, value=str(value)) else: # Unknown otype, fall back to heuristic pass