feat: initial commit - graph-service
This commit is contained in:
commit
c0ab45ab81
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
logs/
|
||||
*.log
|
||||
nohup.out
|
||||
nohup_gpu*.out
|
||||
py3/
|
||||
*.egg-info/
|
||||
*.pid
|
||||
6
ah.py
Normal file
6
ah.py
Normal file
@ -0,0 +1,6 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
from ahserver.webapp import webapp
|
||||
from init import load_graph_service
|
||||
|
||||
if __name__ == '__main__':
|
||||
webapp(load_graph_service)
|
||||
55
build.sh
Executable file
55
build.sh
Executable file
@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Graph Service (NetworkX)
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SERVICE_NAME="graph-service"
|
||||
PORT=9092
|
||||
PY=/data/ymq/wan22-service/py3/bin/python
|
||||
action="${1:-status}"
|
||||
|
||||
case "$action" in
|
||||
deploy|update)
|
||||
echo "=== $SERVICE_NAME Deploy (CPU, port $PORT) ==="
|
||||
if [ -f ah.pid ] && kill -0 $(cat ah.pid) 2>/dev/null; then
|
||||
kill $(cat ah.pid) 2>/dev/null || true; sleep 2
|
||||
fi
|
||||
if [ -d .git ] && [ -f .git/HEAD ]; then
|
||||
git pull origin master 2>/dev/null || true
|
||||
fi
|
||||
mkdir -p logs files wwwroot data
|
||||
export PYTHONPATH="$(pwd)"
|
||||
nohup $PY ah.py > nohup.out 2>&1 &
|
||||
echo $! > ah.pid
|
||||
echo "Started PID $(cat ah.pid) on port $PORT"
|
||||
sleep 3
|
||||
if curl -s http://localhost:$PORT/api/status > /dev/null 2>&1; then
|
||||
echo "Service healthy"
|
||||
else
|
||||
echo "WARNING: not responding, check nohup.out"
|
||||
tail -20 nohup.out
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
if [ -f ah.pid ]; then
|
||||
kill $(cat ah.pid) 2>/dev/null || true; rm -f ah.pid; echo "Stopped"
|
||||
else echo "Not running"; fi
|
||||
;;
|
||||
start)
|
||||
mkdir -p logs files wwwroot data
|
||||
export PYTHONPATH="$(pwd)"
|
||||
nohup $PY ah.py > nohup.out 2>&1 &
|
||||
echo $! > ah.pid; echo "Started PID $(cat ah.pid)"
|
||||
;;
|
||||
status)
|
||||
echo "=== $SERVICE_NAME Status ==="
|
||||
if [ -f ah.pid ] && kill -0 $(cat ah.pid) 2>/dev/null; then
|
||||
echo "Process: running (PID $(cat ah.pid))"
|
||||
else echo "Process: not running"; fi
|
||||
echo "Port: $PORT"
|
||||
if curl -s --max-time 3 http://localhost:$PORT/api/status > /dev/null 2>&1; then
|
||||
echo "HTTP: OK"
|
||||
else echo "HTTP: not responding"; fi
|
||||
;;
|
||||
*) echo "Usage: $0 {deploy|update|stop|start|status}"; exit 1 ;;
|
||||
esac
|
||||
33
conf/config.json
Normal file
33
conf/config.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"password_key": "GraphService2026Key",
|
||||
"filesroot": "$[workdir]$/files",
|
||||
"logger": {
|
||||
"name": "graph-service",
|
||||
"levelname": "info",
|
||||
"logfile": "$[workdir]$/logs/graph-service.log"
|
||||
},
|
||||
"website": {
|
||||
"paths": [["$[workdir]$/wwwroot", ""]],
|
||||
"client_max_size": 52428800,
|
||||
"host": "0.0.0.0",
|
||||
"port": 9092,
|
||||
"coding": "utf-8",
|
||||
"indexes": ["index.html"],
|
||||
"startswiths": [
|
||||
{"leading": "/api/status", "registerfunction": "status"},
|
||||
{"leading": "/api/debug", "registerfunction": "debug"},
|
||||
{"leading": "/api/graph/add_node", "registerfunction": "add_node"},
|
||||
{"leading": "/api/graph/add_edge", "registerfunction": "add_edge"},
|
||||
{"leading": "/api/graph/neighbors", "registerfunction": "neighbors"},
|
||||
{"leading": "/api/graph/path", "registerfunction": "path"},
|
||||
{"leading": "/api/graph/query", "registerfunction": "query"},
|
||||
{"leading": "/api/graph/stats", "registerfunction": "stats"},
|
||||
{"leading": "/api/graph/save", "registerfunction": "save"},
|
||||
{"leading": "/api/graph/load", "registerfunction": "load"}
|
||||
],
|
||||
"processors": [
|
||||
[".tmpl", "tmpl"], [".app", "app"], [".ui", "bui"],
|
||||
[".dspy", "dspy"], [".md", "md"]
|
||||
]
|
||||
}
|
||||
}
|
||||
7
data/default.json
Normal file
7
data/default.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"directed": true,
|
||||
"multigraph": false,
|
||||
"graph": {},
|
||||
"nodes": [],
|
||||
"links": []
|
||||
}
|
||||
29
data/test.json
Normal file
29
data/test.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"directed": true,
|
||||
"multigraph": false,
|
||||
"graph": {},
|
||||
"nodes": [
|
||||
{
|
||||
"name": "张三",
|
||||
"age": 30,
|
||||
"type": "person",
|
||||
"id": "person_1"
|
||||
},
|
||||
{
|
||||
"id": "person_2"
|
||||
},
|
||||
{
|
||||
"id": "company_1"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"source": "person_1",
|
||||
"target": "person_2"
|
||||
},
|
||||
{
|
||||
"source": "person_2",
|
||||
"target": "company_1"
|
||||
}
|
||||
]
|
||||
}
|
||||
184
init.py
Normal file
184
init.py
Normal file
@ -0,0 +1,184 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
from traceback import format_exc
|
||||
from ahserver.serverenv import ServerEnv
|
||||
from appPublic.registerfunction import RegisterFunction
|
||||
from appPublic.log import exception
|
||||
import json
|
||||
|
||||
|
||||
async def status_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import health_check
|
||||
health = health_check()
|
||||
return json.dumps({
|
||||
"service": "graph-service",
|
||||
"backend": health["backend"],
|
||||
"graphs_loaded": health["graphs_loaded"],
|
||||
"data_dir": health["data_dir"],
|
||||
"endpoints": [
|
||||
"/api/status", "/api/graph/add_node", "/api/graph/add_edge",
|
||||
"/api/graph/neighbors", "/api/graph/path", "/api/graph/query",
|
||||
"/api/graph/stats", "/api/graph/save", "/api/graph/load"
|
||||
]
|
||||
}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
async def debug_handler(request, params_kw, *args, **kwargs):
|
||||
"""Debug endpoint to see params_kw contents"""
|
||||
return json.dumps({
|
||||
"params_kw": params_kw,
|
||||
"params_kw_keys": list(params_kw.keys()),
|
||||
"request_method": request.method,
|
||||
"request_content_type": request.content_type
|
||||
}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
async def add_node_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import add_node
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
node_id = params_kw.get("node_id")
|
||||
attrs = params_kw.get("attrs", {})
|
||||
|
||||
if not node_id:
|
||||
return json.dumps({"error": "node_id required", "debug": {"params_kw": params_kw}})
|
||||
|
||||
result = add_node(graph, node_id, **attrs)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def add_edge_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import add_edge
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
source = params_kw.get("source")
|
||||
target = params_kw.get("target")
|
||||
attrs = params_kw.get("attrs", {})
|
||||
|
||||
if not source or not target:
|
||||
return json.dumps({"error": "source and target required"})
|
||||
|
||||
result = add_edge(graph, source, target, **attrs)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def neighbors_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import get_neighbors
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
node_id = params_kw.get("node_id")
|
||||
direction = params_kw.get("direction", "both")
|
||||
depth = int(params_kw.get("depth", 1))
|
||||
|
||||
if not node_id:
|
||||
return json.dumps({"error": "node_id required"})
|
||||
|
||||
result = get_neighbors(graph, node_id, direction, depth)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def path_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import find_path
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
source = params_kw.get("source")
|
||||
target = params_kw.get("target")
|
||||
max_depth = int(params_kw.get("max_depth", 10))
|
||||
|
||||
if not source or not target:
|
||||
return json.dumps({"error": "source and target required"})
|
||||
|
||||
result = find_path(graph, source, target, max_depth)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def query_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import query_nodes
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
filters = params_kw.get("filters")
|
||||
limit = int(params_kw.get("limit", 100))
|
||||
|
||||
result = query_nodes(graph, filters, limit)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def stats_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import get_stats
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
result = get_stats(graph)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def save_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import save_graph
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
result = save_graph(graph)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def load_handler(request, params_kw, *args, **kwargs):
|
||||
import sys, os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from workers.graph_engine import load_graph
|
||||
try:
|
||||
graph = params_kw.get("graph", "default")
|
||||
result = load_graph(graph)
|
||||
return json.dumps({"status": "SUCCEEDED", **result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
exception(f"{e}, {format_exc()}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def load_graph_service():
|
||||
"""Register API handlers"""
|
||||
env = ServerEnv()
|
||||
rf = RegisterFunction()
|
||||
rf.register("status", status_handler)
|
||||
rf.register("debug", debug_handler)
|
||||
rf.register("add_node", add_node_handler)
|
||||
rf.register("add_edge", add_edge_handler)
|
||||
rf.register("neighbors", neighbors_handler)
|
||||
rf.register("path", path_handler)
|
||||
rf.register("query", query_handler)
|
||||
rf.register("stats", stats_handler)
|
||||
rf.register("save", save_handler)
|
||||
rf.register("load", load_handler)
|
||||
0
workers/__init__.py
Normal file
0
workers/__init__.py
Normal file
203
workers/graph_engine.py
Normal file
203
workers/graph_engine.py
Normal file
@ -0,0 +1,203 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
"""NetworkX-based graph engine with persistence."""
|
||||
import os
|
||||
import json
|
||||
import networkx as nx
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
DATA_DIR = "/data/ymq/graph-service/data"
|
||||
_graphs: Dict[str, nx.DiGraph] = {}
|
||||
|
||||
|
||||
def get_graph(graph_name: str = "default") -> nx.DiGraph:
|
||||
"""Get or create a graph by name."""
|
||||
if graph_name not in _graphs:
|
||||
# Try to load from disk
|
||||
filepath = os.path.join(DATA_DIR, f"{graph_name}.json")
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
_graphs[graph_name] = nx.node_link_graph(data, directed=True)
|
||||
else:
|
||||
_graphs[graph_name] = nx.DiGraph()
|
||||
return _graphs[graph_name]
|
||||
|
||||
|
||||
def add_node(graph_name: str, node_id: str, **attrs) -> Dict:
|
||||
"""Add a node to the graph."""
|
||||
g = get_graph(graph_name)
|
||||
g.add_node(node_id, **attrs)
|
||||
return {"status": "ok", "node_id": node_id, "graph": graph_name}
|
||||
|
||||
|
||||
def add_edge(graph_name: str, source: str, target: str, **attrs) -> Dict:
|
||||
"""Add an edge to the graph."""
|
||||
g = get_graph(graph_name)
|
||||
g.add_edge(source, target, **attrs)
|
||||
return {"status": "ok", "source": source, "target": target, "graph": graph_name}
|
||||
|
||||
|
||||
def get_neighbors(graph_name: str, node_id: str, direction: str = "both", depth: int = 1) -> Dict:
|
||||
"""Get neighbors of a node."""
|
||||
g = get_graph(graph_name)
|
||||
|
||||
if node_id not in g:
|
||||
return {"error": f"Node {node_id} not found"}
|
||||
|
||||
# BFS to get neighbors up to depth
|
||||
visited = set()
|
||||
queue = [(node_id, 0)]
|
||||
neighbors = []
|
||||
|
||||
while queue:
|
||||
current, d = queue.pop(0)
|
||||
if d > depth:
|
||||
break
|
||||
|
||||
if d > 0: # Don't include the starting node
|
||||
neighbors.append({
|
||||
"node_id": current,
|
||||
"depth": d,
|
||||
"attrs": dict(g.nodes[current])
|
||||
})
|
||||
|
||||
if current not in visited:
|
||||
visited.add(current)
|
||||
|
||||
if d < depth:
|
||||
# Add successors
|
||||
if direction in ["both", "out"]:
|
||||
for neighbor in g.successors(current):
|
||||
if neighbor not in visited:
|
||||
queue.append((neighbor, d + 1))
|
||||
|
||||
# Add predecessors
|
||||
if direction in ["both", "in"]:
|
||||
for neighbor in g.predecessors(current):
|
||||
if neighbor not in visited:
|
||||
queue.append((neighbor, d + 1))
|
||||
|
||||
return {"node_id": node_id, "neighbors": neighbors, "count": len(neighbors)}
|
||||
|
||||
|
||||
def find_path(graph_name: str, source: str, target: str, max_depth: int = 10) -> Dict:
|
||||
"""Find shortest path between two nodes."""
|
||||
g = get_graph(graph_name)
|
||||
|
||||
if source not in g:
|
||||
return {"error": f"Source node {source} not found"}
|
||||
if target not in g:
|
||||
return {"error": f"Target node {target} not found"}
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(g, source, target)
|
||||
if len(path) > max_depth + 1:
|
||||
return {"error": f"Path too long: {len(path)-1} > {max_depth}"}
|
||||
|
||||
# Get path details
|
||||
path_details = []
|
||||
for i in range(len(path)):
|
||||
node_id = path[i]
|
||||
node_data = {"node_id": node_id, "attrs": dict(g.nodes[node_id])}
|
||||
|
||||
if i < len(path) - 1:
|
||||
# Add edge info
|
||||
next_node = path[i + 1]
|
||||
edge_data = dict(g.edges[node_id, next_node])
|
||||
node_data["edge_to_next"] = edge_data
|
||||
|
||||
path_details.append(node_data)
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"target": target,
|
||||
"path": path,
|
||||
"length": len(path) - 1,
|
||||
"details": path_details
|
||||
}
|
||||
except nx.NetworkXNoPath:
|
||||
return {"error": "No path found"}
|
||||
|
||||
|
||||
def query_nodes(graph_name: str, filters: Optional[Dict] = None, limit: int = 100) -> Dict:
|
||||
"""Query nodes with optional filters."""
|
||||
g = get_graph(graph_name)
|
||||
|
||||
results = []
|
||||
for node_id, attrs in g.nodes(data=True):
|
||||
# Apply filters
|
||||
match = True
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if attrs.get(key) != value:
|
||||
match = False
|
||||
break
|
||||
|
||||
if match:
|
||||
results.append({"node_id": node_id, "attrs": attrs})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return {"nodes": results, "count": len(results)}
|
||||
|
||||
|
||||
def get_stats(graph_name: str) -> Dict:
|
||||
"""Get graph statistics."""
|
||||
g = get_graph(graph_name)
|
||||
|
||||
stats = {
|
||||
"graph": graph_name,
|
||||
"nodes": g.number_of_nodes(),
|
||||
"edges": g.number_of_edges(),
|
||||
"density": nx.density(g),
|
||||
"is_connected": nx.is_weakly_connected(g) if g.number_of_nodes() > 0 else True,
|
||||
}
|
||||
|
||||
if g.number_of_nodes() > 0:
|
||||
# Degree statistics
|
||||
in_degrees = [d for _, d in g.in_degree()]
|
||||
out_degrees = [d for _, d in g.out_degree()]
|
||||
|
||||
stats["avg_in_degree"] = sum(in_degrees) / len(in_degrees)
|
||||
stats["avg_out_degree"] = sum(out_degrees) / len(out_degrees)
|
||||
stats["max_in_degree"] = max(in_degrees)
|
||||
stats["max_out_degree"] = max(out_degrees)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def save_graph(graph_name: str) -> Dict:
|
||||
"""Save graph to disk."""
|
||||
g = get_graph(graph_name)
|
||||
filepath = os.path.join(DATA_DIR, f"{graph_name}.json")
|
||||
|
||||
data = nx.node_link_data(g)
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"status": "ok", "filepath": filepath, "nodes": g.number_of_nodes(), "edges": g.number_of_edges()}
|
||||
|
||||
|
||||
def load_graph(graph_name: str) -> Dict:
|
||||
"""Load graph from disk."""
|
||||
filepath = os.path.join(DATA_DIR, f"{graph_name}.json")
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return {"error": f"Graph file not found: {filepath}"}
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
g = nx.node_link_graph(data, directed=True)
|
||||
_graphs[graph_name] = g
|
||||
|
||||
return {"status": "ok", "nodes": g.number_of_nodes(), "edges": g.number_of_edges()}
|
||||
|
||||
|
||||
def health_check():
|
||||
"""Check service status."""
|
||||
return {
|
||||
"backend": "NetworkX",
|
||||
"graphs_loaded": len(_graphs),
|
||||
"data_dir": DATA_DIR
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user