mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user