""" 故障类型统计工具模块 用于统计故障诊断智能体记录的故障信息 数据来源:PostgreSQL fault_records 表 """ import os import re import json import httpx from datetime import datetime, timedelta from typing import Dict, Any, Optional, List from difflib import SequenceMatcher import asyncio from langchain_core.tools import tool from utils.function_tracker import track_function_calls from config import REPAIR_FEEDBACK_CONFIG neo4j_cache = {} def fuzzy_match_system(user_input: str, system_name: str, threshold: float = 0.6) -> bool: if not user_input or not system_name: return False user_input = user_input.strip().lower() system_name = system_name.strip().lower() if user_input == system_name: return True if user_input in system_name or system_name in user_input: return True similarity = SequenceMatcher(None, user_input, system_name).ratio() return similarity >= threshold async def get_device_system_from_neo4j(device_name: str) -> str: """ 从 Neo4j 图谱中查询设备对应的系统(调用外部接口),带缓存机制 """ if not device_name or not device_name.strip(): return "其他" device_name_clean = device_name.strip().lower() if device_name_clean in neo4j_cache: return neo4j_cache[device_name_clean] from config import DEVICE_SYSTEM_CONFIG endpoint = DEVICE_SYSTEM_CONFIG.get("endpoint") timeout = DEVICE_SYSTEM_CONFIG.get("timeout", 30.0) min_hops = DEVICE_SYSTEM_CONFIG.get("default_min_hops", 2) max_hops = DEVICE_SYSTEM_CONFIG.get("default_max_hops", 8) top_k = DEVICE_SYSTEM_CONFIG.get("default_top_k", 10) if not endpoint: result = "其他" neo4j_cache[device_name_clean] = result return result try: payload = { "device_name": device_name.strip(), "min_hops": min_hops, "max_hops": max_hops, "top_k": top_k } async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( endpoint, json=payload, headers={"Content-Type": "application/json"} ) if response.status_code != 200: result = "其他" neo4j_cache[device_name_clean] = result return result result_json = response.json() if not result_json.get("success", False): result = "其他" neo4j_cache[device_name_clean] = result return result systems = result_json.get("systems", []) if not systems or not isinstance(systems, list): result = "其他" neo4j_cache[device_name_clean] = result return result first_system = systems[0] if isinstance(first_system, dict): system_name = first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") if system_name: result = system_name.strip() neo4j_cache[device_name_clean] = result return result result = "其他" neo4j_cache[device_name_clean] = result return result except Exception as e: print(f"查询设备系统失败: {str(e)}") result = "其他" neo4j_cache[device_name_clean] = result return result def generate_color_palette(n: int) -> List[str]: predefined_colors = [ "#00C2FF", "#4E7CFE", "#9153FF", "#FFB800", "#00D16B", "#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00E676", "#FF4081", "#7C4DFF", "#18FFFF", "#69F0AE", "#FFD740" ] if n <= len(predefined_colors): return predefined_colors[:n] colors = predefined_colors.copy() import colorsys for i in range(len(predefined_colors), n): hue = (i * 0.618033988749895) % 1.0 saturation = 0.7 + (i % 3) * 0.1 value = 0.8 + (i % 2) * 0.1 rgb = colorsys.hsv_to_rgb(hue, saturation, value) color = "#{:02X}{:02X}{:02X}".format( int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255) ) colors.append(color) return colors async def _query_fault_records_from_db( ship_number: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, system_name: Optional[str] = None, limit: int = 5000 ) -> List[Dict[str, Any]]: """ 从数据库查询故障记录(内部共用方法) """ from tools.fault_record_db import query_fault_records return await query_fault_records( ship_number=ship_number, system_name=system_name, start_date=start_date, end_date=end_date, limit=limit ) async def statistics_fault_types( ship_number_filter: Optional[str] = None ) -> Dict[str, Any]: """ 统计故障类型(从数据库读取),按系统分类计算百分比 Args: ship_number_filter: 舷号过滤(可选) Returns: 包含统计结果的字典 """ try: records = await _query_fault_records_from_db(ship_number=ship_number_filter) if not records: return { "success": True, "faultTypeData": [] } system_totals = {} valid_count = 0 for record in records: system_name = record.get("system_name", "其他") if not system_name: system_name = "其他" valid_count += 1 if system_name not in system_totals: system_totals[system_name] = 0 system_totals[system_name] += 1 fault_type_data = [] if valid_count > 0: unique_systems = sorted(system_totals.keys()) colors = generate_color_palette(len(unique_systems)) system_color_map = {system: color for system, color in zip(unique_systems, colors)} for system_name, count in system_totals.items(): percentage = round((count / valid_count) * 100) color = system_color_map.get(system_name, "#6E7D91") fault_type_data.append({ "value": percentage, "name": system_name, "itemStyle": { "color": color } }) fault_type_data.sort(key=lambda x: x["value"], reverse=True) return { "success": True, "faultTypeData": fault_type_data } except Exception as e: print(f"[故障类型统计错误] {str(e)}") import traceback traceback.print_exc() return { "success": False, "faultTypeData": [] } @tool async def fault_type_statistics_tool( ship_number_filter: Optional[str] = None ) -> Dict[str, Any]: """ 故障类型统计工具(供工作流调用) 统计故障类型,按系统分类并计算百分比 Args: ship_number_filter: 舷号过滤(可选) Returns: 统计结果,包含 success 和 faultTypeData 字段 """ result = await statistics_fault_types(ship_number_filter=ship_number_filter) return result async def merge_similar_faults_with_embedding( fault_counter: Dict[str, int], fault_system_map: Dict[str, str], similarity_threshold: float = 0.85 ) -> tuple: """ 使用embedding合并相似的故障 """ if not fault_counter: return {}, {}, {} fault_keys = list(fault_counter.keys()) if len(fault_keys) <= 1: return fault_counter.copy(), fault_system_map.copy(), {} try: from modelsAPI.model_api import OpenaiAPI embeddings = await OpenaiAPI.get_embeddings_batch_async(fault_keys) fault_embedding_map = {key: emb for key, emb in zip(fault_keys, embeddings)} merged_counter = {} merged_system_map = {} merge_mapping = {} merged_keys = set() sorted_faults = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True) for fault_key, count in sorted_faults: if fault_key in merged_keys: continue merged_keys.add(fault_key) merged_counter[fault_key] = count merged_system_map[fault_key] = fault_system_map.get(fault_key, "其他") for other_key, other_count in sorted_faults: if other_key in merged_keys or other_key == fault_key: continue if fault_embedding_map.get(fault_key) and fault_embedding_map.get(other_key): similarity = OpenaiAPI.cosine_similarity( fault_embedding_map[fault_key], fault_embedding_map[other_key] ) if similarity >= similarity_threshold: merged_counter[fault_key] += other_count merged_keys.add(other_key) merge_mapping[other_key] = fault_key return merged_counter, merged_system_map, merge_mapping except Exception as e: print(f"Embedding合并失败,使用原始数据: {str(e)}") return fault_counter.copy(), fault_system_map.copy(), {} async def statistics_fault_frequency( top_n: int = 10, start_date: Optional[str] = None, end_date: Optional[str] = None, system_filter: Optional[str] = None, use_embedding_merge: bool = True, similarity_threshold: float = 0.85 ) -> Dict[str, Any]: """ 统计故障发生次数(从数据库读取),按频率排序 统计所有故障的数据,但只返回top N展示在表格中 Args: top_n: 返回前 N 个高频故障(默认为 10) start_date: 开始日期(格式:YYYY-MM-DD,可选) end_date: 结束日期(格式:YYYY-MM-DD,可选) system_filter: 系统名称过滤(可选) use_embedding_merge: 是否使用embedding合并相似故障(默认True) similarity_threshold: 相似度阈值(默认0.85) Returns: 包含统计结果的字典 """ try: records = await _query_fault_records_from_db( start_date=start_date, end_date=end_date ) if not records: return { "success": True, "totalCount": 0, "totalTypes": 0, "highFreqFaults": [] } fault_counter = {} fault_system_map = {} for record in records: device_name = record.get("device_name", "") fault = record.get("fault", "") system_name = record.get("system_name", "其他") if not device_name or not fault: continue if system_filter and not fuzzy_match_system(system_filter, system_name): continue fault_key = f"{device_name}{fault}" if fault_key not in fault_counter: fault_counter[fault_key] = 0 fault_system_map[fault_key] = system_name fault_counter[fault_key] += 1 if use_embedding_merge and len(fault_counter) > 1: fault_counter, fault_system_map, merge_mapping = await merge_similar_faults_with_embedding( fault_counter, fault_system_map, similarity_threshold ) if merge_mapping: print(f"合并了 {len(merge_mapping)} 个相似故障") # 先计算全部数据的统计量 total_count = sum(fault_counter.values()) total_types = len(fault_counter) # 对全部数据排序 sorted_faults_all = sorted(fault_counter.items(), key=lambda x: x[1], reverse=True) # 只取top N用于展示表格 sorted_faults = sorted_faults_all[:top_n] high_freq_faults = [] for rank, (fault_key, count) in enumerate(sorted_faults, start=1): system_name = fault_system_map.get(fault_key, "其他") high_freq_faults.append({ "rank": rank, "name": fault_key, "system": system_name, "count": count }) return { "success": True, "totalCount": total_count, "totalTypes": total_types, "highFreqFaults": high_freq_faults } except Exception as e: print(f"[故障频率统计错误] {str(e)}") import traceback traceback.print_exc() return { "success": False, "highFreqFaults": [] } @tool async def fault_frequency_statistics_tool( top_n: Optional[int] = 6, start_date: Optional[str] = None, end_date: Optional[str] = None, system_filter: Optional[str] = None ) -> Dict[str, Any]: """ 故障发生次数统计工具(供工作流调用) 统计故障发生次数,按频率排序 Args: top_n: 返回前 N 个高频故障(默认为 6) start_date: 开始日期(格式:YYYY-MM-DD,可选) end_date: 结束日期(格式:YYYY-MM-DD,可选) system_filter: 系统名称过滤(可选) Returns: 统计结果,包含 success 和 highFreqFaults 字段 """ result = await statistics_fault_frequency( top_n=top_n or 10, start_date=start_date, end_date=end_date, system_filter=system_filter ) return result async def statistics_spare_parts( ship_number_filter: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, top_n: int = 6 ) -> Dict[str, Any]: """ 统计备件消耗(从数据库读取) 统计所有备件的数据,但只返回top N展示在表格中 Args: ship_number_filter: 舷号过滤(可选) start_date: 开始日期(格式:YYYY-MM-DD,可选) end_date: 结束日期(格式:YYYY-MM-DD,可选) top_n: 表格中展示的前 N 个备件(默认为 5) Returns: 包含统计结果的字典 """ try: records = await _query_fault_records_from_db( ship_number=ship_number_filter, start_date=start_date, end_date=end_date ) if not records: return { "success": True, "totalCount": 0, "totalTypes": 0, "sparePartData": { "categories": [], "system": [], "counter": [] } } spare_part_counter = {} spare_part_system_map = {} for record in records: spare_parts = record.get("spare_parts", []) system_name = record.get("system_name", "其他") if not spare_parts: continue for part in spare_parts: if not part or not part.strip(): continue part_key = part.strip() if part_key not in spare_part_counter: spare_part_counter[part_key] = 0 spare_part_system_map[part_key] = system_name spare_part_counter[part_key] += 1 # 先计算全部数据的统计量 total_count = sum(spare_part_counter.values()) total_types = len(spare_part_counter) # 对全部数据排序 sorted_parts_all = sorted(spare_part_counter.items(), key=lambda x: x[1], reverse=True) # 只取top N用于展示表格 sorted_parts = sorted_parts_all[:top_n] categories = [] systems = [] counters = [] for part_key, count in sorted_parts: categories.append(part_key) systems.append(spare_part_system_map.get(part_key, "其他")) counters.append(count) return { "success": True, "totalCount": total_count, "totalTypes": total_types, "sparePartData": { "categories": categories, "system": systems, "counter": counters } } except Exception as e: print(f"[备件统计错误] {str(e)}") import traceback traceback.print_exc() return { "success": False, "sparePartData": { "categories": [], "system": [], "counter": [] } } @tool async def spare_parts_statistics_tool( ship_number: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, top_n: Optional[int] = 5 ) -> Dict[str, Any]: """ 备件消耗统计工具(供工作流调用) 统计备件消耗情况 Args: ship_number: 舷号过滤(可选) start_date: 开始日期(格式:YYYY-MM-DD,可选) end_date: 结束日期(格式:YYYY-MM-DD,可选) top_n: 返回前 N 个备件(默认为 5) Returns: 统计结果,包含 success 和 sparePartData 字段 """ result = await statistics_spare_parts( ship_number_filter=ship_number, start_date=start_date, end_date=end_date, top_n=top_n or 5 ) return result async def extract_device_from_text(text: str) -> Optional[str]: """ 从文本中提取设备名称(使用大模型) """ if not text or not text.strip(): return None prompt = f"""从以下文本中提取设备名称: 文本内容: {text} 请识别文本中提到的具体设备名称,如:发动机、压缩机、水泵、发电机、空调机组、液压泵等。 请以JSON格式返回: {{"device": "设备名称"}} 如果文本中没有提到任何设备,请返回 {{"device": null}} 只返回JSON,不要有其他内容。""" try: from modelsAPI.model_api import OpenaiAPI result_text = await OpenaiAPI.open_api_chat_without_thinking( query=prompt, json_output=True ) result_text = result_text.strip() result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE) result_text = re.sub(r'^```\s*', '', result_text) result_text = re.sub(r'```$', '', result_text) json_match = re.search(r'\{.*\}', result_text, re.DOTALL) if json_match: result = json.loads(json_match.group(0)) device = result.get("device") if device and device != "null": return str(device).strip() return None except Exception as e: print(f"大模型提取设备名称失败: {str(e)}") return None async def classify_feedback_content(text: str) -> str: """ 对反馈内容进行分类(使用大模型) """ if not text or not text.strip(): return "其他" prompt = f"""请对以下反馈内容进行分类。 反馈内容: {text} 分类类别(单选,选择最匹配的一个): 1. 信息过时 - 反映文档或手册中的信息已经过时,不再适用 2. 步骤错误 - 反映操作步骤或流程存在错误 3. 步骤缺失 - 反映缺少必要的操作步骤或流程说明 4. 备件信息有误 - 反映备件型号、规格、数量等信息有误 5. 安全提示不足 - 反映缺少必要的安全警示或注意事项 6. 图示不清 - 反映图片、示意图不清晰或难以理解 请分析反馈内容,判断属于哪个类别,以JSON格式返回: {{"category": "类别名称"}} 如果反馈内容不属于以上任何类别,请返回 {{"category": "其他"}} 只返回JSON,不要有其他内容。""" try: from modelsAPI.model_api import OpenaiAPI result_text = await OpenaiAPI.open_api_chat_without_thinking( query=prompt, json_output=True ) result_text = result_text.strip() result_text = re.sub(r'^```json\s*', '', result_text, flags=re.IGNORECASE) result_text = re.sub(r'^```\s*', '', result_text) result_text = re.sub(r'```$', '', result_text) json_match = re.search(r'\{.*\}', result_text, re.DOTALL) if json_match: result = json.loads(json_match.group(0)) category = result.get("category") valid_categories = ["信息过时", "步骤错误", "步骤缺失", "备件信息有误", "安全提示不足", "图示不清", "其他"] if category in valid_categories: return category return "其他" except Exception as e: print(f"大模型分类反馈内容失败: {str(e)}") return "其他" async def analyze_feedback_and_classify(feedback_text: str) -> Dict[str, Any]: """ 分析反馈内容并分类(主接口) """ if not feedback_text or not feedback_text.strip(): return { "success": False, "device": None, "system": None, "category": "其他", "error": "反馈内容为空" } try: device = await extract_device_from_text(feedback_text) system = "其他" if device: system = await get_device_system_from_neo4j(device) category = await classify_feedback_content(feedback_text) return { "success": True, "system": system, "category": category } except Exception as e: return { "success": False, "system": None, "category": "其他", "error": str(e) } _feedback_token_cache = {"token": None, "expires_at": 0} async def _get_feedback_api_token() -> Optional[str]: """ 获取维修反馈API的认证token(带缓存,避免每次请求都认证) """ import time if _feedback_token_cache["token"] and time.time() < _feedback_token_cache["expires_at"]: return _feedback_token_cache["token"] base_url = REPAIR_FEEDBACK_CONFIG["url"] email = REPAIR_FEEDBACK_CONFIG.get("email", "") password = REPAIR_FEEDBACK_CONFIG.get("password", "") if not email or not password: print("[维修反馈认证错误] 未配置认证凭据") return None try: async with httpx.AsyncClient(timeout=30.0) as client: signin_resp = await client.post( f"{base_url}/api/v1/auths/signin", json={"email": email, "password": password} ) if signin_resp.status_code != 200: print(f"[维修反馈认证错误] 登录失败,状态码: {signin_resp.status_code}") return None token = signin_resp.json().get("token", "") if not token: print("[维修反馈认证错误] 响应中未找到token") return None _feedback_token_cache["token"] = token _feedback_token_cache["expires_at"] = time.time() + 3600 return token except Exception as e: print(f"[维修反馈认证错误] {str(e)}") return None async def statistics_repair_feedbacks( source: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, top_n: int = 10 ) -> Dict[str, Any]: """ 统计维修反馈数据(从外部API获取,带认证) Args: source: 来源筛选(好评/差评/反馈),可选 start_date: 开始日期(格式:YYYY-MM-DD),可选 end_date: 结束日期(格式:YYYY-MM-DD),可选 top_n: 返回前 N 条记录(默认为 10) Returns: 包含统计结果的字典 """ try: token = await _get_feedback_api_token() if not token: return { "success": False, "totalCount": 0, "feedbackData": [], "error": "API认证失败,无法获取token" } base_url = REPAIR_FEEDBACK_CONFIG["url"] endpoint = f"{base_url}/api/v1/repair-feedbacks/" params = {} if source and source.strip(): params["source"] = source.strip() async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get( endpoint, params=params, headers={ "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {token}" } ) if response.status_code == 401 or response.status_code == 403: _feedback_token_cache["token"] = None _feedback_token_cache["expires_at"] = 0 token = await _get_feedback_api_token() if token: response = await client.get( endpoint, params=params, headers={ "Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {token}" } ) if response.status_code != 200: print(f"[维修反馈统计错误] API返回状态码: {response.status_code}") return { "success": False, "totalCount": 0, "feedbackData": [], "error": f"API请求失败,状态码: {response.status_code}" } result_json = response.json() if isinstance(result_json, list): feedback_list = result_json elif isinstance(result_json, dict): feedback_list = result_json.get("results", result_json.get("data", [])) else: feedback_list = [] if not feedback_list: return { "success": True, "totalCount": 0, "totalTypes": 0, "feedbackData": [], "sourceDistribution": {} } start_dt = None end_dt = None if start_date: try: start_dt = datetime.strptime(start_date, "%Y-%m-%d") except ValueError: pass if end_date: try: end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: pass if start_dt or end_dt: filtered_list = [] for item in feedback_list: if not isinstance(item, dict): continue raw_created_at = item.get("created_at", "") if not raw_created_at: continue try: item_dt = datetime.fromtimestamp(int(raw_created_at)) if start_dt and item_dt < start_dt: continue if end_dt and item_dt > end_dt: continue filtered_list.append(item) except (ValueError, TypeError, OSError): continue feedback_list = filtered_list total_count = len(feedback_list) processed_data = [] for idx, item in enumerate(feedback_list[:top_n], start=1): if isinstance(item, dict): raw_title = item.get("title", "") raw_content = item.get("content", "") raw_source = item.get("source", source or "") raw_created_at = item.get("created_at", "") raw_user_name = item.get("user_name", "") created_at_str = "" if raw_created_at: try: ts = int(raw_created_at) created_at_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") except (ValueError, TypeError, OSError): created_at_str = str(raw_created_at) processed_item = { "rank": idx, "title": str(raw_title) if raw_title else "", "content": str(raw_content)[:200] if raw_content else "", "source": str(raw_source) if raw_source else "未知", "created_at": created_at_str, "user_name": str(raw_user_name) if raw_user_name else "" } processed_data.append(processed_item) source_counter = {} for item in processed_data: s = item.get("source", "未知") source_counter[s] = source_counter.get(s, 0) + 1 return { "success": True, "totalCount": total_count, "totalTypes": len(feedback_list), "sourceDistribution": source_counter, "feedbackData": processed_data } except Exception as e: print(f"[维修反馈统计错误] {str(e)}") import traceback traceback.print_exc() return { "success": False, "totalCount": 0, "feedbackData": [], "error": str(e) } @tool async def repair_feedback_statistics_tool( source: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, top_n: Optional[int] = 10 ) -> Dict[str, Any]: """ 维修反馈统计工具(供工作流调用) 统计维修反馈情况,支持按来源和时间筛选 Args: source: 来源筛选(可选),如:好评、差评、反馈 start_date: 开始日期(格式:YYYY-MM-DD),可选 end_date: 结束日期(格式:YYYY-MM-DD),可选 top_n: 返回前 N 条记录(默认为 10) Returns: 统计结果,包含 success 和 feedbackData 字段 """ result = await statistics_repair_feedbacks( source=source, start_date=start_date, end_date=end_date, top_n=top_n or 10 ) return result