Spaces:
Running
Running
Z User
feat: ABM Engine v1 β OpenSearch store + Apify enrichment + ICP scoring + k-NN memory + optional Neo4j mirror
fb0ec21 Download app/stores/graph.py from sitcod3/abm-engine: direct link, hf CLI and curl.
- Browser
- Download file 7.45 kB
-
https://huggingface.co/spaces/sitcod3/abm-engine/resolve/main/app/stores/graph.py
- Command line
-
hf download hf://spaces/sitcod3/abm-engine/app/stores/graph.py
-
curl -L -o graph.py https://huggingface.co/spaces/sitcod3/abm-engine/resolve/main/app/stores/graph.py
7.45 kB
| """Optional graph mirror β BetterDB (managed Neo4j) or direct Aura. | |
| The ABM graph (accounts, contacts, enrichment events) mirrors into Neo4j when | |
| a connection is available; the engine runs fully without it (OpenSearch is the | |
| source of truth). Two transports, auto-detected at boot: | |
| 1. BETTERDB_URL + BETTERDB_TOKEN β managed Neo4j behind betterdb.com. The | |
| HTTP query API is probed at a few known paths; BetterDB answers "waiting" | |
| until the owner connects their instance in the console, in which case the | |
| mirror stays disabled and /health reports it honestly. | |
| 2. NEO4J_URI (+user/password) β a direct Neo4j/Aura endpoint via the official | |
| driver (bolt+ssc for Aura). | |
| """ | |
| import logging | |
| from datetime import datetime, timezone | |
| from typing import Any, Dict, List, Optional | |
| import httpx | |
| from ..config import settings | |
| logger = logging.getLogger("abm.graph") | |
| class GraphStore: | |
| def __init__(self) -> None: | |
| self._mode: Optional[str] = None # "betterdb" | "neo4j" | None | |
| self._driver = None | |
| self._http: Optional[httpx.AsyncClient] = None | |
| self._query_path: Optional[str] = None | |
| def mode(self) -> Optional[str]: | |
| return self._mode | |
| async def connect(self) -> bool: | |
| if settings.neo4j_uri: | |
| try: | |
| from neo4j import GraphDatabase # optional dependency | |
| scheme = "neo4j+ssc" if "+ssc" not in settings.neo4j_uri else None | |
| uri = settings.neo4j_uri | |
| if uri.startswith("neo4j://"): | |
| uri = uri.replace("neo4j://", "neo4j+ssc://", 1) | |
| elif scheme and not uri.startswith("neo4j+"): | |
| uri = f"{scheme}://{uri}" | |
| self._driver = GraphDatabase.driver( | |
| uri, auth=(settings.neo4j_user or "neo4j", settings.neo4j_password or "") | |
| ) | |
| await asyncio_wrap(lambda: self._driver.verify_connectivity()) | |
| self._mode = "neo4j" | |
| logger.info("graph: direct Neo4j connected (%s)", uri) | |
| return True | |
| except Exception as exc: | |
| logger.warning("graph: direct Neo4j connect failed: %s", exc) | |
| self._driver = None | |
| if settings.betterdb_url and settings.betterdb_token: | |
| self._http = httpx.AsyncClient( | |
| base_url=settings.betterdb_url.rstrip("/"), | |
| headers={"Authorization": f"Bearer {settings.betterdb_token}"}, | |
| timeout=httpx.Timeout(20.0), | |
| ) | |
| for path in ( | |
| "/api/neo4j/query/v2", | |
| "/neo4j/query/v2", | |
| "/api/db/neo4j/query/v2", | |
| "/db/neo4j/query/v2", | |
| "/api/query", | |
| ): | |
| try: | |
| res = await self._http.post( | |
| path, json={"statement": "RETURN 1 AS ok"} | |
| ) | |
| if res.status_code == 200: | |
| self._query_path = path | |
| self._mode = "betterdb" | |
| logger.info("graph: BetterDB query API live at %s", path) | |
| return True | |
| if res.status_code in (401, 402, 403): | |
| logger.warning("graph: BetterDB answered %s at %s β token %s", | |
| res.status_code, path, "rejected") | |
| except Exception: | |
| continue | |
| logger.info("graph: BetterDB reachable but no query API answered yet " | |
| "(owner must connect the instance in the console)") | |
| return False | |
| async def close(self) -> None: | |
| if self._http is not None: | |
| await self._http.aclose() | |
| if self._driver is not None: | |
| self._driver.close() | |
| async def run(self, cypher: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: | |
| """Run a write/query statement; returns rows (best-effort, logged).""" | |
| if self._mode == "betterdb" and self._query_path: | |
| try: | |
| res = await self._http.post( | |
| self._query_path, json={"statement": cypher, "parameters": params or {}} | |
| ) | |
| if res.status_code != 200: | |
| logger.warning("graph query %s: %s", res.status_code, res.text[:150]) | |
| return [] | |
| body = res.json() | |
| return body.get("records") or body.get("data") or [] | |
| except Exception as exc: | |
| logger.warning("graph query failed: %s", exc) | |
| return [] | |
| if self._mode == "neo4j" and self._driver is not None: | |
| def _run(): | |
| with self._driver.session() as session: | |
| result = session.run(cypher, params or {}) | |
| return [r.data() for r in result] | |
| try: | |
| return await asyncio_wrap(_run) | |
| except Exception as exc: | |
| logger.warning("graph query failed: %s", exc) | |
| return [] | |
| return [] | |
| # ββ mirror API (idempotent MERGEs) βββββββββββββββββββββββββββββββββββ | |
| async def upsert_account(self, account: Dict[str, Any]) -> None: | |
| if self._mode is None: | |
| return | |
| await self.run( | |
| """ | |
| MERGE (a:Account {account_id: $account_id}) | |
| SET a.name = $name, a.domain = $domain, a.industry = $industry, | |
| a.hq_country = $hq_country, a.icp_score = $icp_score, | |
| a.updated_at = $updated_at | |
| """, | |
| { | |
| "account_id": account.get("account_id"), | |
| "name": account.get("name"), | |
| "domain": account.get("domain"), | |
| "industry": account.get("industry"), | |
| "hq_country": account.get("hq_country"), | |
| "icp_score": account.get("icp_score"), | |
| "updated_at": datetime.now(timezone.utc).isoformat(), | |
| }, | |
| ) | |
| async def upsert_contact(self, account_id: str, contact: Dict[str, Any]) -> None: | |
| if self._mode is None: | |
| return | |
| await self.run( | |
| """ | |
| MATCH (a:Account {account_id: $account_id}) | |
| MERGE (c:Contact {contact_id: $contact_id}) | |
| SET c.full_name = $full_name, c.title = $title, c.email = $email, | |
| c.linkedin_url = $linkedin_url | |
| MERGE (c)-[:WORKS_AT]->(a) | |
| """, | |
| { | |
| "account_id": account_id, | |
| "contact_id": contact.get("contact_id"), | |
| "full_name": contact.get("full_name"), | |
| "title": contact.get("title"), | |
| "email": contact.get("email"), | |
| "linkedin_url": contact.get("linkedin_url"), | |
| }, | |
| ) | |
| async def overview(self) -> Dict[str, Any]: | |
| if self._mode is None: | |
| return {"mode": None, "status": "not connected"} | |
| rows = await self.run( | |
| "MATCH (a:Account) WITH count(a) AS accounts " | |
| "OPTIONAL MATCH (c:Contact) RETURN accounts, count(c) AS contacts" | |
| ) | |
| return {"mode": self._mode, "status": "connected", "counts": rows or []} | |
| import asyncio as _asyncio | |
| async def asyncio_wrap(fn): | |
| return await _asyncio.to_thread(fn) | |
| graph = GraphStore() | |