"""Serialize a knowledge graph to JSON / GraphML, or push it to Neo4j.""" from __future__ import annotations import json from pathlib import Path from typing import Any NEO4J_LABELS = { "document": "Document", "chunk": "Chunk", "entity": "Entity", "citation": "Citation", } def export_json(graph: Any, path: Path) -> Path: """Node-link JSON (lossless round-trip with `load_json`).""" import networkx as nx path.parent.mkdir(parents=True, exist_ok=True) data = nx.node_link_data(graph, edges="edges") path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") return path def load_json(path: Path) -> Any: """Load a graph previously written by `export_json`.""" import networkx as nx data = json.loads(path.read_text(encoding="utf-8")) return nx.node_link_graph(data, edges="edges") def export_graphml(graph: Any, path: Path) -> Path: """GraphML for Gephi/Cytoscape. Chunk text is dropped (GraphML attr limits).""" import networkx as nx path.parent.mkdir(parents=True, exist_ok=True) slim = nx.DiGraph() for node, data in graph.nodes(data=True): slim.add_node(node, **{k: v for k, v in data.items() if k != "text"}) slim.add_edges_from( (u, v, {k: val for k, val in data.items() if isinstance(val, (int, float, str, bool))}) for u, v, data in graph.edges(data=True) ) nx.write_graphml(slim, str(path)) return path class Neo4jExporter: """MERGE the graph into a Neo4j database. Credentials come from constructor args or NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD environment variables. """ def __init__( self, uri: str | None = None, user: str | None = None, password: str | None = None, ) -> None: import os self.uri = uri or os.environ.get("NEO4J_URI", "bolt://localhost:7687") self.user = user or os.environ.get("NEO4J_USER", "neo4j") self.password = password or os.environ.get("NEO4J_PASSWORD", "") try: from neo4j import GraphDatabase except ImportError as exc: raise ImportError("Neo4j export needs the driver: uv pip install neo4j") from exc self._driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password)) def close(self) -> None: self._driver.close() def __enter__(self) -> Neo4jExporter: return self def __exit__(self, *exc_info: object) -> None: self.close() def push(self, graph: Any, batch_size: int = 500) -> dict[str, int]: """MERGE all nodes and edges. Returns counts pushed.""" nodes = list(graph.nodes(data=True)) edges = list(graph.edges(data=True)) with self._driver.session() as session: for start in range(0, len(nodes), batch_size): session.execute_write(self._push_nodes, nodes[start : start + batch_size]) for start in range(0, len(edges), batch_size): session.execute_write(self._push_edges, edges[start : start + batch_size]) return {"nodes": len(nodes), "edges": len(edges)} @staticmethod def _push_nodes(tx: Any, batch: list[tuple[str, dict]]) -> None: for node_id, data in batch: label = NEO4J_LABELS.get(data.get("kind", ""), "Node") props = {k: v for k, v in data.items() if k != "kind"} tx.run( f"MERGE (n:{label} {{id: $id}}) SET n += $props", id=node_id, props=props, ) @staticmethod def _push_edges(tx: Any, batch: list[tuple[str, str, dict]]) -> None: for source, target, data in batch: rel = data.get("kind", "RELATED") props = {k: v for k, v in data.items() if k != "kind"} tx.run( f"MATCH (a {{id: $source}}), (b {{id: $target}}) " f"MERGE (a)-[r:{rel}]->(b) SET r += $props", source=source, target=target, props=props, )