"""Cypher 合法性检查节点
输入: state['cypher_query']: List[str] 上游生成的 cypher 语句
输出: state['is_all_validate_cypher']: bool 是否全部合法
为什么生成完还要再查一遍:`generate_neo4j_cypher_node` 里的过滤只看"是不是只读",
拦不住"语法对、但标签/属性图里没有"这类语句 —— 它们能正常执行,只是恒返回空行,
下游会当成"图里没这个知识",比报错更难查。所以这里做三层检查:
1. 静态只读:复用生成节点的 FORBIDDEN_KEYWORDS,同一份白名单,避免两处漂移
2. 对 schema 核账:标签、关系类型、`变量.属性` 都要在 tcm_metadata.json 里存在
3. Neo4j EXPLAIN:只编译不执行,把语法错误在打库之前拦掉
第 3 层要连库,连不上时只跳过这一层(前两层的结论照样有效),不让整个节点挂掉。
"""
import json
import re
from typing import Dict, List, Set, Tuple
from _004_langgraph_more_nodes.agent_state import AgentState
from _004_langgraph_more_nodes.nodes.generate_neo4j_cypher_node import (
FORBIDDEN_KEYWORDS,
)
from common.config import Config
conf = Config()
# 空的 cypher_query 判 False。空集合"全部合法"在逻辑上成立,但这个标志位是给路由用的:
# 没有语句可执行时,必须走兜底分支而不是查库分支,判 True 会让下游拿着空列表白跑一趟。
EMPTY_IS_VALID = False
# ======================
# schema:从 tcm_metadata.json 读出标签 / 关系 / 属性
# ======================
def _load_schema() -> Tuple[Set[str], Set[str], Set[str]]:
"""返回 (标签集合, 关系类型集合, 所有属性名集合)。
属性不按标签分开:cypher 里 `n.name` 的 `n` 要回溯到它的标签才能精确校验,
而 WITH / OPTIONAL MATCH 之后变量的标签可能已经丢了。这里只判"这个属性名在图里
存在吗",宁可漏判也不误杀。
"""
meta = json.loads(conf.TCM_METADATA)
labels = {label["name"] for label in meta.get("labels", [])}
rel_types = {rel["type"] for rel in meta.get("relationships", [])}
props = set()
for label in meta.get("labels", []):
props.update(prop["name"] for prop in label.get("properties", []))
for rel in meta.get("relationships", []):
props.update(prop["name"] for prop in rel.get("properties", []))
return labels, rel_types, props
LABELS, REL_TYPES, PROP_KEYS = _load_schema()
# `(n:Herb)`、`(f:Formula:Prescription)` 里的标签。冒号后面允许空格,多标签用 | 或 :
LABEL_PATTERN = re.compile(r"\(\s*\w*\s*:\s*([A-Za-z_][\w|:\s]*?)\s*[){\s]")
# `-[:HAS_EFFECT]->`、`-[r:HAS_EFFECT|HAS_INGREDIENT*1..2]-`
REL_PATTERN = re.compile(r"\[\s*\w*\s*:\s*([A-Za-z_][\w|\s]*)")
# `n.name`、`h.property_flavor`。前面不能是字母/数字,排开 `count(n).x` 这种不存在的写法
PROP_PATTERN = re.compile(r"\b[A-Za-z_]\w*\.([A-Za-z_]\w+)")
# cypher 内建函数和关键字后面也可能跟点号或被当成标签,先排掉常见的
IGNORED_TOKENS = {"DISTINCT", "AS", "AND", "OR", "NOT", "IN", "NULL", "TRUE", "FALSE"}
STRING_LITERAL = re.compile(r"'[^']*'|\"[^\"]*\"")
def _strip_literals(cypher: str) -> str:
"""把字符串字面量换成空串再做正则匹配。
实体名是数据不是代码,`WHERE s.name = '桂枝汤.加减'` 里的 `.加减` 不该被当成属性,
`'Herb'` 这种字面量也不该被当成标签。
"""
return STRING_LITERAL.sub("''", cypher)
def _split_names(raw: str) -> List[str]:
"""`Formula:Prescription`、`HAS_EFFECT|HAS_INGREDIENT*1..2` 拆成名字列表"""
names = re.split(r"[|:]", raw)
# 关系里可能带 `*1..2` 变长路径,星号后面的部分不是名字
return [name.split("*")[0].strip() for name in names if name.split("*")[0].strip()]
# ======================
# 第 1 层:静态只读
# ======================
def _check_readonly(cypher: str) -> List[str]:
"""返回问题列表,空列表表示这一层通过"""
upper = _strip_literals(cypher).upper()
problems = [
f"包含写操作关键字 {keyword}"
for keyword in FORBIDDEN_KEYWORDS
if re.search(rf"\b{re.escape(keyword)}\b", upper)
]
if "RETURN" not in upper:
problems.append("没有 RETURN,查不出任何结果")
if not upper.lstrip().startswith(("MATCH", "OPTIONAL MATCH", "WITH")):
problems.append("不是以 MATCH / OPTIONAL MATCH / WITH 开头")
return problems
# ======================
# 第 2 层:对 schema 核账
# ======================
def _check_schema(cypher: str) -> List[str]:
"""标签、关系类型、属性名都要在 tcm_metadata.json 里存在"""
text = _strip_literals(cypher)
problems = []
for raw in LABEL_PATTERN.findall(text):
for label in _split_names(raw):
if label.upper() in IGNORED_TOKENS:
continue
if label not in LABELS:
problems.append(f"标签不存在: {label}")
for raw in REL_PATTERN.findall(text):
for rel in _split_names(raw):
if rel not in REL_TYPES:
problems.append(f"关系类型不存在: {rel}")
for prop in PROP_PATTERN.findall(text):
if prop not in PROP_KEYS:
problems.append(f"属性不存在: .{prop}")
# 同一个错误可能在一条语句里出现多次,去重保序
return list(dict.fromkeys(problems))
# ======================
# 第 3 层:Neo4j EXPLAIN
# ======================
_explain_available = None
def _check_explain(cypher: str) -> List[str]:
"""用 EXPLAIN 让 Neo4j 自己编译一遍,只查语法不执行。
连不上库时把 _explain_available 置 False,后续语句直接跳过这一层,
不用每条都重试一次连接。
"""
global _explain_available
if _explain_available is False:
return []
try:
from common.neo4j_manager import neo4j_client
neo4j_client.run_cypher(f"EXPLAIN {cypher}")
_explain_available = True
return []
except Exception as exc:
message = str(exc)
# 区分"库连不上"和"这条语句本身有问题":连接类异常不该算语句不合法
if _explain_available is None and (
"Unable to retrieve routing information" in message
or "ServiceUnavailable" in message
or "AuthError" in type(exc).__name__
or "Couldn't connect" in message
):
_explain_available = False
print(f"[warn] Neo4j 连不上,跳过 EXPLAIN 校验: {type(exc).__name__}")
return []
_explain_available = True
return [f"EXPLAIN 失败: {message.splitlines()[0][:120]}"]
# ======================
# 对外接口
# ======================
def validate_cypher(cypher: str, use_explain: bool = True) -> List[str]:
"""检查单条 cypher,返回问题列表。空列表表示合法。
三层依次短路:静态检查没过就不必再对 schema 和连库了,问题已经确定。
"""
cypher = (cypher or "").strip()
if not cypher:
return ["空语句"]
problems = _check_readonly(cypher)
if problems:
return problems
problems = _check_schema(cypher)
if problems:
return problems
return _check_explain(cypher) if use_explain else []
def check_cypher_node01(state: AgentState, use_explain: bool = True) -> AgentState:
"""检查 cypher_query 里所有语句是否合法,结果写回 is_all_validate_cypher"""
cyphers = state.get("cypher_query") or []
if not cyphers:
state["is_all_validate_cypher"] = EMPTY_IS_VALID
return state
all_valid = True
for cypher in cyphers:
problems = validate_cypher(cypher, use_explain=use_explain)
if problems:
all_valid = False
# 打日志:不合法时下游只看到一个 False,不记下来就无法定位是哪条、错在哪
print(f"[warn] 非法 cypher: {cypher[:80]}")
for problem in problems:
print(f" - {problem}")
state["is_all_validate_cypher"] = all_valid
return state
if __name__ == "__main__":
print(f"schema: {len(LABELS)} 标签 / {len(REL_TYPES)} 关系 / {len(PROP_KEYS)} 属性")
# (语句, 期望合法) —— 前四条来自 generate_neo4j_cypher_node 的真实输出
cases: List[Tuple[str, bool]] = [
(
"MATCH (h:Herb)-[:HAS_EFFECT]->(e:Effect) WHERE h.name IN ['人参'] "
"RETURN h.name AS herb, e.name AS effect LIMIT 20",
True,
),
(
"MATCH (f:Formula)-[:HAS_INGREDIENT]->(h:Herb) WHERE f.name = '四君子汤' "
"RETURN h.name AS herb LIMIT 20",
True,
),
(
"MATCH (s:Source)<-[:FROM_SOURCE]-(f:Formula) WHERE s.name IN ['《伤寒论》'] "
"RETURN f.name AS formula LIMIT 20",
True,
),
(
"MATCH (h:Herb) WHERE h.name = '人参' "
"RETURN h.property_flavor AS flavor, h.meridian AS meridian LIMIT 20",
True,
),
# 标签不在 schema 里:语法合法,执行恒空 —— 第 2 层要抓的就是这种
(
"MATCH (m:Medicine)-[:HAS_EFFECT]->(e:Effect) RETURN m.name AS name LIMIT 20",
False,
),
# 关系名编造
(
"MATCH (h:Herb)-[:CURES]->(d:Disease) RETURN d.name AS disease LIMIT 20",
False,
),
# 属性编造
("MATCH (h:Herb) RETURN h.price AS price LIMIT 20", False),
# 写操作
("MATCH (h:Herb {name: '人参'}) SET h.name = '党参' RETURN h.name AS n", False),
# 没有 RETURN
("MATCH (h:Herb) WHERE h.name = '人参'", False),
# 语法错误:括号不闭合,前两层看不出来,靠 EXPLAIN
("MATCH (h:Herb RETURN h.name AS herb LIMIT 20", False),
# 实体名里带点号和标签同名词,不该被误判
(
"MATCH (h:Herb) WHERE h.name IN ['Herb', '桂枝.加减'] RETURN h.name AS n LIMIT 5",
True,
),
("", False),
]
print("\n=== 单条校验 ===")
wrong = 0
for cypher, expected in cases:
problems = validate_cypher(cypher)
actual = not problems
flag = "OK " if actual == expected else "MISS"
if actual != expected:
wrong += 1
print(f" [{flag}] {'合法' if actual else '非法'} <- {cypher[:64] or '<空>'}")
for problem in problems:
print(f" {problem}")
print(f"\n预期不符 {wrong} 条")
print("\n=== 节点整体 ===")
for cypher_query in [
[cases[0][0], cases[1][0]], # 全合法 -> True
[cases[0][0], cases[4][0]], # 混了一条非法 -> False
[], # 空列表 -> False
]:
result = check_cypher_node01({"cypher_query": list(cypher_query)})
print(f" {len(cypher_query)} 条 -> {result['is_all_validate_cypher']}")
#我们不用这个node了,逻辑有些复杂
#逻辑:
#1. 用正则进行cypher语句的检查如果没问题
#2. 用neo4j的元数据进行cypher语句的检查如果没问题
# 3.用Explain 语句进行检查