808 lines
33 KiB
Python
808 lines
33 KiB
Python
"""
|
||
结果格式化模块
|
||
|
||
功能:从 Neo4j 查询结果中提取节点和路径信息
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
from typing import Dict, List, Any, Optional, Set
|
||
logger = logging.getLogger(__name__)
|
||
|
||
EXCLUDED_RESULT_NODE_LABELS = {"维修工作", "操作程序", "操作使用"}
|
||
_FILTERED_OUT = object()
|
||
|
||
|
||
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 _normalize_label_values(labels: Any) -> Set[str]:
|
||
"""将 labels/标签/type 等字段统一成标签集合。"""
|
||
if labels is None:
|
||
return set()
|
||
|
||
if isinstance(labels, str):
|
||
parts = re.split(r"[,,、/\s]+", labels)
|
||
return {part.strip("[]'\" ") for part in parts if part.strip("[]'\" ")}
|
||
|
||
if isinstance(labels, (list, tuple, set, frozenset)):
|
||
return {str(label).strip() for label in labels if str(label).strip()}
|
||
|
||
return {str(labels).strip()} if str(labels).strip() else set()
|
||
|
||
|
||
def _dict_label_values(data: Dict[str, Any]) -> Set[str]:
|
||
label_keys = ("labels", "标签", "label", "类型", "type", "节点类型", "node_type")
|
||
labels = set()
|
||
for key in label_keys:
|
||
if key in data:
|
||
labels.update(_normalize_label_values(data.get(key)))
|
||
return labels
|
||
|
||
|
||
def _has_excluded_result_label(value: Any) -> bool:
|
||
"""判断一个结果对象是否明确属于需要从 results 隐藏的节点类型。"""
|
||
labels = set()
|
||
|
||
if isinstance(value, dict):
|
||
labels.update(_dict_label_values(value))
|
||
elif hasattr(value, "labels") and (hasattr(value, "element_id") or hasattr(value, "id")):
|
||
try:
|
||
labels.update(_normalize_label_values(value.labels))
|
||
except Exception:
|
||
pass
|
||
|
||
return bool(labels & EXCLUDED_RESULT_NODE_LABELS)
|
||
|
||
|
||
def _mentions_excluded_result_label(text: Any) -> bool:
|
||
if text is None:
|
||
return False
|
||
return any(label in str(text) for label in EXCLUDED_RESULT_NODE_LABELS)
|
||
|
||
|
||
def _is_empty_result_content(value: Any) -> bool:
|
||
return value in (None, {}, [])
|
||
|
||
|
||
def _filter_excluded_result_content(value: Any, parent_key: Any = None) -> Any:
|
||
"""
|
||
只用于 results 字段:过滤掉维修工作、操作程序、操作使用这类节点内容。
|
||
nodes/links 的原始返回不会经过这个函数。
|
||
"""
|
||
if parent_key is not None and _mentions_excluded_result_label(parent_key):
|
||
return _FILTERED_OUT
|
||
|
||
if _has_excluded_result_label(value):
|
||
return _FILTERED_OUT
|
||
|
||
if isinstance(value, dict):
|
||
name_value = value.get("名称", value.get("name"))
|
||
if _mentions_excluded_result_label(name_value):
|
||
return _FILTERED_OUT
|
||
|
||
filtered = {}
|
||
for key, child in value.items():
|
||
if _mentions_excluded_result_label(key):
|
||
continue
|
||
filtered_child = _filter_excluded_result_content(child, key)
|
||
if filtered_child is not _FILTERED_OUT:
|
||
filtered[key] = filtered_child
|
||
|
||
return _FILTERED_OUT if _is_empty_result_content(filtered) else filtered
|
||
|
||
if isinstance(value, list):
|
||
filtered_items = []
|
||
for item in value:
|
||
filtered_item = _filter_excluded_result_content(item)
|
||
if filtered_item is not _FILTERED_OUT and not _is_empty_result_content(filtered_item):
|
||
filtered_items.append(filtered_item)
|
||
return filtered_items
|
||
|
||
return value
|
||
|
||
|
||
def _filter_results_for_output(results: Any) -> List[Any]:
|
||
filtered = _filter_excluded_result_content(results)
|
||
if filtered is _FILTERED_OUT or _is_empty_result_content(filtered):
|
||
return []
|
||
if isinstance(filtered, list):
|
||
return filtered
|
||
return [filtered]
|
||
|
||
|
||
def _filter_result_nodes_for_output(nodes: Any) -> List[Dict[str, Any]]:
|
||
if not isinstance(nodes, list):
|
||
return []
|
||
return [
|
||
node for node in nodes
|
||
if isinstance(node, dict) and not _has_excluded_result_label(node)
|
||
]
|
||
|
||
|
||
|
||
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", {})
|
||
# 这里的过滤只影响 results 字段里的文本展示,不改动接口返回的 data.nodes/data.links。
|
||
nodes = _filter_result_nodes_for_output(data.get("nodes", []))
|
||
results = _filter_results_for_output(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()
|
||
|