feat: graphiti-viz FastAPI 后端 + systemd unit + nginx vhost
查 Graphiti Neo4j → 标准 {nodes,edges} 供 graph-viewer 组件渲染; 六组group_id;
含部署配置(unit/nginx)与 viz.env.example(密钥走⟨secdb⟩不入库); .gitignore 排除 env/vendor/备份
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
viz.env
|
||||
*.env
|
||||
!example.env
|
||||
static/vendor/
|
||||
static/*.bak*
|
||||
static/_backup*/
|
||||
.venv/
|
||||
*.log
|
||||
__pycache__/
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Graphiti 时序知识图谱 · 可视化展示层
|
||||
- FastAPI 单进程: /api/groups, /api/graph?group_id=&limit=, 静态前端(static/)
|
||||
- 只读 Neo4j (graphiti-neo4j, bolt://127.0.0.1:7687), 绝不写库
|
||||
- 输出契约对齐 DeepData KnowledgeGraphView: nodes[{id,labels,properties}], edges[{id,type,source,target,properties}]
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:7687")
|
||||
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
|
||||
NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "")
|
||||
BIND_HOST = os.environ.get("VIZ_BIND", "127.0.0.1")
|
||||
BIND_PORT = int(os.environ.get("VIZ_PORT", "8096"))
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
GROUPS = ["infra", "customers", "org", "sop", "conversations", "knowledge"]
|
||||
MAX_NODES, MAX_EDGES = 500, 800
|
||||
|
||||
app = FastAPI(title="Graphiti Viz", docs_url=None, redoc_url=None)
|
||||
driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
|
||||
|
||||
|
||||
# ---------- entity_type 启发式推断 ----------
|
||||
# labels 属性在 graphiti 里恒为 ["Entity"], 故从名称模式 + 情景共现上下文推断类型
|
||||
def infer_type(name: str, ctx: dict) -> str:
|
||||
"""ctx: {deleg, res, deg} — 情景共现统计"""
|
||||
n = name.lower()
|
||||
if n.startswith("agent_preset"): return "预设数字员工"
|
||||
if n.startswith("agent_"): return "数字员工"
|
||||
if n.startswith("apijob"): return "委托任务"
|
||||
if n.startswith("session"): return "会话"
|
||||
if n.startswith("channel"): return "渠道"
|
||||
if n == "public_api": return "公共接口"
|
||||
if n.startswith("http") or "://" in name or re.match(r"^[\w.-]+\.[a-z]{2,}(/|$)", n):
|
||||
return "URL/域名"
|
||||
if re.match(r"^\d+\.\d+\.\d+\.\d+", name):
|
||||
return "IP地址"
|
||||
if name.startswith("/") or name.startswith("~"):
|
||||
return "文件路径"
|
||||
if re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}", n):
|
||||
return "UUID标识"
|
||||
if any(k in name for k in ("阅读笔记", "笔记", "手册", "章程", "方案")):
|
||||
return "文档"
|
||||
if "部署" in name or name.startswith(("服务-", "svc-")):
|
||||
return "服务部署"
|
||||
if re.match(r"^\d+-p$", n):
|
||||
return "设备编号"
|
||||
if n.endswith(".sh"):
|
||||
return "脚本"
|
||||
# 兜底: 用情景共现推断
|
||||
deleg, res = ctx.get("deleg", 0), ctx.get("res", 0)
|
||||
if deleg > 0 and deleg >= res:
|
||||
return "客户业务对象"
|
||||
if res > 0 and res > deleg:
|
||||
return "基础设施对象"
|
||||
if all(c.isascii() and (c.isalnum() or c in "_-.:") for c in name) and re.search(r"\d", name):
|
||||
return "系统标识"
|
||||
if not re.search(r"[A-Za-z]", name):
|
||||
return "人物/组织"
|
||||
if n.startswith("mag"):
|
||||
return "人物/组织"
|
||||
return "概念"
|
||||
|
||||
|
||||
def _fmt(v):
|
||||
return None if v is None else str(v)
|
||||
|
||||
|
||||
# ---------- Cypher ----------
|
||||
Q_GROUPS = """
|
||||
UNWIND $groups AS g
|
||||
OPTIONAL MATCH (n {group_id: g})
|
||||
WITH g, count(n) AS nodes
|
||||
OPTIONAL MATCH ()-[r]->() WHERE r.group_id = g
|
||||
RETURN g, nodes, count(r) AS rels
|
||||
ORDER BY g
|
||||
"""
|
||||
|
||||
Q_ENTITIES = """
|
||||
MATCH (e:Entity {group_id: $g})
|
||||
RETURN e.uuid AS uuid, e.name AS name, e.labels AS labels,
|
||||
e.summary AS summary, e.created_at AS created_at, e.group_id AS group_id
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
Q_EDGE_TYPES = """
|
||||
MATCH (ep:Episodic {group_id: $g})-[m:MENTIONS]->(e:Entity)
|
||||
WHERE e.uuid IN $uuids
|
||||
WITH e.uuid AS u, count(m) AS deg,
|
||||
sum(CASE WHEN ep.name STARTS WITH '委托-' THEN 1 ELSE 0 END) AS deleg,
|
||||
sum(CASE WHEN ep.content CONTAINS '/' OR ep.content CONTAINS ':' THEN 1 ELSE 0 END) AS res
|
||||
RETURN u, deg, deleg, res
|
||||
"""
|
||||
|
||||
Q_EDGES = """
|
||||
MATCH (a:Entity {group_id: $g})-[r:RELATES_TO]->(b:Entity {group_id: $g})
|
||||
WHERE a.uuid IN $uuids AND b.uuid IN $uuids
|
||||
RETURN elementId(r) AS id, r.name AS name, r.fact AS fact,
|
||||
r.valid_at AS valid_at, r.invalid_at AS invalid_at,
|
||||
r.created_at AS created_at, r.episodes AS episodes,
|
||||
a.uuid AS src, b.uuid AS dst
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/api/groups")
|
||||
def api_groups():
|
||||
with driver.session(database="neo4j") as s:
|
||||
rows = s.run(Q_GROUPS, groups=GROUPS).data()
|
||||
erows = s.run(
|
||||
"UNWIND $groups AS g MATCH (e:Entity {group_id:g}) RETURN g, count(e) AS c",
|
||||
groups=GROUPS).data()
|
||||
emap = {r["g"]: r["c"] for r in erows}
|
||||
out = [{
|
||||
"group_id": r["g"],
|
||||
"nodes": r["nodes"], # 全部带 group_id 的节点(Entity+Episodic)
|
||||
"edges": r["rels"], # 该组全部关系
|
||||
"entities": emap.get(r["g"], 0),
|
||||
} for r in rows]
|
||||
return {"groups": out}
|
||||
|
||||
|
||||
@app.get("/api/graph")
|
||||
def api_graph(group_id: str = Query(...), limit: int = Query(200)):
|
||||
if group_id not in GROUPS:
|
||||
raise HTTPException(400, f"group_id must be one of {GROUPS}")
|
||||
nl = max(10, min(int(limit), MAX_NODES))
|
||||
el = max(10, min(int(limit * 1.5), MAX_EDGES))
|
||||
|
||||
with driver.session(database="neo4j") as s:
|
||||
ents = s.run(Q_ENTITIES, g=group_id, limit=nl).data()
|
||||
uuids = [e["uuid"] for e in ents if e["uuid"]]
|
||||
ctx_map = {}
|
||||
if uuids:
|
||||
try:
|
||||
for r in s.run(Q_EDGE_TYPES, g=group_id, uuids=uuids):
|
||||
ctx_map[r["u"]] = {"deg": r["deg"], "deleg": r["deleg"], "res": r["res"]}
|
||||
except Exception:
|
||||
pass
|
||||
edge_rows = s.run(Q_EDGES, g=group_id, uuids=uuids, limit=el).data() if uuids else []
|
||||
|
||||
nodes = []
|
||||
for e in ents:
|
||||
t = infer_type(e["name"] or "", ctx_map.get(e["uuid"], {}))
|
||||
nodes.append({
|
||||
"id": e["uuid"] or e["name"],
|
||||
"labels": ["Entity", t],
|
||||
"properties": {
|
||||
"entity_type": t,
|
||||
"description": e["summary"] or "",
|
||||
"name": e["name"],
|
||||
"created_at": _fmt(e["created_at"]),
|
||||
"group_id": e["group_id"],
|
||||
},
|
||||
})
|
||||
|
||||
edges = []
|
||||
for r in edge_rows:
|
||||
edges.append({
|
||||
"id": r["id"],
|
||||
"type": r["name"] or "RELATES_TO",
|
||||
"source": r["src"],
|
||||
"target": r["dst"],
|
||||
"properties": {
|
||||
"keywords": r["fact"] or r["name"] or "",
|
||||
"relation": r["name"],
|
||||
"valid_at": _fmt(r["valid_at"]),
|
||||
"invalid_at": _fmt(r["invalid_at"]),
|
||||
"bi_temporal_invalid": bool(r["invalid_at"]),
|
||||
"created_at": _fmt(r["created_at"]),
|
||||
"episodes": r["episodes"] or [],
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"is_truncated": len(ents) >= nl or len(edge_rows) >= el,
|
||||
"requested_node_limit": nl,
|
||||
"requested_edge_limit": el,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
with driver.session(database="neo4j") as s:
|
||||
s.run("RETURN 1").consume()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- static frontend ----------
|
||||
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=BIND_HOST, port=BIND_PORT, log_level="info")
|
||||
@@ -0,0 +1,48 @@
|
||||
# Graphiti 时序知识图谱可视化 · 公网入口
|
||||
# https://graphiti.apescale.com:1030 -> 内网443 -> 本块 -> 127.0.0.1:8096 (FastAPI graphiti-viz)
|
||||
# 证书 graphiti.apescale.com (acme.sh manual DNS, EC-256, 到期 2027-01-04)
|
||||
# A 记录显式覆盖通配符 * -> 222.128.20.101 (RecordID 50704678)
|
||||
# basic auth realm Graphiti; 口令登记于 dsh_secdb
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name graphiti.apescale.com;
|
||||
|
||||
ssl_certificate /home/mag/certs/graphiti-apescale/graphiti.apescale.com.crt;
|
||||
ssl_certificate_key /home/mag/certs/graphiti-apescale/graphiti.apescale.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
client_max_body_size 10m;
|
||||
|
||||
|
||||
# Safari 修复 (2026-10-07): 入口页与编译产物禁缓存 —— 否则 Safari 会拿旧的
|
||||
# babel 版 index.html / app.jsx 强缓存, 改完不生效。
|
||||
# 库文件(vendor/)内容固定、文件名带版本号语义, 给长缓存以加快首屏。
|
||||
location ~* \.(html|jsx)$ {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
}
|
||||
location ~* ^/(app\.compiled\.js|compat-shim\.js|favicon\.(svg|ico))$ {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
}
|
||||
location ~* ^/vendor/.*\.js$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8096;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 余量 (force-graph 无 ws, 留扩展空间)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Graphiti temporal knowledge graph visualization (FastAPI + static frontend)
|
||||
After=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mag
|
||||
Group=mag
|
||||
WorkingDirectory=/www/dk_project/dk_app/graphiti-viz
|
||||
EnvironmentFile=/www/dk_project/dk_app/graphiti-viz/viz.env
|
||||
ExecStart=/www/dk_project/dk_app/graphiti-viz/.venv/bin/python /www/dk_project/dk_app/graphiti-viz/graphiti_viz.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/www/dk_project/dk_app/graphiti-viz/viz.log
|
||||
StandardError=append:/www/dk_project/dk_app/graphiti-viz/viz.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,4 @@
|
||||
NEO4J_URI=bolt://127.0.0.1:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=<从 dsh_secdb ⟨secdb:graphiti-neo4j/neo4j⟩ 填入>
|
||||
VIZ_PORT=8096
|
||||
Reference in New Issue
Block a user