feat(context): add to_kg_dict() adapter for canonical KG shape (#1081)

* feat(context): add to_kg_dict() adapter for canonical KG shape

Convert ContextGraph internal nodes/edges/source representation into the canonical entities/relationships/source_id shape consumed by RDFExporter and TemporalGraphQuery. Add entities_only filtering that drops dangling relationships, plus README examples and unit tests.

* fix(context): harden to_kg_dict against null props and non-str node ids

- Guard properties/metadata with 'or {}' so nodes loaded from JSON null
  no longer raise TypeError when copied (Qodo bug 1)
- Coerce entity id to str(n.node_id) so it matches ContextEdge's
  str-coerced endpoints, preventing valid relationships from being
  dropped during entities_only filtering (Qodo bug 3)

* fix(kg): accept source_id/target_id endpoints in validator and temporal query

to_kg_dict() emits canonical source_id/target_id keys, but GraphValidator
and TemporalGraphQuery only read the legacy source/target keys, so its
output failed validation and lost relationships (Qodo bug 2).

- GraphValidator: resolve endpoints from either key variant and treat a
  resolvable source/target (plus type) as satisfying required fields
- TemporalGraphQuery.analyze_evolution/find_paths: read either variant
- tests: add regression coverage for null props/metadata (bug 1),
  non-string node ids (bug 3), and KG-utility consumability (bug 2)

---------

Co-authored-by: 江俊杰 <jiangjunjie.37@jd.com>
This commit is contained in:
cxzg007
2026-08-18 18:24:52 +05:00
committed by GitHub
co-authored by 江俊杰
parent 17d878cbf3
commit a1194a155d
5 changed files with 277 additions and 28 deletions
+9 -22
View File
@@ -303,17 +303,10 @@ graph.add_causal_relationship(d1, d2, relationship_type="CAUSED")
prov.track_entity("patient_P4821", source="ehr/medication_orders_2024.json",
metadata={"extractor": "NamedEntityRecognizer"})
# Export W3C PROV-O for regulator submission - RDFExporter expects
# {"entities": [...], "relationships": [...]}, so map ContextGraph.to_dict()'s
# {"nodes": [...], "edges": [...]} shape onto it first
graph_dict = graph.to_dict()
kg = {
"entities": [{"id": n["id"], "type": n["type"], "text": n["content"]} for n in graph_dict["nodes"]],
"relationships": [
{"source_id": e["source"], "target_id": e["target"], "type": e["type"]}
for e in graph_dict["edges"]
],
}
# Export W3C PROV-O for regulator submission - to_kg_dict() is the official
# adapter that emits the {"entities": [...], "relationships": [...]} /
# source_id shape RDFExporter expects, so no manual field mapping is needed
kg = graph.to_kg_dict()
RDFExporter().export(kg, "audit_trail.ttl", format="turtle")
```
@@ -887,20 +880,14 @@ fact = BiTemporalFact(
recorded_at=datetime(2024, 3, 5),
)
# Query facts valid within a time window - query_time_range() expects
# {"relationships": [...]} with source_id/target_id keys, which differs from
# ContextGraph.to_dict()'s {"nodes", "edges"} shape, so map it first
graph_dict = graph.to_dict()
kg_relationships = {
"relationships": [
{**e, "source_id": e["source"], "target_id": e["target"]}
for e in graph_dict["edges"]
]
}
# Query facts valid within a time window - to_kg_dict() is the official
# adapter that emits {"entities", "relationships"} with source_id/target_id
# keys, the shape query_time_range() expects (no manual mapping required)
kg = graph.to_kg_dict()
tq = TemporalGraphQuery()
facts_in_window = tq.query_time_range(
kg_relationships, query="valid_facts", start_time="2024-01-01", end_time="2024-12-31"
kg, query="valid_facts", start_time="2024-01-01", end_time="2024-12-31"
)
# Normalize natural language temporal expressions - returns a (start, end) range
+91
View File
@@ -2449,6 +2449,97 @@ class ContextGraph:
},
}
def to_kg_dict(self, entities_only: bool = False) -> Dict[str, Any]:
"""Export graph in the canonical knowledge-graph shape.
This is the official adapter that converts the ContextGraph's internal
``{"nodes", "edges"}`` / ``source`` representation into the
``{"entities", "relationships"}`` / ``source_id`` shape expected by
downstream consumers such as
:class:`~semantica.export.rdf_exporter.RDFExporter` and
:meth:`~semantica.kg.temporal_query.TemporalGraphQuery.query_time_range`.
Users no longer need to hand-map field names between APIs.
Args:
entities_only: If True, only nodes whose ``node_type`` is
``"entity"`` are exported as entities. When False (default),
every node is exported. Relationships whose endpoints are not
in the exported entity set are dropped to avoid dangling
references in downstream consumers.
Returns:
dict: A knowledge-graph dictionary with:
- ``entities``: list of ``{"id", "text", "type", "properties",
"metadata"}`` (plus ``valid_from`` / ``valid_until`` when set)
- ``relationships``: list of ``{"source_id", "target_id",
"type", "weight", "id", "familyId"}`` (plus ``metadata`` and
``valid_from`` / ``valid_until`` when set)
- ``statistics``: ``{"entity_count", "relationship_count"}``
"""
with self._lock:
entities_out = []
for n in self.nodes.values():
if entities_only and n.node_type != "entity":
continue
# Normalize the entity id to ``str`` so it matches ContextEdge,
# which coerces its endpoints to ``str`` in ``__post_init__``.
# Without this, non-string node ids (e.g. numeric ids loaded via
# ``from_dict``) would fail the ``valid_ids`` membership check
# below and silently drop otherwise-valid relationships.
entity_id = str(n.node_id)
entity: Dict[str, Any] = {
"id": entity_id,
"text": n.content,
"type": n.node_type,
# ``properties`` / ``metadata`` may be ``None`` when a node
# was loaded from JSON containing an explicit ``null``;
# guard with ``or {}`` so ``dict(...)`` never raises.
"properties": dict(n.properties or {}),
"metadata": dict(n.metadata or {}),
}
if n.valid_from is not None:
entity["valid_from"] = n.valid_from
if n.valid_until is not None:
entity["valid_until"] = n.valid_until
entities_out.append(entity)
# When only entity nodes are exported, drop relationships whose
# endpoints were filtered out so downstream consumers never see a
# source_id/target_id that is absent from ``entities``.
valid_ids = {e["id"] for e in entities_out} if entities_only else None
relationships_out = []
for e in self.edges:
if valid_ids is not None and (
e.source_id not in valid_ids or e.target_id not in valid_ids
):
continue
rel: Dict[str, Any] = {
"id": e.edge_id,
"familyId": e.family_id or e.edge_id,
"source_id": e.source_id,
"target_id": e.target_id,
"type": e.edge_type,
"weight": e.weight,
}
if e.metadata:
rel["metadata"] = dict(e.metadata)
if e.valid_from is not None:
rel["valid_from"] = e.valid_from
if e.valid_until is not None:
rel["valid_until"] = e.valid_until
relationships_out.append(rel)
return {
"entities": entities_out,
"relationships": relationships_out,
"statistics": {
"entity_count": len(entities_out),
"relationship_count": len(relationships_out),
},
}
def from_dict(self, graph_dict: Dict[str, Any]) -> None:
"""Load graph from dictionary format."""
# Clear existing graph
+19 -5
View File
@@ -185,8 +185,25 @@ class GraphValidator:
# 3. Relationship Validation
for i, rel in enumerate(relationships):
# Check required fields
missing = self.required_rel_fields - set(rel.keys())
# Endpoints may use either the legacy ``source``/``target`` keys or
# the canonical ``source_id``/``target_id`` keys emitted by
# ``ContextGraph.to_kg_dict()``. Accept either variant so both
# representations validate consistently.
src = rel.get("source")
if src is None:
src = rel.get("source_id")
tgt = rel.get("target")
if tgt is None:
tgt = rel.get("target_id")
# Check required fields: ``type`` plus a resolvable source/target.
missing = set()
if "type" not in rel:
missing.add("type")
if src is None:
missing.add("source")
if tgt is None:
missing.add("target")
if missing:
issues.append(ValidationIssue(
code="MISSING_FIELD",
@@ -196,9 +213,6 @@ class GraphValidator:
details={"index": i}
))
continue
src = rel.get("source")
tgt = rel.get("target")
# Check Dangling Edges
def is_valid_id(node_id):
+8 -1
View File
@@ -535,7 +535,8 @@ class TemporalGraphQuery:
relationships = [
rel
for rel in relationships
if rel.get("source") == entity or rel.get("target") == entity
if (rel.get("source") or rel.get("source_id")) == entity
or (rel.get("target") or rel.get("target_id")) == entity
]
if relationship:
@@ -642,8 +643,14 @@ class TemporalGraphQuery:
parsed_end_time = self._parse_time(end_time) if end_time else None
for rel in relationships:
# Accept both the legacy ``source``/``target`` keys and the
# canonical ``source_id``/``target_id`` keys from ``to_kg_dict()``.
s = rel.get("source")
if s is None:
s = rel.get("source_id")
t = rel.get("target")
if t is None:
t = rel.get("target_id")
# Check temporal validity
if start_time or end_time:
+150
View File
@@ -0,0 +1,150 @@
"""Tests for ContextGraph.to_kg_dict() — the official KG-shape adapter.
These tests lock in the contract that to_kg_dict() emits the
``{"entities", "relationships"}`` / ``source_id`` shape expected by
downstream consumers (RDFExporter, TemporalGraphQuery.query_time_range),
so users never need to hand-map field names.
"""
from semantica.context.context_graph import ContextEdge, ContextGraph, ContextNode
def _build_graph():
g = ContextGraph()
g._add_internal_node(ContextNode(node_id="e1", node_type="entity", content="Alice"))
g._add_internal_node(ContextNode(node_id="e2", node_type="entity", content="Bob"))
g._add_internal_node(
ContextNode(node_id="c1", node_type="conversation", content="chat log")
)
g._add_internal_edge(
ContextEdge(
source_id="e1",
target_id="e2",
edge_type="knows",
valid_from="2024-01-01",
valid_until="2024-12-31",
)
)
# Edge touching a non-entity node — used to test entities_only filtering.
g._add_internal_edge(
ContextEdge(source_id="c1", target_id="e1", edge_type="mentions")
)
return g
def test_basic_shape():
kg = _build_graph().to_kg_dict()
assert set(kg.keys()) == {"entities", "relationships", "statistics"}
# Entity shape uses id/text/type (not id/content).
entity = next(e for e in kg["entities"] if e["id"] == "e1")
assert entity["text"] == "Alice"
assert entity["type"] == "entity"
def test_relationship_uses_source_id_target_id():
kg = _build_graph().to_kg_dict()
rel = next(r for r in kg["relationships"] if r["type"] == "knows")
assert rel["source_id"] == "e1"
assert rel["target_id"] == "e2"
# "source"/"target" (the internal names) must NOT leak through.
assert "source" not in rel
assert "target" not in rel
def test_temporal_fields_passthrough():
kg = _build_graph().to_kg_dict()
rel = next(r for r in kg["relationships"] if r["type"] == "knows")
assert rel["valid_from"] == "2024-01-01"
assert rel["valid_until"] == "2024-12-31"
def test_statistics_counts():
kg = _build_graph().to_kg_dict()
assert kg["statistics"]["entity_count"] == len(kg["entities"])
assert kg["statistics"]["relationship_count"] == len(kg["relationships"])
def test_entities_only_filters_nodes():
kg = _build_graph().to_kg_dict(entities_only=True)
types = {e["type"] for e in kg["entities"]}
assert types == {"entity"}
assert len(kg["entities"]) == 2
def test_entities_only_drops_dangling_relationships():
# The "mentions" edge points from a conversation node (filtered out under
# entities_only) and must not appear as a dangling relationship.
kg = _build_graph().to_kg_dict(entities_only=True)
rel_types = {r["type"] for r in kg["relationships"]}
assert "mentions" not in rel_types
assert rel_types == {"knows"}
def test_returned_dicts_are_isolated_from_internal_state():
g = _build_graph()
kg = g.to_kg_dict()
entity = next(e for e in kg["entities"] if e["id"] == "e1")
# Mutating the returned dict must not corrupt internal node properties.
entity["properties"]["injected"] = True
assert "injected" not in g.nodes["e1"].properties
def test_null_properties_and_metadata_do_not_crash():
"""Nodes loaded from JSON ``null`` keep None props/metadata; to_kg_dict
must normalize them instead of raising TypeError (Qodo bug 1)."""
g = ContextGraph()
n = ContextNode(node_id="e1", node_type="entity", content="Alice")
n.properties = None
n.metadata = None
g._add_internal_node(n)
kg = g.to_kg_dict()
entity = kg["entities"][0]
assert entity["properties"] == {}
assert entity["metadata"] == {}
def test_non_string_node_id_is_normalized_and_keeps_edges():
"""ContextEdge coerces endpoints to str; entity ids must be coerced too
so entities_only filtering does not drop valid edges (Qodo bug 3)."""
g = ContextGraph()
g._add_internal_node(ContextNode(node_id=1, node_type="entity", content="one"))
g._add_internal_node(ContextNode(node_id=2, node_type="entity", content="two"))
g._add_internal_edge(ContextEdge(source_id=1, target_id=2, edge_type="links"))
kg = g.to_kg_dict(entities_only=True)
ids = {e["id"] for e in kg["entities"]}
assert ids == {"1", "2"}
assert all(isinstance(e["id"], str) for e in kg["entities"])
# The edge must survive filtering despite the int-vs-str origin.
assert {r["type"] for r in kg["relationships"]} == {"links"}
def test_output_is_consumable_by_kg_utilities():
"""to_kg_dict output must validate and be traversable by KG utilities that
historically read ``source``/``target`` (Qodo bug 2, consumer side)."""
from semantica.kg.graph_validator import GraphValidator, ValidationSeverity
from semantica.kg.temporal_query import TemporalGraphQuery
g = ContextGraph()
g._add_internal_node(ContextNode(node_id="e1", node_type="entity", content="Alice"))
g._add_internal_node(ContextNode(node_id="e2", node_type="entity", content="Bob"))
g._add_internal_edge(ContextEdge(source_id="e1", target_id="e2", edge_type="knows"))
kg = g.to_kg_dict()
# Validator requires entity ``name``; add it so only endpoint compat is tested.
for e in kg["entities"]:
e["name"] = e["text"]
result = GraphValidator().validate(kg)
endpoint_errors = [
i for i in result.issues
if i.code in {"MISSING_FIELD", "DANGLING_EDGE"}
and i.element_type == "relationship"
]
assert endpoint_errors == [], endpoint_errors
# TemporalGraphQuery.analyze_evolution must see the relationship for "e1".
tq = TemporalGraphQuery()
filtered = tq.analyze_evolution(kg, entity="e1")
assert filtered is not None