From a1194a155d52b6d8ca49b8c8ee63eaa861069d91 Mon Sep 17 00:00:00 2001 From: cxzg007 <108442142+cxzg007@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:24:52 +0800 Subject: [PATCH] feat(context): add to_kg_dict() adapter for canonical KG shape (#1081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: 江俊杰 --- README.md | 31 ++---- semantica/context/context_graph.py | 91 +++++++++++++++++ semantica/kg/graph_validator.py | 24 ++++- semantica/kg/temporal_query.py | 9 +- tests/context/test_to_kg_dict.py | 150 +++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+), 28 deletions(-) create mode 100644 tests/context/test_to_kg_dict.py diff --git a/README.md b/README.md index 0821b7ba..49a4c57c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index ad6ecf3f..ac3f134f 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -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 diff --git a/semantica/kg/graph_validator.py b/semantica/kg/graph_validator.py index 865917f1..9d6a2cbb 100644 --- a/semantica/kg/graph_validator.py +++ b/semantica/kg/graph_validator.py @@ -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): diff --git a/semantica/kg/temporal_query.py b/semantica/kg/temporal_query.py index 904e652c..f5bc227a 100644 --- a/semantica/kg/temporal_query.py +++ b/semantica/kg/temporal_query.py @@ -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: diff --git a/tests/context/test_to_kg_dict.py b/tests/context/test_to_kg_dict.py new file mode 100644 index 00000000..086782f8 --- /dev/null +++ b/tests/context/test_to_kg_dict.py @@ -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