""" 图谱检索工具模块 包含图谱搜索、故障图谱检索、操作图谱检索等功能 """ import re import json import asyncio import httpx from difflib import SequenceMatcher from typing import Optional, Dict, Any, List from neo4j import AsyncGraphDatabase from langchain_core.tools import tool from config import ATLAS_CONFIG, GRAPH_SEARCH_CONFIG, NEO4J_CONFIG from modelsAPI.model_api import OpenaiAPI from utils.function_tracker import track_function_calls GRAPH_SEARCH_ENDPOINT = GRAPH_SEARCH_CONFIG.get("search_endpoint") @track_function_calls async def graph_search( query: str, top_k: Optional[int] = 120, use_rerank: Optional[bool] = False, fulltext_top_k: Optional[int] = 15, rerank_top_k: Optional[int] = 15, hops: Optional[int] = 3, verbose: Optional[bool] = False ) -> Dict[str, Any]: """ 图谱检索工具 """ if not query or not query.strip(): return {"success": False, "error": "查询文本不能为空"} query = query.strip() payload = { "query": query, "use_rerank": use_rerank, "top_k": top_k, "fulltext_top_k": fulltext_top_k, "rerank_top_k": rerank_top_k, "hops": hops, "verbose": verbose } headers = { "accept": "application/json", "Content-Type": "application/json" } try: async with httpx.AsyncClient(timeout=180.0, verify=False) as client: response = await client.post(GRAPH_SEARCH_ENDPOINT, json=payload, headers=headers) except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RequestError) as e: return {"success": False, "error": f"请求超时: {str(e)}"} if response.status_code != 200: return {"success": False, "error": f"HTTP {response.status_code}"} result = response.json() if result.get("code") != 200: msg = result.get("message", "未知错误") return {"success": False, "error": msg} data = result.get("data", {}) or {} try: graph_data = [] results_text = "" if isinstance(data, dict): raw_nodes = data.get("nodes", []) _filter_searchable_labels(raw_nodes) # 过滤 nodes 的 properties 字段,仅保留"名称"属性 for node in raw_nodes: if isinstance(node, dict): props = node.get("properties", {}) or node.get("props", {}) name_value = props.get("名称", props.get("name", "")) node.pop("properties", None) node.pop("props", None) if name_value: node["properties"] = {"名称": name_value} graph_item = { "nodes": raw_nodes, "links": data.get("links", []), } graph_item = apply_styles(graph_item) graph_data.append(graph_item) results_text = data.get("results", "") elif isinstance(data, list): parts = [] for item in data: if isinstance(item, dict): raw_nodes = item.get("nodes", []) _filter_searchable_labels(raw_nodes) # 过滤 nodes 的 properties 字段,仅保留"名称"属性 for node in raw_nodes: if isinstance(node, dict): props = node.get("properties", {}) or node.get("props", {}) name_value = props.get("名称", props.get("name", "")) node.pop("properties", None) node.pop("props", None) if name_value: node["properties"] = {"名称": name_value} graph_item = { "nodes": raw_nodes, "links": item.get("links", []), } graph_item = apply_styles(graph_item) graph_data.append(graph_item) if item.get("results"): parts.append(item["results"]) results_text = "\n".join(parts) for graph_item in graph_data: links = graph_item.get("links", []) for link in links: link.pop("properties", None) return { "success": True, "xxxx.graph": graph_data, "results": results_text, } except Exception as e: return {"success": False, "error": str(e)} _MATCH_PUNCT_RE = re.compile(r"[\s\-_·/\\|,,。;;::()()\[\]【】{}<>《》\"'“”‘’]+") _DEVICE_SUFFIX_RE = re.compile(r"(设备|装置|系统|组件|部件|总成|单元|机构|模块|装配|船用|舰用)$") def _normalize_match_text(text: str) -> str: """归一化节点名称,降低空格、标点、常见后缀造成的误差。""" if not text: return "" normalized = _MATCH_PUNCT_RE.sub("", str(text).strip().lower()) prev = None while normalized and normalized != prev: prev = normalized normalized = _DEVICE_SUFFIX_RE.sub("", normalized) return normalized def _string_similarity(a: str, b: str) -> float: """ 简单字符串相似度:综合长度差、包含关系和公共字符比例。 0~1 之间,越大越相似。 """ if not a or not b: return 0.0 a = _normalize_match_text(a) b = _normalize_match_text(b) 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.95 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 seq_ratio = SequenceMatcher(None, a, b).ratio() len_diff = abs(len(a) - len(b)) len_penalty = max(0.0, 1.0 - len_diff / max(len(a), len(b), 1)) return 0.45 * seq_ratio + 0.35 * jaccard + 0.20 * len_penalty def _coerce_embedding_vector(value: Any) -> Optional[List[float]]: """把 Neo4j 里的 embedding 统一成 float 列表;无法解析时返回 None。""" if value is None: return None if isinstance(value, str): try: value = json.loads(value) except json.JSONDecodeError: return None if not isinstance(value, (list, tuple)) or not value: return None try: return [float(v) for v in value] except (TypeError, ValueError): return None def _candidate_display_name(candidate: Dict[str, Any], name_key: str = "display_name") -> str: name = candidate.get(name_key) or "" if name: return str(name) props = candidate.get("props") or {} return str(props.get("name") or props.get("名称") or props.get("设备名称") or "") async def _safe_hybrid_match_with_embeddings( query_text: str, candidates: List[Dict[str, Any]], name_key: str = "display_name", embedding_key: str = "embedding", text_weight: float = 0.45, semantic_weight: float = 0.55, min_text_score: float = 0.36, min_combined_score: float = 0.43, ) -> tuple: """ 先用名称相似度兜底,再叠加 embedding。 embedding 缺失、维度不一致或服务异常时,不让匹配流程失败。 """ if not candidates: return None, 0.0 query_embedding = None try: query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text)) except Exception: query_embedding = None scored_candidates = [] has_usable_semantic = False for idx, candidate in enumerate(candidates): name = _candidate_display_name(candidate, name_key=name_key) text_score = _string_similarity(query_text, name) semantic_score = None candidate_embedding = _coerce_embedding_vector( (candidate.get("props") or {}).get(embedding_key) or candidate.get(embedding_key) ) if query_embedding and candidate_embedding and len(query_embedding) == len(candidate_embedding): semantic_score = OpenaiAPI.cosine_similarity(query_embedding, candidate_embedding) has_usable_semantic = True if semantic_score is None: combined_score = text_score else: combined_score = text_weight * text_score + semantic_weight * semantic_score if text_score >= 0.82: combined_score = max(combined_score, text_score) scored_candidates.append({ "candidate": candidate, "text_score": text_score, "semantic_score": semantic_score, "combined_score": combined_score, "index": idx, }) scored_candidates.sort( key=lambda x: ( x["combined_score"], x["text_score"], x["semantic_score"] if x["semantic_score"] is not None else -1.0, -x["index"], ), reverse=True, ) best = scored_candidates[0] if best["text_score"] >= 0.82: return best["candidate"], best["combined_score"] if has_usable_semantic and best["combined_score"] >= min_combined_score: return best["candidate"], best["combined_score"] if best["text_score"] >= min_text_score: return best["candidate"], best["text_score"] return None, best["combined_score"] async def _match_scoped_nodes_with_graph_indexes( session, query_text: str, fulltext_index: str, vector_index: str, candidate_ids: Optional[List[Any]] = None, fallback_candidates: Optional[List[Dict[str, Any]]] = None, fulltext_top_k: int = 500, vector_top_k: int = 500, text_weight: float = 0.20, fulltext_weight: float = 0.35, vector_weight: float = 0.45, min_text_score: float = 0.34, min_combined_score: float = 0.42, ) -> tuple: """ 使用 Neo4j 全文索引和向量索引在候选范围内匹配节点。 仅使用索引结果做融合匹配;fallback_candidates 只用于补齐范围查询中的附加字段。 """ query_text = str(query_text or "").strip() if not query_text: return None, 0.0, {"match_source": "empty_query"} ids_filter = candidate_ids if candidate_ids else None indexed_candidates: Dict[Any, Dict[str, Any]] = {} try: fulltext_query = query_text.replace("\\", "\\\\").replace('"', '\\"') fulltext_cypher = """ CALL db.index.fulltext.queryNodes($fulltext_index, $query) YIELD node, score WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids WITH node, score ORDER BY score DESC LIMIT $top_k RETURN id(node) AS id, labels(node) AS labels, properties(node) AS props, coalesce(node.名称, node.name, node.设备名称) AS display_name, node.embedding AS embedding, score AS fulltext_score """ fulltext_records = await session.run( fulltext_cypher, fulltext_index=fulltext_index, query=fulltext_query.strip() or query_text, candidate_ids=ids_filter, top_k=fulltext_top_k, ) async for record in fulltext_records: item = _node_record_to_dict(record) item["fulltext_score"] = float(record.get("fulltext_score") or 0.0) indexed_candidates[item["id"]] = item except Exception as e: print(f"[图谱索引匹配] 全文索引 {fulltext_index} 检索失败: {str(e)}") query_embedding = None try: query_embedding = _coerce_embedding_vector(await OpenaiAPI.get_embeddings_async(query_text)) except Exception: query_embedding = None if query_embedding: try: vector_cypher = """ CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding) YIELD node, score WHERE $candidate_ids IS NULL OR id(node) IN $candidate_ids RETURN id(node) AS id, labels(node) AS labels, properties(node) AS props, coalesce(node.名称, node.name, node.设备名称) AS display_name, node.embedding AS embedding, score AS vector_score """ vector_records = await session.run( vector_cypher, vector_index=vector_index, top_k=vector_top_k, embedding=query_embedding, candidate_ids=ids_filter, ) async for record in vector_records: item = _node_record_to_dict(record) item["vector_score"] = float(record.get("vector_score") or 0.0) existing = indexed_candidates.get(item["id"], item) existing["vector_score"] = item["vector_score"] if not existing.get("display_name") and item.get("display_name"): existing["display_name"] = item["display_name"] indexed_candidates[item["id"]] = existing except Exception as e: print(f"[图谱索引匹配] 向量索引 {vector_index} 检索失败: {str(e)}") if fallback_candidates and indexed_candidates: fallback_by_id = { item.get("id"): item for item in fallback_candidates if item.get("id") is not None } for candidate_id, candidate in indexed_candidates.items(): fallback_item = fallback_by_id.get(candidate_id) if not fallback_item: continue if not candidate.get("display_name") and fallback_item.get("display_name"): candidate["display_name"] = fallback_item["display_name"] if "real_device_id" in fallback_item and "real_device_id" not in candidate: candidate["real_device_id"] = fallback_item["real_device_id"] if "real_device_name" in fallback_item and "real_device_name" not in candidate: candidate["real_device_name"] = fallback_item["real_device_name"] if indexed_candidates: max_fulltext_score = max( [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0] ) scored = [] for candidate in indexed_candidates.values(): name = _candidate_display_name(candidate) text_score = _string_similarity(query_text, name) fulltext_score = candidate.get("fulltext_score", 0.0) fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0 vector_score = candidate.get("vector_score", 0.0) combined_score = ( text_weight * text_score + fulltext_weight * fulltext_norm + vector_weight * vector_score ) if text_score >= 0.82: combined_score = max(combined_score, text_score) scored.append({ "candidate": candidate, "text_score": text_score, "fulltext_score": fulltext_score, "fulltext_norm": fulltext_norm, "vector_score": vector_score, "combined_score": combined_score, }) scored.sort( key=lambda x: ( x["combined_score"], x["text_score"], x["vector_score"], x["fulltext_norm"], ), reverse=True, ) best = scored[0] second = scored[1] if len(scored) > 1 else None has_index_signal = best["fulltext_norm"] > 0 or best["vector_score"] > 0 is_ambiguous = ( second is not None and best["text_score"] < 0.82 and best["combined_score"] < 0.60 and (best["combined_score"] - second["combined_score"]) < 0.03 ) if ( best["text_score"] >= 0.82 or ( has_index_signal and not is_ambiguous and best["combined_score"] >= min_combined_score and best["text_score"] >= min_text_score ) ): return best["candidate"], best["combined_score"], { "match_source": "neo4j_index", "text_score": best["text_score"], "fulltext_score": best["fulltext_score"], "vector_score": best["vector_score"], } best_score = max([c.get("combined_score", 0.0) for c in indexed_candidates.values()] or [0.0]) return None, best_score, {"match_source": "none"} 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 结构统一为字典。 """ if hasattr(record, "data"): data = record.data() else: data = record or {} result = { "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, } if "embedding" in data: result["embedding"] = data.get("embedding") if "real_device_id" in data: result["real_device_id"] = data.get("real_device_id") if "real_device_name" in data: result["real_device_name"] = data.get("real_device_name") return result @track_function_calls async def normalize_device_name_by_ship_graph( device_name: str, fulltext_index: str = "设备_fulltext", vector_index: str = "设备_vector", fulltext_top_k: int = 500, vector_top_k: int = 500, min_text_score: float = 0.34, min_combined_score: float = 0.42, ) -> Dict[str, Any]: """ 正在标准化设备名称。。。 标准化失败时返回 success=False,由调用方继续使用原始设备名。 """ original_device_name = str(device_name or "").strip() if not original_device_name: return {"success": False, "device_name": original_device_name, "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, "device_name": original_device_name, "error": "Neo4j 配置不完整,请检查 NEO4J_CONFIG", } try: async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver: async with driver.session() as session: ship_query = """ MATCH (s:舰艇) RETURN DISTINCT id(s) AS id, labels(s) AS labels, properties(s) AS props, coalesce(s.名称, s.name, s.舷号) AS display_name LIMIT 2 """ ship_records = await session.run(ship_query) ship_candidates = [_node_record_to_dict(r) async for r in ship_records] if len(ship_candidates) != 1: return { "success": False, "device_name": original_device_name, "error": f"当前图谱舰艇节点数量不是唯一值: {len(ship_candidates)}", } ship_node = ship_candidates[0] ship_id = ship_node["id"] ship_name = _candidate_display_name(ship_node) scope_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (ship)-[:包含*1..6]->(n:设备) WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm WHERE nm IS NOT NULL RETURN collect(id(n)) AS device_ids """ scope_records = await session.run(scope_query, ship_id=ship_id) scope_row = await scope_records.single() device_ids = scope_row.get("device_ids") if scope_row else [] if not device_ids: return { "success": False, "device_name": original_device_name, "ship_name": ship_name, "error": "唯一舰艇节点下未找到设备候选节点", } async def _fallback_match(reason: str = "") -> Dict[str, Any]: device_query = """ MATCH (n:设备) WHERE id(n) IN $device_ids WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm WHERE nm IS NOT NULL RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props, nm AS display_name, n.embedding AS embedding LIMIT 2000 """ device_records = await session.run(device_query, device_ids=device_ids) device_candidates = [_node_record_to_dict(r) async for r in device_records] if not device_candidates: return { "success": False, "device_name": original_device_name, "ship_name": ship_name, "error": "唯一舰艇节点下未找到设备候选节点", } device_node, device_score = await _safe_hybrid_match_with_embeddings( query_text=original_device_name, candidates=device_candidates, name_key="display_name", embedding_key="embedding", text_weight=0.45, semantic_weight=0.55, min_text_score=min_text_score, min_combined_score=min_combined_score, ) if not device_node: return { "success": False, "device_name": original_device_name, "ship_name": ship_name, "best_score": device_score, "error": f"未找到足够匹配的设备节点(最高得分: {device_score:.3f})", } matched_name = _candidate_display_name(device_node) or original_device_name return { "success": True, "device_name": matched_name, "original_device_name": original_device_name, "ship_name": ship_name, "score": device_score, "matched_node_id": device_node.get("id"), "match_source": "fallback", "fallback_reason": reason, } query_embedding = None try: query_embedding = _coerce_embedding_vector( await OpenaiAPI.get_embeddings_async(original_device_name) ) except Exception: query_embedding = None indexed_candidates: Dict[Any, Dict[str, Any]] = {} try: fulltext_query = original_device_name.replace("\\", "\\\\").replace('"', '\\"') fulltext_query = fulltext_query.strip() or original_device_name fulltext_cypher = """ CALL db.index.fulltext.queryNodes($fulltext_index, $query) YIELD node, score WHERE id(node) IN $device_ids WITH node, score ORDER BY score DESC LIMIT $top_k RETURN id(node) AS id, labels(node) AS labels, properties(node) AS props, coalesce(node.名称, node.name, node.设备名称) AS display_name, node.embedding AS embedding, score AS fulltext_score """ fulltext_records = await session.run( fulltext_cypher, fulltext_index=fulltext_index, query=fulltext_query, device_ids=device_ids, top_k=fulltext_top_k, ) async for record in fulltext_records: item = _node_record_to_dict(record) item["fulltext_score"] = float(record.get("fulltext_score") or 0.0) indexed_candidates[item["id"]] = item except Exception as e: print(f"[设备标准化] 全文索引检索失败: {str(e)}") if query_embedding: try: vector_cypher = """ CALL db.index.vector.queryNodes($vector_index, $top_k, $embedding) YIELD node, score WHERE id(node) IN $device_ids RETURN id(node) AS id, labels(node) AS labels, properties(node) AS props, coalesce(node.名称, node.name, node.设备名称) AS display_name, node.embedding AS embedding, score AS vector_score """ vector_records = await session.run( vector_cypher, vector_index=vector_index, top_k=vector_top_k, embedding=query_embedding, device_ids=device_ids, ) async for record in vector_records: item = _node_record_to_dict(record) item["vector_score"] = float(record.get("vector_score") or 0.0) existing = indexed_candidates.get(item["id"], item) existing["vector_score"] = item["vector_score"] if not existing.get("display_name") and item.get("display_name"): existing["display_name"] = item["display_name"] indexed_candidates[item["id"]] = existing except Exception as e: print(f"[设备标准化] 向量索引检索失败: {str(e)}") if not indexed_candidates: return await _fallback_match("索引未召回设备候选") max_fulltext_score = max( [c.get("fulltext_score", 0.0) for c in indexed_candidates.values()] or [0.0] ) scored = [] for candidate in indexed_candidates.values(): name = _candidate_display_name(candidate) text_score = _string_similarity(original_device_name, name) fulltext_score = candidate.get("fulltext_score", 0.0) fulltext_norm = fulltext_score / max_fulltext_score if max_fulltext_score > 0 else 0.0 vector_score = candidate.get("vector_score", 0.0) combined_score = ( 0.20 * text_score + 0.35 * fulltext_norm + 0.45 * vector_score ) if text_score >= 0.82: combined_score = max(combined_score, text_score) scored.append({ "candidate": candidate, "text_score": text_score, "fulltext_score": fulltext_score, "fulltext_norm": fulltext_norm, "vector_score": vector_score, "combined_score": combined_score, }) scored.sort( key=lambda x: ( x["combined_score"], x["text_score"], x["vector_score"], x["fulltext_norm"], ), reverse=True, ) best = scored[0] best_candidate = best["candidate"] if ( best["text_score"] < 0.82 and best["combined_score"] < min_combined_score and best["text_score"] < min_text_score ): return await _fallback_match( f"索引最高得分不足: {best['combined_score']:.3f}" ) matched_name = _candidate_display_name(best_candidate) or original_device_name return { "success": True, "device_name": matched_name, "original_device_name": original_device_name, "ship_name": ship_name, "score": best["combined_score"], "matched_node_id": best_candidate.get("id"), "match_source": "neo4j_index", "text_score": best["text_score"], "fulltext_score": best["fulltext_score"], "vector_score": best["vector_score"], } except Exception as e: return { "success": False, "device_name": original_device_name, "error": f"设备名称标准化失败: {str(e)}", } 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 = [l for l in (node_dict.get("labels") or []) if l != "Searchable"] label = labels[0] if labels else "" return {"id": nid, "name": name, "label": label} def _filter_props_for_graph(props: Dict[str, Any]) -> Dict[str, Any]: exclude_keys = { "last_updated", "created_at", "knowledge_source", "fulltext", "embedding", } return {k: v for k, v in (props or {}).items() if k not in exclude_keys} def _record_to_graph_node(data: Dict[str, Any]) -> Dict[str, Any]: props = data.get("props") or {} name = data.get("name") or props.get("名称") or props.get("name") or str(data.get("id")) return { "id": data.get("id"), "name": name, "labels": data.get("labels") or [], "properties": {"名称": name} if name else _filter_props_for_graph(props), } async def _get_unique_or_numbered_ship(session, ship_number: str = "") -> Optional[Dict[str, Any]]: if ship_number and str(ship_number).strip(): ship_key = str(ship_number).strip() ship_query = """ MATCH (s:舰艇) WHERE s.舷号 = $ship_key OR s.舷号 CONTAINS $ship_key OR $ship_key CONTAINS s.舷号 RETURN id(s) AS id, elementId(s) AS eid, labels(s) AS labels, properties(s) AS props, coalesce(s.名称, s.name, s.舷号) AS display_name LIMIT 1 """ records = await session.run(ship_query, ship_key=ship_key) record = await records.single() return _node_record_to_dict(record) if record else None ship_query = """ MATCH (s:舰艇) RETURN id(s) AS id, elementId(s) AS eid, labels(s) AS labels, properties(s) AS props, coalesce(s.名称, s.name, s.舷号) AS display_name LIMIT 2 """ records = await session.run(ship_query) ships = [_node_record_to_dict(r) async for r in records] return ships[0] if len(ships) == 1 else None async def _fetch_node_graph_records(session, node_ids: List[Any]) -> List[Dict[str, Any]]: if not node_ids: return [] query = """ MATCH (n) WHERE id(n) IN $node_ids RETURN elementId(n) AS id, labels(n) AS labels, coalesce(n.名称, n.name) AS name, properties(n) AS props """ records = await session.run(query, node_ids=node_ids) return [_record_to_graph_node(r.data()) async for r in records] 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[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) @track_function_calls async def find_system_by_device( device_name: str, min_hops: int = 2, max_hops: int = 8, top_k: int = 10, ) -> Dict[str, Any]: """根据设备名称反向检索所属系统节点。""" if not device_name or not str(device_name).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_node = await _get_unique_or_numbered_ship(session, "") device_candidate_ids = None if ship_node: scope_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (ship)-[:包含*1..6]->(n:设备) WITH DISTINCT n, coalesce(n.名称, n.name, n.设备名称) AS nm WHERE nm IS NOT NULL RETURN collect(id(n)) AS device_ids """ scope_records = await session.run(scope_query, ship_id=ship_node.get("id")) scope_row = await scope_records.single() device_candidate_ids = scope_row.get("device_ids") if scope_row else None if not device_candidate_ids: return {"success": False, "error": "唯一舰艇节点下未找到设备候选节点"} device_node, device_score, _ = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=str(device_name).strip(), fulltext_index="设备_fulltext", vector_index="设备_vector", candidate_ids=device_candidate_ids, fallback_candidates=None, fulltext_top_k=max(top_k, 500), vector_top_k=max(top_k, 500), min_text_score=0.34, min_combined_score=0.42, ) if not device_node: return {"success": False, "error": f"未找到匹配的设备节点(最高得分: {device_score:.3f})"} device_id = device_node.get("id") effective_max_hops = max(1, int(max_hops)) nearest_hops_query = f""" MATCH (d:设备) WHERE id(d) = $device_id MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d) RETURN min(length(path)) AS nearest_hops """ nearest_records = await session.run(nearest_hops_query, device_id=device_id) nearest_row = await nearest_records.single() nearest_hops = nearest_row.get("nearest_hops") if nearest_row else None if nearest_hops is None: return { "success": False, "error": "未找到设备对应的系统节点", "device": { "id": device_id, "name": _candidate_display_name(device_node), "labels": device_node.get("labels", []), "properties": _filter_props_for_graph(device_node.get("props", {})), }, } system_query = f""" MATCH (d:设备) WHERE id(d) = $device_id MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d) WHERE length(path) = $nearest_hops RETURN DISTINCT id(s) AS id, elementId(s) AS eid, labels(s) AS labels, properties(s) AS props, coalesce(s.名称, s.name) AS display_name, length(path) AS hops ORDER BY display_name """ system_records = await session.run( system_query, device_id=device_id, nearest_hops=nearest_hops, ) systems_raw = [_node_record_to_dict(r) async for r in system_records] if not systems_raw: return { "success": False, "error": "未找到设备对应的系统节点", "device": { "id": device_id, "name": _candidate_display_name(device_node), "labels": device_node.get("labels", []), "properties": _filter_props_for_graph(device_node.get("props", {})), }, } systems = [] for item in systems_raw: systems.append({ "id": item.get("id"), "name": _candidate_display_name(item), "labels": item.get("labels", []), "properties": _filter_props_for_graph(item.get("props", {})), }) path_query = f""" MATCH (d:设备) WHERE id(d) = $device_id MATCH path = (s:系统)-[:包含*1..{effective_max_hops}]->(d) WHERE length(path) = $nearest_hops WITH path LIMIT 5 WITH nodes(path) AS ns, relationships(path) AS rs RETURN ns, rs """ path_records = await session.run( path_query, device_id=device_id, nearest_hops=nearest_hops, ) nodes_g: List[Dict[str, Any]] = [] links_g: List[Dict[str, Any]] = [] seen_nodes = set() seen_links = set() async for record in path_records: for node in record.get("ns") or []: nid = node.element_id if hasattr(node, "element_id") else node.id if nid in seen_nodes: continue seen_nodes.add(nid) props = dict(node) if hasattr(node, "__iter__") else {} nodes_g.append({ "id": nid, "name": props.get("名称") or props.get("name") or str(nid), "labels": list(node.labels) if hasattr(node, "labels") else [], "properties": _filter_props_for_graph(props), }) for rel in record.get("rs") or []: rid = rel.element_id if hasattr(rel, "element_id") else rel.id if rid in seen_links: continue seen_links.add(rid) links_g.append({ "id": rid, "label": rel.type if hasattr(rel, "type") else str(rel.type), "source": rel.start_node.element_id if hasattr(rel.start_node, "element_id") else rel.start_node.id, "target": rel.end_node.element_id if hasattr(rel.end_node, "element_id") else rel.end_node.id, }) return { "success": True, "device": { "id": device_id, "name": _candidate_display_name(device_node), "labels": device_node.get("labels", []), "properties": _filter_props_for_graph(device_node.get("props", {})), "match_score": device_score, }, "systems": systems, "graph": {"nodes": nodes_g, "links": links_g}, "meta": { "system_count": len(systems), "min_hops": min_hops, "max_hops": max_hops, }, } except Exception as e: return {"success": False, "error": f"检索失败: {str(e)}"} @track_function_calls async def fault_graph_reason_and_projects( ship_number: Optional[str] = None, device_name: str = "", fault_symptom: str = "", ) -> Dict[str, Any]: """ 图谱检索工具 """ 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: device_key = str(device_name).strip() ship_id = None ship_node = None # 有舷号时,先定位舰艇节点 if ship_number and str(ship_number).strip(): ship_key = str(ship_number).strip() 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 ship_candidates: ship_node = ship_candidates[0] ship_id = ship_node["id"] if ship_id is not None: device_scope_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (ship)-[:包含*0..6]->(n:设备) WITH n, coalesce(n.名称, n.name, n.设备名称) AS nm WHERE nm IS NOT NULL RETURN collect(DISTINCT id(n)) AS device_ids """ scope_records = await session.run(device_scope_query, ship_id=ship_id) scope_row = await scope_records.single() device_candidate_ids = scope_row.get("device_ids") if scope_row else [] else: device_candidate_ids = None if ship_id is not None and not device_candidate_ids: return {"success": False, "error": "未找到匹配的设备节点"} device_node, device_score, device_match_meta = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=device_key, fulltext_index="设备_fulltext", vector_index="设备_vector", candidate_ids=device_candidate_ids if ship_id is not None else None, fallback_candidates=None, fulltext_top_k=500, vector_top_k=500, min_text_score=0.34, min_combined_score=0.42, ) if not device_node: return {"success": False, "error": f"未找到匹配的设备节点(最高得分: {device_score:.3f})"} device_id = device_node["id"] fault_mode_scope_query = """ MATCH (start_e:设备) WHERE id(start_e) = $device_id MATCH (start_e)-[:包含*0..3]-(real_e:设备) MATCH (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) RETURN DISTINCT id(fm) AS id, labels(fm) AS labels, fm.名称 AS display_name, id(real_e) AS real_device_id, real_e.名称 AS real_device_name """ fm_records = await session.run(fault_mode_scope_query, device_id=device_id) fault_mode_scope = [_node_record_to_dict(r) async for r in fm_records] fault_modes = fault_mode_scope if not fault_modes: return {"success": False, "error": "在该设备节点下未找到故障模式节点"} fault_mode_ids = [ item.get("id") for item in fault_modes if item.get("id") is not None ] fault_mode_node, fault_score, fault_mode_match_meta = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=fault_symptom, fulltext_index="故障模式_fulltext", vector_index="故障模式_vector", candidate_ids=fault_mode_ids, fallback_candidates=None, fulltext_top_k=500, vector_top_k=500, min_text_score=0.30, min_combined_score=0.40, ) if fault_mode_node: scope_by_id = { item.get("id"): item for item in fault_mode_scope if item.get("id") is not None } scope_item = scope_by_id.get(fault_mode_node.get("id")) if scope_item: if "real_device_id" in scope_item and "real_device_id" not in fault_mode_node: fault_mode_node["real_device_id"] = scope_item["real_device_id"] if "real_device_name" in scope_item and "real_device_name" not in fault_mode_node: fault_mode_node["real_device_name"] = scope_item["real_device_name"] if not fault_mode_node: return {"success": False, "error": f"未能在故障模式节点中找到与故障现象足够匹配的节点(最高得分: {fault_score:.3f})"} fault_mode_id = fault_mode_node.get("id") if fault_mode_id is None: return {"success": False, "error": "故障模式节点缺少 id,无法继续检索维修项目"} real_device_id = fault_mode_node.get("real_device_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) 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", } if ship_id is not None: graph_nodes_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (real_e:设备) WHERE id(real_e) = $real_device_id MATCH path0 = (ship)-[:包含*0..6]->(real_e) MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) WHERE id(fm) = $fault_mode_id OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目) WHERE id(rp) IN $repair_ids WITH path0, path1, collect(DISTINCT rp) AS rps WITH nodes(path0) + nodes(path1) + 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, real_device_id=real_device_id, fault_mode_id=fault_mode_id, repair_ids=repair_ids or [-1], ) else: # 无舷号:不查舰艇路径,只查设备→故障模式→维修项目 graph_nodes_query = """ MATCH (real_e:设备) WHERE id(real_e) = $real_device_id MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) WHERE id(fm) = $fault_mode_id OPTIONAL MATCH (fm)<-[:修理]-(rp:维修项目) WHERE id(rp) IN $repair_ids WITH path1, collect(DISTINCT rp) AS rps WITH nodes(path1) + 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, real_device_id=real_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 [] labels = [l for l in labels if l != "Searchable"] name = data_r.get("name") or str(nid) props = data_r.get("props") or {} # 仅保留"名称"属性 name_value = props.get("名称", props.get("name", "")) properties = {"名称": name_value} if name_value else {} nodes_g.append( { "id": nid, "name": name, "labels": labels, "properties": properties, } ) if ship_id is not None: graph_links_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (real_e:设备) WHERE id(real_e) = $real_device_id MATCH path0 = (ship)-[:包含*0..6]->(real_e) MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) WHERE id(fm) = $fault_mode_id WITH relationships(path0) + relationships(path1) 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, real_device_id=real_device_id, fault_mode_id=fault_mode_id, repair_ids=repair_ids or [-1], ) else: # 无舷号:只查设备→故障模式的边 graph_links_query = """ MATCH (real_e:设备) WHERE id(real_e) = $real_device_id MATCH path1 = (real_e)-[:维修]-(w:维修工作)-[:表征]-(fm:故障模式) WHERE id(fm) = $fault_mode_id WITH relationships(path1) 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, real_device_id=real_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 links_g.append( { "id": data_r.get("id"), "label": data_r.get("label", ""), "source": src, "target": tgt, } ) 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) parts = [] if fault_reason and str(fault_reason).strip(): parts.append(str(fault_reason).strip()) if repair_plans_data: for plan in repair_plans_data: if plan is not None: plan_str = str(plan).strip() if plan_str: parts.append(plan_str) if parts: combined_text = "\n".join(parts) else: combined_text = "暂无故障信息或修复方案" results_obj = combined_text results_text = json.dumps(results_obj, ensure_ascii=False) graph_item = {"nodes": nodes_g, "links": links_g} graph_item = apply_styles(graph_item) return {"success": True, "xxxx.graph": [graph_item], "results": results_text} except Exception as e: return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"} def add_node_style(node): node_type = node.get("labels", []) node_type = [l for l in node_type if l != "Searchable"] if node_type and len(node_type) > 0: node_type = node_type[0] else: node_type = "" if node_type == "舰艇": node["color"] = "#F89A64" node["size"] = 86 elif node_type == "系统": node["color"] = "#C25C21" node["size"] = 86 elif node_type == "子系统": node["color"] = "#7D2D00" node["size"] = 86 elif node_type == "设备": node["color"] = "#540802" node["size"] = 86 elif node_type in ["功能", "环境条件", "接口", "安全警告", "调试", "器材保障", "图册", "设计单位", "维修项目", "操作项目"]: node["color"] = "#2261DF" node["size"] = 52 else: node["color"] = "#68C3FF" node["size"] = 53 return node def add_link_style(link, nodes_dict): source_id = link.get("source") target_id = link.get("target") source_type = "" target_type = "" if source_id and source_id in nodes_dict: source_labels = [l for l in nodes_dict[source_id].get("labels", []) if l != "Searchable"] if source_labels and len(source_labels) > 0: source_type = source_labels[0] if target_id and target_id in nodes_dict: target_labels = [l for l in nodes_dict[target_id].get("labels", []) if l != "Searchable"] if target_labels and len(target_labels) > 0: target_type = target_labels[0] hierarchy_types = ["舰艇", "系统", "子系统", "设备"] if source_type in hierarchy_types and target_type in hierarchy_types: link["color"] = "#F59E0B" else: link["color"] = "#FFFFFF" return link def _filter_searchable_labels(nodes: List[Dict[str, Any]]): for node in nodes: if isinstance(node, dict) and "labels" in node: node["labels"] = [l for l in node["labels"] if l != "Searchable"] def apply_styles(graph_data): nodes = graph_data.get("nodes", []) links = graph_data.get("links", []) nodes_dict = {n.get("id"): n for n in nodes if n.get("id")} for node in nodes: add_node_style(node) for link in links: add_link_style(link, nodes_dict) return graph_data @tool async def fault_graph_rag_search( ship_number: Optional[str] = None, device_name: str = "", fault_symptom: str = "", ) -> str: """ 故障诊断图谱检索工具(供工作流调用):仅返回一段话摘要,与 graph_rag_search 一致。 根据设备名称、故障现象在图谱中定位设备→故障模式→维修项目,并生成文字摘要。舷号为可选参数,提供时可缩小搜索范围。 工作流中用于与 RAG 结果一起作为上下文,不返回节点/关系(展示用 fault_graph_reason_and_projects)。 """ result = await fault_graph_reason_and_projects( ship_number=ship_number, device_name=device_name, fault_symptom=fault_symptom, ) if not result.get("success", False): return result.get("error", "检索失败") return result.get("results", "") @track_function_calls async def operation_graph_search_tool( ship_number: str, device_name: str, operation_item: str, ) -> Dict[str, Any]: """ 图谱检索工具 """ if not device_name or not str(device_name).strip(): return {"success": False, "error": "设备名称不能为空"} if not operation_item or not str(operation_item).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_node = await _get_unique_or_numbered_ship(session, "") if not ship_node: return {"success": False, "error": "未找到唯一舰艇节点"} ship_id = ship_node.get("id") scope_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (ship)-[:包含*0..6]->(d:设备) WITH DISTINCT d, coalesce(d.名称, d.name, d.设备名称) AS nm WHERE nm IS NOT NULL RETURN collect(id(d)) AS device_ids """ scope_records = await session.run(scope_query, ship_id=ship_id) scope_row = await scope_records.single() device_ids = scope_row.get("device_ids") if scope_row else [] if not device_ids: return {"success": False, "error": "该舰艇范围内未找到设备节点"} device_node, device_score, _ = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=str(device_name).strip(), fulltext_index="设备_fulltext", vector_index="设备_vector", candidate_ids=device_ids, fallback_candidates=None, fulltext_top_k=500, vector_top_k=500, min_text_score=0.34, min_combined_score=0.42, ) if not device_node: return {"success": False, "error": f"在该舰艇范围内未找到匹配的设备节点(最高得分: {device_score:.3f})"} device_id = device_node.get("id") op_scope_query = """ MATCH (start_d:设备) WHERE id(start_d) = $device_id MATCH (start_d)-[:包含*0..6]-(real_d:设备) MATCH (real_d)-[:支持操作]-(op_use:操作使用) -[:使用]-(op_proc:操作程序) -[:操作时使用]-(op_item:操作项目) RETURN DISTINCT id(op_item) AS id, elementId(op_item) AS eid, labels(op_item) AS labels, properties(op_item) AS props, coalesce(op_item.名称, op_item.name) AS display_name, id(real_d) AS real_device_id """ op_records = await session.run(op_scope_query, device_id=device_id) op_scope = [_node_record_to_dict(r) async for r in op_records] op_ids = [item.get("id") for item in op_scope if item.get("id") is not None] if not op_ids: return {"success": False, "error": "该设备下未找到操作项目节点"} op_node, op_score, _ = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=str(operation_item).strip(), fulltext_index="操作项目_fulltext", vector_index="操作项目_vector", candidate_ids=op_ids, fallback_candidates=op_scope, fulltext_top_k=500, vector_top_k=500, min_text_score=0.30, min_combined_score=0.40, ) if not op_node: return {"success": False, "error": f"未找到匹配的操作项目节点(最高得分: {op_score:.3f})"} op_item_id = op_node.get("id") real_device_id = op_node.get("real_device_id") or device_id graph_nodes_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (real_d:设备) WHERE id(real_d) = $real_device_id MATCH (op_item:操作项目) WHERE id(op_item) = $op_item_id MATCH path1 = (ship)-[:包含*0..6]->(real_d) MATCH path2 = (real_d)-[:支持操作]->(op_use:操作使用) -[:使用]->(op_proc:操作程序) -[:操作时使用]->(op_item) WITH nodes(path1) + nodes(path2) AS ns UNWIND ns AS n WITH DISTINCT n 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, real_device_id=real_device_id, op_item_id=op_item_id, ) nodes_g = [_record_to_graph_node(r.data()) async for r in node_records] graph_links_query = """ MATCH (ship:舰艇) WHERE id(ship) = $ship_id MATCH (real_d:设备) WHERE id(real_d) = $real_device_id MATCH (op_item:操作项目) WHERE id(op_item) = $op_item_id MATCH path1 = (ship)-[:包含*0..6]->(real_d) MATCH path2 = (real_d)-[:支持操作]->(op_use:操作使用) -[:使用]->(op_proc:操作程序) -[:操作时使用]->(op_item) WITH relationships(path1) + relationships(path2) AS rs UNWIND rs AS r WITH DISTINCT r RETURN elementId(r) AS id, type(r) AS label, elementId(startNode(r)) AS source, elementId(endNode(r)) AS target """ link_records = await session.run( graph_links_query, ship_id=ship_id, real_device_id=real_device_id, op_item_id=op_item_id, ) links_g = [] node_ids = {n.get("id") for n in nodes_g} async for r in link_records: data = r.data() if data.get("source") in node_ids and data.get("target") in node_ids: links_g.append({ "id": data.get("id"), "label": data.get("label", ""), "source": data.get("source"), "target": data.get("target"), }) op_props = _filter_props_for_graph(op_node.get("props") or {}) results_text = json.dumps(op_props, ensure_ascii=False) if op_props else "" graph_item = apply_styles({"nodes": nodes_g, "links": links_g}) return {"success": True, "xxxx.graph": [graph_item], "results": results_text} except Exception as e: return {"success": False, "error": f"Neo4j 查询失败: {str(e)}"} @tool async def operation_graph_rag_search( ship_number: str, device_name: str, operation_item: str, ) -> str: """ 操作图谱检索工具(供工作流调用):仅返回一段话摘要,与 graph_rag_search 一致。 根据舷号、设备名称、操作项目在图谱中定位舰艇→设备→操作项目,并生成文字摘要。 工作流中用于与 RAG 结果一起作为上下文,不返回节点/关系(展示用 operation_graph_search_tool)。 """ result = await operation_graph_search_tool( ship_number=ship_number, device_name=device_name, operation_item=operation_item, ) if not result.get("success", False): return result.get("error", "检索失败") return result.get("results", "") @tool async def graph_rag_search(query: str, entity_type: Optional[str] = None, top_k: int = 120) -> str: """ GraphRAG检索工具(异步版本) 在知识图谱中检索与query相关的实体和关系 Args: query: 检索查询 entity_type: 实体类型过滤(如:设备、故障、维修项目)- 注意:此参数在当前实现中暂未使用 top_k: 返回结果数量(保留参数以兼容接口) Returns: 图谱检索结果文本字符串(旧格式,保持兼容) """ print(f"graph_rag_query+++++++++++{query}") result = await graph_search(query) if not result.get("success", False): return "" results_text = result.get("results", "") if not results_text: return "" if len(results_text) > 1000: results_text = results_text[:1000] + "..." return results_text async def atlas_retrieval( node_names: List[str], top_k: int = 10, timeout: int = 30 ) -> Dict[str, Any]: """ 图册检索工具:从图谱中找到节点并关联图册PDF Args: node_names: 节点名称列表,如 ["主发动机", "压缩机"] top_k: 返回的结果数量 timeout: 请求超时时间(秒) Returns: 包含success和data字段的字典 data格式示例: { "船舶主发动机": { "船舶主发动机的原理图": [ "http://192.168.0.46:59085/files/163-06A0015-B01001_船舶主发动机原理图.pdf" ] } } """ if not node_names: return { "success": False, "error": "node_names不能为空", "data": {} } 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", "data": {}} try: async with AsyncGraphDatabase.driver(uri, auth=(user, password)) as driver: async with driver.session() as session: label_indexes = { "设备": ("设备_fulltext", "设备_vector"), "系统": ("系统_fulltext", "系统_vector"), "子系统": ("子系统_fulltext", "子系统_vector"), } matched_nodes: List[Dict[str, Any]] = [] seen_node_ids = set() for node_name in node_names: query_name = str(node_name or "").strip() if not query_name: continue label_matches = [] for label, (fulltext_index, vector_index) in label_indexes.items(): node, score, meta = await _match_scoped_nodes_with_graph_indexes( session=session, query_text=query_name, fulltext_index=fulltext_index, vector_index=vector_index, candidate_ids=None, fallback_candidates=None, fulltext_top_k=max(top_k, 500), vector_top_k=max(top_k, 500), min_text_score=0.30, min_combined_score=0.38, ) if node: label_matches.append((node, score, meta, label)) if not label_matches: continue label_matches.sort(key=lambda x: x[1], reverse=True) best_node, best_score, best_meta, best_label = label_matches[0] node_id = best_node.get("id") if node_id in seen_node_ids: continue seen_node_ids.add(node_id) matched_nodes.append({ "id": node_id, "name": _candidate_display_name(best_node), "labels": best_node.get("labels") or [best_label], "props": best_node.get("props", {}), "score": best_score, }) if not matched_nodes: return {"success": False, "error": "未找到匹配的节点", "data": {}} source_ids = [n["id"] for n in matched_nodes if n.get("id") is not None] atlas_query = """ MATCH (source) WHERE id(source) IN $source_ids MATCH (source)-[*1..1]-(atlas:图册) RETURN id(source) AS source_id, coalesce(source.名称, source.name) AS source_name, id(atlas) AS atlas_id, coalesce(atlas.名称, atlas.name) AS atlas_name, properties(atlas) AS atlas_props """ records = await session.run(atlas_query, source_ids=source_ids) result_data: Dict[str, Dict[str, List[str]]] = {} async for record in records: source_name = record.get("source_name") atlas_name = record.get("atlas_name") atlas_props = record.get("atlas_props") or {} pdf_url = None for key, value in atlas_props.items(): key_s = str(key) if "图" in key_s or "pdf" in key_s.lower() or "url" in key_s.lower() or "文件" in key_s: if isinstance(value, str) and value.startswith(("http", "/")): pdf_url = value break if not source_name or not atlas_name: continue result_data.setdefault(source_name, {}).setdefault(atlas_name, []) if pdf_url and pdf_url not in result_data[source_name][atlas_name]: result_data[source_name][atlas_name].append(pdf_url) if not result_data: return { "success": False, "error": "未找到关联的图册节点", "data": {}, "matched_nodes": matched_nodes, } return {"success": True, "data": result_data, "error": None} except Exception as e: return { "success": False, "error": str(e), "data": {} } def format_atlas_results(atlas_data: Dict[str, Any]) -> str: """ 格式化图册检索结果为文本,便于在提示词中使用 Args: atlas_data: atlas_retrieval返回的data字段 Returns: 格式化后的文本 """ if not atlas_data: return "" parts = [] for node_name, node_data in atlas_data.items(): if isinstance(node_data, dict): for title, urls in node_data.items(): if isinstance(urls, list) and urls: parts.append(f"\n【{node_name} - {title}】") for url in urls: # 确保URL后面有明确的换行,避免和其他文字连在一起 parts.append(f"- {url}\n") return "\n".join(parts) if parts else "" async def extract_entity_names_from_history( history_messages: List[Dict[str, Any]], device_name: str = "", operation_item: str = "", fault: str = "", last_generated_scheme: str = "" ) -> List[str]: """ 从历史记录中抽取部件/设备/零部件名称 Args: history_messages: 历史对话记录 device_name: 设备名称 operation_item: 操作项目名称 fault: 故障现象 last_generated_scheme: 最后一次生成的方案(用于重点抽取) Returns: 抽取的实体名称列表 """ entity_names = [] # 添加明确提供的名称 if device_name: entity_names.append(device_name) if operation_item: entity_names.append(operation_item) if fault: entity_names.append(fault) # 去重 entity_names = list(dict.fromkeys(entity_names)) # 调用大模型进一步抽取和补充 if entity_names or last_generated_scheme: try: extraction_prompt = f"""从以下信息中抽取尽可能多的设备、部件、零部件、机构、系统名称(用于图册检索)。 注意: - 请尽可能多抽取,宁多勿少,准不准没关系 - 包括所有提及的机械部件、电子设备、机构系统等 - 即使是重复提到的也可以保留,我们会去重 - 重点从【生成的方案】中抽取,这里信息最全面 【示例 - 输入】 船舶主发动机(舷号:101)故障分析与维修方案 1. 故障概况 设备名称:船舶主发动机 舷号:101 故障现象:主机运行中出现异响 2. 故障原因分析 主轴承或连杆大端轴承磨损:轴承间隙过大或磨损严重会导致金属撞击声。 曲轴主轴颈跳动超差:曲轴旋转不平衡或跳动量超出标准。 飞轮连接螺栓松动:飞轮与曲轴连接处的螺栓松动。 活塞头烧蚀或活塞环断裂:活塞环断裂或活塞头部损坏可能导致敲缸声。 排气阀密封面磨损或阀座烧蚀:气门间隙异常或密封失效。 涡轮增压器轴承损坏:增压器内部轴承损坏。 3. 维修方案 执行项目:MT003 检测曲轴组件(含飞轮紧固状态检查)。 执行项目:MTO03 检测曲轴主轴颈跳动量。 【示例 - 输出】 {{ "entity_names": ["船舶主发动机", "主轴承", "连杆大端轴承", "曲轴", "飞轮", "飞轮连接螺栓", "活塞头", "活塞环", "排气阀", "排气阀座", "涡轮增压器", "涡轮增压器轴承", "曲轴组件"] }} 【设备/操作】 - 设备名: {device_name or '无'} - 操作项: {operation_item or '无'} - 故障: {fault or '无'} 【生成的方案】 {last_generated_scheme or '无'} 请输出JSON格式,格式如下: {{ "entity_names": ["名称1", "名称2", "名称3", "..."] }} 仅输出JSON,不要其他内容。""" response = await OpenaiAPI.open_api_chat_without_thinking( query=extraction_prompt, json_output=True, system_prompt="你是一个专业的实体抽取助手,负责从文本中抽取尽可能多的设备、部件、零部件、机构、系统名称。请尽量多抽取,宁多勿少。重点从生成的维修方案中抽取。" ) parsed = _safe_json_extract(response) if isinstance(parsed, dict): extracted = parsed.get("entity_names", []) if extracted: entity_names.extend(extracted) entity_names = list(dict.fromkeys(entity_names)) except Exception as e: print(f"[图册检索] 实体抽取失败: {str(e)}") # 过滤空值和太短的名称 entity_names = [name.strip() for name in entity_names if name and len(name.strip()) >= 2] return entity_names[:30] # 最多30个,尽量多找图册 def _safe_json_extract(text: str) -> Any: """安全地从文本中提取JSON""" try: # 尝试直接解析 return json.loads(text) except json.JSONDecodeError: # 尝试用正则提取 match = re.search(r'\{[\s\S]*\}', text) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass return None