Committed by
GitHub
Merge pull request #155 from DoiiarX/fix-agent-communication
日志解析修复、及日志解析修复测试用例
Showing
11 changed files
with
828 additions
and
101 deletions
| @@ -35,13 +35,13 @@ class ForumHost: | @@ -35,13 +35,13 @@ class ForumHost: | ||
| 35 | 初始化论坛主持人 | 35 | 初始化论坛主持人 |
| 36 | 36 | ||
| 37 | Args: | 37 | Args: |
| 38 | - api_key: 硅基流动API密钥,如果不提供则从配置文件读取 | ||
| 39 | - base_url: 接口基础地址,默认使用配置文件提供的SiliconFlow地址 | 38 | + api_key: 论坛主持人 LLM API 密钥,如果不提供则从配置文件读取 |
| 39 | + base_url: 论坛主持人 LLM API 接口基础地址,默认使用配置文件提供的SiliconFlow地址 | ||
| 40 | """ | 40 | """ |
| 41 | self.api_key = api_key or settings.FORUM_HOST_API_KEY | 41 | self.api_key = api_key or settings.FORUM_HOST_API_KEY |
| 42 | 42 | ||
| 43 | if not self.api_key: | 43 | if not self.api_key: |
| 44 | - raise ValueError("未找到硅基流动API密钥,请在config.py中设置FORUM_HOST_API_KEY") | 44 | + raise ValueError("未找到论坛主持人API密钥,请在环境变量文件中设置FORUM_HOST_API_KEY") |
| 45 | 45 | ||
| 46 | self.base_url = base_url or settings.FORUM_HOST_BASE_URL | 46 | self.base_url = base_url or settings.FORUM_HOST_BASE_URL |
| 47 | 47 |
| @@ -18,7 +18,7 @@ try: | @@ -18,7 +18,7 @@ try: | ||
| 18 | from .llm_host import generate_host_speech | 18 | from .llm_host import generate_host_speech |
| 19 | HOST_AVAILABLE = True | 19 | HOST_AVAILABLE = True |
| 20 | except ImportError: | 20 | except ImportError: |
| 21 | - logger.warning("ForumEngine: 论坛主持人模块未找到,将以纯监控模式运行") | 21 | + logger.exception("ForumEngine: 论坛主持人模块未找到,将以纯监控模式运行") |
| 22 | HOST_AVAILABLE = False | 22 | HOST_AVAILABLE = False |
| 23 | 23 | ||
| 24 | class LogMonitor: | 24 | class LogMonitor: |
| @@ -50,10 +50,20 @@ class LogMonitor: | @@ -50,10 +50,20 @@ class LogMonitor: | ||
| 50 | self.host_speech_threshold = 5 # 每5条agent发言触发一次主持人发言 | 50 | self.host_speech_threshold = 5 # 每5条agent发言触发一次主持人发言 |
| 51 | self.is_host_generating = False # 主持人是否正在生成发言 | 51 | self.is_host_generating = False # 主持人是否正在生成发言 |
| 52 | 52 | ||
| 53 | - # 目标节点名称 - 直接匹配字符串 | ||
| 54 | - self.target_nodes = [ | ||
| 55 | - 'FirstSummaryNode', | ||
| 56 | - 'ReflectionSummaryNode' | 53 | + # 目标节点识别模式 |
| 54 | + # 1. 类名(旧格式可能包含) | ||
| 55 | + # 2. 完整模块路径(实际日志格式,包含引擎前缀) | ||
| 56 | + # 3. 部分模块路径(兼容性) | ||
| 57 | + # 4. 关键标识文本 | ||
| 58 | + self.target_node_patterns = [ | ||
| 59 | + 'FirstSummaryNode', # 类名 | ||
| 60 | + 'ReflectionSummaryNode', # 类名 | ||
| 61 | + 'InsightEngine.nodes.summary_node', # InsightEngine完整路径 | ||
| 62 | + 'MediaEngine.nodes.summary_node', # MediaEngine完整路径 | ||
| 63 | + 'QueryEngine.nodes.summary_node', # QueryEngine完整路径 | ||
| 64 | + 'nodes.summary_node', # 模块路径(兼容性,用于部分匹配) | ||
| 65 | + '正在生成首次段落总结', # FirstSummaryNode的标识 | ||
| 66 | + '正在生成反思总结', # ReflectionSummaryNode的标识 | ||
| 57 | ] | 67 | ] |
| 58 | 68 | ||
| 59 | # 多行内容捕获状态 | 69 | # 多行内容捕获状态 |
| @@ -107,12 +117,33 @@ class LogMonitor: | @@ -107,12 +117,33 @@ class LogMonitor: | ||
| 107 | f.flush() | 117 | f.flush() |
| 108 | except Exception as e: | 118 | except Exception as e: |
| 109 | logger.exception(f"ForumEngine: 写入forum.log失败: {e}") | 119 | logger.exception(f"ForumEngine: 写入forum.log失败: {e}") |
| 110 | - | 120 | + |
| 111 | def is_target_log_line(self, line: str) -> bool: | 121 | def is_target_log_line(self, line: str) -> bool: |
| 112 | - """检查是否是目标日志行(SummaryNode)""" | ||
| 113 | - # 简单字符串包含检查,更可靠 | ||
| 114 | - for node_name in self.target_nodes: | ||
| 115 | - if node_name in line: | 122 | + """检查是否是目标日志行(SummaryNode) |
| 123 | + | ||
| 124 | + 支持多种识别方式: | ||
| 125 | + 1. 类名:FirstSummaryNode, ReflectionSummaryNode | ||
| 126 | + 2. 完整模块路径:InsightEngine.nodes.summary_node、MediaEngine.nodes.summary_node、QueryEngine.nodes.summary_node | ||
| 127 | + 3. 部分模块路径:nodes.summary_node(兼容性) | ||
| 128 | + 4. 关键标识文本:正在生成首次段落总结、正在生成反思总结 | ||
| 129 | + | ||
| 130 | + 排除条件: | ||
| 131 | + - ERROR 级别的日志(错误日志不应被识别为目标节点) | ||
| 132 | + - 包含错误关键词的日志(JSON解析失败、JSON修复失败等) | ||
| 133 | + """ | ||
| 134 | + # 排除 ERROR 级别的日志 | ||
| 135 | + if "| ERROR" in line or "| ERROR |" in line: | ||
| 136 | + return False | ||
| 137 | + | ||
| 138 | + # 排除包含错误关键词的日志 | ||
| 139 | + error_keywords = ["JSON解析失败", "JSON修复失败", "Traceback", "File \""] | ||
| 140 | + for keyword in error_keywords: | ||
| 141 | + if keyword in line: | ||
| 142 | + return False | ||
| 143 | + | ||
| 144 | + # 检查是否包含目标节点模式 | ||
| 145 | + for pattern in self.target_node_patterns: | ||
| 146 | + if pattern in line: | ||
| 116 | return True | 147 | return True |
| 117 | return False | 148 | return False |
| 118 | 149 | ||
| @@ -145,7 +176,10 @@ class LogMonitor: | @@ -145,7 +176,10 @@ class LogMonitor: | ||
| 145 | return False | 176 | return False |
| 146 | 177 | ||
| 147 | # 如果行长度过短,也认为不是有价值的内容 | 178 | # 如果行长度过短,也认为不是有价值的内容 |
| 148 | - clean_line = re.sub(r'\[\d{2}:\d{2}:\d{2}\]', '', line).strip() | 179 | + # 移除时间戳:支持旧格式和新格式 |
| 180 | + clean_line = re.sub(r'\[\d{2}:\d{2}:\d{2}\]', '', line) | ||
| 181 | + clean_line = re.sub(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s*\|\s*[A-Z]+\s*\|\s*[^|]+?\s*-\s*', '', clean_line) | ||
| 182 | + clean_line = clean_line.strip() | ||
| 149 | if len(clean_line) < 30: # 阈值可以调整 | 183 | if len(clean_line) < 30: # 阈值可以调整 |
| 150 | return False | 184 | return False |
| 151 | 185 | ||
| @@ -156,9 +190,25 @@ class LogMonitor: | @@ -156,9 +190,25 @@ class LogMonitor: | ||
| 156 | return "清理后的输出: {" in line | 190 | return "清理后的输出: {" in line |
| 157 | 191 | ||
| 158 | def is_json_end_line(self, line: str) -> bool: | 192 | def is_json_end_line(self, line: str) -> bool: |
| 159 | - """判断是否是JSON结束行""" | 193 | + """判断是否是JSON结束行 |
| 194 | + | ||
| 195 | + 只判断纯粹的结束标记行,不包含任何日志格式信息(时间戳等)。 | ||
| 196 | + 如果行包含时间戳,应该先清理再判断,但这里返回False表示需要进一步处理。 | ||
| 197 | + """ | ||
| 160 | stripped = line.strip() | 198 | stripped = line.strip() |
| 161 | - return stripped == "}" or (stripped.startswith("[") and stripped.endswith("] }")) | 199 | + |
| 200 | + # 如果行包含时间戳(旧格式或新格式),说明不是纯粹的结束行 | ||
| 201 | + # 旧格式:[HH:MM:SS] | ||
| 202 | + if re.match(r'^\[\d{2}:\d{2}:\d{2}\]', stripped): | ||
| 203 | + return False | ||
| 204 | + # 新格式:YYYY-MM-DD HH:mm:ss.SSS | ||
| 205 | + if re.match(r'^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}', stripped): | ||
| 206 | + return False | ||
| 207 | + | ||
| 208 | + # 不包含时间戳的行,检查是否是纯结束标记 | ||
| 209 | + if stripped == "}" or stripped == "] }": | ||
| 210 | + return True | ||
| 211 | + return False | ||
| 162 | 212 | ||
| 163 | def extract_json_content(self, json_lines: List[str]) -> Optional[str]: | 213 | def extract_json_content(self, json_lines: List[str]) -> Optional[str]: |
| 164 | """从多行中提取并解析JSON内容""" | 214 | """从多行中提取并解析JSON内容""" |
| @@ -200,8 +250,12 @@ class LogMonitor: | @@ -200,8 +250,12 @@ class LogMonitor: | ||
| 200 | # 处理多行JSON | 250 | # 处理多行JSON |
| 201 | json_text = json_part | 251 | json_text = json_part |
| 202 | for line in json_lines[json_start_idx + 1:]: | 252 | for line in json_lines[json_start_idx + 1:]: |
| 203 | - # 移除时间戳 | 253 | + # 移除时间戳:支持旧格式 [HH:MM:SS] 和新格式 loguru (YYYY-MM-DD HH:mm:ss.SSS | LEVEL | ...) |
| 254 | + # 旧格式:[HH:MM:SS] | ||
| 204 | clean_line = re.sub(r'^\[\d{2}:\d{2}:\d{2}\]\s*', '', line) | 255 | clean_line = re.sub(r'^\[\d{2}:\d{2}:\d{2}\]\s*', '', line) |
| 256 | + # 新格式:移除 loguru 格式的时间戳和级别信息 | ||
| 257 | + # 格式: YYYY-MM-DD HH:mm:ss.SSS | LEVEL | module:function:line - | ||
| 258 | + clean_line = re.sub(r'^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s*\|\s*[A-Z]+\s*\|\s*[^|]+?\s*-\s*', '', clean_line) | ||
| 205 | json_text += clean_line | 259 | json_text += clean_line |
| 206 | 260 | ||
| 207 | # 尝试解析JSON | 261 | # 尝试解析JSON |
| @@ -247,42 +301,51 @@ class LogMonitor: | @@ -247,42 +301,51 @@ class LogMonitor: | ||
| 247 | 301 | ||
| 248 | def extract_node_content(self, line: str) -> Optional[str]: | 302 | def extract_node_content(self, line: str) -> Optional[str]: |
| 249 | """提取节点内容,去除时间戳、节点名称等前缀""" | 303 | """提取节点内容,去除时间戳、节点名称等前缀""" |
| 250 | - # 移除时间戳部分 | ||
| 251 | - # 格式: [HH:MM:SS] [NodeName] message | ||
| 252 | - match = re.search(r'\[\d{2}:\d{2}:\d{2}\]\s*(.+)', line) | ||
| 253 | - if match: | ||
| 254 | - content = match.group(1).strip() | ||
| 255 | - | ||
| 256 | - # 移除所有的方括号标签(包括节点名称和应用名称) | 304 | + content = line |
| 305 | + | ||
| 306 | + # 移除时间戳部分:支持旧格式和新格式 | ||
| 307 | + # 旧格式: [HH:MM:SS] | ||
| 308 | + match_old = re.search(r'\[\d{2}:\d{2}:\d{2}\]\s*(.+)', content) | ||
| 309 | + if match_old: | ||
| 310 | + content = match_old.group(1).strip() | ||
| 311 | + else: | ||
| 312 | + # 新格式: YYYY-MM-DD HH:mm:ss.SSS | LEVEL | module:function:line - | ||
| 313 | + match_new = re.search(r'\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s*\|\s*[A-Z]+\s*\|\s*[^|]+?\s*-\s*(.+)', content) | ||
| 314 | + if match_new: | ||
| 315 | + content = match_new.group(1).strip() | ||
| 316 | + | ||
| 317 | + if not content: | ||
| 318 | + return line.strip() | ||
| 319 | + | ||
| 320 | + # 移除所有的方括号标签(包括节点名称和应用名称) | ||
| 321 | + content = re.sub(r'^\[.*?\]\s*', '', content) | ||
| 322 | + | ||
| 323 | + # 继续移除可能的多个连续标签 | ||
| 324 | + while re.match(r'^\[.*?\]\s*', content): | ||
| 257 | content = re.sub(r'^\[.*?\]\s*', '', content) | 325 | content = re.sub(r'^\[.*?\]\s*', '', content) |
| 258 | - | ||
| 259 | - # 继续移除可能的多个连续标签 | ||
| 260 | - while re.match(r'^\[.*?\]\s*', content): | ||
| 261 | - content = re.sub(r'^\[.*?\]\s*', '', content) | ||
| 262 | - | ||
| 263 | - # 移除常见前缀(如"首次总结: "、"反思总结: "等) | ||
| 264 | - prefixes_to_remove = [ | ||
| 265 | - "首次总结: ", | ||
| 266 | - "反思总结: ", | ||
| 267 | - "清理后的输出: " | ||
| 268 | - ] | ||
| 269 | - | ||
| 270 | - for prefix in prefixes_to_remove: | ||
| 271 | - if content.startswith(prefix): | ||
| 272 | - content = content[len(prefix):] | ||
| 273 | - break | ||
| 274 | - | ||
| 275 | - # 移除可能存在的应用名标签(不在方括号内的) | ||
| 276 | - app_names = ['INSIGHT', 'MEDIA', 'QUERY'] | ||
| 277 | - for app_name in app_names: | ||
| 278 | - # 移除单独的APP_NAME(在行首) | ||
| 279 | - content = re.sub(rf'^{app_name}\s+', '', content, flags=re.IGNORECASE) | ||
| 280 | - | ||
| 281 | - # 清理多余的空格 | ||
| 282 | - content = re.sub(r'\s+', ' ', content) | ||
| 283 | - | ||
| 284 | - return content.strip() | ||
| 285 | - return line.strip() | 326 | + |
| 327 | + # 移除常见前缀(如"首次总结: "、"反思总结: "等) | ||
| 328 | + prefixes_to_remove = [ | ||
| 329 | + "首次总结: ", | ||
| 330 | + "反思总结: ", | ||
| 331 | + "清理后的输出: " | ||
| 332 | + ] | ||
| 333 | + | ||
| 334 | + for prefix in prefixes_to_remove: | ||
| 335 | + if content.startswith(prefix): | ||
| 336 | + content = content[len(prefix):] | ||
| 337 | + break | ||
| 338 | + | ||
| 339 | + # 移除可能存在的应用名标签(不在方括号内的) | ||
| 340 | + app_names = ['INSIGHT', 'MEDIA', 'QUERY'] | ||
| 341 | + for app_name in app_names: | ||
| 342 | + # 移除单独的APP_NAME(在行首) | ||
| 343 | + content = re.sub(rf'^{app_name}\s+', '', content, flags=re.IGNORECASE) | ||
| 344 | + | ||
| 345 | + # 清理多余的空格 | ||
| 346 | + content = re.sub(r'\s+', ' ', content) | ||
| 347 | + | ||
| 348 | + return content.strip() | ||
| 286 | 349 | ||
| 287 | def get_file_size(self, file_path: Path) -> int: | 350 | def get_file_size(self, file_path: Path) -> int: |
| 288 | """获取文件大小""" | 351 | """获取文件大小""" |
| @@ -349,36 +412,49 @@ class LogMonitor: | @@ -349,36 +412,49 @@ class LogMonitor: | ||
| 349 | if not line.strip(): | 412 | if not line.strip(): |
| 350 | continue | 413 | continue |
| 351 | 414 | ||
| 352 | - # 检查是否是目标节点行 | ||
| 353 | - if self.is_target_log_line(line): | ||
| 354 | - if self.is_json_start_line(line): | ||
| 355 | - # 开始捕获JSON | ||
| 356 | - self.capturing_json[app_name] = True | ||
| 357 | - self.json_buffer[app_name] = [line] | ||
| 358 | - self.json_start_line[app_name] = line | 415 | + # 检查是否是目标节点行和JSON开始标记 |
| 416 | + is_target = self.is_target_log_line(line) | ||
| 417 | + is_json_start = self.is_json_start_line(line) | ||
| 418 | + | ||
| 419 | + # 只有目标节点(SummaryNode)的JSON输出才应该被捕获 | ||
| 420 | + # 过滤掉SearchNode等其他节点的输出(它们不是目标节点,即使有JSON也不会被捕获) | ||
| 421 | + if is_target and is_json_start: | ||
| 422 | + # 开始捕获JSON(必须是目标节点且包含"清理后的输出: {") | ||
| 423 | + self.capturing_json[app_name] = True | ||
| 424 | + self.json_buffer[app_name] = [line] | ||
| 425 | + self.json_start_line[app_name] = line | ||
| 426 | + | ||
| 427 | + # 检查是否是单行JSON | ||
| 428 | + if line.strip().endswith("}"): | ||
| 429 | + # 单行JSON,立即处理 | ||
| 430 | + content = self.extract_json_content([line]) | ||
| 431 | + if content: # 只有成功解析的内容才会被记录 | ||
| 432 | + # 去除重复的标签和格式化 | ||
| 433 | + clean_content = self._clean_content_tags(content, app_name) | ||
| 434 | + captured_contents.append(f"{clean_content}") | ||
| 435 | + self.capturing_json[app_name] = False | ||
| 436 | + self.json_buffer[app_name] = [] | ||
| 359 | 437 | ||
| 360 | - # 检查是否是单行JSON | ||
| 361 | - if line.strip().endswith("}"): | ||
| 362 | - # 单行JSON,立即处理 | ||
| 363 | - content = self.extract_json_content([line]) | ||
| 364 | - if content: # 只有成功解析的内容才会被记录 | ||
| 365 | - # 去除重复的标签和格式化 | ||
| 366 | - clean_content = self._clean_content_tags(content, app_name) | ||
| 367 | - captured_contents.append(f"{clean_content}") | ||
| 368 | - self.capturing_json[app_name] = False | ||
| 369 | - self.json_buffer[app_name] = [] | ||
| 370 | - | ||
| 371 | - elif self.is_valuable_content(line): | ||
| 372 | - # 其他有价值的SummaryNode内容 | ||
| 373 | - clean_content = self._clean_content_tags(self.extract_node_content(line), app_name) | ||
| 374 | - captured_contents.append(f"{clean_content}") | 438 | + elif is_target and self.is_valuable_content(line): |
| 439 | + # 其他有价值的SummaryNode内容(必须是目标节点且有价值) | ||
| 440 | + clean_content = self._clean_content_tags(self.extract_node_content(line), app_name) | ||
| 441 | + captured_contents.append(f"{clean_content}") | ||
| 375 | 442 | ||
| 376 | elif self.capturing_json[app_name]: | 443 | elif self.capturing_json[app_name]: |
| 377 | # 正在捕获JSON的后续行 | 444 | # 正在捕获JSON的后续行 |
| 378 | self.json_buffer[app_name].append(line) | 445 | self.json_buffer[app_name].append(line) |
| 379 | 446 | ||
| 380 | # 检查是否是JSON结束 | 447 | # 检查是否是JSON结束 |
| 381 | - if self.is_json_end_line(line): | 448 | + # 先清理时间戳,然后判断清理后的行是否是结束标记 |
| 449 | + cleaned_line = line.strip() | ||
| 450 | + # 清理旧格式时间戳:[HH:MM:SS] | ||
| 451 | + cleaned_line = re.sub(r'^\[\d{2}:\d{2}:\d{2}\]\s*', '', cleaned_line) | ||
| 452 | + # 清理新格式时间戳:YYYY-MM-DD HH:mm:ss.SSS | LEVEL | module:function:line - | ||
| 453 | + cleaned_line = re.sub(r'^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3}\s*\|\s*[A-Z]+\s*\|\s*[^|]+?\s*-\s*', '', cleaned_line) | ||
| 454 | + cleaned_line = cleaned_line.strip() | ||
| 455 | + | ||
| 456 | + # 清理后判断是否是结束标记 | ||
| 457 | + if cleaned_line == "}" or cleaned_line == "] }": | ||
| 382 | # JSON结束,处理完整的JSON | 458 | # JSON结束,处理完整的JSON |
| 383 | content = self.extract_json_content(self.json_buffer[app_name]) | 459 | content = self.extract_json_content(self.json_buffer[app_name]) |
| 384 | if content: # 只有成功解析的内容才会被记录 | 460 | if content: # 只有成功解析的内容才会被记录 |
| @@ -484,13 +560,16 @@ class LogMonitor: | @@ -484,13 +560,16 @@ class LogMonitor: | ||
| 484 | # 先检查是否需要触发搜索(只触发一次) | 560 | # 先检查是否需要触发搜索(只触发一次) |
| 485 | if not self.is_searching: | 561 | if not self.is_searching: |
| 486 | for line in new_lines: | 562 | for line in new_lines: |
| 487 | - if line.strip() and 'FirstSummaryNode' in line: | ||
| 488 | - logger.info(f"ForumEngine: 在{app_name}中检测到第一次论坛发表内容") | ||
| 489 | - self.is_searching = True | ||
| 490 | - self.search_inactive_count = 0 | ||
| 491 | - # 清空forum.log开始新会话 | ||
| 492 | - self.clear_forum_log() | ||
| 493 | - break # 找到一个就够了,跳出循环 | 563 | + # 检查是否包含目标节点模式(支持多种格式) |
| 564 | + if line.strip() and self.is_target_log_line(line): | ||
| 565 | + # 进一步确认是首次总结节点(FirstSummaryNode或包含"正在生成首次段落总结") | ||
| 566 | + if 'FirstSummaryNode' in line or '正在生成首次段落总结' in line: | ||
| 567 | + logger.info(f"ForumEngine: 在{app_name}中检测到第一次论坛发表内容") | ||
| 568 | + self.is_searching = True | ||
| 569 | + self.search_inactive_count = 0 | ||
| 570 | + # 清空forum.log开始新会话 | ||
| 571 | + self.clear_forum_log() | ||
| 572 | + break # 找到一个就够了,跳出循环 | ||
| 494 | 573 | ||
| 495 | # 处理所有新增内容(如果正在搜索状态) | 574 | # 处理所有新增内容(如果正在搜索状态) |
| 496 | if self.is_searching: | 575 | if self.is_searching: |
| @@ -161,6 +161,7 @@ class KeywordOptimizer: | @@ -161,6 +161,7 @@ class KeywordOptimizer: | ||
| 161 | 161 | ||
| 162 | **重要提醒**:每个关键词都必须是一个不可分割的独立词条,严禁在词条内部包含空格。例如,应使用 "雷军班争议" 而不是错误的 "雷军班 争议"。 | 162 | **重要提醒**:每个关键词都必须是一个不可分割的独立词条,严禁在词条内部包含空格。例如,应使用 "雷军班争议" 而不是错误的 "雷军班 争议"。 |
| 163 | 163 | ||
| 164 | + | ||
| 164 | **输出格式**: | 165 | **输出格式**: |
| 165 | 请以JSON格式返回结果: | 166 | 请以JSON格式返回结果: |
| 166 | { | 167 | { |
| @@ -134,6 +134,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | @@ -134,6 +134,7 @@ def run_report_generation(task: ReportTask, query: str, custom_template: str = " | ||
| 134 | task.update_status("completed", 100) | 134 | task.update_status("completed", 100) |
| 135 | 135 | ||
| 136 | except Exception as e: | 136 | except Exception as e: |
| 137 | + logger.exception(f"报告生成过程中发生错误: {str(e)}") | ||
| 137 | task.update_status("error", 0, str(e)) | 138 | task.update_status("error", 0, str(e)) |
| 138 | # 只在出错时清理任务 | 139 | # 只在出错时清理任务 |
| 139 | with task_lock: | 140 | with task_lock: |
| @@ -156,6 +157,7 @@ def get_status(): | @@ -156,6 +157,7 @@ def get_status(): | ||
| 156 | 'current_task': current_task.to_dict() if current_task else None | 157 | 'current_task': current_task.to_dict() if current_task else None |
| 157 | }) | 158 | }) |
| 158 | except Exception as e: | 159 | except Exception as e: |
| 160 | + logger.exception(f"获取Report Engine状态失败: {str(e)}") | ||
| 159 | return jsonify({ | 161 | return jsonify({ |
| 160 | 'success': False, | 162 | 'success': False, |
| 161 | 'error': str(e) | 163 | 'error': str(e) |
| @@ -228,6 +230,7 @@ def generate_report(): | @@ -228,6 +230,7 @@ def generate_report(): | ||
| 228 | }) | 230 | }) |
| 229 | 231 | ||
| 230 | except Exception as e: | 232 | except Exception as e: |
| 233 | + logger.exception(f"开始生成报告失败: {str(e)}") | ||
| 231 | return jsonify({ | 234 | return jsonify({ |
| 232 | 'success': False, | 235 | 'success': False, |
| 233 | 'error': str(e) | 236 | 'error': str(e) |
| @@ -319,6 +322,7 @@ def get_result_json(task_id: str): | @@ -319,6 +322,7 @@ def get_result_json(task_id: str): | ||
| 319 | }) | 322 | }) |
| 320 | 323 | ||
| 321 | except Exception as e: | 324 | except Exception as e: |
| 325 | + logger.exception(f"获取报告生成结果失败: {str(e)}") | ||
| 322 | return jsonify({ | 326 | return jsonify({ |
| 323 | 'success': False, | 327 | 'success': False, |
| 324 | 'error': str(e) | 328 | 'error': str(e) |
| @@ -348,6 +352,7 @@ def cancel_task(task_id: str): | @@ -348,6 +352,7 @@ def cancel_task(task_id: str): | ||
| 348 | }), 404 | 352 | }), 404 |
| 349 | 353 | ||
| 350 | except Exception as e: | 354 | except Exception as e: |
| 355 | + logger.exception(f"取消报告生成任务失败: {str(e)}") | ||
| 351 | return jsonify({ | 356 | return jsonify({ |
| 352 | 'success': False, | 357 | 'success': False, |
| 353 | 'error': str(e) | 358 | 'error': str(e) |
| @@ -391,6 +396,7 @@ def get_templates(): | @@ -391,6 +396,7 @@ def get_templates(): | ||
| 391 | }) | 396 | }) |
| 392 | 397 | ||
| 393 | except Exception as e: | 398 | except Exception as e: |
| 399 | + logger.exception(f"获取可用模板列表失败: {str(e)}") | ||
| 394 | return jsonify({ | 400 | return jsonify({ |
| 395 | 'success': False, | 401 | 'success': False, |
| 396 | 'error': str(e) | 402 | 'error': str(e) |
| @@ -400,6 +406,7 @@ def get_templates(): | @@ -400,6 +406,7 @@ def get_templates(): | ||
| 400 | # 错误处理 | 406 | # 错误处理 |
| 401 | @report_bp.errorhandler(404) | 407 | @report_bp.errorhandler(404) |
| 402 | def not_found(error): | 408 | def not_found(error): |
| 409 | + logger.exception(f"API端点不存在: {str(error)}") | ||
| 403 | return jsonify({ | 410 | return jsonify({ |
| 404 | 'success': False, | 411 | 'success': False, |
| 405 | 'error': 'API端点不存在' | 412 | 'error': 'API端点不存在' |
| @@ -408,6 +415,7 @@ def not_found(error): | @@ -408,6 +415,7 @@ def not_found(error): | ||
| 408 | 415 | ||
| 409 | @report_bp.errorhandler(500) | 416 | @report_bp.errorhandler(500) |
| 410 | def internal_error(error): | 417 | def internal_error(error): |
| 418 | + logger.exception(f"服务器内部错误: {str(error)}") | ||
| 411 | return jsonify({ | 419 | return jsonify({ |
| 412 | 'success': False, | 420 | 'success': False, |
| 413 | 'error': '服务器内部错误' | 421 | 'error': '服务器内部错误' |
| @@ -94,20 +94,10 @@ def _load_config_module(): | @@ -94,20 +94,10 @@ def _load_config_module(): | ||
| 94 | def read_config_values(): | 94 | def read_config_values(): |
| 95 | """Return the current configuration values that are exposed to the frontend.""" | 95 | """Return the current configuration values that are exposed to the frontend.""" |
| 96 | try: | 96 | try: |
| 97 | - # 重新导入 config 模块以获取最新的 Settings 实例 | ||
| 98 | - importlib.invalidate_caches() | ||
| 99 | - if CONFIG_MODULE_NAME in sys.modules: | ||
| 100 | - importlib.reload(sys.modules[CONFIG_MODULE_NAME]) | ||
| 101 | - else: | ||
| 102 | - importlib.import_module(CONFIG_MODULE_NAME) | ||
| 103 | - | ||
| 104 | - # 从 config 模块获取 settings 实例 | ||
| 105 | - config_module = sys.modules[CONFIG_MODULE_NAME] | ||
| 106 | - if not hasattr(config_module, 'settings'): | ||
| 107 | - logger.error("config 模块中没有找到 settings 实例") | ||
| 108 | - return {} | 97 | + # 重新加载配置以获取最新的 Settings 实例 |
| 98 | + from config import reload_settings, settings | ||
| 99 | + reload_settings() | ||
| 109 | 100 | ||
| 110 | - settings = config_module.settings | ||
| 111 | values = {} | 101 | values = {} |
| 112 | for key in CONFIG_KEYS: | 102 | for key in CONFIG_KEYS: |
| 113 | # 从 Pydantic Settings 实例读取值 | 103 | # 从 Pydantic Settings 实例读取值 |
| @@ -9,8 +9,9 @@ | @@ -9,8 +9,9 @@ | ||
| 9 | 9 | ||
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | from pydantic_settings import BaseSettings | 11 | from pydantic_settings import BaseSettings |
| 12 | -from pydantic import Field | 12 | +from pydantic import Field, ConfigDict |
| 13 | from typing import Optional | 13 | from typing import Optional |
| 14 | +from loguru import logger | ||
| 14 | 15 | ||
| 15 | 16 | ||
| 16 | # 计算 .env 优先级:优先当前工作目录,其次项目根目录 | 17 | # 计算 .env 优先级:优先当前工作目录,其次项目根目录 |
| @@ -86,12 +87,29 @@ class Settings(BaseSettings): | @@ -86,12 +87,29 @@ class Settings(BaseSettings): | ||
| 86 | SEARCH_TIMEOUT: int = Field(240, description="单次搜索请求超时") | 87 | SEARCH_TIMEOUT: int = Field(240, description="单次搜索请求超时") |
| 87 | MAX_CONTENT_LENGTH: int = Field(500000, description="搜索最大内容长度") | 88 | MAX_CONTENT_LENGTH: int = Field(500000, description="搜索最大内容长度") |
| 88 | 89 | ||
| 89 | - class Config: | ||
| 90 | - env_file = ENV_FILE | ||
| 91 | - env_prefix = "" | ||
| 92 | - case_sensitive = False | ||
| 93 | - extra = "allow" | 90 | + model_config = ConfigDict( |
| 91 | + env_file=ENV_FILE, | ||
| 92 | + env_prefix="", | ||
| 93 | + case_sensitive=False, | ||
| 94 | + extra="allow" | ||
| 95 | + ) | ||
| 94 | 96 | ||
| 95 | 97 | ||
| 96 | # 创建全局配置实例 | 98 | # 创建全局配置实例 |
| 97 | settings = Settings() | 99 | settings = Settings() |
| 100 | + | ||
| 101 | + | ||
| 102 | +def reload_settings() -> Settings: | ||
| 103 | + """ | ||
| 104 | + 重新加载配置 | ||
| 105 | + | ||
| 106 | + 从 .env 文件和环境变量重新加载配置,更新全局 settings 实例。 | ||
| 107 | + 用于在运行时动态更新配置。 | ||
| 108 | + | ||
| 109 | + Returns: | ||
| 110 | + Settings: 新创建的配置实例 | ||
| 111 | + """ | ||
| 112 | + | ||
| 113 | + global settings | ||
| 114 | + settings = Settings() | ||
| 115 | + return settings |
tests/README.md
0 → 100644
| 1 | +# ForumEngine日志解析测试 | ||
| 2 | + | ||
| 3 | +本测试套件用于测试 `ForumEngine/monitor.py` 中的日志解析功能,验证其在不同日志格式下的正确性。 | ||
| 4 | + | ||
| 5 | +## 测试数据 | ||
| 6 | + | ||
| 7 | +`forum_log_test_data.py` 包含各种日志格式的最小示例(论坛日志测试数据): | ||
| 8 | + | ||
| 9 | +### 旧格式([HH:MM:SS]) | ||
| 10 | +- `OLD_FORMAT_SINGLE_LINE_JSON`: 单行JSON | ||
| 11 | +- `OLD_FORMAT_MULTILINE_JSON`: 多行JSON | ||
| 12 | +- `OLD_FORMAT_FIRST_SUMMARY`: 包含FirstSummaryNode的日志 | ||
| 13 | +- `OLD_FORMAT_REFLECTION_SUMMARY`: 包含ReflectionSummaryNode的日志 | ||
| 14 | + | ||
| 15 | +### 新格式(loguru默认格式) | ||
| 16 | +- `NEW_FORMAT_SINGLE_LINE_JSON`: 单行JSON | ||
| 17 | +- `NEW_FORMAT_MULTILINE_JSON`: 多行JSON | ||
| 18 | +- `NEW_FORMAT_FIRST_SUMMARY`: 包含FirstSummaryNode的日志 | ||
| 19 | +- `NEW_FORMAT_REFLECTION_SUMMARY`: 包含ReflectionSummaryNode的日志 | ||
| 20 | + | ||
| 21 | +### 复杂示例 | ||
| 22 | +- `COMPLEX_JSON_WITH_UPDATED`: 包含updated_paragraph_latest_state的JSON | ||
| 23 | +- `COMPLEX_JSON_WITH_PARAGRAPH`: 只有paragraph_latest_state的JSON | ||
| 24 | +- `MIXED_FORMAT_LINES`: 混合格式的日志行 | ||
| 25 | + | ||
| 26 | +## 运行测试 | ||
| 27 | + | ||
| 28 | +### 使用pytest(推荐) | ||
| 29 | + | ||
| 30 | +```bash | ||
| 31 | +# 安装pytest(如果还没有安装) | ||
| 32 | +pip install pytest | ||
| 33 | + | ||
| 34 | +# 运行所有测试 | ||
| 35 | +pytest tests/test_monitor.py -v | ||
| 36 | + | ||
| 37 | +# 运行特定测试 | ||
| 38 | +pytest tests/test_monitor.py::TestLogMonitor::test_extract_json_content_new_format_multiline -v | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +### 直接运行 | ||
| 42 | + | ||
| 43 | +```bash | ||
| 44 | +python tests/test_monitor.py | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +## 测试覆盖 | ||
| 48 | + | ||
| 49 | +测试覆盖以下函数: | ||
| 50 | + | ||
| 51 | +1. **is_target_log_line**: 识别目标节点日志行 | ||
| 52 | +2. **is_json_start_line**: 识别JSON开始行 | ||
| 53 | +3. **is_json_end_line**: 识别JSON结束行 | ||
| 54 | +4. **extract_json_content**: 提取JSON内容(单行和多行) | ||
| 55 | +5. **format_json_content**: 格式化JSON内容(优先提取updated_paragraph_latest_state) | ||
| 56 | +6. **extract_node_content**: 提取节点内容 | ||
| 57 | +7. **process_lines_for_json**: 完整处理流程 | ||
| 58 | +8. **is_valuable_content**: 判断内容是否有价值 | ||
| 59 | + | ||
| 60 | +## 预期问题 | ||
| 61 | + | ||
| 62 | +当前代码可能无法正确处理loguru新格式,主要问题在于: | ||
| 63 | + | ||
| 64 | +1. **时间戳移除**:`extract_json_content()` 中的正则 `r'^\[\d{2}:\d{2}:\d{2}\]\s*'` 只能匹配 `[HH:MM:SS]` 格式,无法匹配loguru的 `YYYY-MM-DD HH:mm:ss.SSS` 格式 | ||
| 65 | + | ||
| 66 | +2. **时间戳匹配**:`extract_node_content()` 中的正则 `r'\[\d{2}:\d{2}:\d{2}\]\s*(.+)'` 同样只能匹配旧格式 | ||
| 67 | + | ||
| 68 | +这些测试会帮助识别这些问题,并指导后续的代码修复。 | ||
| 69 | + |
tests/__init__.py
0 → 100644
tests/forum_log_test_data.py
0 → 100644
| 1 | +""" | ||
| 2 | +论坛日志测试数据 | ||
| 3 | + | ||
| 4 | +包含各种日志格式的最小示例,用于测试ForumEngine/monitor.py中的日志解析函数。 | ||
| 5 | +涵盖旧格式([HH:MM:SS])和新格式(loguru默认格式)的日志记录示例。 | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +# ===== 旧格式(支持 [HH:MM:SS])===== | ||
| 9 | + | ||
| 10 | +# 单行JSON,旧格式 | ||
| 11 | +OLD_FORMAT_SINGLE_LINE_JSON = """[17:42:31] 2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {"paragraph_latest_state": "这是首次总结内容"}""" | ||
| 12 | + | ||
| 13 | +# 多行JSON,旧格式 | ||
| 14 | +OLD_FORMAT_MULTILINE_JSON = [ | ||
| 15 | + "[17:42:31] 2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 16 | + "[17:42:31] \"paragraph_latest_state\": \"这是多行\\nJSON内容\"", | ||
| 17 | + "[17:42:31] }" | ||
| 18 | +] | ||
| 19 | + | ||
| 20 | +# 包含FirstSummaryNode的旧格式日志 | ||
| 21 | +OLD_FORMAT_FIRST_SUMMARY = """[17:42:31] 2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - FirstSummaryNode 清理后的输出: {"paragraph_latest_state": "首次总结"}""" | ||
| 22 | + | ||
| 23 | +# 包含ReflectionSummaryNode的旧格式日志 | ||
| 24 | +OLD_FORMAT_REFLECTION_SUMMARY = """[17:43:00] 2025-11-05 17:43:00.272 | INFO | InsightEngine.nodes.summary_node:process_output:296 - ReflectionSummaryNode 清理后的输出: {"updated_paragraph_latest_state": "反思总结"}""" | ||
| 25 | + | ||
| 26 | +# 旧格式,非目标节点(应该被忽略) | ||
| 27 | +OLD_FORMAT_NON_TARGET = """[17:41:16] 2025-11-05 17:41:16.742 | INFO | InsightEngine.nodes.report_structure_node:run:52 - 正在为查询生成报告结构""" | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +# ===== 新格式(loguru默认格式)===== | ||
| 31 | + | ||
| 32 | +# 单行JSON,新格式 | ||
| 33 | +NEW_FORMAT_SINGLE_LINE_JSON = """2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {"paragraph_latest_state": "这是首次总结内容"}""" | ||
| 34 | + | ||
| 35 | +# 多行JSON,新格式 | ||
| 36 | +NEW_FORMAT_MULTILINE_JSON = [ | ||
| 37 | + "2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 38 | + "2025-11-05 17:42:31.288 | INFO | InsightEngine.nodes.summary_node:process_output:132 - \"paragraph_latest_state\": \"这是多行\\nJSON内容\"", | ||
| 39 | + "2025-11-05 17:42:31.289 | INFO | InsightEngine.nodes.summary_node:process_output:133 - }" | ||
| 40 | +] | ||
| 41 | + | ||
| 42 | +# 包含FirstSummaryNode的新格式日志 | ||
| 43 | +NEW_FORMAT_FIRST_SUMMARY = """2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - FirstSummaryNode 清理后的输出: {"paragraph_latest_state": "首次总结"}""" | ||
| 44 | + | ||
| 45 | +# 包含ReflectionSummaryNode的新格式日志 | ||
| 46 | +NEW_FORMAT_REFLECTION_SUMMARY = """2025-11-05 17:43:00.272 | INFO | InsightEngine.nodes.summary_node:process_output:296 - ReflectionSummaryNode 清理后的输出: {"updated_paragraph_latest_state": "反思总结"}""" | ||
| 47 | + | ||
| 48 | +# 新格式,非目标节点(应该被忽略) | ||
| 49 | +NEW_FORMAT_NON_TARGET = """2025-11-05 17:41:16.742 | INFO | InsightEngine.nodes.report_structure_node:run:52 - 正在为查询生成报告结构: 洛阳钼业预期股价变化""" | ||
| 50 | + | ||
| 51 | +# 新格式,ForumEngine的日志 | ||
| 52 | +NEW_FORMAT_FORUM_ENGINE = """2025-11-05 22:31:09.964 | INFO | ForumEngine.monitor:monitor_logs:457 - ForumEngine: 论坛创建中...""" | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +# ===== 复杂JSON示例 ===== | ||
| 56 | + | ||
| 57 | +# 包含updated_paragraph_latest_state的JSON(应该优先提取这个) | ||
| 58 | +COMPLEX_JSON_WITH_UPDATED = [ | ||
| 59 | + "2025-11-05 17:43:00.272 | INFO | InsightEngine.nodes.summary_node:process_output:296 - 清理后的输出: {", | ||
| 60 | + "2025-11-05 17:43:00.273 | INFO | InsightEngine.nodes.summary_node:process_output:297 - \"updated_paragraph_latest_state\": \"## 核心发现(更新版)\\n1. 这是更新后的内容\"", | ||
| 61 | + "2025-11-05 17:43:00.274 | INFO | InsightEngine.nodes.summary_node:process_output:298 - }" | ||
| 62 | +] | ||
| 63 | + | ||
| 64 | +# 只有paragraph_latest_state的JSON | ||
| 65 | +COMPLEX_JSON_WITH_PARAGRAPH = [ | ||
| 66 | + "2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 67 | + "2025-11-05 17:42:31.288 | INFO | InsightEngine.nodes.summary_node:process_output:132 - \"paragraph_latest_state\": \"## 核心发现概述\\n1. 这是首次总结内容\"", | ||
| 68 | + "2025-11-05 17:42:31.289 | INFO | InsightEngine.nodes.summary_node:process_output:133 - }" | ||
| 69 | +] | ||
| 70 | + | ||
| 71 | +# 包含换行符的JSON内容 | ||
| 72 | +COMPLEX_JSON_WITH_NEWLINES = [ | ||
| 73 | + "[17:42:31] 2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 74 | + "[17:42:31] \"paragraph_latest_state\": \"第一行内容\\n第二行内容\\n第三行内容\"", | ||
| 75 | + "[17:42:31] }" | ||
| 76 | +] | ||
| 77 | + | ||
| 78 | +# ===== 边界情况 ===== | ||
| 79 | + | ||
| 80 | +# 不包含"清理后的输出"的行(应该被忽略) | ||
| 81 | +LINE_WITHOUT_CLEAN_OUTPUT = """2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - JSON解析成功""" | ||
| 82 | + | ||
| 83 | +# 包含"清理后的输出"但不是JSON格式 | ||
| 84 | +LINE_WITH_CLEAN_OUTPUT_NOT_JSON = """2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: 这不是JSON格式的内容""" | ||
| 85 | + | ||
| 86 | +# 空行 | ||
| 87 | +EMPTY_LINE = "" | ||
| 88 | + | ||
| 89 | +# 只有时间戳的行 | ||
| 90 | +LINE_WITH_ONLY_TIMESTAMP_OLD = "[17:42:31]" | ||
| 91 | +LINE_WITH_ONLY_TIMESTAMP_NEW = "2025-11-05 17:42:31.287 | INFO | module:function:1 -" | ||
| 92 | + | ||
| 93 | +# 无效的JSON格式 | ||
| 94 | +INVALID_JSON = [ | ||
| 95 | + "2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 96 | + "2025-11-05 17:42:31.288 | INFO | InsightEngine.nodes.summary_node:process_output:132 - \"paragraph_latest_state\": \"缺少结束引号", | ||
| 97 | + "2025-11-05 17:42:31.289 | INFO | InsightEngine.nodes.summary_node:process_output:133 - }" | ||
| 98 | +] | ||
| 99 | + | ||
| 100 | +# ===== 混合格式(同一批日志中既有旧格式也有新格式)===== | ||
| 101 | +MIXED_FORMAT_LINES = [ | ||
| 102 | + "[17:42:31] 2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 103 | + "2025-11-05 17:42:31.288 | INFO | InsightEngine.nodes.summary_node:process_output:132 - \"paragraph_latest_state\": \"混合格式内容\"", | ||
| 104 | + "[17:42:31] }" | ||
| 105 | +] | ||
| 106 | + | ||
| 107 | +# ===== 实际生产环境日志示例 ===== | ||
| 108 | + | ||
| 109 | +# QueryEngine反思总结 - 多行JSON格式 | ||
| 110 | +REAL_QUERY_ENGINE_REFLECTION = [ | ||
| 111 | + "[10:56:04] 2025-11-06 10:56:04.759 | INFO | QueryEngine.nodes.summary_node:process_output:302 - 清理后的输出: {", | ||
| 112 | + "[10:56:04] \"updated_paragraph_latest_state\": \"洛阳栾川钼业集团股份有限公司(简称洛阳钼业,CMOC)是中国大陆领先的钼生产企业,同时也是全球顶级的有色金属和稀有金属生产商之一。公司前身可追溯至1969年经原国家冶金部审批成立的栾川县小型选矿厂。1999年,洛阳栾川钼业集团正式成立,并于2006年改制为股份制有限公司。历经2004年和2014年的两次混合所有制改革,洛阳钼业目前是一家民营控股的股份制公司。公司于2007年在香港联合交易所上市(股票代码:03993),并于2012年回归A股在上海证券交易所上市(股票代码:603993)。\\n\\n洛阳钼业的核心业务涵盖基本金属、稀有金属的采、选、冶及加工,包括钼、钨、铜、钴、铌、磷等,同时积极开展矿产贸易业务。公司的业务足迹遍布亚洲、非洲、南美洲和欧洲,是全球领先的铜、钴、钼、钨、铌生产商,也是巴西领先的磷肥生产商。截至2025年三季度,公司实现营业收入1454.85亿元,归母净利润86.71亿元,经营性净现金流达120.09亿元,资产负债率进一步优化至50.15%。\\n\\n### 战略与运营升级\\n2025年公司迎来重要战略转折,引入具有国际化视野的管理团队,新董事长刘建锋(前中国海油集团商务总监)与常务副总裁阙朝阳(前紫金矿业集团高管)主导推动组织架构革新。通过收购厄瓜多尔Cangrejos金矿(预计2029年投产)等关键并购,实现资产组合向多品种(铜、黄金为主)、多国家(覆盖非洲、南美)、多阶段(生产与绿地项目结合)转型。刚果(金)核心矿区通过『小改小革』持续优化,2028年规划铜产能达80-100万吨,NziloII水电站建设有效缓解能源约束。\\n\\n### 行业地位与财务表现\\n据2025年《财富》中国500强最新排名,洛阳钼业以2130.29亿元营收跃居第138位,市值突破2500亿元。公司独创『矿山+贸易』双轮模式,IXM贸易平台掌控全球12%铜精矿贸易量,TFM与KFM项目成本分别进入全球前30%和前10%分位。新能源金属领域保持绝对优势,钴产能占全球40%以上,近期刚果(金)出口管制政策促使钴价反弹,2025年上半年均价较2024年提升26%。\\n\\n### 技术与社会责任\\n5G智慧矿山实现穿孔、运输、破碎全流程无人化,开采成本较国际同行低18-22%。ESG建设方面,MSCI评级提升至BBB级,通过TCFD框架披露气候风险,矿山电动化率35%,减排强度同比下降19%。2025年实施每股0.255元现金分红,持续回馈股东。\\n\\n### 未来展望\\n管理层预计2026-2028年将维持高强度并购,重点关注铜金资源,已储备多个潜在项目。厄瓜多尔Cangrejos金矿(预计年产黄金11.5吨)与KFM二期扩建构成中期增长极,摩根士丹利预测2027年铜当量产能突破150万吨。公司资产负债率控制在50%安全线内,在手现金超320亿元,为战略布局提供充足弹药。\"", | ||
| 113 | + "[10:56:04] }" | ||
| 114 | +] | ||
| 115 | + | ||
| 116 | +# InsightEngine反思总结 - 多行JSON格式(包含"正在生成反思总结"标识) | ||
| 117 | +REAL_INSIGHT_ENGINE_REFLECTION = [ | ||
| 118 | + "[10:55:19] 2025-11-06 10:55:19.563 | INFO | InsightEngine.nodes.summary_node:run:265 - 正在生成反思总结", | ||
| 119 | + "[10:56:41] 2025-11-06 10:56:41.626 | INFO | InsightEngine.nodes.summary_node:process_output:296 - 清理后的输出: {", | ||
| 120 | + "[10:56:41] \"updated_paragraph_latest_state\": \"## 核心发现(更新版)\\n洛阳钼业2025年第三季度市场表现呈现结构性分化,在全球铜价同比上涨18%(LME三个月期铜均价$8,927/吨)的背景下,公司股价却累计下跌12.3%,与申万有色金属指数7.8%的涨幅形成鲜明对比。深入分析显示,这种背离主要源于三大矛盾:全球能源转型红利与区域性运营风险的博弈、资源禀赋优势与ESG短板的冲突、以及机构估值框架转变与散户认知滞后的错位。最新舆情监测发现,专业投资者讨论焦点已从产量数据转向刚果(金)社区索赔案件的司法进展(涉及金额预估2.3亿美元),而散户仍在热议『新能源金属』概念炒作。\\n\\n## 详细数据画像\\n### 产量与成本\\n- 刚果(金)TFM铜钴矿:Q3铜产量12.8万吨(环比-7%),钴产量5,200吨(环比-9%),单位现金成本升至$1.52/lb(Q2为$1.38),因当地罢工导致14天停产(损失产值约3.2亿元)\\n- 巴西铌磷矿:铌铁产量2.1万吨(同比+4%),磷肥产量28万吨(创纪录),海运费用占比升至23%(2024年平均17%),但因巴西雷亚尔贬值节约本地成本1.8亿元\\n- 澳洲NPM铜金矿:铜品位下滑至0.72%(上年同期0.81%),但通过提高回收率维持产量稳定(回收率提升2.3个百分点至89.7%)\\n\\n### 财务指标\\n- 营收:Q3实现287亿元(同比+9.2%,环比-5.3%),低于彭博一致预期6%,主因铜钴销量下滑\\n- 现金流:经营活动现金流净额42亿元(同比-18%),资本开支达35亿元(KFM项目占72%)\\n- 负债:资产负债率升至58.3%(2024年末54.1%),新增20亿元公司债票面利率6.8%(较同行高120bp)\\n\\n### 市场反应\\n- 股价表现:三季度累计换手率287%,显著高于紫金矿业(189%)和江西铜业(156%),振幅达43%\\n- 机构动向:北向资金持仓减少1.2亿股,挪威养老基金持股比例从2.1%降至1.4%(ESG调仓)\\n- 舆情热度:百度指数『洛阳钼业』日均搜索量3,215次(同业排名第4),但专业平台Wind词频统计显示分析师关注度排名第2(含327份研报)\\n\\n## 多元声音汇聚\\n产业视角:\\n1. 【Fastmarkets分析师】『刚果(金)新矿业税实施后,TFM项目有效税率从31.5%升至35.8%,每磅铜的税负增加$0.12』(报告被引用87次)\\n2. 【巴西矿业协会】『尽管海运成本上升,洛阳钼业的铌磷矿仍是全球成本曲线左端20%的优质资产』\\n3. 【刚果矿业部长声明】『要求外资矿业企业本地采购比例需在2026年前达到40%』(现行25%)\\n4. 【澳洲矿产委员会】『NPM矿的劳工成本已超出可承受范围,可能影响2026年扩产计划』\\n\\n投资者声音:\\n5. 【雪球用户@价值挖掘机】『DCF模型显示,若刚果政策风险溢价上调200bp,公司合理估值应下调15-20%』(附详细测算表格,获专业认证)\\n6. 【股吧热帖】『社保基金三季报减持后,融资余额反而增加4.3亿元,多空博弈激烈』(单日点击量超10万)\\n7. 【推特机构账号】『MSCI将公司治理(G)评分从6.2降至5.4,主因董事会独立性问题』\\n8. 【机构投资者调研纪要】『至少7家基金质疑刚果子公司分红政策(近三年分红率仅12%)』\\n9. 【Reddit散户讨论】『看涨期权持仓量暴增300%,集中行权价9元』\\n\\n国际视角:\\n10. 【彭博社报道】『洛阳钼业与嘉能可的KFM项目股权谈判陷入僵局,双方对2026年后钴价预期差异达$5/lb』\\n11. 【刚果当地媒体】『TFM周边社区新提起3起环境诉讼,要求赔偿金合计8,000万美元』\\n12. 【澳洲矿业工人论坛】『NPM矿区的工会正酝酿新一轮薪资谈判,现有合同溢价已达行业平均125%』\\n13. 【路透社】『中国进出口银行可能为KFM项目提供15亿美元再融资』\\n14. 【非洲发展银行报告】『刚果矿业社区冲突事件同比增加47%』\\n\\n## 深层洞察升级\\n### 政策风险量化\\n通过蒙特卡洛模拟测算,在以下情境下:(1)刚果 royalty rate 上调3个百分点(2)海运成本维持当前水平(3)钴价徘徊在$25/lb,公司2026年EBITDA可能缩水23-28亿元。敏感性分析显示,刚果政策变量对估值影响权重从去年的18%升至31%。地缘政治专家指出,刚果大选临近使矿业政策不确定性指数达78(警戒线70)。\\n\\n### ESG影响拆解\\n- 环境(E):尾矿库管理被MSCI标红,主要因刚果项目的水循环利用率仅72%(国际同行平均85%),且2025年发生2次小规模渗漏\\n- 社会(S):社区关系评分暴跌,源于Q3当地雇佣比例降至43%(承诺目标60%),且医疗投入同比减少15%\\n- 治理(G):董事会中独立董事占比33%(仅1名具国际矿业经验),低于国际矿业公司平均45%的水平\\n\\n### 资金行为解析\\n龙虎榜数据显示,三季度机构专用席位净卖出23亿元(创历史季度纪录),但同时量化基金交易占比从12%升至19%,显示算法交易对股价波动增强效应。北向资金持仓成本分析表明,外资止损线集中在6.8元附近(现价7.2元)。值得注意的是,大宗交易溢价率从Q2的-3%收窄至-1.2%,暗示部分长线资金开始逢低吸纳。\\n\\n## 趋势和模式识别\\n1. 信息分层加剧:专业机构通过LME库存数据(近期亚洲仓库铜库存增加35%)预判供需变化,而散户仍依赖券商研报的乐观预测(『买入』评级占比仍达68%但较Q2下降11%)\\n2. ESG因子定价权提升:负面评级直接导致11月3日股价跳空低开3.2%,创三个月最大单日缺口\\n3. 多空博弈新特征:融券余额历史首次突破5亿元(日均利率达8.6%),同时场外期权隐含波动率升至52%(高于行业平均38%)\\n4. 成本通胀传导滞后:虽然硫酸等辅料价格上涨23%,但产品售价仅提升9%,毛利率承压明显\\n\\n## 对比分析\\n| 维度 | 洛阳钼业 | 紫金矿业 | 江西铜业 | 行业平均 |\\n|---------------------|------------------------|------------------------|------------------------|------------------------|\\n| 海外营收占比 | 68% | 55% | 32% | 48% |\\n| 铜矿现金成本 | $1.52/lb | $1.35/lb | $1.48/lb | $1.45/lb |\\n| ESG评级 | BB-(MSCI) | BBB(S&P) | BB+(MSCI) | BBB-(S&P) |\\n| Q3机构调研次数 | 87次 | 126次 | 53次 | 89次 |\\n| 散户持股比例 | 41% | 38% | 45% | 42% |\\n| 海外项目纠纷数 | 4起 | 2起 | 1起 | 2.3起 |\\n| 研发投入占比 | 0.8% | 1.2% | 0.9% | 1.1% |\\n\\n*数据周期:2025年第三季度,来源:公司公告、各评级机构、沪深交易所、彭博终端*\"", | ||
| 121 | + "[10:56:41] }" | ||
| 122 | +] | ||
| 123 | + | ||
| 124 | +# MediaEngine反思总结 - 单行JSON格式 | ||
| 125 | +REAL_MEDIA_ENGINE_REFLECTION = """[10:56:15] 2025-11-06 10:56:15.779 | INFO | MediaEngine.nodes.summary_node:run:268 - 正在生成反思总结 | ||
| 126 | +[10:56:42] 2025-11-06 10:56:42.337 | INFO | MediaEngine.nodes.summary_node:process_output:302 - 清理后的输出: {"updated_paragraph_latest_state": "## 综合信息概览\\r\\n根据当前查询需求,本段将围绕洛阳钼业的基本情况展开分析,重点涵盖其公司成立时间、总部位置、主营业务以及在全球矿业领域的地位。尽管本次提供的搜索结果为空,但基于对公开权威信息的掌握和行业常识,结合企业官网、年报及主流财经媒体的历史报道,可以系统性地还原洛阳钼业的核心概况。作为全球领先的多元化矿业集团,洛阳钼业在中国乃至世界有色金属行业中占据重要地位,其发展历程、战略布局与资源控制能力均体现出显著的国际化特征。\\r\\n\\r\\n## 文本内容深度分析\\r\\n洛阳钼业全称为洛阳栾川钼业集团股份有限公司,成立于2003年,其前身可追溯至1969年建立的栾川钼矿,标志着企业在钼钨资源开发领域拥有深厚的历史积淀。公司于2007年在香港联交所主板上市(股票代码:03993.HK),并于2012年在上海证券交易所主板上市(股票代码:603993),形成A+H股双资本平台格局,增强了融资能力和国际影响力。总部位于河南省洛阳市栾川县,地处中国中部重要的矿产资源富集区,依托当地丰富的钼、钨等战略金属储量,构建了从采矿、选矿到深加工的一体化产业链。公司的主营业务聚焦于基本金属和稀有金属的勘探、开采、加工与销售,核心产品包括钼、钨、铜、钴、铌、磷以及黄金等,形成了多元化的矿产品组合,有效提升了抗周期波动的能力。尤其在钼资源方面,洛阳钼业拥有的栾川矿区被誉为'世界三大钼矿之一',其钼金属储量位居全球前列;而在钨资源方面也具备世界级规模,是中国乃至全球最重要的钨生产商之一。近年来,通过一系列跨国并购,公司成功拓展至非洲和南美市场,特别是在刚果(金)运营的Tenke Fungurume铜钴矿,使其成为全球第二大钴生产商,在新能源电池原材料供应链中占据关键地位。此外,公司在巴西持有的铌矿(Catalão和Boa Vista项目)同样是全球高品位铌资源的重要供应源,铌广泛应用于高强度合金钢制造,服务于航空航天与高端装备制造领域。\\r\\n\\r\\n## 视觉信息解读\\r\\n虽然本次未提供相关图片资料,但从以往公开发布的公司宣传材料、年报封面及矿山实景图中可以推断出,洛阳钼业的品牌视觉通常以深蓝、灰色为主色调,象征着工业稳重与科技感,配以矿山开采场景、现代化选矿厂或地球仪元素,突出其'全球化矿业巨头'的定位。例如,在年度报告中常见大型露天矿坑航拍图,展现宏大的开采规模;也有员工在智能化控制中心监控生产流程的画面,体现数字化转型成果。这些视觉符号共同塑造了一个传统资源型企业向高科技、绿色化、国际化综合矿业集团转型的形象。若能获取近期官方发布的图片,预计将看到更多关于绿色矿山建设、生态修复工程以及海外项目本地社区合作的内容,反映ESG(环境、社会与治理)理念的深入实践。\\r\\n\\r\\n## 数据综合分析\\r\\n从财务与运营数据来看,洛阳钼业近年来保持稳健增长态势。根据2023年年报显示,公司全年实现营业收入约1,445亿元人民币,归母净利润超过80亿元,资产总额逾2,000亿元,展现出强大的盈利能力和资产实力。在资源储量方面,据JORC标准披露,公司控制的钼金属储量超过200万吨,钨储量约80万吨,铜资源量达数千万吨级别,钴资源量亦达数百万吨,资源禀赋极为优越。产量方面,2023年公司年产钼约1.7万吨、钨精矿折合WO₃约2.5万吨、铜金属约22万吨、钴金属约2.5万吨,其中铜钴产量主要来自刚果(金)和澳大利亚Northparkes项目。在全球矿业排名中,洛阳钼业连续多年入选《福布斯》全球企业2000强,并在《财富》中国500强中位列前茅。据SNL Metals & Mining等机构统计,其钴产量市场份额约占全球总产量的15%-18%,仅次于嘉能可(Glencore),居世界第二位;而钼产品的市场占有率同样位居全球前三。此外,公司研发投入持续增加,2023年研发费用超15亿元,主要用于智能矿山建设、低品位矿石综合利用技术及碳减排工艺优化,体现了向高质量发展模式转型的决心。\\r\\n\\r\\n## 多维度洞察\\r\\n综上所述,洛阳钼业不仅是一家根植于中国河南的地方性矿业企业,更已发展为具有全球资源配置能力的跨国矿业集团。其成功路径体现出'立足本土优势资源+战略性海外扩张'的双轮驱动模式。在国内,依托栾川世界级钼钨矿床建立了稳固的基本盘;在海外,通过精准并购实现了对关键战略矿产——尤其是新能源所需铜钴资源——的有效掌控,契合全球能源转型趋势。与此同时,公司积极推进数字化、智能化和绿色矿山建设,如在北秘鲁的Kisanfu铜钴矿采用无人驾驶运输系统和远程监控平台,提升安全与效率。未来,随着电动汽车、储能系统和可再生能源基础设施对铜、钴、铌等金属需求的持续攀升,洛阳钼业的战略价值将进一步凸显。然而,其海外运营也面临地缘政治风险、环保合规压力及社区关系管理等挑战,尤其是在刚果(金)等资源丰富但治理相对薄弱的国家。因此,如何平衡经济效益与社会责任、强化可持续发展能力,将是决定其长期竞争力的关键所在。"}""" | ||
| 127 | + | ||
| 128 | +# ===== SearchNode输出示例(应该被过滤,不应进入论坛)===== | ||
| 129 | + | ||
| 130 | +# SearchNode首次搜索查询 - 多行JSON格式 | ||
| 131 | +SEARCH_NODE_FIRST_SEARCH = [ | ||
| 132 | + "[11:16:35] 2025-11-06 11:16:35.567 | INFO | InsightEngine.nodes.search_node:process_output:97 - 清理后的输出: {", | ||
| 133 | + "[11:16:35] \"search_query\": \"大家怎么看\"", | ||
| 134 | + "[11:16:35] \"search_tool\": \"search_topic_globally\"", | ||
| 135 | + "[11:16:35] \"reasoning\": \"这是搜索查询的推理\"", | ||
| 136 | + "[11:16:35] \"enable_sentiment\": true", | ||
| 137 | + "[11:16:35] }" | ||
| 138 | +] | ||
| 139 | + | ||
| 140 | +# SearchNode反思搜索查询 - 单行JSON格式 | ||
| 141 | +SEARCH_NODE_REFLECTION_SEARCH = """[11:17:05] 2025-11-06 11:17:05.547 | INFO | InsightEngine.nodes.search_node:process_output:232 - 清理后的输出: {"search_query": "AI教育 数据泄露 不公平", "search_tool": "search_hot_content", "reasoning": "需要了解近期关于AI教育的热点争议,特别是公众最关心的数据安全和公平性问题,以补充具体案例和真实舆情数据", "time_period": "week", "enable_sentiment": true}""" | ||
| 142 | + | ||
| 143 | +# ===== 错误日志示例(应该被过滤,不应进入论坛)===== | ||
| 144 | + | ||
| 145 | +# SummaryNode的JSON解析失败错误日志 | ||
| 146 | +SUMMARY_NODE_JSON_ERROR = "[11:55:31] 2025-11-06 11:55:31.763 | ERROR | MediaEngine.nodes.summary_node:process_output:141 - JSON解析失败: Unterminated string starting at: line 1 column 28 (char 27)" | ||
| 147 | + | ||
| 148 | +# SummaryNode的JSON修复失败错误日志 | ||
| 149 | +SUMMARY_NODE_JSON_FIX_ERROR = "[11:55:31] 2025-11-06 11:55:31.799 | ERROR | MediaEngine.nodes.summary_node:process_output:149 - JSON修复失败,直接使用清理后的文本" | ||
| 150 | + | ||
| 151 | +# SummaryNode的ERROR级别日志(包含nodes.summary_node但不应被捕获) | ||
| 152 | +SUMMARY_NODE_ERROR_LOG = "[11:55:31] 2025-11-06 11:55:31.763 | ERROR | MediaEngine.nodes.summary_node:process_output:141 - 发生错误:无法处理输出" | ||
| 153 | + | ||
| 154 | +# SummaryNode的Traceback错误日志(虽然包含nodes.summary_node,但不应被捕获) | ||
| 155 | +SUMMARY_NODE_TRACEBACK = """[11:55:31] File "D:\\Programing\\BettaFish\\SingleEngineApp\\..\\MediaEngine\\nodes\\summary_node.py", line 138, in process_output | ||
| 156 | +[11:55:31] result = json.loads(cleaned_output)""" | ||
| 157 | + |
tests/run_tests.py
0 → 100644
| 1 | +""" | ||
| 2 | +简单的测试运行脚本 | ||
| 3 | + | ||
| 4 | +可以直接运行此脚本来执行测试 | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import sys | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +# 添加项目根目录到路径 | ||
| 11 | +project_root = Path(__file__).parent.parent | ||
| 12 | +sys.path.insert(0, str(project_root)) | ||
| 13 | + | ||
| 14 | +from test_monitor import TestLogMonitor | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def main(): | ||
| 18 | + """运行所有测试""" | ||
| 19 | + print("=" * 60) | ||
| 20 | + print("ForumEngine 日志解析测试") | ||
| 21 | + print("=" * 60) | ||
| 22 | + print() | ||
| 23 | + | ||
| 24 | + test_instance = TestLogMonitor() | ||
| 25 | + test_instance.setup_method() | ||
| 26 | + | ||
| 27 | + # 获取所有测试方法 | ||
| 28 | + test_methods = [method for method in dir(test_instance) if method.startswith('test_')] | ||
| 29 | + | ||
| 30 | + passed = 0 | ||
| 31 | + failed = 0 | ||
| 32 | + | ||
| 33 | + for test_method_name in test_methods: | ||
| 34 | + test_method = getattr(test_instance, test_method_name) | ||
| 35 | + print(f"运行测试: {test_method_name}...", end=" ") | ||
| 36 | + | ||
| 37 | + try: | ||
| 38 | + test_method() | ||
| 39 | + print("✓ 通过") | ||
| 40 | + passed += 1 | ||
| 41 | + except AssertionError as e: | ||
| 42 | + print(f"✗ 失败: {e}") | ||
| 43 | + failed += 1 | ||
| 44 | + except Exception as e: | ||
| 45 | + print(f"✗ 错误: {e}") | ||
| 46 | + failed += 1 | ||
| 47 | + | ||
| 48 | + print() | ||
| 49 | + print("=" * 60) | ||
| 50 | + print(f"测试结果: {passed} 通过, {failed} 失败") | ||
| 51 | + print("=" * 60) | ||
| 52 | + | ||
| 53 | + if failed > 0: | ||
| 54 | + sys.exit(1) | ||
| 55 | + else: | ||
| 56 | + sys.exit(0) | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +if __name__ == "__main__": | ||
| 60 | + main() | ||
| 61 | + |
tests/test_monitor.py
0 → 100644
| 1 | +""" | ||
| 2 | +测试ForumEngine/monitor.py中的日志解析函数 | ||
| 3 | + | ||
| 4 | +测试各种日志格式下的解析能力,包括: | ||
| 5 | +1. 旧格式:[HH:MM:SS] | ||
| 6 | +2. 新格式:loguru默认格式 (YYYY-MM-DD HH:mm:ss.SSS | LEVEL | ...) | ||
| 7 | +3. 只应当接收FirstSummaryNode、ReflectionSummaryNode等SummaryNode的输出,不应当接收SearchNode的输出 | ||
| 8 | +""" | ||
| 9 | + | ||
| 10 | +import sys | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +# 添加项目根目录到路径 | ||
| 14 | +project_root = Path(__file__).parent.parent | ||
| 15 | +sys.path.insert(0, str(project_root)) | ||
| 16 | + | ||
| 17 | +from ForumEngine.monitor import LogMonitor | ||
| 18 | +from tests import forum_log_test_data as test_data | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class TestLogMonitor: | ||
| 22 | + """测试LogMonitor的日志解析功能""" | ||
| 23 | + | ||
| 24 | + def setup_method(self): | ||
| 25 | + """每个测试方法前的初始化""" | ||
| 26 | + self.monitor = LogMonitor(log_dir="tests/test_logs") | ||
| 27 | + | ||
| 28 | + def test_is_target_log_line_old_format(self): | ||
| 29 | + """测试旧格式的目标节点识别""" | ||
| 30 | + # 应该识别包含FirstSummaryNode的行 | ||
| 31 | + assert self.monitor.is_target_log_line(test_data.OLD_FORMAT_FIRST_SUMMARY) == True | ||
| 32 | + # 应该识别包含ReflectionSummaryNode的行 | ||
| 33 | + assert self.monitor.is_target_log_line(test_data.OLD_FORMAT_REFLECTION_SUMMARY) == True | ||
| 34 | + # 不应该识别非目标节点 | ||
| 35 | + assert self.monitor.is_target_log_line(test_data.OLD_FORMAT_NON_TARGET) == False | ||
| 36 | + | ||
| 37 | + def test_is_target_log_line_new_format(self): | ||
| 38 | + """测试新格式的目标节点识别""" | ||
| 39 | + # 应该识别包含FirstSummaryNode的行 | ||
| 40 | + assert self.monitor.is_target_log_line(test_data.NEW_FORMAT_FIRST_SUMMARY) == True | ||
| 41 | + # 应该识别包含ReflectionSummaryNode的行 | ||
| 42 | + assert self.monitor.is_target_log_line(test_data.NEW_FORMAT_REFLECTION_SUMMARY) == True | ||
| 43 | + # 不应该识别非目标节点 | ||
| 44 | + assert self.monitor.is_target_log_line(test_data.NEW_FORMAT_NON_TARGET) == False | ||
| 45 | + | ||
| 46 | + def test_is_json_start_line_old_format(self): | ||
| 47 | + """测试旧格式的JSON开始行识别""" | ||
| 48 | + assert self.monitor.is_json_start_line(test_data.OLD_FORMAT_SINGLE_LINE_JSON) == True | ||
| 49 | + assert self.monitor.is_json_start_line(test_data.OLD_FORMAT_MULTILINE_JSON[0]) == True | ||
| 50 | + assert self.monitor.is_json_start_line(test_data.OLD_FORMAT_NON_TARGET) == False | ||
| 51 | + | ||
| 52 | + def test_is_json_start_line_new_format(self): | ||
| 53 | + """测试新格式的JSON开始行识别""" | ||
| 54 | + assert self.monitor.is_json_start_line(test_data.NEW_FORMAT_SINGLE_LINE_JSON) == True | ||
| 55 | + assert self.monitor.is_json_start_line(test_data.NEW_FORMAT_MULTILINE_JSON[0]) == True | ||
| 56 | + assert self.monitor.is_json_start_line(test_data.NEW_FORMAT_NON_TARGET) == False | ||
| 57 | + | ||
| 58 | + def test_is_json_end_line(self): | ||
| 59 | + """测试JSON结束行识别""" | ||
| 60 | + assert self.monitor.is_json_end_line("}") == True | ||
| 61 | + assert self.monitor.is_json_end_line("] }") == True | ||
| 62 | + assert self.monitor.is_json_end_line("[17:42:31] }") == False # 需要先清理时间戳 | ||
| 63 | + assert self.monitor.is_json_end_line("2025-11-05 17:42:31.289 | INFO | module:function:133 - }") == False # 需要先清理时间戳 | ||
| 64 | + | ||
| 65 | + def test_extract_json_content_old_format_single_line(self): | ||
| 66 | + """测试旧格式单行JSON提取""" | ||
| 67 | + lines = [test_data.OLD_FORMAT_SINGLE_LINE_JSON] | ||
| 68 | + result = self.monitor.extract_json_content(lines) | ||
| 69 | + assert result is not None | ||
| 70 | + assert "这是首次总结内容" in result | ||
| 71 | + | ||
| 72 | + def test_extract_json_content_new_format_single_line(self): | ||
| 73 | + """测试新格式单行JSON提取""" | ||
| 74 | + lines = [test_data.NEW_FORMAT_SINGLE_LINE_JSON] | ||
| 75 | + result = self.monitor.extract_json_content(lines) | ||
| 76 | + assert result is not None | ||
| 77 | + assert "这是首次总结内容" in result | ||
| 78 | + | ||
| 79 | + def test_extract_json_content_old_format_multiline(self): | ||
| 80 | + """测试旧格式多行JSON提取""" | ||
| 81 | + result = self.monitor.extract_json_content(test_data.OLD_FORMAT_MULTILINE_JSON) | ||
| 82 | + assert result is not None | ||
| 83 | + assert "多行" in result | ||
| 84 | + assert "JSON内容" in result | ||
| 85 | + | ||
| 86 | + def test_extract_json_content_new_format_multiline(self): | ||
| 87 | + """测试新格式多行JSON提取(支持loguru格式的时间戳移除)""" | ||
| 88 | + result = self.monitor.extract_json_content(test_data.NEW_FORMAT_MULTILINE_JSON) | ||
| 89 | + assert result is not None | ||
| 90 | + assert "多行" in result | ||
| 91 | + assert "JSON内容" in result | ||
| 92 | + | ||
| 93 | + def test_extract_json_content_updated_priority(self): | ||
| 94 | + """测试updated_paragraph_latest_state优先提取""" | ||
| 95 | + result = self.monitor.extract_json_content(test_data.COMPLEX_JSON_WITH_UPDATED) | ||
| 96 | + assert result is not None | ||
| 97 | + assert "更新版" in result | ||
| 98 | + assert "核心发现" in result | ||
| 99 | + | ||
| 100 | + def test_extract_json_content_paragraph_only(self): | ||
| 101 | + """测试只有paragraph_latest_state的情况""" | ||
| 102 | + result = self.monitor.extract_json_content(test_data.COMPLEX_JSON_WITH_PARAGRAPH) | ||
| 103 | + assert result is not None | ||
| 104 | + assert "首次总结" in result or "核心发现" in result | ||
| 105 | + | ||
| 106 | + def test_format_json_content(self): | ||
| 107 | + """测试JSON内容格式化""" | ||
| 108 | + # 测试updated_paragraph_latest_state优先 | ||
| 109 | + json_obj = { | ||
| 110 | + "updated_paragraph_latest_state": "更新后的内容", | ||
| 111 | + "paragraph_latest_state": "首次内容" | ||
| 112 | + } | ||
| 113 | + result = self.monitor.format_json_content(json_obj) | ||
| 114 | + assert result == "更新后的内容" | ||
| 115 | + | ||
| 116 | + # 测试只有paragraph_latest_state | ||
| 117 | + json_obj = { | ||
| 118 | + "paragraph_latest_state": "首次内容" | ||
| 119 | + } | ||
| 120 | + result = self.monitor.format_json_content(json_obj) | ||
| 121 | + assert result == "首次内容" | ||
| 122 | + | ||
| 123 | + # 测试都没有的情况 | ||
| 124 | + json_obj = {"other_field": "其他内容"} | ||
| 125 | + result = self.monitor.format_json_content(json_obj) | ||
| 126 | + assert "清理后的输出" in result | ||
| 127 | + | ||
| 128 | + def test_extract_node_content_old_format(self): | ||
| 129 | + """测试旧格式的节点内容提取""" | ||
| 130 | + line = "[17:42:31] [INSIGHT] [FirstSummaryNode] 清理后的输出: 这是测试内容" | ||
| 131 | + result = self.monitor.extract_node_content(line) | ||
| 132 | + assert result is not None | ||
| 133 | + assert "测试内容" in result | ||
| 134 | + | ||
| 135 | + def test_extract_node_content_new_format(self): | ||
| 136 | + """测试新格式的节点内容提取""" | ||
| 137 | + line = "2025-11-05 17:42:31.287 | INFO | InsightEngine.nodes.summary_node:process_output:131 - FirstSummaryNode 清理后的输出: 这是测试内容" | ||
| 138 | + result = self.monitor.extract_node_content(line) | ||
| 139 | + assert result is not None | ||
| 140 | + assert "测试内容" in result | ||
| 141 | + | ||
| 142 | + def test_process_lines_for_json_old_format(self): | ||
| 143 | + """测试旧格式的完整处理流程""" | ||
| 144 | + lines = [ | ||
| 145 | + test_data.OLD_FORMAT_NON_TARGET, # 应该被忽略 | ||
| 146 | + test_data.OLD_FORMAT_MULTILINE_JSON[0], | ||
| 147 | + test_data.OLD_FORMAT_MULTILINE_JSON[1], | ||
| 148 | + test_data.OLD_FORMAT_MULTILINE_JSON[2], | ||
| 149 | + ] | ||
| 150 | + result = self.monitor.process_lines_for_json(lines, "insight") | ||
| 151 | + assert len(result) > 0 | ||
| 152 | + assert any("多行" in content for content in result) | ||
| 153 | + | ||
| 154 | + def test_process_lines_for_json_new_format(self): | ||
| 155 | + """测试新格式的完整处理流程""" | ||
| 156 | + lines = [ | ||
| 157 | + test_data.NEW_FORMAT_NON_TARGET, # 应该被忽略 | ||
| 158 | + test_data.NEW_FORMAT_MULTILINE_JSON[0], | ||
| 159 | + test_data.NEW_FORMAT_MULTILINE_JSON[1], | ||
| 160 | + test_data.NEW_FORMAT_MULTILINE_JSON[2], | ||
| 161 | + ] | ||
| 162 | + result = self.monitor.process_lines_for_json(lines, "insight") | ||
| 163 | + assert len(result) > 0 | ||
| 164 | + assert any("多行" in content for content in result) | ||
| 165 | + assert any("JSON内容" in content for content in result) | ||
| 166 | + | ||
| 167 | + def test_process_lines_for_json_mixed_format(self): | ||
| 168 | + """测试混合格式的处理""" | ||
| 169 | + result = self.monitor.process_lines_for_json(test_data.MIXED_FORMAT_LINES, "insight") | ||
| 170 | + assert len(result) > 0 | ||
| 171 | + assert any("混合格式内容" in content for content in result) | ||
| 172 | + | ||
| 173 | + def test_is_valuable_content(self): | ||
| 174 | + """测试有价值内容的判断""" | ||
| 175 | + # 包含"清理后的输出"应该是有价值的 | ||
| 176 | + assert self.monitor.is_valuable_content(test_data.OLD_FORMAT_SINGLE_LINE_JSON) == True | ||
| 177 | + | ||
| 178 | + # 排除短小提示信息 | ||
| 179 | + assert self.monitor.is_valuable_content("JSON解析成功") == False | ||
| 180 | + assert self.monitor.is_valuable_content("成功生成") == False | ||
| 181 | + | ||
| 182 | + # 空行应该被过滤 | ||
| 183 | + assert self.monitor.is_valuable_content("") == False | ||
| 184 | + | ||
| 185 | + def test_extract_json_content_real_query_engine(self): | ||
| 186 | + """测试QueryEngine实际生产环境日志提取""" | ||
| 187 | + result = self.monitor.extract_json_content(test_data.REAL_QUERY_ENGINE_REFLECTION) | ||
| 188 | + assert result is not None | ||
| 189 | + assert "洛阳栾川钼业集团" in result | ||
| 190 | + assert "CMOC" in result | ||
| 191 | + assert "updated_paragraph_latest_state" not in result # 应该已经提取内容,不包含字段名 | ||
| 192 | + | ||
| 193 | + def test_extract_json_content_real_insight_engine(self): | ||
| 194 | + """测试InsightEngine实际生产环境日志提取(包含标识行)""" | ||
| 195 | + # 先测试能否识别标识行 | ||
| 196 | + assert self.monitor.is_target_log_line(test_data.REAL_INSIGHT_ENGINE_REFLECTION[0]) == True # 包含"正在生成反思总结" | ||
| 197 | + assert self.monitor.is_target_log_line(test_data.REAL_INSIGHT_ENGINE_REFLECTION[1]) == True # 包含nodes.summary_node | ||
| 198 | + | ||
| 199 | + # 测试JSON提取(从第二行开始,因为第一行是标识行) | ||
| 200 | + json_lines = test_data.REAL_INSIGHT_ENGINE_REFLECTION[1:] # 跳过标识行 | ||
| 201 | + result = self.monitor.extract_json_content(json_lines) | ||
| 202 | + assert result is not None | ||
| 203 | + assert "核心发现" in result | ||
| 204 | + assert "更新版" in result | ||
| 205 | + assert "洛阳钼业2025年第三季度" in result | ||
| 206 | + | ||
| 207 | + def test_extract_json_content_real_media_engine(self): | ||
| 208 | + """测试MediaEngine实际生产环境日志提取(单行JSON)""" | ||
| 209 | + # MediaEngine是单行JSON格式,需要先分割成行 | ||
| 210 | + lines = test_data.REAL_MEDIA_ENGINE_REFLECTION.split('\n') | ||
| 211 | + | ||
| 212 | + # 测试能否识别标识行 | ||
| 213 | + assert self.monitor.is_target_log_line(lines[0]) == True # 包含"正在生成反思总结" | ||
| 214 | + assert self.monitor.is_target_log_line(lines[1]) == True # 包含nodes.summary_node和"清理后的输出" | ||
| 215 | + | ||
| 216 | + # 测试JSON提取(从包含JSON的行开始) | ||
| 217 | + json_line = lines[1] # 第二行包含完整的单行JSON | ||
| 218 | + result = self.monitor.extract_json_content([json_line]) | ||
| 219 | + assert result is not None | ||
| 220 | + assert "综合信息概览" in result | ||
| 221 | + assert "洛阳钼业" in result | ||
| 222 | + assert "updated_paragraph_latest_state" not in result # 应该已经提取内容 | ||
| 223 | + | ||
| 224 | + def test_process_lines_for_json_real_query_engine(self): | ||
| 225 | + """测试QueryEngine实际日志的完整处理流程""" | ||
| 226 | + result = self.monitor.process_lines_for_json(test_data.REAL_QUERY_ENGINE_REFLECTION, "query") | ||
| 227 | + assert len(result) > 0 | ||
| 228 | + assert any("洛阳栾川钼业集团" in content for content in result) | ||
| 229 | + | ||
| 230 | + def test_process_lines_for_json_real_insight_engine(self): | ||
| 231 | + """测试InsightEngine实际日志的完整处理流程(包含标识行)""" | ||
| 232 | + result = self.monitor.process_lines_for_json(test_data.REAL_INSIGHT_ENGINE_REFLECTION, "insight") | ||
| 233 | + assert len(result) > 0 | ||
| 234 | + assert any("核心发现" in content for content in result) | ||
| 235 | + assert any("更新版" in content for content in result) | ||
| 236 | + | ||
| 237 | + def test_process_lines_for_json_real_media_engine(self): | ||
| 238 | + """测试MediaEngine实际日志的完整处理流程(单行JSON)""" | ||
| 239 | + # 将单行字符串分割成多行 | ||
| 240 | + lines = test_data.REAL_MEDIA_ENGINE_REFLECTION.split('\n') | ||
| 241 | + result = self.monitor.process_lines_for_json(lines, "media") | ||
| 242 | + assert len(result) > 0 | ||
| 243 | + assert any("综合信息概览" in content for content in result) | ||
| 244 | + assert any("洛阳钼业" in content for content in result) | ||
| 245 | + | ||
| 246 | + def test_filter_search_node_output(self): | ||
| 247 | + """测试过滤SearchNode的输出(重要:SearchNode不应进入论坛)""" | ||
| 248 | + # SearchNode的输出包含"清理后的输出: {",但不包含目标节点模式 | ||
| 249 | + search_lines = test_data.SEARCH_NODE_FIRST_SEARCH | ||
| 250 | + result = self.monitor.process_lines_for_json(search_lines, "insight") | ||
| 251 | + # SearchNode的输出应该被过滤,不应该被捕获 | ||
| 252 | + assert len(result) == 0 | ||
| 253 | + | ||
| 254 | + def test_filter_search_node_output_single_line(self): | ||
| 255 | + """测试过滤SearchNode的单行JSON输出""" | ||
| 256 | + # SearchNode的单行JSON格式 | ||
| 257 | + search_line = test_data.SEARCH_NODE_REFLECTION_SEARCH | ||
| 258 | + result = self.monitor.process_lines_for_json([search_line], "insight") | ||
| 259 | + # SearchNode的输出应该被过滤 | ||
| 260 | + assert len(result) == 0 | ||
| 261 | + | ||
| 262 | + def test_search_node_vs_summary_node_mixed(self): | ||
| 263 | + """测试混合场景:SearchNode和SummaryNode同时存在,只捕获SummaryNode""" | ||
| 264 | + lines = [ | ||
| 265 | + # SearchNode输出(应该被过滤) | ||
| 266 | + "[11:16:35] 2025-11-06 11:16:35.567 | INFO | InsightEngine.nodes.search_node:process_output:97 - 清理后的输出: {", | ||
| 267 | + "[11:16:35] \"search_query\": \"测试查询\"", | ||
| 268 | + "[11:16:35] }", | ||
| 269 | + # SummaryNode输出(应该被捕获) | ||
| 270 | + "[11:17:05] 2025-11-06 11:17:05.547 | INFO | InsightEngine.nodes.summary_node:process_output:131 - 清理后的输出: {", | ||
| 271 | + "[11:17:05] \"paragraph_latest_state\": \"这是总结内容\"", | ||
| 272 | + "[11:17:05] }", | ||
| 273 | + ] | ||
| 274 | + result = self.monitor.process_lines_for_json(lines, "insight") | ||
| 275 | + # 应该只捕获SummaryNode的输出,不包含SearchNode的输出 | ||
| 276 | + assert len(result) > 0 | ||
| 277 | + assert any("总结内容" in content for content in result) | ||
| 278 | + # 确保不包含搜索查询内容 | ||
| 279 | + assert not any("search_query" in content for content in result) | ||
| 280 | + assert not any("测试查询" in content for content in result) | ||
| 281 | + | ||
| 282 | + def test_filter_error_logs_from_summary_node(self): | ||
| 283 | + """测试过滤SummaryNode的错误日志(重要:错误日志不应进入论坛)""" | ||
| 284 | + # JSON解析失败错误日志 | ||
| 285 | + assert self.monitor.is_target_log_line(test_data.SUMMARY_NODE_JSON_ERROR) == False | ||
| 286 | + | ||
| 287 | + # JSON修复失败错误日志 | ||
| 288 | + assert self.monitor.is_target_log_line(test_data.SUMMARY_NODE_JSON_FIX_ERROR) == False | ||
| 289 | + | ||
| 290 | + # ERROR级别日志 | ||
| 291 | + assert self.monitor.is_target_log_line(test_data.SUMMARY_NODE_ERROR_LOG) == False | ||
| 292 | + | ||
| 293 | + # Traceback错误日志 | ||
| 294 | + for line in test_data.SUMMARY_NODE_TRACEBACK.split('\n'): | ||
| 295 | + assert self.monitor.is_target_log_line(line) == False | ||
| 296 | + | ||
| 297 | + def test_error_logs_not_captured(self): | ||
| 298 | + """测试错误日志不会被捕获到论坛""" | ||
| 299 | + error_lines = [ | ||
| 300 | + test_data.SUMMARY_NODE_JSON_ERROR, | ||
| 301 | + test_data.SUMMARY_NODE_JSON_FIX_ERROR, | ||
| 302 | + test_data.SUMMARY_NODE_ERROR_LOG, | ||
| 303 | + ] | ||
| 304 | + | ||
| 305 | + for line in error_lines: | ||
| 306 | + result = self.monitor.process_lines_for_json([line], "media") | ||
| 307 | + # 错误日志不应该被捕获 | ||
| 308 | + assert len(result) == 0 | ||
| 309 | + | ||
| 310 | + def test_mixed_valid_and_error_logs(self): | ||
| 311 | + """测试混合场景:有效日志和错误日志同时存在,只捕获有效日志""" | ||
| 312 | + lines = [ | ||
| 313 | + # 错误日志(应该被过滤) | ||
| 314 | + test_data.SUMMARY_NODE_JSON_ERROR, | ||
| 315 | + test_data.SUMMARY_NODE_JSON_FIX_ERROR, | ||
| 316 | + # 有效SummaryNode输出(应该被捕获) | ||
| 317 | + "[11:55:31] 2025-11-06 11:55:31.762 | INFO | MediaEngine.nodes.summary_node:process_output:134 - 清理后的输出: {", | ||
| 318 | + "[11:55:31] \"paragraph_latest_state\": \"这是有效的总结内容\"", | ||
| 319 | + "[11:55:31] }", | ||
| 320 | + ] | ||
| 321 | + result = self.monitor.process_lines_for_json(lines, "media") | ||
| 322 | + # 应该只捕获有效日志,不包含错误日志 | ||
| 323 | + assert len(result) > 0 | ||
| 324 | + assert any("有效的总结内容" in content for content in result) | ||
| 325 | + # 确保不包含错误信息 | ||
| 326 | + assert not any("JSON解析失败" in content for content in result) | ||
| 327 | + assert not any("JSON修复失败" in content for content in result) | ||
| 328 | + | ||
| 329 | + | ||
| 330 | +def run_tests(): | ||
| 331 | + """运行所有测试""" | ||
| 332 | + import pytest | ||
| 333 | + | ||
| 334 | + # 运行测试 | ||
| 335 | + pytest.main([__file__, "-v"]) | ||
| 336 | + | ||
| 337 | + | ||
| 338 | +if __name__ == "__main__": | ||
| 339 | + run_tests() | ||
| 340 | + |
-
Please register or login to post a comment