Files
PlantAI-rag-new/sdk/app/services/neo4j.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

647 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""services/neo4j.py —— Neo4j Bolt 直连(官方 neo4j 驱动,全异步)。
选型与凭据依据 DISCOVERY.md §2:
- 首选 neo4j-plant-srt-direct-v1-full(bolt 127.0.0.1:18788,db `neo4j`,
用户 neo4j / 密码在 sdk/.env),schema canonical,QA 7920 生产使用。
- 回退 neo4j-plant-srt-pc2025-v3-20260817-normalized(bolt 127.0.0.1:19788),
schema 不同(Entity/ASSERTS),本波未做 Cypher 适配(TODO)。
- 再次 plantai-protected-neo4j-1(bolt 127.0.0.1:17687),空库。
所有本模块异常统一抛 :class:`Neo4jUnavailable`,由路由层转 503 降级信封。
凭据只存在于本文件(或 env 覆盖),绝不下发浏览器。
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any
from neo4j import AsyncDriver, AsyncGraphDatabase, exceptions as neo4j_exc
logger = logging.getLogger(__name__)
# 可选 env 覆盖(测试/部署);未设置时使用 DISCOVERY.md 实测凭据。
_ENV_PREFIX = "BFF_NEO4J_"
PROBE_CACHE_TTL = 5.0
# 连接/查询超时(秒)。entity 详情与一跳邻域走全量属性,超时给得宽些。
CONNECT_TIMEOUT = 3.0
QUERY_TIMEOUT = 10.0
NEIGHBORHOOD_QUERY_TIMEOUT = 30.0
OVERVIEW_QUERY_TIMEOUT = 60.0 # 2026-08-25 PM 修复:1644 万边 COUNT 聚合冷缓存 >10s
NAME_INDEX = "srt2_canonical_entity_name_norm"
FULLTEXT_INDEX = None # DISCOVERY.md §2:任何实例都不存在全文索引,且不得创建
# status 展示名(须与 API.md §6 fixture 一致)
DIRECT_ACTIVE_NAME = "neo4j-plant-srt-direct-v1-full"
NORMALIZED_ACTIVE_NAME = "neo4j-plant-srt-pc2025-v3-20260817-normalized"
PROTECTED_ACTIVE_NAME = "plantai-protected-neo4j-1"
_CANONICAL_LABELS = ["SRT2CanonicalEntity", "SRT2CanonicalAssertion", "SRT2Paper"]
_CANONICAL_REL_TYPES = ["SRT2_CANONICAL_ASSERTS", "SRT2_SUPPORTED_BY"]
class Neo4jUnavailable(Exception):
"""Neo4j 上游不可用(连接失败/认证失败/超时/Cypher 错误统一转此异常)。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
class _Instance:
"""一个候选实例。本波只实现 canonical schema(direct 实例)的 Cypher。"""
__slots__ = ("name", "uri", "auth", "database", "schema", "label", "rel")
def __init__(
self,
name: str,
uri: str,
user: str,
password: str,
database: str,
schema: str,
label: str | None,
rel: str | None,
) -> None:
self.name = name
self.uri = uri
self.auth = (user, password)
self.database = database
self.schema = schema
self.label = label
self.rel = rel
def _env(name: str, default: str) -> str:
return os.environ.get(f"{_ENV_PREFIX}{name}", default)
_INSTANCES: list[_Instance] = [
_Instance(
DIRECT_ACTIVE_NAME,
_env("URI_DIRECT", "bolt://127.0.0.1:18788"),
"neo4j",
_env("PASSWORD_DIRECT", ""),
"neo4j",
"canonical",
"SRT2CanonicalEntity",
"SRT2_CANONICAL_ASSERTS",
),
_Instance(
NORMALIZED_ACTIVE_NAME,
_env("URI_NORMALIZED", "bolt://127.0.0.1:19788"),
"neo4j",
_env("PASSWORD_NORMALIZED", ""),
"neo4j",
"normalized",
None,
None,
),
_Instance(
PROTECTED_ACTIVE_NAME,
_env("URI_PROTECTED", "bolt://127.0.0.1:17687"),
"neo4j",
_env("PASSWORD_PROTECTED", ""),
"neo4j",
"empty",
None,
None,
),
]
class Neo4jService:
"""生命周期:app 启动时 create,关闭时 close()。health 路由与 kg 路由共用。
内部为每个候选实例缓存一个 AsyncDriver(懒建)。实例选择只发生在
显式调用 select()(probe/kg_status)或模块加载时:每个请求的处理函数
首先调用 select(),得到当前实例(未探测时默认首选,惰性回退)。
切换由下一次显式 probe 触发,探测结果缓存 5s。
"""
def __init__(self, instances: list[_Instance] | None = None) -> None:
self._instances = instances if instances is not None else _INSTANCES
self._drivers: dict[str, AsyncDriver] = {}
self._active: _Instance = self._instances[0]
self._selected = False # 是否已成功选出一个实例
self._last_probe: float = 0.0
self._last_probe_ok: bool = False
self._lock = asyncio.Lock()
# ---------- 生命周期 ----------
async def close(self) -> None:
for driver in self._drivers.values():
await driver.close()
self._drivers.clear()
def _driver_for(self, instance: _Instance) -> AsyncDriver:
driver = self._drivers.get(instance.name)
if driver is None:
driver = AsyncGraphDatabase.driver(
instance.uri,
auth=instance.auth,
connection_timeout=CONNECT_TIMEOUT,
connection_acquisition_timeout=CONNECT_TIMEOUT,
)
self._drivers[instance.name] = driver
return driver
# ---------- 实例选择 ----------
async def select(self) -> _Instance:
"""返回当前可用实例。
顺序探测(5s 结果缓存):direct → normalized → protected。
首次调用若未探测成功则依次尝试探测,全失败抛 Neo4jUnavailable。
"""
if self._selected:
return self._active
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL:
if self._last_probe_ok:
return self._active
# 探测失败结果同样缓存 5s,避免每个请求都打 3 个实例
raise Neo4jUnavailable("Neo4j 不可用(最近探测失败,结果缓存 5s)")
await self._probe()
if self._selected:
return self._active
raise Neo4jUnavailable("所有 Neo4j 候选实例均不可达")
async def _probe(self) -> None:
async with self._lock:
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL:
if not self._last_probe_ok:
raise Neo4jUnavailable("Neo4j 不可用(最近探测失败,结果缓存 5s)")
return
self._selected = False
last_error: BaseException | None = None
for instance in self._instances:
try:
await self._check_instance(instance)
except Neo4jUnavailable as exc:
last_error = exc
continue
self._active = instance
self._selected = True
break
self._last_probe = now
self._last_probe_ok = self._selected
if not self._selected:
raise Neo4jUnavailable(
f"所有 Neo4j 候选实例均不可达:{last_error}", cause=last_error
)
async def _check_instance(self, instance: _Instance) -> None:
"""连通性检查:TCP 预检 + `RETURN 1`,整体限时 CONNECT_TIMEOUT。
先做 TCP 连通预检(毫秒级):端口没开时直接判死,避免官方驱动
的指数退避重试拖慢健康探测。TCP 通但 Bolt 协议失败时才走
wait_for + 驱动(仍限时 3s)。
"""
if not await self._tcp_reachable(instance.uri):
raise Neo4jUnavailable(f"{instance.name} 连接失败(TCP 不可达)")
driver = self._driver_for(instance)
try:
await asyncio.wait_for(
driver.execute_query(
"RETURN 1 AS x", database_=instance.database, routing_="r"
),
timeout=CONNECT_TIMEOUT,
)
except asyncio.TimeoutError as exc:
raise Neo4jUnavailable(f"{instance.name} 连接超时", cause=exc) from exc
except neo4j_exc.DriverError as exc:
raise Neo4jUnavailable(
f"{instance.name} 连接失败:{exc.__class__.__name__}", cause=exc
) from exc
@staticmethod
async def _tcp_reachable(uri: str) -> bool:
"""bolt://host:port 的 TCP 连通预检(超时 0.5s)。"""
try:
host, port = uri.split("://", 1)[1].rsplit(":", 1)
except (ValueError, IndexError):
return False
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, int(port)), timeout=0.5
)
except (OSError, asyncio.TimeoutError, ValueError):
return False
writer.close()
try:
await writer.wait_closed()
except (OSError, AttributeError):
pass
return True
# ---------- 通用查询 ----------
async def _query(
self,
cypher: str,
params: dict[str, Any] | None = None,
*,
timeout: float = QUERY_TIMEOUT,
) -> list[dict[str, Any]]:
"""按当前实例执行查询,返回 records 列表(每条为 dict)。"""
instance = await self.select()
driver = self._driver_for(instance)
try:
records, _, _ = await asyncio.wait_for(
driver.execute_query(
cypher,
parameters_=params or {},
database_=instance.database,
routing_="r",
),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
raise Neo4jUnavailable(
f"Neo4j 查询超时(>{timeout}s)", cause=exc
) from exc
except neo4j_exc.DriverError as exc:
raise Neo4jUnavailable(
f"Neo4j 查询失败:{exc.__class__.__name__}", cause=exc
) from exc
return [dict(record) for record in records]
@staticmethod
def _canonical(instance: _Instance) -> _Instance:
"""当前仅 canonical schema 的 Cypher 就绪。"""
if instance.schema != "canonical":
# TODO(A3):normalized 实例(Entity/ASSERTS,无 name_norm 索引,
# 等值 6.81s)与空库实例的 Cypher 适配层,需 PM 决策后实现。
raise Neo4jUnavailable(
f"Neo4j 实例 {instance.name} 已连通,但其 schema({instance.schema})"
"的 Cypher 适配尚未实现,暂不可用于图谱查询"
)
return instance
# ---------- 健康探测 ----------
async def probe(self) -> dict[str, Any]:
"""连通 + 计数 + 索引检查(/api/health 调用),结果缓存 5s。
返回 {"ok": bool, "active": str|null, "detail": str|null},
主动探测时即使失败也返回 dict(health 聚合不因此抛错)。
"""
now = time.monotonic()
if now - self._last_probe < PROBE_CACHE_TTL and self._last_probe_ok:
return {"ok": True, "active": self._active.name, "detail": None}
try:
await self._probe()
except Neo4jUnavailable as exc:
return {"ok": False, "active": None, "detail": str(exc)}
instance = self._active
detail: str | None = None
if instance.schema == "canonical":
try:
counts = await self._counts(instance)
indexes = await self._name_indexes(instance)
# 成员判断而非全等:同一标签上存在唯一约束索引等其它索引是正常
# 现象(DISCOVERY §2.2 实测有 srt2_canonical_entity_id),
# 只有 name_norm 索引真正不在列表里才报缺失。
if NAME_INDEX not in indexes:
detail = f"name_norm 索引缺失:{indexes or '(无任何索引)'}"
except Neo4jUnavailable as exc:
# 连通了但计数/索引检查失败:不视为整体失败,附 detail
detail = f"计数/索引检查失败:{exc}"
return {"ok": True, "active": instance.name, "detail": detail}
async def _counts(self, instance: _Instance) -> dict[str, int]:
rows = await self._query(
"""
CALL {
MATCH (e:SRT2CanonicalEntity) RETURN count(e) AS entity_count
}
CALL {
MATCH (a:SRT2CanonicalAssertion) RETURN count(a) AS assertion_count
}
RETURN entity_count, assertion_count
""",
timeout=QUERY_TIMEOUT,
)
row = rows[0] if rows else {}
return {
"entity_count": row.get("entity_count", 0),
"assertion_count": row.get("assertion_count", 0),
}
async def _name_indexes(self, instance: _Instance) -> list[str]:
"""该实体标签上的所有索引名(含约束索引),滤掉空名。
SHOW INDEXES 的 labelsOrTypes 可以是字符串或字符串列表,用
`ANY(x IN labelsOrTypes WHERE x = $label)` 兼容两者。
"""
rows = await self._query(
"SHOW INDEXES YIELD name, labelsOrTypes "
"WHERE ANY(x IN labelsOrTypes WHERE x = $label) "
"RETURN name",
{"label": instance.label},
timeout=QUERY_TIMEOUT,
)
return [r["name"] for r in rows if r.get("name")]
# ---------- /api/kg/status ----------
async def kg_status(self) -> dict[str, Any]:
"""labels / relationship_types / entity_count / assertion_count /
name_index / fulltext_index(null,实例无全文索引)。"""
instance = self._canonical(await self.select())
counts = await self._counts(instance)
indexes = await self._name_indexes(instance)
name_index = NAME_INDEX if NAME_INDEX in indexes else None
return {
"ok": True,
"active": instance.name,
"schema": instance.schema,
"labels": _CANONICAL_LABELS,
"relationship_types": _CANONICAL_REL_TYPES,
"entity_count": counts["entity_count"],
"assertion_count": counts["assertion_count"],
"name_index": name_index,
"fulltext_index": FULLTEXT_INDEX,
}
# ---------- /api/kg/overview ----------
async def kg_overview(self) -> dict[str, Any]:
"""按 entity_type 与 relation_category 计数。
注意 1644 万边:只做 COUNT 聚合,绝不拉取全量边。
"""
self._canonical(await self.select())
rows = await self._query(
"""
MATCH (e:SRT2CanonicalEntity)
WITH e.entity_type AS t, count(*) AS c
WHERE t IS NOT NULL
RETURN t AS type, c AS count
ORDER BY c DESC, t ASC
LIMIT 200
""",
timeout=OVERVIEW_QUERY_TIMEOUT,
)
entities = [{"type": r["type"], "count": r["count"]} for r in rows]
# 关系类别取自边上的 relation_category(属于关系属性,不在边集合里拉边)
rows = await self._query(
"""
MATCH ()-[r:SRT2_CANONICAL_ASSERTS]->()
WITH r.relation_category AS cat, count(*) AS c
WHERE cat IS NOT NULL
RETURN cat AS type, c AS count
ORDER BY c DESC, cat ASC
LIMIT 200
""",
timeout=OVERVIEW_QUERY_TIMEOUT,
)
relations = [{"type": r["type"], "count": r["count"]} for r in rows]
return {
"entities_by_type": entities,
"relations_by_category": relations,
}
# ---------- /api/kg/entities ----------
async def search_entities(
self, q: str, limit: int = 20
) -> list[dict[str, Any]]:
"""DISCOVERY.md §2.4 Cypher (a):name_norm = 或 STARTS WITH(toLower 归一化)。
走 name_norm RANGE 索引(实测 0.09-0.10s)。结果按 name ASC 排序保证稳定。
返回 [{id, name, type, support_count}]。
"""
self._canonical(await self.select())
limit = min(max(int(limit), 1), 20)
q_norm = q.lower()
rows = await self._query(
"""
MATCH (e:SRT2CanonicalEntity)
WHERE e.name_norm = $q_norm OR e.name_norm STARTS WITH $q_norm
RETURN e.canonical_id AS canonical_id, e.name AS name,
e.entity_type AS entity_type, e.support_count AS support_count
ORDER BY e.name ASC
LIMIT $limit
""",
{"q_norm": q_norm, "limit": limit},
timeout=QUERY_TIMEOUT,
)
return [
{
"id": r.get("canonical_id"),
"name": r.get("name"),
"type": r.get("entity_type"),
"support_count": r.get("support_count"),
}
for r in rows
]
async def _resolve_id(self, id_or_name: str) -> str | None:
"""canonical_id 或 name_norm 定位(DISCOVERY.md §2.4 Cypher b)。"""
if id_or_name.startswith("cent_"):
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {canonical_id: $id}) "
"RETURN e.canonical_id AS canonical_id",
{"id": id_or_name},
timeout=QUERY_TIMEOUT,
)
else:
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {name_norm: $name_norm}) "
"RETURN e.canonical_id AS canonical_id LIMIT 5",
{"name_norm": id_or_name.lower()},
timeout=QUERY_TIMEOUT,
)
return rows[0].get("canonical_id") if rows else None
# ---------- /api/kg/entities/{id} ----------
async def entity(self, id_or_name: str) -> dict[str, Any] | None:
"""按 canonical_id 或 name_norm 取实体全量属性;未命中返回 None。"""
canonical_id = await self._resolve_id(id_or_name)
if canonical_id is None:
return None
rows = await self._query(
"MATCH (e:SRT2CanonicalEntity {canonical_id: $id}) "
"RETURN e {.*} AS node",
{"id": canonical_id},
timeout=QUERY_TIMEOUT,
)
if not rows:
return None
props = rows[0].get("node") or {}
return {
"id": props.get("canonical_id"),
"name": props.get("name"),
"type": props.get("entity_type"),
"properties": props,
}
# ---------- /api/kg/entities/{id}/neighborhood ----------
async def neighborhood(
self, id_or_name: str, depth: int = 1, limit: int = 200
) -> dict[str, Any]:
"""有界邻域扩展(DISCOVERY.md §2.4 Cypher (c) 思路)。
- depth ∈ {1,2},默认 1;limit ≤ 200。
- 每跳按 paper_count/support_count 降序截断(防 hub 吃满预算)。
- OPTIONAL MATCH 保证孤立节点不丢。
- 无向匹配(-()-),entity 按 canonical_id 去重。
- depth=1 绝不执行第二跳(两跳实测约 13s,不默认开启)。
- 返回 API.md §6 的 {center, center_name, depth, nodes, edges}。
"""
self._canonical(await self.select()) # 非 canonical 实例在此抛 Neo4jUnavailable
depth = depth if depth in (1, 2) else 1
limit = min(max(int(limit), 1), 200)
canonical_id = await self._resolve_id(id_or_name)
if canonical_id is None:
return {"center": None, "center_name": None, "depth": depth, "nodes": [], "edges": []}
params = {"seed": canonical_id, "max_nodes": limit, "max_edges": limit}
if depth == 1:
cypher = """
MATCH (s:SRT2CanonicalEntity {canonical_id: $seed})
OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]-(n1:SRT2CanonicalEntity)
WITH s, r1, n1
ORDER BY coalesce(r1.paper_count, 0) DESC, coalesce(r1.support_count, 0) DESC
WITH s,
collect(DISTINCT n1)[..$max_nodes] AS level1,
[r IN collect(DISTINCT r1)[..$max_edges] | r {.*,
source_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN s.canonical_id ELSE endNode(r).canonical_id END,
target_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN endNode(r).canonical_id ELSE s.canonical_id END}] AS edges1
RETURN s {.*} AS start,
[n IN level1 | n {.*}] AS nodes,
edges1 AS edges
"""
else:
cypher = """
MATCH (s:SRT2CanonicalEntity {canonical_id: $seed})
OPTIONAL MATCH (s)-[r1:SRT2_CANONICAL_ASSERTS]-(n1:SRT2CanonicalEntity)
WITH s, r1, n1
ORDER BY coalesce(r1.paper_count, 0) DESC, coalesce(r1.support_count, 0) DESC
WITH s,
collect(DISTINCT n1)[..$max_nodes] AS level1,
[r IN collect(DISTINCT r1)[..$max_edges] | r {.*,
source_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN s.canonical_id ELSE endNode(r).canonical_id END,
target_canonical_id: CASE WHEN startNode(r).canonical_id = s.canonical_id
THEN endNode(r).canonical_id ELSE s.canonical_id END}] AS edges1
OPTIONAL MATCH (n1)-[r2:SRT2_CANONICAL_ASSERTS]-(n2:SRT2CanonicalEntity)
WHERE n1 IN level1
WITH s, level1, edges1, r2, n2
ORDER BY coalesce(r2.paper_count, 0) DESC, coalesce(r2.support_count, 0) DESC
WITH s, level1, edges1,
collect(DISTINCT n2)[..$max_nodes] AS level2,
[r IN collect(DISTINCT r2)[..$max_edges] | r {.*,
source_canonical_id: startNode(r).canonical_id,
target_canonical_id: endNode(r).canonical_id}] AS edges2
RETURN s {.*} AS start,
[n IN (level1 + level2) | n {.*}] AS nodes,
(edges1 + edges2) AS edges
"""
rows = await self._query(
cypher, params, timeout=NEIGHBORHOOD_QUERY_TIMEOUT
)
if not rows:
return {"center": None, "center_name": None, "depth": depth, "nodes": [], "edges": []}
row = rows[0]
start = row.get("start") or {}
node_list: list[dict[str, Any]] = [
{
"id": start.get("canonical_id"),
"label": start.get("name"),
"type": start.get("entity_type"),
}
]
node_index: dict[str, int] = {}
if start.get("canonical_id") is not None:
node_index[start["canonical_id"]] = 0
for raw in row.get("nodes") or []:
nid = raw.get("canonical_id")
if nid is None or nid in node_index:
continue
node_index[nid] = len(node_list)
node_list.append(
{
"id": nid,
"label": raw.get("name"),
"type": raw.get("entity_type"),
}
)
edges: list[dict[str, Any]] = []
for raw in row.get("edges") or []:
src = raw.get("source_canonical_id")
tgt = raw.get("target_canonical_id")
if src is None or tgt is None:
continue
if src not in node_index or tgt not in node_index:
continue
# 自环边(实体自身关系,如 gene_structure)对可视化无意义,过滤
if src == tgt:
continue
edges.append(
{
"source": src,
"target": tgt,
"relation": raw.get("relation_category")
if raw.get("relation_category") is not None
else raw.get("relation_norm"),
"label": raw.get("relation_category")
if raw.get("relation_category") is not None
else raw.get("relation_norm"),
"assertion_id": raw.get("assertion_id"),
"paper_count": raw.get("paper_count"),
"support_count": raw.get("support_count"),
"title": raw.get("title"),
"doi": raw.get("doi"),
"pmid": raw.get("pmid"),
"chain_id": None,
"hop": None,
}
)
return {
"center": start.get("canonical_id"),
"center_name": start.get("name"),
"depth": depth,
"nodes": node_list,
"edges": edges,
}
# ---------- 调试/冒烟 ----------
async def raw_query(self, cypher: str, **params: Any) -> list[dict[str, Any]]:
"""只读原始查询(冒烟测试用)。"""
return await self._query(cypher, params, timeout=QUERY_TIMEOUT)
# 模块级单例:health 路由与 kg 路由共用同一连接池(app 关闭时 close)。
service = Neo4jService()
async def close_neo4j() -> None:
"""app shutdown 钩子。"""
await service.close()
async def probe_neo4j() -> dict[str, Any]:
"""/api/health 用的探测(不抛错)。"""
return await service.probe()