kgrag/graph_search/fault_graph_search.py
2026-07-29 18:10:19 +08:00

492 lines
20 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.

import json
import os
import re
from dotenv import load_dotenv
from neo4j import AsyncGraphDatabase
from typing import Any, Dict, List, Optional
load_dotenv()
NEO4J_CONFIG = {
"uri": os.getenv("NEO4J_URI", "bolt://192.168.0.46:57687"),
"user": os.getenv("NEO4J_USER", "neo4j"),
"password": os.getenv("NEO4J_PASSWORD","zdht123@"),
}
def _string_similarity(a: str, b: str) -> float:
"""
简单字符串相似度:综合长度差、包含关系和公共字符比例。
0~1 之间,越大越相似。
"""
if not a or not b:
return 0.0
a = str(a).strip().lower()
b = str(b).strip().lower()
if not a or not b:
return 0.0
if a == b:
return 1.0
if a in b or b in a:
# 明确包含关系,给较高分
return 0.9
# 公共字符集比例
set_a, set_b = set(a), set(b)
inter = len(set_a & set_b)
union = len(set_a | set_b) or 1
jaccard = inter / union
# 长度差惩罚
len_diff = abs(len(a) - len(b))
len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1))
return 0.5 * jaccard + 0.5 * len_penalty
def _pick_best_by_name(candidates: List[Dict[str, Any]], target_name: str) -> Optional[Dict[str, Any]]:
"""
在候选节点中,根据名称相似度选择最优节点。
约定每个节点为:{id, labels, props, display_name}
"""
if not candidates or not target_name:
return None
best = None
best_score = 0.0
for item in candidates:
name = (item.get("display_name") or "").strip()
if not name:
# 尝试从属性中推断名称
props = item.get("props") or {}
name = props.get("name") or props.get("名称") or props.get("设备名称") or ""
score = _string_similarity(target_name, name)
if score > best_score:
best_score = score
best = item
# 设置一个最低阈值,过于不相似则认为未命中
return best if best_score >= 0.3 else None
def _extract_mt_codes(text: str) -> List[str]:
"""
从文本中抽取形如 MT007 的维修项目编号MT + 3位数字
"""
if not text:
return []
codes = re.findall(r"\bMT\d{3}\b", str(text))
# 去重但保持顺序
seen = set()
ordered: List[str] = []
for c in codes:
if c not in seen:
seen.add(c)
ordered.append(c)
return ordered
def _node_record_to_dict(record: Any) -> Dict[str, Any]:
"""
将 Cypher 返回的 id/labels/props 结构统一为字典。
约定 Cypher 中使用:
RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props, <可选的 display_name>
"""
# neo4j.Record 提供 data() 方法;若不是 Record则假定本身为 dict
if hasattr(record, "data"):
data = record.data()
else:
data = record or {}
return {
"id": data.get("id"),
"labels": data.get("labels") or [],
"props": data.get("props") or {},
"display_name": data.get("display_name")
if "display_name" in data
else None,
}
def _to_graph_display_node(node_dict: Dict[str, Any]) -> Dict[str, Any]:
"""将内部节点格式转为 graph_search 展示用节点id、name、label。"""
nid = node_dict.get("id")
props = node_dict.get("props") or {}
name = (
node_dict.get("display_name")
or props.get("名称")
or props.get("name")
or str(nid)
)
labels = node_dict.get("labels") or []
label = labels[0] if labels else ""
return {"id": nid, "name": name, "label": label}
def _build_fault_graph_display(
ship_node: Optional[Dict[str, Any]],
device_node: Optional[Dict[str, Any]],
fault_mode_node: Optional[Dict[str, Any]],
repair_projects: List[Dict[str, Any]],
) -> tuple:
"""
构建与 graph_search 一致的图谱展示结构nodes 与 links。
返回 (nodes: list, links: list)。
关系:舰艇-包含->设备,设备-存在->故障模式,设备-维修时使用->维修项目。
"""
nodes: List[Dict[str, Any]] = []
links: List[Dict[str, Any]] = []
seen_ids = set()
def add_node(nd: Optional[Dict[str, Any]]):
if not nd or nd.get("id") in seen_ids:
return
seen_ids.add(nd["id"])
nodes.append(_to_graph_display_node(nd))
def add_link(source_id: Any, target_id: Any, rel_type: str = ""):
links.append({"source": source_id, "target": target_id, "type": rel_type})
if ship_node:
add_node(ship_node)
if device_node:
add_node(device_node)
if ship_node:
add_link(ship_node["id"], device_node["id"], "包含")
if fault_mode_node:
add_node(fault_mode_node)
if device_node:
add_link(device_node["id"], fault_mode_node["id"], "存在")
for p in repair_projects or []:
add_node(p)
if device_node:
add_link(device_node["id"], p["id"], "维修时使用")
return nodes, links
def _build_fault_graph_results_text(
ship_node: Optional[Dict[str, Any]],
device_node: Optional[Dict[str, Any]],
fault_mode_node: Optional[Dict[str, Any]],
fault_reason: str,
repair_plan: str,
mt_codes: List[str],
repair_projects: List[Dict[str, Any]],
ship_number: str,
device_name: str,
fault_symptom: str,
) -> str:
"""生成与 graph_rag_search 类似的一段话,供展示与下游使用。"""
ship_name = ""
if ship_node:
ship_name = (ship_node.get("display_name") or (ship_node.get("props") or {}).get("名称") or "").strip()
device_name_d = ""
if device_node:
device_name_d = (device_node.get("display_name") or (device_node.get("props") or {}).get("名称") or "").strip()
fault_name = ""
if fault_mode_node:
fault_name = (fault_mode_node.get("display_name") or (fault_mode_node.get("props") or {}).get("名称") or "").strip()
parts = [
f"根据舷号「{ship_number}」、设备「{device_name}」、故障现象「{fault_symptom}」进行图谱检索:",
f"定位到舰艇「{ship_name or '未知'}」、设备节点「{device_name_d or '未知'}」、故障模式「{fault_name or '未知'}」。",
]
if fault_reason:
parts.append(f"故障原因:{fault_reason}")
if repair_plan:
parts.append(f"维修方案摘要:{repair_plan[:500]}{'' if len(repair_plan) > 500 else ''}")
if mt_codes:
parts.append(f"涉及维修项目编号:{''.join(mt_codes)}")
if repair_projects:
proj_names = []
for p in repair_projects:
nm = (p.get("display_name") or (p.get("props") or {}).get("名称") or (p.get("props") or {}).get("维修项目编号") or "").strip()
if nm:
proj_names.append(nm)
if proj_names:
parts.append(f"匹配到的维修项目:{''.join(proj_names)}")
return "\n".join(parts)
async def fault_graph_reason_and_projects(
ship_number: str,
device_name: str,
fault_symptom: str,
) -> Dict[str, Any]:
"""
图谱检索工具
"""
if not ship_number or not str(ship_number).strip():
return {"success": False, "error": "舷号不能为空"}
if not device_name or not str(device_name).strip():
return {"success": False, "error": "设备名称不能为空"}
if not fault_symptom or not str(fault_symptom).strip():
return {"success": False, "error": "故障现象不能为空"}
uri = NEO4J_CONFIG.get("uri")
user = NEO4J_CONFIG.get("user")
password = NEO4J_CONFIG.get("password")
if not uri or not user or not password:
return {"success": False, "error": "Neo4j 配置不完整,请检查 NEO4J_CONFIG"}
try:
async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver:
async with driver.session() as session:
ship_key = str(ship_number).strip()
device_key = str(device_name).strip()
# 1) 先按舷号定位舰艇节点(限定范围)
ship_query = """
MATCH (s:舰艇)
WHERE s.舷号 IN [$ship_key]
RETURN DISTINCT
id(s) AS id,
labels(s) AS labels,
properties(s) AS props,
coalesce(s.名称, s.name, s.舷号) AS display_name
LIMIT 10
"""
ship_records = await session.run(ship_query, ship_key=ship_key)
ship_candidates = [_node_record_to_dict(r) async for r in ship_records]
if not ship_candidates:
return {"success": False, "error": "在图谱中未找到对应舷号的舰艇节点"}
ship_node = ship_candidates[0]
ship_id = ship_node["id"]
# 2) 在该舰艇范围内定位设备候选节点
device_query = """
MATCH (ship:舰艇)
WHERE id(ship) = $ship_id
MATCH (ship)-[:包含*0..8]->(n:设备)
WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm
WHERE nm IS NOT NULL AND (
toLower(nm) CONTAINS toLower($device_key)
OR toLower($device_key) CONTAINS toLower(nm)
)
RETURN DISTINCT
id(n) AS id,
labels(n) AS labels,
properties(n) AS props,
nm AS display_name
LIMIT 50
"""
dev_records = await session.run(
device_query, ship_id=ship_id, device_key=device_key
)
device_candidates = [_node_record_to_dict(r) async for r in dev_records]
device_node = _pick_best_by_name(device_candidates, device_key)
if not device_node:
return {"success": False, "error": "在该舰艇范围内未找到匹配的设备节点"}
device_id = device_node["id"]
# 3) 设备 -> (包含层级中的真实设备)-> 维修工作 -> 故障模式
# 路径示例:
# (start_e:设备 {名称:'主发动机'})-[:包含*0..8]-(real_e:设备)
# -[:发生故障使用维修工作]-(w:维修工作)-[:包含故障]-(fm:故障模式)
fault_mode_query = """
MATCH (start_e:设备)
WHERE id(start_e) = $device_id
MATCH (start_e)-[:包含*0..8]-(real_e:设备)
MATCH (real_e)-[:发生故障使用维修工作]-(w:维修工作)-[:包含故障]-(fm:故障模式)
RETURN DISTINCT
id(fm) AS id,
labels(fm) AS labels,
properties(fm) AS props,
fm.名称 AS display_name
"""
fm_records = await session.run(fault_mode_query, device_id=device_id)
fault_modes = [_node_record_to_dict(r) async for r in fm_records]
if not fault_modes:
return {"success": False, "error": "在该设备节点下未找到故障模式节点"}
fault_mode_node = _pick_best_by_name(fault_modes, fault_symptom)
if not fault_mode_node:
return {"success": False, "error": "未能在故障模式节点中找到与故障现象足够匹配的节点"}
fault_mode_id = fault_mode_node.get("id")
if fault_mode_id is None:
return {"success": False, "error": "故障模式节点缺少 id无法继续检索维修项目"}
fm_props = fault_mode_node.get("props") or {}
fault_reason = fm_props.get("故障原因") or ""
repair_plan = fm_props.get("维修方案") or ""
mt_codes = _extract_mt_codes(repair_plan)
# 4) 故障模式 -> 维修项目(维修方案)
repair_project_query = """
MATCH (fm:故障模式)
WHERE id(fm) = $fault_mode_id
OPTIONAL MATCH (fm)<-[:修理]-(p:维修项目)
RETURN DISTINCT
id(p) AS id,
labels(p) AS labels,
properties(p) AS props,
p.名称 AS display_name
"""
rp_records = await session.run(
repair_project_query, fault_mode_id=fault_mode_id
)
all_projects = [_node_record_to_dict(r) async for r in rp_records]
repair_projects: List[Dict[str, Any]] = []
if mt_codes:
for proj in all_projects:
props = proj.get("props") or {}
proj_code = (props.get("维修项目编号") or "").strip()
if proj_code and (
proj_code in mt_codes or any(c in proj_code for c in mt_codes)
):
repair_projects.append(proj)
elif not proj_code:
values_text = " ".join(
[str(v) for v in props.values() if v is not None]
)
if any(code in values_text for code in mt_codes):
repair_projects.append(proj)
else:
repair_projects = all_projects
repair_ids = [
p.get("id") for p in repair_projects if p.get("id") is not None
]
exclude_keys = {
"last_updated",
"created_at",
"knowledge_source",
"fulltext",
"embedding",
}
# 4) nodes包含路径上的设备 / 维修工作 / 故障模式 / 维修项目
graph_nodes_query = """
MATCH (ship:舰艇)
WHERE id(ship) = $ship_id
MATCH (start_e:设备)
WHERE id(start_e) = $device_id
MATCH path0 = (ship)-[:包含*0..8]->(start_e)
MATCH path1 = (start_e)-[:包含*0..8]-(real_e:设备)
MATCH path2 = (real_e)-[:发生故障使用维修工作]-(w:维修工作)-[:包含故障]-(fm:故障模式)
WHERE id(fm) = $fault_mode_id
OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目)
WHERE id(rp) IN $repair_ids
WITH path0, path1, path2, collect(DISTINCT rp) AS rps
WITH nodes(path0) + nodes(path1) + nodes(path2) + rps AS ns
UNWIND ns AS n
WITH DISTINCT n
WHERE n IS NOT NULL
RETURN elementId(n) AS id,
labels(n) AS labels,
coalesce(n.名称, n.name) AS name,
properties(n) AS props
"""
node_records = await session.run(
graph_nodes_query,
ship_id=ship_id,
device_id=device_id,
fault_mode_id=fault_mode_id,
repair_ids=repair_ids or [-1],
)
nodes_g: List[Dict[str, Any]] = []
async for r in node_records:
data_r = r.data()
nid = data_r.get("id")
if nid is None:
continue
labels = data_r.get("labels") or []
name = data_r.get("name") or str(nid)
props = data_r.get("props") or {}
props_filtered = {
k: v for k, v in props.items() if k not in exclude_keys
}
nodes_g.append(
{
"id": nid,
"name": name,
"labels": labels,
"properties": props_filtered,
}
)
# 5) links包含 / 发生故障使用维修工作 / 包含故障 / 修理
graph_links_query = """
MATCH (ship:舰艇)
WHERE id(ship) = $ship_id
MATCH (start_e:设备)
WHERE id(start_e) = $device_id
MATCH path0 = (ship)-[:包含*0..8]->(start_e)
MATCH path1 = (start_e)-[:包含*0..8]-(real_e:设备)
MATCH path2 = (real_e)-[:发生故障使用维修工作]-(w:维修工作)-[:包含故障]-(fm:故障模式)
WHERE id(fm) = $fault_mode_id
WITH relationships(path0) + relationships(path1) + relationships(path2) AS rs
UNWIND rs AS r
RETURN elementId(r) AS id,
type(r) AS label,
elementId(startNode(r)) AS source,
elementId(endNode(r)) AS target,
properties(r) AS props
UNION
MATCH (fm:故障模式)
WHERE id(fm) = $fault_mode_id
MATCH (fm)<-[r:修理]-(rp:维修项目)
WHERE id(rp) IN $repair_ids
RETURN elementId(r) AS id,
type(r) AS label,
elementId(startNode(r)) AS source,
elementId(endNode(r)) AS target,
properties(r) AS props
"""
link_records = await session.run(
graph_links_query,
ship_id=ship_id,
device_id=device_id,
fault_mode_id=fault_mode_id,
repair_ids=repair_ids or [-1],
)
links_g: List[Dict[str, Any]] = []
async for r in link_records:
data_r = r.data()
src = data_r.get("source")
tgt = data_r.get("target")
if src is None or tgt is None:
continue
rel_props = data_r.get("props") or {}
rel_props_filtered = {
k: v for k, v in rel_props.items() if k not in exclude_keys
}
links_g.append(
{
"id": data_r.get("id"),
"label": data_r.get("label", ""),
"source": src,
"target": tgt,
"properties": rel_props_filtered,
}
)
node_ids = {n["id"] for n in nodes_g}
links_g = [
l
for l in links_g
if l.get("source") in node_ids and l.get("target") in node_ids
]
repair_plans_data: List[Dict[str, Any]] = []
for p in repair_projects:
props = p.get("props") or {}
filtered = {k: v for k, v in props.items() if k not in exclude_keys}
if filtered:
repair_plans_data.append(filtered)
results_obj = {"fault_reason": fault_reason, "repair_plans": repair_plans_data}
results_text = json.dumps(results_obj, ensure_ascii=False)
graph_item = {"nodes": nodes_g, "links": links_g, "results": results_text}
return {"success": True, "xxxx.graph": [graph_item]}
except Exception as e:
return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"}