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
+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