492 lines
20 KiB
Python
492 lines
20 KiB
Python
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)}"}
|