From df148f5da82542dcafd3f43931b1bac48a893c11 Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 7 Oct 2026 02:27:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20graphiti-viz=20FastAPI=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20+=20systemd=20unit=20+=20nginx=20vhost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查 Graphiti Neo4j → 标准 {nodes,edges} 供 graph-viewer 组件渲染; 六组group_id; 含部署配置(unit/nginx)与 viz.env.example(密钥走⟨secdb⟩不入库); .gitignore 排除 env/vendor/备份 --- .gitignore | 9 ++ graphiti_viz.py | 207 +++++++++++++++++++++++++++++++++++ nginx/graphiti_apescale.conf | 48 ++++++++ systemd/graphiti-viz.service | 18 +++ viz.env.example | 4 + 5 files changed, 286 insertions(+) create mode 100644 .gitignore create mode 100644 graphiti_viz.py create mode 100644 nginx/graphiti_apescale.conf create mode 100644 systemd/graphiti-viz.service create mode 100644 viz.env.example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4208f85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +viz.env +*.env +!example.env +static/vendor/ +static/*.bak* +static/_backup*/ +.venv/ +*.log +__pycache__/ diff --git a/graphiti_viz.py b/graphiti_viz.py new file mode 100644 index 0000000..19bc037 --- /dev/null +++ b/graphiti_viz.py @@ -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") diff --git a/nginx/graphiti_apescale.conf b/nginx/graphiti_apescale.conf new file mode 100644 index 0000000..1582ef0 --- /dev/null +++ b/nginx/graphiti_apescale.conf @@ -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; + } +} diff --git a/systemd/graphiti-viz.service b/systemd/graphiti-viz.service new file mode 100644 index 0000000..50b5e70 --- /dev/null +++ b/systemd/graphiti-viz.service @@ -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 diff --git a/viz.env.example b/viz.env.example new file mode 100644 index 0000000..910d43a --- /dev/null +++ b/viz.env.example @@ -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