mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
220 lines
9.7 KiB
Python
220 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Topological Engine Client — Private connector to NoDupeLabs Obsidian + Neo4j graph.
|
|
|
|
Provides:
|
|
- Obsidian vault read/write/search
|
|
- Neo4j Cypher execution, road upserts, and Obsidian link imports
|
|
- Health checks
|
|
|
|
All endpoints require TOPOLOGICAL_ENGINE_TOKEN (Bearer auth).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from typing import Dict, List, Any, Optional
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
|
|
|
|
class TopologicalEngineClient:
|
|
"""Client for the private topological engine (NoDupeLabs)."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str = None,
|
|
token: str = None,
|
|
timeout: int = 30
|
|
):
|
|
self.base_url = (base_url or os.getenv(
|
|
"TOPOLOGICAL_ENGINE_URL", "http://localhost:3000"
|
|
)).rstrip("/")
|
|
self.token = token or os.getenv("TOPOLOGICAL_ENGINE_TOKEN", "")
|
|
self.timeout = timeout
|
|
self._session = requests.Session()
|
|
if self.token:
|
|
self._session.headers["Authorization"] = f"Bearer {self.token}"
|
|
self._session.headers["Content-Type"] = "application/json"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Health
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def health(self) -> Dict[str, Any]:
|
|
"""Ping the topological engine health endpoint."""
|
|
url = f"{self.base_url}/topology/health"
|
|
try:
|
|
resp = self._session.get(url, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e), "healthy": False}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Obsidian
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def write_obsidian_note(
|
|
self,
|
|
title: str = None,
|
|
folder: str = "Research Stack",
|
|
body: str = "",
|
|
path: str = None,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Write or overwrite an Obsidian note.
|
|
|
|
Uses 'path' if provided (legacy compat), otherwise constructs from title+folder.
|
|
"""
|
|
url = f"{self.base_url}/topology/obsidian/write-note"
|
|
payload = {
|
|
"folder": folder,
|
|
"body": body,
|
|
}
|
|
if title:
|
|
payload["title"] = title
|
|
if path:
|
|
# Router uses title+folder; simulate by extracting title from path
|
|
payload["title"] = path.replace(".md", "").split("/")[-1]
|
|
if "/" in path:
|
|
payload["folder"] = "/".join(path.replace(".md", "").split("/")[:-1])
|
|
if metadata:
|
|
payload.update({k: v for k, v in metadata.items() if k not in payload})
|
|
try:
|
|
resp = self._session.post(url, json=payload, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
def read_obsidian_note(self, path: str) -> Dict[str, Any]:
|
|
"""Read an Obsidian note by vault-relative path."""
|
|
encoded = quote(path, safe="")
|
|
url = f"{self.base_url}/topology/obsidian/read-note?path={encoded}"
|
|
try:
|
|
resp = self._session.get(url, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
def search_obsidian(self, query: str, limit: int = 20) -> Dict[str, Any]:
|
|
"""Full-text search across the Obsidian vault."""
|
|
url = f"{self.base_url}/topology/obsidian/search"
|
|
payload = {"query": query, "limit": limit}
|
|
try:
|
|
resp = self._session.post(url, json=payload, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Neo4j — simple pairwise road upsert (matches API exactly)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def neo4j_upsert_road(
|
|
self,
|
|
source_id: str,
|
|
source_label: str,
|
|
target_id: str,
|
|
target_label: str,
|
|
relation: str,
|
|
authority_scope: str = "candidate",
|
|
outcome: str = "hold",
|
|
torsion: float = 0,
|
|
coherence: float = 1.0,
|
|
provenance_hash: str = None,
|
|
source_of_truth: str = None,
|
|
quarantine_status: str = "none"
|
|
) -> Dict[str, Any]:
|
|
"""Upsert a simple pairwise road (2 nodes + 1 relationship)."""
|
|
url = f"{self.base_url}/topology/neo4j/upsert-road"
|
|
payload = {
|
|
"sourceId": source_id,
|
|
"sourceLabel": source_label,
|
|
"targetId": target_id,
|
|
"targetLabel": target_label,
|
|
"relation": relation,
|
|
"authority_scope": authority_scope,
|
|
"outcome": outcome,
|
|
"torsion": torsion,
|
|
"coherence": coherence,
|
|
"provenance_hash": provenance_hash or "",
|
|
"source_of_truth": source_of_truth or "Neo4j candidate; Graph.lean audit required",
|
|
"quarantine_status": quarantine_status,
|
|
}
|
|
try:
|
|
resp = self._session.post(url, json=payload, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Neo4j — arbitrary Cypher (for complex batch structures)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def neo4j_cypher(
|
|
self,
|
|
query: str,
|
|
parameters: Optional[Dict[str, Any]] = None,
|
|
read_only: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""Run an arbitrary Cypher query."""
|
|
url = f"{self.base_url}/topology/neo4j/cypher"
|
|
payload = {"cypher": query, "params": parameters or {}, "readOnly": read_only}
|
|
try:
|
|
resp = self._session.post(url, json=payload, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
def neo4j_import_obsidian_links(
|
|
self,
|
|
vault_path: Optional[str] = None,
|
|
limit: int = 1000
|
|
) -> Dict[str, Any]:
|
|
"""Trigger import of Obsidian [[wiki-links]] into Neo4j."""
|
|
url = f"{self.base_url}/topology/neo4j/import-obsidian-links"
|
|
payload = {"limit": limit}
|
|
if vault_path:
|
|
payload["vault_path"] = vault_path
|
|
try:
|
|
resp = self._session.post(url, json=payload, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except requests.RequestException as e:
|
|
return {"error": str(e)}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Batch helpers
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
def batch_upsert_roads(self, roads: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Upsert multiple simple roads, returning results per road."""
|
|
results = []
|
|
for road in roads:
|
|
results.append(self.neo4j_upsert_road(**road))
|
|
return results
|
|
|
|
def batch_cypher(self, statements: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Run multiple Cypher statements."""
|
|
results = []
|
|
for stmt in statements:
|
|
results.append(self.neo4j_cypher(
|
|
query=stmt["query"],
|
|
parameters=stmt.get("parameters", {}),
|
|
read_only=stmt.get("read_only", False)
|
|
))
|
|
return results
|
|
|
|
def ensure_indexes(self) -> Dict[str, Any]:
|
|
"""Idempotent index/constraints for Research Stack graph."""
|
|
statements = [
|
|
{"query": "CREATE INDEX rs_node_id IF NOT EXISTS FOR (n:Node) ON (n.id)", "read_only": False},
|
|
{"query": "CREATE INDEX rs_obsidian_id IF NOT EXISTS FOR (n:ObsidianNote) ON (n.id)", "read_only": False},
|
|
]
|
|
return {"statements": statements, "results": self.batch_cypher(statements)}
|