Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

10 changed files with 527 additions and 35 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
logs/
*.log
nohup.out
nohup_gpu*.out
py3/
*.egg-info/
*.pid

View File

@ -1,35 +0,0 @@
# Graph Service — 图数据库服务
## 概述
基于 NetworkX 的内存图数据库服务,支持节点/边的增删查、邻居查询、路径搜索和图持久化。用于知识图谱的实体关系存储。
## 后端
NetworkX (纯 Python无需外部依赖)
## API
| 端点 | 方法 | 说明 |
|------|------|------|
| /api/status | GET | 服务状态和图统计 |
| /api/graph/add_node | POST | 添加节点 |
| /api/graph/add_edge | POST | 添加边 |
| /api/graph/neighbors | POST | 查询邻居 (支持深度) |
| /api/graph/path | POST | 最短路径查询 |
| /api/graph/query | POST | 通用图查询 |
| /api/graph/stats | POST | 图统计信息 |
| /api/graph/save | POST | 保存图到磁盘 |
| /api/graph/load | POST | 从磁盘加载图 |
## 部署
```bash
cd /data/ymq/graph-service
bash build.sh
sudo systemctl restart graph-service
```
## 端口
9092

6
ah.py Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"directed": true,
"multigraph": false,
"graph": {},
"nodes": [],
"links": []
}

29
data/test.json Normal file
View 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
View 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
View File

203
workers/graph_engine.py Normal file
View 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
}