""" 结果格式化模块 功能:从 Neo4j 查询结果中提取节点和路径信息 """ import logging from typing import Dict, List, Any, Optional logger = logging.getLogger(__name__) class SimpleNode: """ 简单的Node模拟类,用于将节点数据包装为Neo4j Node对象格式(降级策略) """ def __init__(self, node_data: dict, labels: List[str], node_id: str): """ Args: node_data: 节点数据字典 labels: 节点标签列表 node_id: 节点ID """ self._data = node_data self._labels = labels self._node_id = node_id @property def element_id(self): """返回节点ID""" return self._node_id @property def id(self): """返回节点ID(兼容旧版本)""" return self._node_id @property def labels(self): """返回节点标签""" return self._labels def get(self, key, default=None): """获取节点属性""" return self._data.get(key, default) def __iter__(self): """使节点可以转换为字典""" return iter(self._data.items()) def __getitem__(self, key): """使节点可以像字典一样访问""" return self._data[key] class SimplePath: """ 简单的Path模拟类,用于将节点包装为path结构(降级策略) 包含nodes和relationships属性,其中relationships为空列表 """ def __init__(self, nodes: List): """ Args: nodes: 节点列表(SimpleNode对象列表) """ self.nodes = nodes self.relationships = [] # 降级策略中,只有节点,没有关系 # def extract_knowledge_source(knowledge_source_value: Any) -> List[Dict[str, str]]: # """ # 从节点属性中的 knowledge_source 字段提取信息并格式化为标准格式 # # Args: # knowledge_source_value: 节点属性中的 knowledge_source 值(可能是字符串、字典、列表等) # # Returns: # List[Dict]: 格式化后的 knowledge_source 列表,格式为 [{"filename":"","url":"","info":""}] # """ # if knowledge_source_value is None: # return [{"filename": "", "url": "", "info": ""}] # # result = [] # # # 如果是字符串,尝试解析为JSON或直接使用 # if isinstance(knowledge_source_value, str): # # 尝试解析JSON字符串 # try: # import json # parsed = json.loads(knowledge_source_value) # if isinstance(parsed, list): # knowledge_source_value = parsed # elif isinstance(parsed, dict): # knowledge_source_value = [parsed] # else: # # 如果不是JSON,将字符串作为info # return [{"filename": "", "url": "", "info": knowledge_source_value}] # except: # # 解析失败,将字符串作为info # return [{"filename": "", "url": "", "info": knowledge_source_value}] # # # 如果是字典,转换为列表 # if isinstance(knowledge_source_value, dict): # knowledge_source_value = [knowledge_source_value] # # # 如果是列表,处理每个元素 # if isinstance(knowledge_source_value, list): # for item in knowledge_source_value: # if isinstance(item, dict): # # 从字典中提取字段 # formatted_item = { # "filename": str(item.get("filename", item.get("file_name", item.get("file", "")))), # "url": str(item.get("url", item.get("link", item.get("uri", "")))), # "info": str(item.get("info", item.get("information", item.get("description", "")))) # } # result.append(formatted_item) # elif isinstance(item, str): # # 如果是字符串,作为info # result.append({"filename": "", "url": "", "info": item}) # else: # # 其他类型,转换为字符串作为info # result.append({"filename": "", "url": "", "info": str(item)}) # else: # # 其他类型,转换为字符串作为info # result.append({"filename": "", "url": "", "info": str(knowledge_source_value)}) # # # 如果没有结果,返回默认值 # if not result: # result = [{"filename": "", "url": "", "info": ""}] # # return result def extract_knowledge_source(knowledge_source_value: Any) -> List[Dict[str, str]]: """ 从节点属性中的 knowledge_source 字段提取信息并格式化为标准格式(不含 info 字段) Args: knowledge_source_value: 节点属性中的 knowledge_source 值(可能是字符串、字典、列表等) Returns: List[Dict]: 格式化后的 knowledge_source 列表,格式为 [{"filename":"","url":""}] """ if knowledge_source_value is None: return [{"filename": "", "url": ""}] result = [] # 如果是字符串,尝试解析为JSON或直接使用 if isinstance(knowledge_source_value, str): # 尝试解析JSON字符串 try: import json parsed = json.loads(knowledge_source_value) if isinstance(parsed, list): knowledge_source_value = parsed elif isinstance(parsed, dict): knowledge_source_value = [parsed] else: # 如果不是JSON,跳过该字符串 return [{"filename": "", "url": ""}] except: # 解析失败,跳过该字符串 return [{"filename": "", "url": ""}] # 如果是字典,转换为列表 if isinstance(knowledge_source_value, dict): knowledge_source_value = [knowledge_source_value] # 如果是列表,处理每个元素 if isinstance(knowledge_source_value, list): for item in knowledge_source_value: if isinstance(item, dict): # 从字典中提取字段(不含 info) formatted_item = { "filename": str(item.get("filename", item.get("file_name", item.get("file", "")))), "url": str(item.get("url", item.get("link", item.get("uri", "")))) } result.append(formatted_item) elif isinstance(item, str): # 如果是字符串,跳过 continue else: # 其他类型,跳过 continue else: # 其他类型,跳过 return [{"filename": "", "url": ""}] # 如果没有结果,返回默认值 if not result: result = [{"filename": "", "url": ""}] return result def remove_sensitive_fields(data: Any) -> Any: """ 递归删除数据中的 embedding、fulltext 和 path 字段 参考 test.py 的实现方式:在转换为 dict 后立即 pop 掉敏感字段 Args: data: 要处理的数据(可以是字典、列表、Neo4j对象或基本类型) Returns: 清理后的数据 """ # 如果是 Neo4j Node 对象,转换为字典并删除敏感字段(参考 test.py) if hasattr(data, 'labels') and (hasattr(data, 'element_id') or hasattr(data, 'id')): try: props = dict(data) props.pop('embedding', None) props.pop('fulltext', None) # 递归处理属性中的嵌套结构 return remove_sensitive_fields(props) except: return data # 如果是 Neo4j Relationship 对象,转换为字典并删除敏感字段 if hasattr(data, 'type') and (hasattr(data, 'element_id') or hasattr(data, 'id')): try: props = dict(data) props.pop('embedding', None) props.pop('fulltext', None) # 递归处理属性中的嵌套结构 return remove_sensitive_fields(props) except: return data if isinstance(data, dict): # 创建新字典,排除 embedding、fulltext 和 path result = {} for key, value in data.items(): if key not in ['embedding', 'fulltext', 'path']: # 递归处理嵌套的字典、列表和 Neo4j 对象 result[key] = remove_sensitive_fields(value) return result elif isinstance(data, list): # 递归处理列表中的每个元素 return [remove_sensitive_fields(item) for item in data] else: # 基本类型直接返回 return data def format_results(results: List[Dict[str, Any]]) -> Dict[str, Any]: """ 格式化图谱查询结果,提取所有节点和路径(自动去重) 从 rerank 后的结果字典列表中提取 path 信息,生成 nodes 和 links Args: results: rerank 后的结果列表(字典列表,每个字典包含 rerank_score 和 path 等信息) Returns: Dict: 包含 nodes 和 links 的字典(已去重,只保留最高分) """ if not results: return {"nodes": [], "links": []} all_nodes = {} # key: node_id(str), value: node dict all_links = {} # key: rel_id(str), value: link dict node_scores = {} # key: node_id(str), value: highest rerank score for the node def process_path(path, current_score): """处理单个 Path 对象""" if path is None: return def process_node(node): """处理单个节点的辅助函数""" if node is None: return node_id = str(node.element_id) if hasattr(node, 'element_id') else str(node.id) if node_id not in all_nodes: props = dict(node) props.pop('embedding', None) props.pop('fulltext', None) props.pop('切片', None) # 提取 name 用于外部字段(从props中提取后移除,避免出现在properties中) node_name = props.pop('name', None) or node.get("名称", f"Node_{node_id}") # 直接过滤掉 last_updated 和 created_at props.pop('last_updated', None) props.pop('created_at', None) # 提取并格式化 knowledge_source(从props中提取后移除原始值) knowledge_source_value = props.pop('knowledge_source', None) formatted_knowledge_source = extract_knowledge_source(knowledge_source_value) all_nodes[node_id] = { "id": node_id, "name": node_name, "labels": list(node.labels), "properties": props, "knowledge_source": formatted_knowledge_source } # 初始化节点的最高分数 if current_score > 0: node_scores[node_id] = current_score else: # 如果节点已存在,比较并保留最高分数 existing_score = node_scores.get(node_id, 0) if current_score > existing_score: node_scores[node_id] = current_score try: # 处理节点:优先使用 path.nodes,如果为空则使用 start/end 或 start_node/end_node nodes_processed = False # 首先尝试使用 path.nodes if hasattr(path, 'nodes'): try: # 直接迭代 path.nodes(可能是生成器) for node in path.nodes: process_node(node) nodes_processed = True except Exception as e: logger.debug(f"迭代 path.nodes 时出错: {e}") # 如果 path.nodes 没有处理任何节点(size=0的情况),尝试使用 start/end 节点 if not nodes_processed: start_node = None end_node = None # 尝试多种方式获取起始节点(优先使用 start_node,其次使用 start) if hasattr(path, 'start_node'): try: start_node = path.start_node except Exception: pass elif hasattr(path, 'start'): try: start_node = path.start except Exception: pass # 尝试多种方式获取结束节点(优先使用 end_node,其次使用 end) if hasattr(path, 'end_node'): try: end_node = path.end_node except Exception: pass elif hasattr(path, 'end'): try: end_node = path.end except Exception: pass # 处理起始节点 if start_node is not None: process_node(start_node) # 处理结束节点(只有当与起始节点不同时才处理,避免重复) if end_node is not None and (start_node is None or end_node != start_node): process_node(end_node) # 处理关系 for rel in path.relationships: rel_id = str(rel.element_id) if hasattr(rel, 'element_id') else str(rel.id) if rel_id not in all_links: rel_props = dict(rel) rel_props.pop('embedding', None) rel_props.pop('fulltext', None) start_node_id = str(rel.start_node.element_id) if hasattr(rel.start_node, 'element_id') else str(rel.start_node.id) end_node_id = str(rel.end_node.element_id) if hasattr(rel.end_node, 'element_id') else str(rel.end_node.id) all_links[rel_id] = { "id": rel_id, "label": rel.type, "source": start_node_id, "target": end_node_id, "properties": rel_props } except Exception as e: logger.warning(f"处理 Path 对象时出错: {e}", exc_info=True) for result in results: try: # 获取当前记录的rerank_score current_rerank_score = result.get('rerank_score', 0) # 查找 path 字段(可能是 path 或 paths) path_value = None if 'path' in result: path_value = result['path'] elif 'paths' in result: path_value = result['paths'] else: # 尝试从所有值中查找 Path 对象 for key, value in result.items(): if value is not None and hasattr(value, 'nodes') and hasattr(value, 'relationships'): path_value = value break # 处理 path_value if path_value is not None: # 处理单个 Path 对象 if hasattr(path_value, 'nodes') and hasattr(path_value, 'relationships'): process_path(path_value, current_rerank_score) # 处理路径列表 elif isinstance(path_value, list): if path_value: # 列表不为空 for path in path_value: if path is not None and hasattr(path, 'nodes') and hasattr(path, 'relationships'): process_path(path, current_rerank_score) except Exception as e: logger.warning(f"格式化结果记录失败: {e}", exc_info=True) # 为每个节点添加最高rerank分数到其属性中(只保留高分) for node_id, score in node_scores.items(): if node_id in all_nodes: node = all_nodes[node_id] if 'properties' not in node: node['properties'] = {} # 只保留最高分,使用 分数 作为 key node['properties']['分数'] = score # 最终清理:确保所有嵌套数据中的敏感字段都被删除 cleaned_nodes = [remove_sensitive_fields(node) for node in all_nodes.values()] cleaned_links = [remove_sensitive_fields(link) for link in all_links.values()] return { "nodes": cleaned_nodes, "links": cleaned_links } def format_entry_nodes_as_results(entry_nodes: dict) -> List[Dict[str, Any]]: """ 将入口节点信息格式化为检索结果(降级策略) 将节点作为path,节点作为结果 Args: entry_nodes: 入口节点字典,格式如 {"设备": [{"name": "xxx", "score": 0.9, "node": {...}}]} Returns: List[Dict]: 结果列表,每个元素是一个结果记录,包含path字段(用于 graph_retrieval.py 的降级策略) """ results = [] if not entry_nodes: return results for label, nodes in entry_nodes.items(): if not isinstance(nodes, list) or not nodes: continue # 标签列表(优先使用entry_nodes中的label,如果节点数据中有labels则使用节点数据的labels) label_list = [label] if label else [] for node_info in nodes: node_name = node_info.get("name") or node_info.get("_name") or (node_info.get("node", {}).get("名称")) or str(node_info.get("node", "")) node_data = node_info.get("node", {}) node_score = node_info.get("score", 0.8) # 如果node_data是Neo4j Node对象,转换为字典 if hasattr(node_data, 'labels') and (hasattr(node_data, 'element_id') or hasattr(node_data, 'id')): # 从Neo4j Node对象提取labels node_labels = list(node_data.labels) if hasattr(node_data, 'labels') else label_list if node_labels: label_list = node_labels # 转换为字典 node_data = dict(node_data) # 跳过无效节点 if not isinstance(node_data, dict) and not node_name: continue # 提取节点ID(兜底情况下使用简单的ID格式) if isinstance(node_data, dict): # 优先使用element_id或id(Neo4j原生ID) if node_data.get("element_id"): node_id = str(node_data.get("element_id")) elif node_data.get("id"): node_id = str(node_data.get("id")) else: # 兜底情况:使用节点的名称属性作为简单ID # 优先使用中文属性名"名称",其次使用"name" node_id = node_data.get("名称") or node_data.get("name") or node_name if node_id: node_id = str(node_id) else: # 如果都没有,生成一个简单的ID node_id = f"{label}_{len(results)}" else: node_id = str(node_name) if node_name else f"{label}_{len(results)}" # 提取节点属性(排除内部字段和指定字段) node_properties = {} if isinstance(node_data, dict): for key, value in node_data.items(): if key not in ["element_id", "id", "labels", "embedding", "fulltext", "name", "切片"]: node_properties[key] = value # 如果节点数据中有labels,使用它 if "labels" in node_data and node_data["labels"]: if isinstance(node_data["labels"], list): label_list = node_data["labels"] elif hasattr(node_data["labels"], '__iter__'): label_list = list(node_data["labels"]) # 如果没有属性,跳过 if not node_properties: continue # 直接过滤掉 last_updated 和 created_at node_properties.pop('last_updated', None) node_properties.pop('created_at', None) # 使用remove_sensitive_fields递归删除embedding和fulltext字段(确保嵌套结构也被清理) node_properties = remove_sensitive_fields(node_properties) # 提取并格式化 knowledge_source knowledge_source_value = node_properties.get('knowledge_source') formatted_knowledge_source = extract_knowledge_source(knowledge_source_value) # 创建SimpleNode对象(knowledge_source 会在 format_results 的 process_node 中再次处理) simple_node = SimpleNode( node_data=node_properties, labels=label_list, node_id=node_id ) # 创建SimplePath对象(只包含一个节点) simple_path = SimplePath(nodes=[simple_node]) # 构建结果字典 # 包含path字段(节点作为path),同时将节点属性作为result字段 result = { "path": simple_path, "result": node_properties # 节点作为结果(已删除embedding和fulltext) } results.append(result) return results def _format_value(value: Any) -> str: """ 将任意值转为简洁字符串,自适应处理: - 数字或字符串:直接使用 - 字典:检查是否有 result 字段,分解 result 字段,去除 knowledge_source - 列表:递归处理每个元素 """ # 如果直接是数字或字符串,直接返回 if isinstance(value, (str, int, float)): return str(value) # 如果是字典,检查是否有 result 字段 elif isinstance(value, dict): # 如果字典中有 result 字段,优先处理 result 字段的内容 if 'result' in value: result_value = value['result'] # 处理 result 字段(去除 knowledge_source 和指定字段) if isinstance(result_value, dict): # 复制字典,去除 knowledge_source、last_updated、created_at 和指定字段 skip_keys = {'knowledge_source', 'name', '切片', 'last_updated', 'created_at'} cleaned_result = {k: v for k, v in result_value.items() if k not in skip_keys} return _format_value(cleaned_result) elif isinstance(result_value, list): # 如果是列表,递归处理每个元素(去除 knowledge_source) if len(result_value) == 0: return "[]" # 如果是简单列表,直接连接 if all(isinstance(x, (str, int, float)) for x in result_value[:3]): return ", ".join(str(x) for x in result_value[:10]) + ("..." if len(result_value) > 10 else "") # 复杂列表,递归处理每个元素 cleaned_items = [] for item in result_value[:10]: if isinstance(item, dict): # 去除 knowledge_source、last_updated、created_at 和指定字段 skip_keys = {'knowledge_source', 'name', '切片', 'last_updated', 'created_at'} cleaned_item = {k: v for k, v in item.items() if k not in skip_keys} cleaned_items.append(_format_value(cleaned_item)) else: cleaned_items.append(_format_value(item)) result_str = "; ".join(cleaned_items) if len(result_value) > 10: result_str += f" ...(共 {len(result_value)} 条)" return result_str else: # result 字段是其他类型,直接格式化 return _format_value(result_value) else: # 没有 result 字段,正常处理字典,去除 knowledge_source、last_updated、created_at 和指定字段 skip_keys = {'knowledge_source', 'name', '切片', 'last_updated', 'created_at'} cleaned_dict = {k: v for k, v in value.items() if k not in skip_keys} # 展平字典为 key: value 形式,避免嵌套 JSON parts = [] for k, v in cleaned_dict.items(): if not isinstance(v, (dict, list)) or len(str(v)) < 100: # 避免大对象 parts.append(f"{k}: {_format_value(v)}") return "; ".join(parts) if parts else "{...}" # 如果是列表 elif isinstance(value, list): if len(value) == 0: return "[]" # 如果是简单列表(如 [1,2,3] 或 ["a","b"]) if all(isinstance(x, (str, int, float)) for x in value[:3]): return ", ".join(str(x) for x in value[:10]) + ("..." if len(value) > 10 else "") else: # 复杂对象列表,检查是否有 {'result': {...}} 格式的元素 # 检查前几个元素,判断是否都是 {'result': {...}} 格式 has_result_format = False if value and isinstance(value[0], dict) and 'result' in value[0]: # 检查是否所有元素都是 {'result': {...}} 格式 has_result_format = all( isinstance(item, dict) and 'result' in item and isinstance(item.get('result'), dict) for item in value[:3] # 只检查前3个元素来判断模式 ) formatted_items = [] for item in value[:10]: # 最多处理10个元素 if has_result_format and isinstance(item, dict) and 'result' in item: # 如果是 {'result': {...}} 格式,分解 result 字段并去除 knowledge_source 和指定字段 result_value = item['result'] if isinstance(result_value, dict): # 去除 knowledge_source、last_updated、created_at 和指定字段 skip_keys = {'knowledge_source', 'name', '切片', 'last_updated', 'created_at'} cleaned_result = {k: v for k, v in result_value.items() if k not in skip_keys} formatted_items.append(_format_value(cleaned_result)) else: formatted_items.append(_format_value(result_value)) elif isinstance(item, dict): # 如果不是 {'result': {...}} 格式,去除字典中的 knowledge_source、last_updated、created_at 和指定字段 skip_keys = {'knowledge_source', 'name', '切片', 'last_updated', 'created_at'} cleaned_item = {k: v for k, v in item.items() if k not in skip_keys} formatted_items.append(_format_value(cleaned_item)) else: # 其他类型,正常处理 formatted_items.append(_format_value(item)) result = "; ".join(formatted_items) if len(value) > 10: result += f" ...(共 {len(value)} 条)" return result else: return str(value)[:200] # 截断超长内容 def serialize_graph_for_llm(graph_response: dict) -> str: """ 通用图谱结果序列化器,支持: - nodes(实体详情) - results(任意查询返回值:聚合/记录/字符串等) """ data = graph_response.get("data", {}) nodes = data.get("nodes", []) results = data.get("results", []) output_lines = [] # ========== 1. 处理 results(核心查询返回值)========== if results: output_lines.append("【图谱查询直接结果】") if len(results) == 1 and not isinstance(results[0], (dict, list)): # 单值结果(如 count, max, 字符串) output_lines.append(f" {results[0]}") else: # 多条记录或复杂结构 for i, res in enumerate(results): # 最多展示10条 formatted = _format_value(res) output_lines.append(f" [{i + 1}] {formatted}") output_lines.append("") # ========== 2. 处理 nodes(完整实体信息)========== if nodes: output_lines.append("【相关图谱实体详情】") for node in nodes: # 防止过长,最多15个节点 name = node.get("name", "Unnamed") labels = node.get("labels", []) props = node.get("properties", {}) output_lines.append(f" - 名称: {name}") if labels: output_lines.append(f" 标签: {', '.join(labels)}") # 过滤掉低价值字段(如 分数, 内部 id, name, 切片, last_updated, created_at) skip_props = {"分数", "id", "name", "切片", "last_updated", "created_at", "最后更新时间", "创建时间"} for k, v in props.items(): if k not in skip_props and v not in (None, ""): output_lines.append(f" {k}: {v}") # 不再显示手册来源(knowledge_source),因为用户要求在 results 字段中不显示 output_lines.append("") # 节点间空行 # ========== 返回结果 ========== if not output_lines: return "图谱查询完成,但未返回有效数据。" return "\n".join(output_lines).rstrip()