"""Neo4j sync for SciPaths workflow graph animation.""" from __future__ import annotations import os from typing import Any, Optional _DRIVER = None NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "graphscout123") def neo4j_available() -> bool: try: driver = get_driver() if driver is None: return False driver.verify_connectivity() return True except Exception: return False def get_driver(): global _DRIVER if _DRIVER is not None: return _DRIVER try: from neo4j import GraphDatabase except ImportError: return None try: _DRIVER = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) _DRIVER.verify_connectivity() return _DRIVER except Exception: _DRIVER = None return None def reset_run(run_id: str) -> bool: driver = get_driver() if driver is None or not run_id: return False try: with driver.session() as session: session.run( "MATCH (n:SciPathsNode {run_id: $run_id}) DETACH DELETE n", run_id=run_id, ) return True except Exception: return False def upsert_graph(run_id: str, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> bool: """Replace the run's graph snapshot with the current cumulative nodes/edges.""" driver = get_driver() if driver is None or not run_id: return False try: with driver.session() as session: session.execute_write(_upsert_tx, run_id, nodes, edges) return True except Exception: return False def _upsert_tx(tx, run_id: str, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> None: tx.run("MATCH (n:SciPathsNode {run_id: $run_id}) DETACH DELETE n", run_id=run_id) for node in nodes: tx.run( """ CREATE (n:SciPathsNode { run_id: $run_id, id: $id, kind: $kind, label: $label, title: $title, detail: $detail, step_added: $step_added, pulse: $pulse, color: $color, size: $size }) """, run_id=run_id, id=str(node.get("id") or ""), kind=str(node.get("kind") or "node"), label=str(node.get("label") or ""), title=str(node.get("title") or node.get("label") or ""), detail=str(node.get("detail") or ""), step_added=int(node.get("step_added") or 0), pulse=bool(node.get("pulse")), color=str(node.get("color") or "#6a7a74"), size=int(node.get("size") or 16), ) for edge in edges: tx.run( """ MATCH (a:SciPathsNode {run_id: $run_id, id: $source}) MATCH (b:SciPathsNode {run_id: $run_id, id: $target}) CREATE (a)-[:SCI_REL { run_id: $run_id, id: $id, kind: $kind, pulse: $pulse, muted: $muted }]->(b) """, run_id=run_id, source=str(edge.get("source") or ""), target=str(edge.get("target") or ""), id=str(edge.get("id") or ""), kind=str(edge.get("kind") or "rel"), pulse=bool(edge.get("pulse")), muted=bool(edge.get("muted")), ) def fetch_graph(run_id: str) -> Optional[dict[str, Any]]: driver = get_driver() if driver is None or not run_id: return None try: with driver.session() as session: nodes = session.run( """ MATCH (n:SciPathsNode {run_id: $run_id}) RETURN n.id AS id, n.kind AS kind, n.label AS label, n.title AS title, n.detail AS detail, n.step_added AS step_added, n.pulse AS pulse, n.color AS color, n.size AS size """, run_id=run_id, ).data() edges = session.run( """ MATCH (a:SciPathsNode {run_id: $run_id})-[r:SCI_REL]->(b:SciPathsNode {run_id: $run_id}) RETURN r.id AS id, a.id AS source, b.id AS target, r.kind AS kind, r.pulse AS pulse, r.muted AS muted """, run_id=run_id, ).data() if not nodes: return None return {"nodes": nodes, "edges": edges} except Exception: return None