mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 05:31:52 +00:00
fix(message_handler): 取消分页拉取、Ruff 自动修复
Merge pull request #39 from exynos967/master
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""
|
"""
|
||||||
QQ群日常分析插件
|
QQ群日常分析插件
|
||||||
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
||||||
|
|
||||||
@@ -7,12 +7,13 @@ QQ群日常分析插件
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from astrbot.api.event import filter
|
from astrbot.api.event import filter
|
||||||
from astrbot.api.star import Context, Star
|
from astrbot.api.star import Context, Star
|
||||||
from astrbot.api import logger, AstrBotConfig
|
from astrbot.api import logger, AstrBotConfig
|
||||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
|
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
|
||||||
|
AiocqhttpMessageEvent,
|
||||||
|
)
|
||||||
from astrbot.core.message.components import File
|
from astrbot.core.message.components import File
|
||||||
from astrbot.core.star.filter.permission import PermissionType
|
from astrbot.core.star.filter.permission import PermissionType
|
||||||
|
|
||||||
@@ -39,7 +40,12 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
# 初始化模块化组件
|
# 初始化模块化组件
|
||||||
global config_manager, bot_manager, message_analyzer, report_generator, auto_scheduler
|
global \
|
||||||
|
config_manager, \
|
||||||
|
bot_manager, \
|
||||||
|
message_analyzer, \
|
||||||
|
report_generator, \
|
||||||
|
auto_scheduler
|
||||||
|
|
||||||
config_manager = ConfigManager(config)
|
config_manager = ConfigManager(config)
|
||||||
bot_manager = BotManager(config_manager)
|
bot_manager = BotManager(config_manager)
|
||||||
@@ -52,7 +58,7 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
message_analyzer,
|
message_analyzer,
|
||||||
report_generator,
|
report_generator,
|
||||||
bot_manager,
|
bot_manager,
|
||||||
self.html_render # 传入html_render函数
|
self.html_render, # 传入html_render函数
|
||||||
)
|
)
|
||||||
|
|
||||||
# 延迟启动自动调度器,给系统时间初始化
|
# 延迟启动自动调度器,给系统时间初始化
|
||||||
@@ -85,45 +91,52 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
"""插件被卸载/停用时调用,清理资源"""
|
"""插件被卸载/停用时调用,清理资源"""
|
||||||
try:
|
try:
|
||||||
logger.info("开始清理QQ群日常分析插件资源...")
|
logger.info("开始清理QQ群日常分析插件资源...")
|
||||||
|
|
||||||
global auto_scheduler, bot_manager, message_analyzer, report_generator, config_manager
|
global \
|
||||||
|
auto_scheduler, \
|
||||||
|
bot_manager, \
|
||||||
|
message_analyzer, \
|
||||||
|
report_generator, \
|
||||||
|
config_manager
|
||||||
|
|
||||||
# 停止自动调度器
|
# 停止自动调度器
|
||||||
if auto_scheduler:
|
if auto_scheduler:
|
||||||
logger.info("正在停止自动调度器...")
|
logger.info("正在停止自动调度器...")
|
||||||
await auto_scheduler.stop_scheduler()
|
await auto_scheduler.stop_scheduler()
|
||||||
logger.info("自动调度器已停止")
|
logger.info("自动调度器已停止")
|
||||||
|
|
||||||
# 清理bot管理器资源
|
# 清理bot管理器资源
|
||||||
# if bot_manager:
|
# if bot_manager:
|
||||||
# logger.info("正在清理bot管理器资源...")
|
# logger.info("正在清理bot管理器资源...")
|
||||||
# # 如果有其他需要清理的资源,可以在这里添加
|
# # 如果有其他需要清理的资源,可以在这里添加
|
||||||
|
|
||||||
# # 清理消息分析器资源
|
# # 清理消息分析器资源
|
||||||
# if message_analyzer:
|
# if message_analyzer:
|
||||||
# logger.info("正在清理消息分析器资源...")
|
# logger.info("正在清理消息分析器资源...")
|
||||||
# # 如果有其他需要清理的资源,可以在这里添加
|
# # 如果有其他需要清理的资源,可以在这里添加
|
||||||
|
|
||||||
# # 清理报告生成器资源
|
# # 清理报告生成器资源
|
||||||
# if report_generator:
|
# if report_generator:
|
||||||
# logger.info("正在清理报告生成器资源...")
|
# logger.info("正在清理报告生成器资源...")
|
||||||
# # 如果有其他需要清理的资源,可以在这里添加
|
# # 如果有其他需要清理的资源,可以在这里添加
|
||||||
|
|
||||||
# 重置全局变量
|
# 重置全局变量
|
||||||
auto_scheduler = None
|
auto_scheduler = None
|
||||||
bot_manager = None
|
bot_manager = None
|
||||||
message_analyzer = None
|
message_analyzer = None
|
||||||
report_generator = None
|
report_generator = None
|
||||||
config_manager = None
|
config_manager = None
|
||||||
|
|
||||||
logger.info("QQ群日常分析插件资源清理完成")
|
logger.info("QQ群日常分析插件资源清理完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"插件资源清理失败: {e}")
|
logger.error(f"插件资源清理失败: {e}")
|
||||||
|
|
||||||
@filter.command("群分析")
|
@filter.command("群分析")
|
||||||
@filter.permission_type(PermissionType.ADMIN)
|
@filter.permission_type(PermissionType.ADMIN)
|
||||||
async def analyze_group_daily(self, event: AiocqhttpMessageEvent, days: Optional[int] = None):
|
async def analyze_group_daily(
|
||||||
|
self, event: AiocqhttpMessageEvent, days: Optional[int] = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
分析群聊日常活动
|
分析群聊日常活动
|
||||||
用法: /群分析 [天数]
|
用法: /群分析 [天数]
|
||||||
@@ -147,7 +160,9 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 设置分析天数
|
# 设置分析天数
|
||||||
analysis_days = days if days and 1 <= days <= 7 else config_manager.get_analysis_days()
|
analysis_days = (
|
||||||
|
days if days and 1 <= days <= 7 else config_manager.get_analysis_days()
|
||||||
|
)
|
||||||
|
|
||||||
yield event.plain_result(f"🔍 开始分析群聊近{analysis_days}天的活动,请稍候...")
|
yield event.plain_result(f"🔍 开始分析群聊近{analysis_days}天的活动,请稍候...")
|
||||||
|
|
||||||
@@ -156,21 +171,31 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取群聊消息
|
# 获取群聊消息
|
||||||
messages = await message_analyzer.message_handler.fetch_group_messages(bot_manager.get_bot_instance(), group_id, analysis_days)
|
messages = await message_analyzer.message_handler.fetch_group_messages(
|
||||||
|
bot_manager.get_bot_instance(), group_id, analysis_days
|
||||||
|
)
|
||||||
if not messages:
|
if not messages:
|
||||||
yield event.plain_result("❌ 未找到足够的群聊记录,请确保群内有足够的消息历史")
|
yield event.plain_result(
|
||||||
|
"❌ 未找到足够的群聊记录,请确保群内有足够的消息历史"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 检查消息数量是否足够分析
|
# 检查消息数量是否足够分析
|
||||||
min_threshold = config_manager.get_min_messages_threshold()
|
min_threshold = config_manager.get_min_messages_threshold()
|
||||||
if len(messages) < min_threshold:
|
if len(messages) < min_threshold:
|
||||||
yield event.plain_result(f"❌ 消息数量不足({len(messages)}条),至少需要{min_threshold}条消息才能进行有效分析")
|
yield event.plain_result(
|
||||||
|
f"❌ 消息数量不足({len(messages)}条),至少需要{min_threshold}条消息才能进行有效分析"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
yield event.plain_result(f"📊 已获取{len(messages)}条消息,正在进行智能分析...")
|
yield event.plain_result(
|
||||||
|
f"📊 已获取{len(messages)}条消息,正在进行智能分析..."
|
||||||
|
)
|
||||||
|
|
||||||
# 进行分析 - 传递 unified_msg_origin 以获取正确的 LLM 提供商
|
# 进行分析 - 传递 unified_msg_origin 以获取正确的 LLM 提供商
|
||||||
analysis_result = await message_analyzer.analyze_messages(messages, group_id, event.unified_msg_origin)
|
analysis_result = await message_analyzer.analyze_messages(
|
||||||
|
messages, group_id, event.unified_msg_origin
|
||||||
|
)
|
||||||
|
|
||||||
# 检查分析结果
|
# 检查分析结果
|
||||||
if not analysis_result or not analysis_result.get("statistics"):
|
if not analysis_result or not analysis_result.get("statistics"):
|
||||||
@@ -180,23 +205,32 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
# 生成报告
|
# 生成报告
|
||||||
output_format = config_manager.get_output_format()
|
output_format = config_manager.get_output_format()
|
||||||
if output_format == "image":
|
if output_format == "image":
|
||||||
image_url = await report_generator.generate_image_report(analysis_result, group_id, self.html_render)
|
image_url = await report_generator.generate_image_report(
|
||||||
|
analysis_result, group_id, self.html_render
|
||||||
|
)
|
||||||
if image_url:
|
if image_url:
|
||||||
yield event.image_result(image_url)
|
yield event.image_result(image_url)
|
||||||
else:
|
else:
|
||||||
# 如果图片生成失败,回退到文本报告
|
# 如果图片生成失败,回退到文本报告
|
||||||
logger.warning("图片报告生成失败,回退到文本报告")
|
logger.warning("图片报告生成失败,回退到文本报告")
|
||||||
text_report = report_generator.generate_text_report(analysis_result)
|
text_report = report_generator.generate_text_report(analysis_result)
|
||||||
yield event.plain_result(f"⚠️ 图片报告生成失败,以下是文本版本:\n\n{text_report}")
|
yield event.plain_result(
|
||||||
|
f"⚠️ 图片报告生成失败,以下是文本版本:\n\n{text_report}"
|
||||||
|
)
|
||||||
elif output_format == "pdf":
|
elif output_format == "pdf":
|
||||||
if not config_manager.pyppeteer_available:
|
if not config_manager.pyppeteer_available:
|
||||||
yield event.plain_result("❌ PDF 功能不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2")
|
yield event.plain_result(
|
||||||
|
"❌ PDF 功能不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
pdf_path = await report_generator.generate_pdf_report(analysis_result, group_id)
|
pdf_path = await report_generator.generate_pdf_report(
|
||||||
|
analysis_result, group_id
|
||||||
|
)
|
||||||
if pdf_path:
|
if pdf_path:
|
||||||
# 发送 PDF 文件
|
# 发送 PDF 文件
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
pdf_file = File(name=Path(pdf_path).name, file=pdf_path)
|
pdf_file = File(name=Path(pdf_path).name, file=pdf_path)
|
||||||
result = event.make_result()
|
result = event.make_result()
|
||||||
result.chain.append(pdf_file)
|
result.chain.append(pdf_file)
|
||||||
@@ -212,20 +246,24 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
# 回退到文本报告
|
# 回退到文本报告
|
||||||
logger.warning("PDF 报告生成失败,回退到文本报告")
|
logger.warning("PDF 报告生成失败,回退到文本报告")
|
||||||
text_report = report_generator.generate_text_report(analysis_result)
|
text_report = report_generator.generate_text_report(analysis_result)
|
||||||
yield event.plain_result(f"\n📝 以下是文本版本的分析报告:\n\n{text_report}")
|
yield event.plain_result(
|
||||||
|
f"\n📝 以下是文本版本的分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
text_report = report_generator.generate_text_report(analysis_result)
|
text_report = report_generator.generate_text_report(analysis_result)
|
||||||
yield event.plain_result(text_report)
|
yield event.plain_result(text_report)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"群分析失败: {e}", exc_info=True)
|
logger.error(f"群分析失败: {e}", exc_info=True)
|
||||||
yield event.plain_result(f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员")
|
yield event.plain_result(
|
||||||
|
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
|
||||||
|
)
|
||||||
|
|
||||||
@filter.command("设置格式")
|
@filter.command("设置格式")
|
||||||
@filter.permission_type(PermissionType.ADMIN)
|
@filter.permission_type(PermissionType.ADMIN)
|
||||||
async def set_output_format(self, event: AiocqhttpMessageEvent, format_type: str = ""):
|
async def set_output_format(
|
||||||
|
self, event: AiocqhttpMessageEvent, format_type: str = ""
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
设置分析报告输出格式
|
设置分析报告输出格式
|
||||||
用法: /设置格式 [image|text|pdf]
|
用法: /设置格式 [image|text|pdf]
|
||||||
@@ -241,7 +279,9 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
if not format_type:
|
if not format_type:
|
||||||
current_format = config_manager.get_output_format()
|
current_format = config_manager.get_output_format()
|
||||||
pdf_status = '✅' if config_manager.pyppeteer_available else '❌ (需安装 pyppeteer)'
|
pdf_status = (
|
||||||
|
"✅" if config_manager.pyppeteer_available else "❌ (需安装 pyppeteer)"
|
||||||
|
)
|
||||||
yield event.plain_result(f"""📊 当前输出格式: {current_format}
|
yield event.plain_result(f"""📊 当前输出格式: {current_format}
|
||||||
|
|
||||||
可用格式:
|
可用格式:
|
||||||
@@ -258,7 +298,9 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if format_type == "pdf" and not config_manager.pyppeteer_available:
|
if format_type == "pdf" and not config_manager.pyppeteer_available:
|
||||||
yield event.plain_result("❌ PDF 格式不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2")
|
yield event.plain_result(
|
||||||
|
"❌ PDF 格式不可用,请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
config_manager.set_output_format(format_type)
|
config_manager.set_output_format(format_type)
|
||||||
@@ -281,7 +323,7 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
# 安装 pyppeteer
|
# 安装 pyppeteer
|
||||||
result = await PDFInstaller.install_pyppeteer(config_manager)
|
result = await PDFInstaller.install_pyppeteer(config_manager)
|
||||||
yield event.plain_result(result)
|
yield event.plain_result(result)
|
||||||
|
|
||||||
# 提供系统依赖安装指导
|
# 提供系统依赖安装指导
|
||||||
system_deps_result = await PDFInstaller.install_system_deps()
|
system_deps_result = await PDFInstaller.install_system_deps()
|
||||||
yield event.plain_result(system_deps_result)
|
yield event.plain_result(system_deps_result)
|
||||||
@@ -292,7 +334,9 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
@filter.command("分析设置")
|
@filter.command("分析设置")
|
||||||
@filter.permission_type(PermissionType.ADMIN)
|
@filter.permission_type(PermissionType.ADMIN)
|
||||||
async def analysis_settings(self, event: AiocqhttpMessageEvent, action: str = "status"):
|
async def analysis_settings(
|
||||||
|
self, event: AiocqhttpMessageEvent, action: str = "status"
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
管理分析设置
|
管理分析设置
|
||||||
用法: /分析设置 [enable|disable|status|reload|test]
|
用法: /分析设置 [enable|disable|status|reload|test]
|
||||||
@@ -360,7 +404,9 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
else: # status
|
else: # status
|
||||||
enabled_groups = config_manager.get_enabled_groups()
|
enabled_groups = config_manager.get_enabled_groups()
|
||||||
status = "已启用" if group_id in enabled_groups else "未启用"
|
status = "已启用" if group_id in enabled_groups else "未启用"
|
||||||
auto_status = "已启用" if config_manager.get_enable_auto_analysis() else "未启用"
|
auto_status = (
|
||||||
|
"已启用" if config_manager.get_enable_auto_analysis() else "未启用"
|
||||||
|
)
|
||||||
auto_time = config_manager.get_auto_analysis_time()
|
auto_time = config_manager.get_auto_analysis_time()
|
||||||
|
|
||||||
pdf_status = PDFInstaller.get_pdf_status(config_manager)
|
pdf_status = PDFInstaller.get_pdf_status(config_manager)
|
||||||
@@ -379,5 +425,3 @@ class QQGroupDailyAnalysis(Star):
|
|||||||
💡 可用命令: enable, disable, status, reload, test
|
💡 可用命令: enable, disable, status, reload, test
|
||||||
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
|
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
|
||||||
💡 其他命令: /设置格式, /安装PDF""")
|
💡 其他命令: /设置格式, /安装PDF""")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
QQ群日常分析插件 - 源代码包
|
QQ群日常分析插件 - 源代码包
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__author__ = "SXP-Simon"
|
__author__ = "SXP-Simon"
|
||||||
|
|||||||
@@ -6,7 +6,4 @@
|
|||||||
from .llm_analyzer import LLMAnalyzer
|
from .llm_analyzer import LLMAnalyzer
|
||||||
from .statistics import UserAnalyzer
|
from .statistics import UserAnalyzer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["LLMAnalyzer", "UserAnalyzer"]
|
||||||
'LLMAnalyzer',
|
|
||||||
'UserAnalyzer'
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -8,9 +8,4 @@ from .topic_analyzer import TopicAnalyzer
|
|||||||
from .user_title_analyzer import UserTitleAnalyzer
|
from .user_title_analyzer import UserTitleAnalyzer
|
||||||
from .golden_quote_analyzer import GoldenQuoteAnalyzer
|
from .golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["BaseAnalyzer", "TopicAnalyzer", "UserTitleAnalyzer", "GoldenQuoteAnalyzer"]
|
||||||
'BaseAnalyzer',
|
|
||||||
'TopicAnalyzer',
|
|
||||||
'UserTitleAnalyzer',
|
|
||||||
'GoldenQuoteAnalyzer'
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -4,184 +4,206 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Tuple, Any, Optional
|
from typing import List, Dict, Tuple, Any
|
||||||
from datetime import datetime
|
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
from ...models.data_models import TokenUsage
|
from ...models.data_models import TokenUsage
|
||||||
from ..utils.json_utils import parse_json_response
|
from ..utils.json_utils import parse_json_response
|
||||||
from ..utils.llm_utils import call_provider_with_retry, extract_token_usage, extract_response_text
|
from ..utils.llm_utils import (
|
||||||
import re
|
call_provider_with_retry,
|
||||||
|
extract_token_usage,
|
||||||
|
extract_response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseAnalyzer(ABC):
|
class BaseAnalyzer(ABC):
|
||||||
"""
|
"""
|
||||||
基础分析器抽象类
|
基础分析器抽象类
|
||||||
定义所有分析器的通用接口和流程
|
定义所有分析器的通用接口和流程
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context, config_manager):
|
def __init__(self, context, config_manager):
|
||||||
"""
|
"""
|
||||||
初始化基础分析器
|
初始化基础分析器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: AstrBot上下文对象
|
context: AstrBot上下文对象
|
||||||
config_manager: 配置管理器
|
config_manager: 配置管理器
|
||||||
"""
|
"""
|
||||||
self.context = context
|
self.context = context
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_data_type(self) -> str:
|
def get_data_type(self) -> str:
|
||||||
"""
|
"""
|
||||||
获取数据类型标识
|
获取数据类型标识
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
数据类型字符串
|
数据类型字符串
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_max_count(self) -> int:
|
def get_max_count(self) -> int:
|
||||||
"""
|
"""
|
||||||
获取最大提取数量
|
获取最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
最大数量
|
最大数量
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_prompt(self, data: Any) -> str:
|
def build_prompt(self, data: Any) -> str:
|
||||||
"""
|
"""
|
||||||
构建LLM提示词
|
构建LLM提示词
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: 输入数据
|
data: 输入数据
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词字符串
|
提示词字符串
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取数据
|
使用正则表达式提取数据
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: LLM响应文本
|
result_text: LLM响应文本
|
||||||
max_count: 最大提取数量
|
max_count: 最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提取到的数据列表
|
提取到的数据列表
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def create_data_objects(self, data_list: List[Dict]) -> List[Any]:
|
def create_data_objects(self, data_list: List[Dict]) -> List[Any]:
|
||||||
"""
|
"""
|
||||||
创建数据对象列表
|
创建数据对象列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_list: 原始数据列表
|
data_list: 原始数据列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
数据对象列表
|
数据对象列表
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def analyze(self, data: Any, umo: str = None) -> Tuple[List[Any], TokenUsage]:
|
async def analyze(self, data: Any, umo: str = None) -> Tuple[List[Any], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
统一的分析流程
|
统一的分析流程
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: 输入数据
|
data: 输入数据
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(分析结果列表, Token使用统计)
|
(分析结果列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1. 构建提示词
|
# 1. 构建提示词
|
||||||
logger.debug(f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}")
|
logger.debug(
|
||||||
logger.debug(f"{self.get_data_type()}分析输入数据长度: {len(data) if hasattr(data, '__len__') else 'N/A'}")
|
f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"{self.get_data_type()}分析输入数据长度: {len(data) if hasattr(data, '__len__') else 'N/A'}"
|
||||||
|
)
|
||||||
|
|
||||||
prompt = self.build_prompt(data)
|
prompt = self.build_prompt(data)
|
||||||
logger.info(f"开始{self.get_data_type()}分析,构建提示词完成")
|
logger.info(f"开始{self.get_data_type()}分析,构建提示词完成")
|
||||||
logger.debug(f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}")
|
logger.debug(
|
||||||
logger.debug(f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}...")
|
f"{self.get_data_type()}分析prompt长度: {len(prompt) if prompt else 0}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"{self.get_data_type()}分析prompt前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||||
|
)
|
||||||
|
|
||||||
# 检查 prompt 是否为空
|
# 检查 prompt 是否为空
|
||||||
if not prompt or not prompt.strip():
|
if not prompt or not prompt.strip():
|
||||||
logger.warning(f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用")
|
logger.warning(
|
||||||
|
f"{self.get_data_type()}分析: prompt 为空或只包含空白字符,跳过LLM调用"
|
||||||
|
)
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
# 2. 调用LLM
|
# 2. 调用LLM
|
||||||
max_tokens = self.get_max_tokens()
|
max_tokens = self.get_max_tokens()
|
||||||
temperature = self.get_temperature()
|
temperature = self.get_temperature()
|
||||||
|
|
||||||
response = await call_provider_with_retry(
|
response = await call_provider_with_retry(
|
||||||
self.context, self.config_manager, prompt,
|
self.context, self.config_manager, prompt, max_tokens, temperature, umo
|
||||||
max_tokens, temperature, umo
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
logger.error(f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)")
|
logger.error(
|
||||||
|
f"{self.get_data_type()}分析调用LLM失败: provider返回None(重试失败)"
|
||||||
|
)
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
# 3. 提取token使用统计
|
# 3. 提取token使用统计
|
||||||
token_usage_dict = extract_token_usage(response)
|
token_usage_dict = extract_token_usage(response)
|
||||||
token_usage = TokenUsage(
|
token_usage = TokenUsage(
|
||||||
prompt_tokens=token_usage_dict["prompt_tokens"],
|
prompt_tokens=token_usage_dict["prompt_tokens"],
|
||||||
completion_tokens=token_usage_dict["completion_tokens"],
|
completion_tokens=token_usage_dict["completion_tokens"],
|
||||||
total_tokens=token_usage_dict["total_tokens"]
|
total_tokens=token_usage_dict["total_tokens"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. 提取响应文本
|
# 4. 提取响应文本
|
||||||
result_text = extract_response_text(response)
|
result_text = extract_response_text(response)
|
||||||
logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...")
|
logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...")
|
||||||
|
|
||||||
# 5. 尝试JSON解析
|
# 5. 尝试JSON解析
|
||||||
success, parsed_data, error_msg = parse_json_response(result_text, self.get_data_type())
|
success, parsed_data, error_msg = parse_json_response(
|
||||||
|
result_text, self.get_data_type()
|
||||||
|
)
|
||||||
|
|
||||||
if success and parsed_data:
|
if success and parsed_data:
|
||||||
# JSON解析成功,创建数据对象
|
# JSON解析成功,创建数据对象
|
||||||
data_objects = self.create_data_objects(parsed_data)
|
data_objects = self.create_data_objects(parsed_data)
|
||||||
logger.info(f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据")
|
logger.info(
|
||||||
|
f"{self.get_data_type()}分析成功,解析到 {len(data_objects)} 条数据"
|
||||||
|
)
|
||||||
return data_objects, token_usage
|
return data_objects, token_usage
|
||||||
|
|
||||||
# 6. JSON解析失败,使用正则表达式降级
|
# 6. JSON解析失败,使用正则表达式降级
|
||||||
logger.warning(f"{self.get_data_type()}JSON解析失败,尝试正则表达式提取: {error_msg}")
|
logger.warning(
|
||||||
|
f"{self.get_data_type()}JSON解析失败,尝试正则表达式提取: {error_msg}"
|
||||||
|
)
|
||||||
regex_data = self.extract_with_regex(result_text, self.get_max_count())
|
regex_data = self.extract_with_regex(result_text, self.get_max_count())
|
||||||
|
|
||||||
if regex_data:
|
if regex_data:
|
||||||
logger.info(f"{self.get_data_type()}正则表达式提取成功,获得 {len(regex_data)} 条数据")
|
logger.info(
|
||||||
|
f"{self.get_data_type()}正则表达式提取成功,获得 {len(regex_data)} 条数据"
|
||||||
|
)
|
||||||
data_objects = self.create_data_objects(regex_data)
|
data_objects = self.create_data_objects(regex_data)
|
||||||
return data_objects, token_usage
|
return data_objects, token_usage
|
||||||
else:
|
else:
|
||||||
# 最后的降级方案 - 两种方法都失败
|
# 最后的降级方案 - 两种方法都失败
|
||||||
logger.error(f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表")
|
logger.error(
|
||||||
|
f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表"
|
||||||
|
)
|
||||||
return [], token_usage
|
return [], token_usage
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.get_data_type()}分析失败: {e}", exc_info=True)
|
logger.error(f"{self.get_data_type()}分析失败: {e}", exc_info=True)
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
def get_max_tokens(self) -> int:
|
def get_max_tokens(self) -> int:
|
||||||
"""
|
"""
|
||||||
获取最大token数,子类可重写
|
获取最大token数,子类可重写
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
最大token数
|
最大token数
|
||||||
"""
|
"""
|
||||||
return 10000
|
return 10000
|
||||||
|
|
||||||
def get_temperature(self) -> float:
|
def get_temperature(self) -> float:
|
||||||
"""
|
"""
|
||||||
获取温度参数,子类可重写
|
获取温度参数,子类可重写
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
温度参数
|
温度参数
|
||||||
"""
|
"""
|
||||||
return 0.6
|
return 0.6
|
||||||
|
|
||||||
|
|
||||||
@@ -12,59 +12,56 @@ from ..utils.json_utils import extract_golden_quotes_with_regex
|
|||||||
from ..utils import InfoUtils
|
from ..utils import InfoUtils
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GoldenQuoteAnalyzer(BaseAnalyzer):
|
class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||||
"""
|
"""
|
||||||
金句分析器
|
金句分析器
|
||||||
专门处理群聊金句的提取和分析
|
专门处理群聊金句的提取和分析
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_data_type(self) -> str:
|
def get_data_type(self) -> str:
|
||||||
"""获取数据类型标识"""
|
"""获取数据类型标识"""
|
||||||
return "金句"
|
return "金句"
|
||||||
|
|
||||||
def get_max_count(self) -> int:
|
def get_max_count(self) -> int:
|
||||||
"""获取最大金句数量"""
|
"""获取最大金句数量"""
|
||||||
return self.config_manager.get_max_golden_quotes()
|
return self.config_manager.get_max_golden_quotes()
|
||||||
|
|
||||||
def get_max_tokens(self) -> int:
|
def get_max_tokens(self) -> int:
|
||||||
"""获取最大token数"""
|
"""获取最大token数"""
|
||||||
return self.config_manager.get_golden_quote_max_tokens()
|
return self.config_manager.get_golden_quote_max_tokens()
|
||||||
|
|
||||||
def get_temperature(self) -> float:
|
def get_temperature(self) -> float:
|
||||||
"""获取温度参数"""
|
"""获取温度参数"""
|
||||||
return 0.7
|
return 0.7
|
||||||
|
|
||||||
def build_prompt(self, messages: List[Dict]) -> str:
|
def build_prompt(self, messages: List[Dict]) -> str:
|
||||||
"""
|
"""
|
||||||
构建金句分析提示词
|
构建金句分析提示词
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊的文本消息列表
|
messages: 群聊的文本消息列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词字符串
|
提示词字符串
|
||||||
"""
|
"""
|
||||||
if not messages:
|
if not messages:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 构建消息文本
|
# 构建消息文本
|
||||||
messages_text = "\n".join([
|
messages_text = "\n".join(
|
||||||
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
[f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in messages]
|
||||||
for msg in messages
|
)
|
||||||
])
|
|
||||||
|
|
||||||
max_golden_quotes = self.get_max_count()
|
max_golden_quotes = self.get_max_count()
|
||||||
|
|
||||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||||
prompt_template = self.config_manager.get_golden_quote_analysis_prompt()
|
prompt_template = self.config_manager.get_golden_quote_analysis_prompt()
|
||||||
|
|
||||||
if prompt_template:
|
if prompt_template:
|
||||||
# 使用配置中的 prompt 并替换变量
|
# 使用配置中的 prompt 并替换变量
|
||||||
try:
|
try:
|
||||||
prompt = prompt_template.format(
|
prompt = prompt_template.format(
|
||||||
max_golden_quotes=max_golden_quotes,
|
max_golden_quotes=max_golden_quotes, messages_text=messages_text
|
||||||
messages_text=messages_text
|
|
||||||
)
|
)
|
||||||
logger.info("使用配置中的金句分析提示词")
|
logger.info("使用配置中的金句分析提示词")
|
||||||
return prompt
|
return prompt
|
||||||
@@ -72,117 +69,117 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
|||||||
logger.warning(f"金句分析提示词变量格式错误: {e}")
|
logger.warning(f"金句分析提示词变量格式错误: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"应用金句分析提示词失败: {e}")
|
logger.warning(f"应用金句分析提示词失败: {e}")
|
||||||
|
|
||||||
logger.warning("未找到有效的金句分析提示词配置,请检查配置文件")
|
logger.warning("未找到有效的金句分析提示词配置,请检查配置文件")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取金句信息
|
使用正则表达式提取金句信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: LLM响应文本
|
result_text: LLM响应文本
|
||||||
max_count: 最大提取数量
|
max_count: 最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
金句数据列表
|
金句数据列表
|
||||||
"""
|
"""
|
||||||
return extract_golden_quotes_with_regex(result_text, max_count)
|
return extract_golden_quotes_with_regex(result_text, max_count)
|
||||||
|
|
||||||
def create_data_objects(self, quotes_data: List[Dict]) -> List[GoldenQuote]:
|
def create_data_objects(self, quotes_data: List[Dict]) -> List[GoldenQuote]:
|
||||||
"""
|
"""
|
||||||
创建金句对象列表
|
创建金句对象列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
quotes_data: 原始金句数据列表
|
quotes_data: 原始金句数据列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
GoldenQuote对象列表
|
GoldenQuote对象列表
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
quotes = []
|
quotes = []
|
||||||
max_quotes = self.get_max_count()
|
max_quotes = self.get_max_count()
|
||||||
|
|
||||||
for quote_data in quotes_data[:max_quotes]:
|
for quote_data in quotes_data[:max_quotes]:
|
||||||
# 确保数据格式正确
|
# 确保数据格式正确
|
||||||
content = quote_data.get("content", "").strip()
|
content = quote_data.get("content", "").strip()
|
||||||
sender = quote_data.get("sender", "").strip()
|
sender = quote_data.get("sender", "").strip()
|
||||||
reason = quote_data.get("reason", "").strip()
|
reason = quote_data.get("reason", "").strip()
|
||||||
|
|
||||||
# 验证必要字段
|
# 验证必要字段
|
||||||
if not content or not sender or not reason:
|
if not content or not sender or not reason:
|
||||||
logger.warning(f"金句数据格式不完整,跳过: {quote_data}")
|
logger.warning(f"金句数据格式不完整,跳过: {quote_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
quotes.append(GoldenQuote(
|
quotes.append(
|
||||||
content=content,
|
GoldenQuote(content=content, sender=sender, reason=reason)
|
||||||
sender=sender,
|
)
|
||||||
reason=reason
|
|
||||||
))
|
|
||||||
|
|
||||||
return quotes
|
return quotes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建金句对象失败: {e}")
|
logger.error(f"创建金句对象失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def extract_interesting_messages(self, messages: List[Dict]) -> List[Dict]:
|
def extract_interesting_messages(self, messages: List[Dict]) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
提取圣经的文本消息
|
提取圣经的文本消息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
圣经的文本消息列表
|
圣经的文本消息列表
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
interesting_messages = []
|
interesting_messages = []
|
||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
sender = msg.get("sender", {})
|
sender = msg.get("sender", {})
|
||||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||||
|
|
||||||
for content in msg.get("message", []):
|
for content in msg.get("message", []):
|
||||||
if content.get("type") == "text":
|
if content.get("type") == "text":
|
||||||
text = content.get("data", {}).get("text", "").strip()
|
text = content.get("data", {}).get("text", "").strip()
|
||||||
# 过滤长度适中、可能圣经的消息
|
# 过滤长度适中、可能圣经的消息
|
||||||
if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")):
|
if 5 <= len(text) <= 100 and not text.startswith(
|
||||||
interesting_messages.append({
|
("http", "www", "/")
|
||||||
"sender": nickname,
|
):
|
||||||
"time": msg_time,
|
interesting_messages.append(
|
||||||
"content": text
|
{"sender": nickname, "time": msg_time, "content": text}
|
||||||
})
|
)
|
||||||
|
|
||||||
return interesting_messages
|
return interesting_messages
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"提取圣经消息失败: {e}")
|
logger.error(f"提取圣经消息失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def analyze_golden_quotes(self, messages: List[Dict], umo: str = None) -> Tuple[List[GoldenQuote], TokenUsage]:
|
async def analyze_golden_quotes(
|
||||||
|
self, messages: List[Dict], umo: str = None
|
||||||
|
) -> Tuple[List[GoldenQuote], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
分析群聊金句
|
分析群聊金句
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(金句列表, Token使用统计)
|
(金句列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 提取圣经的文本消息
|
# 提取圣经的文本消息
|
||||||
interesting_messages = self.extract_interesting_messages(messages)
|
interesting_messages = self.extract_interesting_messages(messages)
|
||||||
|
|
||||||
if not interesting_messages:
|
if not interesting_messages:
|
||||||
logger.info("没有符合条件的圣经消息,返回空结果")
|
logger.info("没有符合条件的圣经消息,返回空结果")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
logger.info(f"开始从 {len(interesting_messages)} 条圣经消息中提取金句")
|
logger.info(f"开始从 {len(interesting_messages)} 条圣经消息中提取金句")
|
||||||
return await self.analyze(interesting_messages, umo)
|
return await self.analyze(interesting_messages, umo)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"金句分析失败: {e}")
|
logger.error(f"金句分析失败: {e}")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|||||||
@@ -18,30 +18,30 @@ class TopicAnalyzer(BaseAnalyzer):
|
|||||||
话题分析器
|
话题分析器
|
||||||
专门处理群聊话题的提取和分析
|
专门处理群聊话题的提取和分析
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_data_type(self) -> str:
|
def get_data_type(self) -> str:
|
||||||
"""获取数据类型标识"""
|
"""获取数据类型标识"""
|
||||||
return "话题"
|
return "话题"
|
||||||
|
|
||||||
def get_max_count(self) -> int:
|
def get_max_count(self) -> int:
|
||||||
"""获取最大话题数量"""
|
"""获取最大话题数量"""
|
||||||
return self.config_manager.get_max_topics()
|
return self.config_manager.get_max_topics()
|
||||||
|
|
||||||
def get_max_tokens(self) -> int:
|
def get_max_tokens(self) -> int:
|
||||||
"""获取最大token数"""
|
"""获取最大token数"""
|
||||||
return self.config_manager.get_topic_max_tokens()
|
return self.config_manager.get_topic_max_tokens()
|
||||||
|
|
||||||
def get_temperature(self) -> float:
|
def get_temperature(self) -> float:
|
||||||
"""获取温度参数"""
|
"""获取温度参数"""
|
||||||
return 0.6
|
return 0.6
|
||||||
|
|
||||||
def build_prompt(self, messages: List[Dict]) -> str:
|
def build_prompt(self, messages: List[Dict]) -> str:
|
||||||
"""
|
"""
|
||||||
构建话题分析提示词
|
构建话题分析提示词
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词字符串
|
提示词字符串
|
||||||
"""
|
"""
|
||||||
@@ -49,49 +49,61 @@ class TopicAnalyzer(BaseAnalyzer):
|
|||||||
if not isinstance(messages, list):
|
if not isinstance(messages, list):
|
||||||
logger.error(f"build_prompt 期望列表,但收到: {type(messages)}")
|
logger.error(f"build_prompt 期望列表,但收到: {type(messages)}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 检查消息列表是否为空
|
# 检查消息列表是否为空
|
||||||
if not messages:
|
if not messages:
|
||||||
logger.warning("build_prompt 收到空消息列表")
|
logger.warning("build_prompt 收到空消息列表")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 提取文本消息
|
# 提取文本消息
|
||||||
text_messages = []
|
text_messages = []
|
||||||
for i, msg in enumerate(messages):
|
for i, msg in enumerate(messages):
|
||||||
logger.debug(f"build_prompt 处理第 {i+1} 条消息,类型: {type(msg)}")
|
logger.debug(f"build_prompt 处理第 {i + 1} 条消息,类型: {type(msg)}")
|
||||||
|
|
||||||
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
|
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
logger.warning(f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}")
|
logger.warning(
|
||||||
|
f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sender = msg.get("sender", {})
|
sender = msg.get("sender", {})
|
||||||
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
||||||
if not isinstance(sender, dict):
|
if not isinstance(sender, dict):
|
||||||
logger.warning(f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}")
|
logger.warning(
|
||||||
|
f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||||
|
|
||||||
message_list = msg.get("message", [])
|
message_list = msg.get("message", [])
|
||||||
logger.debug(f"build_prompt 消息 {i+1} 的 message 字段类型: {type(message_list)}, 长度: {len(message_list) if hasattr(message_list, '__len__') else 'N/A'}")
|
logger.debug(
|
||||||
|
f"build_prompt 消息 {i + 1} 的 message 字段类型: {type(message_list)}, 长度: {len(message_list) if hasattr(message_list, '__len__') else 'N/A'}"
|
||||||
|
)
|
||||||
|
|
||||||
# 提取文本内容,可能分布在多个 content 中
|
# 提取文本内容,可能分布在多个 content 中
|
||||||
text_parts = []
|
text_parts = []
|
||||||
for j, content in enumerate(message_list):
|
for j, content in enumerate(message_list):
|
||||||
logger.debug(f"build_prompt 处理消息 {i+1} 的内容 {j+1}, 类型: {type(content)}")
|
logger.debug(
|
||||||
|
f"build_prompt 处理消息 {i + 1} 的内容 {j + 1}, 类型: {type(content)}"
|
||||||
|
)
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
logger.warning(f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}")
|
logger.warning(
|
||||||
|
f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
content_type = content.get("type", "")
|
content_type = content.get("type", "")
|
||||||
logger.debug(f"build_prompt 内容类型: {content_type}")
|
logger.debug(f"build_prompt 内容类型: {content_type}")
|
||||||
|
|
||||||
if content_type == "text":
|
if content_type == "text":
|
||||||
text = content.get("data", {}).get("text", "").strip()
|
text = content.get("data", {}).get("text", "").strip()
|
||||||
logger.debug(f"build_prompt 提取到的文本: '{text}' (长度: {len(text)})")
|
logger.debug(
|
||||||
|
f"build_prompt 提取到的文本: '{text}' (长度: {len(text)})"
|
||||||
|
)
|
||||||
if text:
|
if text:
|
||||||
text_parts.append(text)
|
text_parts.append(text)
|
||||||
elif content_type == "at":
|
elif content_type == "at":
|
||||||
@@ -108,53 +120,62 @@ class TopicAnalyzer(BaseAnalyzer):
|
|||||||
reply_text = f"[回复:{reply_id}]"
|
reply_text = f"[回复:{reply_id}]"
|
||||||
text_parts.append(reply_text)
|
text_parts.append(reply_text)
|
||||||
logger.debug(f"build_prompt 提取到回复消息: {reply_text}")
|
logger.debug(f"build_prompt 提取到回复消息: {reply_text}")
|
||||||
|
|
||||||
# 合并所有文本部分
|
# 合并所有文本部分
|
||||||
combined_text = "".join(text_parts).strip()
|
combined_text = "".join(text_parts).strip()
|
||||||
logger.debug(f"build_prompt 合并后的文本: '{combined_text}' (长度: {len(combined_text)})")
|
logger.debug(
|
||||||
|
f"build_prompt 合并后的文本: '{combined_text}' (长度: {len(combined_text)})"
|
||||||
if combined_text and len(combined_text) > 2 and not combined_text.startswith("/"):
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
combined_text
|
||||||
|
and len(combined_text) > 2
|
||||||
|
and not combined_text.startswith("/")
|
||||||
|
):
|
||||||
# 清理消息内容
|
# 清理消息内容
|
||||||
cleaned_text = combined_text.replace('“', '"').replace('”', '"')
|
cleaned_text = combined_text.replace("“", '"').replace("”", '"')
|
||||||
cleaned_text = cleaned_text.replace('‘', "'").replace('’', "'")
|
cleaned_text = cleaned_text.replace("‘", "'").replace("’", "'")
|
||||||
cleaned_text = cleaned_text.replace('\n', ' ').replace('\r', ' ')
|
cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ")
|
||||||
cleaned_text = cleaned_text.replace('\t', ' ')
|
cleaned_text = cleaned_text.replace("\t", " ")
|
||||||
cleaned_text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned_text)
|
cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text)
|
||||||
|
|
||||||
logger.debug(f"build_prompt 清理后的文本: '{cleaned_text}'")
|
logger.debug(f"build_prompt 清理后的文本: '{cleaned_text}'")
|
||||||
|
|
||||||
text_messages.append({
|
text_messages.append(
|
||||||
"sender": nickname,
|
{"sender": nickname, "time": msg_time, "content": cleaned_text}
|
||||||
"time": msg_time,
|
)
|
||||||
"content": cleaned_text
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)")
|
logger.debug(
|
||||||
|
f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"build_prompt 处理第 {i+1} 条消息时出错: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"build_prompt 处理第 {i + 1} 条消息时出错: {e}", exc_info=True
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not text_messages:
|
if not text_messages:
|
||||||
logger.warning("build_prompt 没有提取到有效的文本消息,返回空prompt")
|
logger.warning("build_prompt 没有提取到有效的文本消息,返回空prompt")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 构建消息文本
|
# 构建消息文本
|
||||||
messages_text = "\n".join([
|
messages_text = "\n".join(
|
||||||
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
[
|
||||||
for msg in text_messages
|
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
||||||
])
|
for msg in text_messages
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
max_topics = self.get_max_count()
|
max_topics = self.get_max_count()
|
||||||
|
|
||||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||||
prompt_template = self.config_manager.get_topic_analysis_prompt()
|
prompt_template = self.config_manager.get_topic_analysis_prompt()
|
||||||
|
|
||||||
if prompt_template:
|
if prompt_template:
|
||||||
# 使用配置中的 prompt 并替换变量
|
# 使用配置中的 prompt 并替换变量
|
||||||
try:
|
try:
|
||||||
prompt = prompt_template.format(
|
prompt = prompt_template.format(
|
||||||
max_topics=max_topics,
|
max_topics=max_topics, messages_text=messages_text
|
||||||
messages_text=messages_text
|
|
||||||
)
|
)
|
||||||
logger.info("使用配置中的话题分析提示词")
|
logger.info("使用配置中的话题分析提示词")
|
||||||
return prompt
|
return prompt
|
||||||
@@ -162,181 +183,205 @@ class TopicAnalyzer(BaseAnalyzer):
|
|||||||
logger.warning(f"话题分析提示词变量格式错误: {e}")
|
logger.warning(f"话题分析提示词变量格式错误: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"应用话题分析提示词失败: {e}")
|
logger.warning(f"应用话题分析提示词失败: {e}")
|
||||||
|
|
||||||
logger.warning("未找到有效的话题分析提示词配置,请检查配置文件")
|
logger.warning("未找到有效的话题分析提示词配置,请检查配置文件")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_with_regex(self, result_text: str, max_topics: int) -> List[Dict]:
|
def extract_with_regex(self, result_text: str, max_topics: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取话题信息
|
使用正则表达式提取话题信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: LLM响应文本
|
result_text: LLM响应文本
|
||||||
max_topics: 最大话题数量
|
max_topics: 最大话题数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
话题数据列表
|
话题数据列表
|
||||||
"""
|
"""
|
||||||
return extract_topics_with_regex(result_text, max_topics)
|
return extract_topics_with_regex(result_text, max_topics)
|
||||||
|
|
||||||
def create_data_objects(self, topics_data: List[Dict]) -> List[SummaryTopic]:
|
def create_data_objects(self, topics_data: List[Dict]) -> List[SummaryTopic]:
|
||||||
"""
|
"""
|
||||||
创建话题对象列表
|
创建话题对象列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topics_data: 原始话题数据列表
|
topics_data: 原始话题数据列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SummaryTopic对象列表
|
SummaryTopic对象列表
|
||||||
"""
|
"""
|
||||||
logger.debug(f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}")
|
logger.debug(
|
||||||
|
f"create_data_objects 开始处理,输入数据数量: {len(topics_data) if topics_data else 0}"
|
||||||
|
)
|
||||||
logger.debug(f"输入数据类型: {type(topics_data)}")
|
logger.debug(f"输入数据类型: {type(topics_data)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
topics = []
|
topics = []
|
||||||
max_topics = self.get_max_count()
|
max_topics = self.get_max_count()
|
||||||
|
|
||||||
logger.debug(f"处理前 {max_topics} 条话题数据")
|
logger.debug(f"处理前 {max_topics} 条话题数据")
|
||||||
|
|
||||||
for i, topic_data in enumerate(topics_data[:max_topics]):
|
for i, topic_data in enumerate(topics_data[:max_topics]):
|
||||||
logger.debug(f"处理第 {i+1} 条话题数据,类型: {type(topic_data)}")
|
logger.debug(f"处理第 {i + 1} 条话题数据,类型: {type(topic_data)}")
|
||||||
|
|
||||||
# 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误
|
# 确保topic_data是字典类型,避免'str' object has no attribute 'get'错误
|
||||||
if not isinstance(topic_data, dict):
|
if not isinstance(topic_data, dict):
|
||||||
logger.warning(f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}")
|
logger.warning(
|
||||||
|
f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 确保数据格式正确
|
# 确保数据格式正确
|
||||||
topic_name = topic_data.get("topic", "").strip()
|
topic_name = topic_data.get("topic", "").strip()
|
||||||
contributors = topic_data.get("contributors", [])
|
contributors = topic_data.get("contributors", [])
|
||||||
detail = topic_data.get("detail", "").strip()
|
detail = topic_data.get("detail", "").strip()
|
||||||
|
|
||||||
logger.debug(f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}...")
|
logger.debug(
|
||||||
|
f"话题数据 - 名称: {topic_name}, 参与者: {contributors}, 详情: {detail[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
# 验证必要字段
|
# 验证必要字段
|
||||||
if not topic_name or not detail:
|
if not topic_name or not detail:
|
||||||
logger.warning(f"话题数据格式不完整,跳过: {topic_data}")
|
logger.warning(f"话题数据格式不完整,跳过: {topic_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 确保参与者列表有效
|
# 确保参与者列表有效
|
||||||
if not contributors or not isinstance(contributors, list):
|
if not contributors or not isinstance(contributors, list):
|
||||||
contributors = ["群友"]
|
contributors = ["群友"]
|
||||||
else:
|
else:
|
||||||
# 清理参与者名称
|
# 清理参与者名称
|
||||||
contributors = [str(c).strip() for c in contributors if c and str(c).strip()] or ["群友"]
|
contributors = [
|
||||||
|
str(c).strip() for c in contributors if c and str(c).strip()
|
||||||
topics.append(SummaryTopic(
|
] or ["群友"]
|
||||||
topic=topic_name,
|
|
||||||
contributors=contributors[:5], # 最多5个参与者
|
topics.append(
|
||||||
detail=detail
|
SummaryTopic(
|
||||||
))
|
topic=topic_name,
|
||||||
|
contributors=contributors[:5], # 最多5个参与者
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理第 {i+1} 条话题数据时出错: {e}", exc_info=True)
|
logger.error(f"处理第 {i + 1} 条话题数据时出错: {e}", exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象")
|
logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象")
|
||||||
return topics
|
return topics
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建话题对象失败: {e}", exc_info=True)
|
logger.error(f"创建话题对象失败: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def extract_text_messages(self, messages: List[Dict]) -> List[Dict]:
|
def extract_text_messages(self, messages: List[Dict]) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
从群聊消息中提取文本消息
|
从群聊消息中提取文本消息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提取的文本消息列表
|
提取的文本消息列表
|
||||||
"""
|
"""
|
||||||
logger.debug(f"extract_text_messages 开始处理,输入消息数量: {len(messages) if messages else 0}")
|
logger.debug(
|
||||||
|
f"extract_text_messages 开始处理,输入消息数量: {len(messages) if messages else 0}"
|
||||||
|
)
|
||||||
logger.debug(f"extract_text_messages 输入消息类型: {type(messages)}")
|
logger.debug(f"extract_text_messages 输入消息类型: {type(messages)}")
|
||||||
|
|
||||||
if not messages:
|
if not messages:
|
||||||
logger.warning("extract_text_messages 收到空消息列表")
|
logger.warning("extract_text_messages 收到空消息列表")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
text_messages = []
|
text_messages = []
|
||||||
|
|
||||||
for i, msg in enumerate(messages):
|
for i, msg in enumerate(messages):
|
||||||
logger.debug(f"处理第 {i+1} 条消息,类型: {type(msg)}")
|
logger.debug(f"处理第 {i + 1} 条消息,类型: {type(msg)}")
|
||||||
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
|
# 确保msg是字典类型,避免'str' object has no attribute 'get'错误
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
logger.warning(f"跳过非字典类型的消息: {type(msg)} - {msg}")
|
logger.warning(f"跳过非字典类型的消息: {type(msg)} - {msg}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sender = msg.get("sender", {})
|
sender = msg.get("sender", {})
|
||||||
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
||||||
if not isinstance(sender, dict):
|
if not isinstance(sender, dict):
|
||||||
logger.warning(f"extract_text_messages 跳过sender非字典类型的消息: {type(sender)} - {sender}")
|
logger.warning(
|
||||||
|
f"extract_text_messages 跳过sender非字典类型的消息: {type(sender)} - {sender}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||||
|
|
||||||
for content in msg.get("message", []):
|
for content in msg.get("message", []):
|
||||||
if content.get("type") == "text":
|
if content.get("type") == "text":
|
||||||
text = content.get("data", {}).get("text", "").strip()
|
text = content.get("data", {}).get("text", "").strip()
|
||||||
if text and len(text) > 2 and not text.startswith("/"):
|
if text and len(text) > 2 and not text.startswith("/"):
|
||||||
# 清理消息内容
|
# 清理消息内容
|
||||||
text = text.replace('""', '"').replace('""', '"')
|
text = text.replace('""', '"').replace('""', '"')
|
||||||
text = text.replace(''', "'").replace(''', "'")
|
text = text.replace(""", "'").replace(""", "'")
|
||||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
text = text.replace("\n", " ").replace("\r", " ")
|
||||||
text = text.replace('\t', ' ')
|
text = text.replace("\t", " ")
|
||||||
text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text)
|
text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", text)
|
||||||
text_messages.append({
|
text_messages.append(
|
||||||
"sender": nickname,
|
{
|
||||||
"time": msg_time,
|
"sender": nickname,
|
||||||
"content": text.strip()
|
"time": msg_time,
|
||||||
})
|
"content": text.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理第 {i+1} 条消息时出错: {e}", exc_info=True)
|
logger.error(f"处理第 {i + 1} 条消息时出错: {e}", exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息")
|
logger.debug(
|
||||||
|
f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息"
|
||||||
|
)
|
||||||
if text_messages:
|
if text_messages:
|
||||||
logger.debug(f"extract_text_messages 第一条文本消息: {text_messages[0]}")
|
logger.debug(f"extract_text_messages 第一条文本消息: {text_messages[0]}")
|
||||||
return text_messages
|
return text_messages
|
||||||
|
|
||||||
async def analyze_topics(self, messages: List[Dict], umo: str = None) -> Tuple[List[SummaryTopic], TokenUsage]:
|
async def analyze_topics(
|
||||||
|
self, messages: List[Dict], umo: str = None
|
||||||
|
) -> Tuple[List[SummaryTopic], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
分析群聊话题
|
分析群聊话题
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(话题列表, Token使用统计)
|
(话题列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}")
|
logger.debug(
|
||||||
|
f"analyze_topics 开始处理,消息数量: {len(messages) if messages else 0}"
|
||||||
|
)
|
||||||
logger.debug(f"消息类型: {type(messages)}")
|
logger.debug(f"消息类型: {type(messages)}")
|
||||||
if messages:
|
if messages:
|
||||||
logger.debug(f"第一条消息类型: {type(messages[0]) if messages else '无'}")
|
logger.debug(
|
||||||
|
f"第一条消息类型: {type(messages[0]) if messages else '无'}"
|
||||||
|
)
|
||||||
logger.debug(f"第一条消息内容: {messages[0] if messages else '无'}")
|
logger.debug(f"第一条消息内容: {messages[0] if messages else '无'}")
|
||||||
|
|
||||||
# 检查是否有有效的文本消息
|
# 检查是否有有效的文本消息
|
||||||
text_messages = self.extract_text_messages(messages)
|
text_messages = self.extract_text_messages(messages)
|
||||||
logger.debug(f"提取到 {len(text_messages)} 条文本消息")
|
logger.debug(f"提取到 {len(text_messages)} 条文本消息")
|
||||||
|
|
||||||
if not text_messages:
|
if not text_messages:
|
||||||
logger.info("没有有效的文本消息,返回空结果")
|
logger.info("没有有效的文本消息,返回空结果")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
logger.info(f"开始分析 {len(text_messages)} 条文本消息中的话题")
|
logger.info(f"开始分析 {len(text_messages)} 条文本消息中的话题")
|
||||||
logger.debug(f"文本消息类型: {type(text_messages)}")
|
logger.debug(f"文本消息类型: {type(text_messages)}")
|
||||||
if text_messages:
|
if text_messages:
|
||||||
logger.debug(f"第一条文本消息类型: {type(text_messages[0])}")
|
logger.debug(f"第一条文本消息类型: {type(text_messages[0])}")
|
||||||
logger.debug(f"第一条文本消息内容: {text_messages[0]}")
|
logger.debug(f"第一条文本消息内容: {text_messages[0]}")
|
||||||
|
|
||||||
# 直接传入原始消息,让 build_prompt 方法处理
|
# 直接传入原始消息,让 build_prompt 方法处理
|
||||||
return await self.analyze(messages, umo)
|
return await self.analyze(messages, umo)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"话题分析失败: {e}", exc_info=True)
|
logger.error(f"话题分析失败: {e}", exc_info=True)
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|||||||
@@ -15,93 +15,93 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
|||||||
用户称号分析器
|
用户称号分析器
|
||||||
专门处理用户称号分配和MBTI类型分析
|
专门处理用户称号分配和MBTI类型分析
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_data_type(self) -> str:
|
def get_data_type(self) -> str:
|
||||||
"""获取数据类型标识"""
|
"""获取数据类型标识"""
|
||||||
return "用户称号"
|
return "用户称号"
|
||||||
|
|
||||||
def get_max_count(self) -> int:
|
def get_max_count(self) -> int:
|
||||||
"""获取最大用户称号数量"""
|
"""获取最大用户称号数量"""
|
||||||
return self.config_manager.get_max_user_titles()
|
return self.config_manager.get_max_user_titles()
|
||||||
|
|
||||||
def get_max_tokens(self) -> int:
|
def get_max_tokens(self) -> int:
|
||||||
"""获取最大token数"""
|
"""获取最大token数"""
|
||||||
return self.config_manager.get_user_title_max_tokens()
|
return self.config_manager.get_user_title_max_tokens()
|
||||||
|
|
||||||
def get_temperature(self) -> float:
|
def get_temperature(self) -> float:
|
||||||
"""获取温度参数"""
|
"""获取温度参数"""
|
||||||
return 0.5
|
return 0.5
|
||||||
|
|
||||||
def build_prompt(self, user_data: Dict) -> str:
|
def build_prompt(self, user_data: Dict) -> str:
|
||||||
"""
|
"""
|
||||||
构建用户称号分析提示词
|
构建用户称号分析提示词
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_data: 用户数据字典,包含用户统计信息
|
user_data: 用户数据字典,包含用户统计信息
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词字符串
|
提示词字符串
|
||||||
"""
|
"""
|
||||||
user_summaries = user_data.get("user_summaries", [])
|
user_summaries = user_data.get("user_summaries", [])
|
||||||
|
|
||||||
if not user_summaries:
|
if not user_summaries:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 构建用户数据文本
|
# 构建用户数据文本
|
||||||
users_text = "\n".join([
|
users_text = "\n".join(
|
||||||
f"- {user['name']} (QQ:{user['qq']}): "
|
[
|
||||||
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
|
f"- {user['name']} (QQ:{user['qq']}): "
|
||||||
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
|
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
|
||||||
f"回复比例{user['reply_ratio']}"
|
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
|
||||||
for user in user_summaries
|
f"回复比例{user['reply_ratio']}"
|
||||||
])
|
for user in user_summaries
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||||
prompt_template = self.config_manager.get_user_title_analysis_prompt()
|
prompt_template = self.config_manager.get_user_title_analysis_prompt()
|
||||||
|
|
||||||
if prompt_template:
|
if prompt_template:
|
||||||
# 使用配置中的 prompt 并替换变量
|
# 使用配置中的 prompt 并替换变量
|
||||||
try:
|
try:
|
||||||
prompt = prompt_template.format(
|
prompt = prompt_template.format(users_text=users_text)
|
||||||
users_text=users_text
|
|
||||||
)
|
|
||||||
logger.info("使用配置中的用户称号分析提示词")
|
logger.info("使用配置中的用户称号分析提示词")
|
||||||
return prompt
|
return prompt
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
logger.warning(f"用户称号分析提示词变量格式错误: {e}")
|
logger.warning(f"用户称号分析提示词变量格式错误: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"应用用户称号分析提示词失败: {e}")
|
logger.warning(f"应用用户称号分析提示词失败: {e}")
|
||||||
|
|
||||||
logger.warning("未找到有效的用户称号分析提示词配置,请检查配置文件")
|
logger.warning("未找到有效的用户称号分析提示词配置,请检查配置文件")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
def extract_with_regex(self, result_text: str, max_count: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取用户称号信息
|
使用正则表达式提取用户称号信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: LLM响应文本
|
result_text: LLM响应文本
|
||||||
max_count: 最大提取数量
|
max_count: 最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
用户称号数据列表
|
用户称号数据列表
|
||||||
"""
|
"""
|
||||||
return extract_user_titles_with_regex(result_text, max_count)
|
return extract_user_titles_with_regex(result_text, max_count)
|
||||||
|
|
||||||
def create_data_objects(self, titles_data: List[Dict]) -> List[UserTitle]:
|
def create_data_objects(self, titles_data: List[Dict]) -> List[UserTitle]:
|
||||||
"""
|
"""
|
||||||
创建用户称号对象列表
|
创建用户称号对象列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
titles_data: 原始用户称号数据列表
|
titles_data: 原始用户称号数据列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
UserTitle对象列表
|
UserTitle对象列表
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
titles = []
|
titles = []
|
||||||
max_titles = self.get_max_count()
|
max_titles = self.get_max_count()
|
||||||
|
|
||||||
for title_data in titles_data[:max_titles]:
|
for title_data in titles_data[:max_titles]:
|
||||||
# 确保数据格式正确
|
# 确保数据格式正确
|
||||||
name = title_data.get("name", "").strip()
|
name = title_data.get("name", "").strip()
|
||||||
@@ -109,122 +109,146 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
|||||||
title = title_data.get("title", "").strip()
|
title = title_data.get("title", "").strip()
|
||||||
mbti = title_data.get("mbti", "").strip()
|
mbti = title_data.get("mbti", "").strip()
|
||||||
reason = title_data.get("reason", "").strip()
|
reason = title_data.get("reason", "").strip()
|
||||||
|
|
||||||
# 验证必要字段
|
# 验证必要字段
|
||||||
if not name or not title or not mbti or not reason:
|
if not name or not title or not mbti or not reason:
|
||||||
logger.warning(f"用户称号数据格式不完整,跳过: {title_data}")
|
logger.warning(f"用户称号数据格式不完整,跳过: {title_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 验证QQ号格式
|
# 验证QQ号格式
|
||||||
try:
|
try:
|
||||||
qq = int(qq)
|
qq = int(qq)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.warning(f"QQ号格式无效,跳过: {qq}")
|
logger.warning(f"QQ号格式无效,跳过: {qq}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
titles.append(UserTitle(
|
titles.append(
|
||||||
name=name,
|
UserTitle(name=name, qq=qq, title=title, mbti=mbti, reason=reason)
|
||||||
qq=qq,
|
)
|
||||||
title=title,
|
|
||||||
mbti=mbti,
|
|
||||||
reason=reason
|
|
||||||
))
|
|
||||||
|
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建用户称号对象失败: {e}")
|
logger.error(f"创建用户称号对象失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def prepare_user_data(self, messages: List[Dict], user_analysis: Dict, top_users: List[Dict] = None) -> Dict:
|
def prepare_user_data(
|
||||||
|
self, messages: List[Dict], user_analysis: Dict, top_users: List[Dict] = None
|
||||||
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
准备用户数据
|
准备用户数据
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
user_analysis: 用户分析统计
|
user_analysis: 用户分析统计
|
||||||
top_users: 活跃用户列表(从get_top_users获取)
|
top_users: 活跃用户列表(从get_top_users获取)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
准备好的用户数据字典
|
准备好的用户数据字典
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 获取机器人QQ号用于过滤
|
# 获取机器人QQ号用于过滤
|
||||||
bot_qq_id = self.config_manager.get_bot_qq_id()
|
bot_qq_id = self.config_manager.get_bot_qq_id()
|
||||||
|
|
||||||
user_summaries = []
|
user_summaries = []
|
||||||
|
|
||||||
# 如果提供了top_users列表,只分析这些活跃用户
|
# 如果提供了top_users列表,只分析这些活跃用户
|
||||||
if top_users:
|
if top_users:
|
||||||
logger.info(f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析")
|
logger.info(
|
||||||
target_user_ids = {str(user['user_id']) for user in top_users}
|
f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析"
|
||||||
|
)
|
||||||
|
target_user_ids = {str(user["user_id"]) for user in top_users}
|
||||||
else:
|
else:
|
||||||
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户
|
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户
|
||||||
logger.info("未提供活跃用户列表,使用消息数>=5的用户")
|
logger.info("未提供活跃用户列表,使用消息数>=5的用户")
|
||||||
target_user_ids = {user_id for user_id, stats in user_analysis.items()
|
target_user_ids = {
|
||||||
if stats["message_count"] >= 5}
|
user_id
|
||||||
|
for user_id, stats in user_analysis.items()
|
||||||
|
if stats["message_count"] >= 5
|
||||||
|
}
|
||||||
|
|
||||||
for user_id, stats in user_analysis.items():
|
for user_id, stats in user_analysis.items():
|
||||||
# 过滤机器人自己的消息
|
# 过滤机器人自己的消息
|
||||||
if bot_qq_id and str(user_id) == str(bot_qq_id):
|
if bot_qq_id and str(user_id) == str(bot_qq_id):
|
||||||
logger.debug(f"过滤掉机器人QQ号: {user_id}")
|
logger.debug(f"过滤掉机器人QQ号: {user_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 只处理活跃用户
|
# 只处理活跃用户
|
||||||
if user_id not in target_user_ids:
|
if user_id not in target_user_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 分析用户特征
|
# 分析用户特征
|
||||||
night_messages = sum(stats["hours"][h] for h in range(6))
|
night_messages = sum(stats["hours"][h] for h in range(6))
|
||||||
day_messages = stats["message_count"] - night_messages
|
avg_chars = (
|
||||||
avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0
|
stats["char_count"] / stats["message_count"]
|
||||||
|
if stats["message_count"] > 0
|
||||||
user_summaries.append({
|
else 0
|
||||||
"name": stats["nickname"],
|
)
|
||||||
"qq": int(user_id),
|
|
||||||
"message_count": stats["message_count"],
|
user_summaries.append(
|
||||||
"avg_chars": round(avg_chars, 1),
|
{
|
||||||
"emoji_ratio": round(stats["emoji_count"] / stats["message_count"], 2) if stats["message_count"] > 0 else 0,
|
"name": stats["nickname"],
|
||||||
"night_ratio": round(night_messages / stats["message_count"], 2) if stats["message_count"] > 0 else 0,
|
"qq": int(user_id),
|
||||||
"reply_ratio": round(stats["reply_count"] / stats["message_count"], 2) if stats["message_count"] > 0 else 0
|
"message_count": stats["message_count"],
|
||||||
})
|
"avg_chars": round(avg_chars, 1),
|
||||||
|
"emoji_ratio": round(
|
||||||
|
stats["emoji_count"] / stats["message_count"], 2
|
||||||
|
)
|
||||||
|
if stats["message_count"] > 0
|
||||||
|
else 0,
|
||||||
|
"night_ratio": round(night_messages / stats["message_count"], 2)
|
||||||
|
if stats["message_count"] > 0
|
||||||
|
else 0,
|
||||||
|
"reply_ratio": round(
|
||||||
|
stats["reply_count"] / stats["message_count"], 2
|
||||||
|
)
|
||||||
|
if stats["message_count"] > 0
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if not user_summaries:
|
if not user_summaries:
|
||||||
return {"user_summaries": []}
|
return {"user_summaries": []}
|
||||||
|
|
||||||
# 按消息数量排序
|
# 按消息数量排序
|
||||||
user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
|
user_summaries.sort(key=lambda x: x["message_count"], reverse=True)
|
||||||
|
|
||||||
return {"user_summaries": user_summaries}
|
return {"user_summaries": user_summaries}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"准备用户数据失败: {e}")
|
logger.error(f"准备用户数据失败: {e}")
|
||||||
return {"user_summaries": []}
|
return {"user_summaries": []}
|
||||||
|
|
||||||
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[UserTitle], TokenUsage]:
|
async def analyze_user_titles(
|
||||||
|
self,
|
||||||
|
messages: List[Dict],
|
||||||
|
user_analysis: Dict,
|
||||||
|
umo: str = None,
|
||||||
|
top_users: List[Dict] = None,
|
||||||
|
) -> Tuple[List[UserTitle], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
分析用户称号
|
分析用户称号
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
user_analysis: 用户分析统计
|
user_analysis: 用户分析统计
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
top_users: 活跃用户列表(从get_top_users获取,可选)
|
top_users: 活跃用户列表(从get_top_users获取,可选)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(用户称号列表, Token使用统计)
|
(用户称号列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 准备用户数据,传入活跃用户列表
|
# 准备用户数据,传入活跃用户列表
|
||||||
user_data = self.prepare_user_data(messages, user_analysis, top_users)
|
user_data = self.prepare_user_data(messages, user_analysis, top_users)
|
||||||
|
|
||||||
if not user_data["user_summaries"]:
|
if not user_data["user_summaries"]:
|
||||||
logger.info("没有符合条件的用户,返回空结果")
|
logger.info("没有符合条件的用户,返回空结果")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
logger.info(f"开始分析 {len(user_data['user_summaries'])} 个活跃用户的称号")
|
logger.info(f"开始分析 {len(user_data['user_summaries'])} 个活跃用户的称号")
|
||||||
return await self.analyze(user_data, umo)
|
return await self.analyze(user_data, umo)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"用户称号分析失败: {e}")
|
logger.error(f"用户称号分析失败: {e}")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from .analyzers.user_title_analyzer import UserTitleAnalyzer
|
|||||||
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
|
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||||
from .utils.llm_utils import call_provider_with_retry
|
from .utils.llm_utils import call_provider_with_retry
|
||||||
from .utils.json_utils import fix_json
|
from .utils.json_utils import fix_json
|
||||||
from .utils.json_utils import extract_topics_with_regex, extract_user_titles_with_regex, extract_golden_quotes_with_regex
|
|
||||||
|
|
||||||
|
|
||||||
class LLMAnalyzer:
|
class LLMAnalyzer:
|
||||||
@@ -21,32 +20,34 @@ class LLMAnalyzer:
|
|||||||
作为统一入口,协调各个专门的分析器进行不同类型的分析
|
作为统一入口,协调各个专门的分析器进行不同类型的分析
|
||||||
保持向后兼容性,提供原有的接口
|
保持向后兼容性,提供原有的接口
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context, config_manager):
|
def __init__(self, context, config_manager):
|
||||||
"""
|
"""
|
||||||
初始化LLM分析器
|
初始化LLM分析器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: AstrBot上下文对象
|
context: AstrBot上下文对象
|
||||||
config_manager: 配置管理器
|
config_manager: 配置管理器
|
||||||
"""
|
"""
|
||||||
self.context = context
|
self.context = context
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
|
|
||||||
# 初始化各个专门的分析器
|
# 初始化各个专门的分析器
|
||||||
self.topic_analyzer = TopicAnalyzer(context, config_manager)
|
self.topic_analyzer = TopicAnalyzer(context, config_manager)
|
||||||
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
|
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
|
||||||
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
||||||
|
|
||||||
async def analyze_topics(self, messages: List[Dict], umo: str = None) -> Tuple[List[SummaryTopic], TokenUsage]:
|
async def analyze_topics(
|
||||||
|
self, messages: List[Dict], umo: str = None
|
||||||
|
) -> Tuple[List[SummaryTopic], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
使用LLM分析话题
|
使用LLM分析话题
|
||||||
保持原有接口,委托给专门的TopicAnalyzer处理
|
保持原有接口,委托给专门的TopicAnalyzer处理
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(话题列表, Token使用统计)
|
(话题列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
@@ -56,37 +57,47 @@ class LLMAnalyzer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"话题分析失败: {e}")
|
logger.error(f"话题分析失败: {e}")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
async def analyze_user_titles(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[UserTitle], TokenUsage]:
|
async def analyze_user_titles(
|
||||||
|
self,
|
||||||
|
messages: List[Dict],
|
||||||
|
user_analysis: Dict,
|
||||||
|
umo: str = None,
|
||||||
|
top_users: List[Dict] = None,
|
||||||
|
) -> Tuple[List[UserTitle], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
使用LLM分析用户称号
|
使用LLM分析用户称号
|
||||||
保持原有接口,委托给专门的UserTitleAnalyzer处理
|
保持原有接口,委托给专门的UserTitleAnalyzer处理
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
user_analysis: 用户分析统计
|
user_analysis: 用户分析统计
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
top_users: 活跃用户列表(可选)
|
top_users: 活跃用户列表(可选)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(用户称号列表, Token使用统计)
|
(用户称号列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("开始用户称号分析")
|
logger.info("开始用户称号分析")
|
||||||
return await self.user_title_analyzer.analyze_user_titles(messages, user_analysis, umo, top_users)
|
return await self.user_title_analyzer.analyze_user_titles(
|
||||||
|
messages, user_analysis, umo, top_users
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"用户称号分析失败: {e}")
|
logger.error(f"用户称号分析失败: {e}")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
async def analyze_golden_quotes(self, messages: List[Dict], umo: str = None) -> Tuple[List[GoldenQuote], TokenUsage]:
|
async def analyze_golden_quotes(
|
||||||
|
self, messages: List[Dict], umo: str = None
|
||||||
|
) -> Tuple[List[GoldenQuote], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
使用LLM分析群聊金句
|
使用LLM分析群聊金句
|
||||||
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
|
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(金句列表, Token使用统计)
|
(金句列表, Token使用统计)
|
||||||
"""
|
"""
|
||||||
@@ -96,98 +107,120 @@ class LLMAnalyzer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"金句分析失败: {e}")
|
logger.error(f"金句分析失败: {e}")
|
||||||
return [], TokenUsage()
|
return [], TokenUsage()
|
||||||
|
|
||||||
async def analyze_all_concurrent(self, messages: List[Dict], user_analysis: Dict, umo: str = None, top_users: List[Dict] = None) -> Tuple[List[SummaryTopic], List[UserTitle], List[GoldenQuote], TokenUsage]:
|
async def analyze_all_concurrent(
|
||||||
|
self,
|
||||||
|
messages: List[Dict],
|
||||||
|
user_analysis: Dict,
|
||||||
|
umo: str = None,
|
||||||
|
top_users: List[Dict] = None,
|
||||||
|
) -> Tuple[List[SummaryTopic], List[UserTitle], List[GoldenQuote], TokenUsage]:
|
||||||
"""
|
"""
|
||||||
并发执行所有分析任务(话题、用户称号、金句)
|
并发执行所有分析任务(话题、用户称号、金句)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: 群聊消息列表
|
messages: 群聊消息列表
|
||||||
user_analysis: 用户分析统计
|
user_analysis: 用户分析统计
|
||||||
umo: 模型唯一标识符
|
umo: 模型唯一标识符
|
||||||
top_users: 活跃用户列表(可选)
|
top_users: 活跃用户列表(可选)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
(话题列表, 用户称号列表, 金句列表, 总Token使用统计)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("开始并发执行所有分析任务")
|
logger.info("开始并发执行所有分析任务")
|
||||||
|
|
||||||
# 并发执行三个分析任务
|
# 并发执行三个分析任务
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
self.topic_analyzer.analyze_topics(messages, umo),
|
self.topic_analyzer.analyze_topics(messages, umo),
|
||||||
self.user_title_analyzer.analyze_user_titles(messages, user_analysis, umo, top_users),
|
self.user_title_analyzer.analyze_user_titles(
|
||||||
|
messages, user_analysis, umo, top_users
|
||||||
|
),
|
||||||
self.golden_quote_analyzer.analyze_golden_quotes(messages, umo),
|
self.golden_quote_analyzer.analyze_golden_quotes(messages, umo),
|
||||||
return_exceptions=True
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 处理结果
|
# 处理结果
|
||||||
topics, topic_usage = [], TokenUsage()
|
topics, topic_usage = [], TokenUsage()
|
||||||
user_titles, title_usage = [], TokenUsage()
|
user_titles, title_usage = [], TokenUsage()
|
||||||
golden_quotes, quote_usage = [], TokenUsage()
|
golden_quotes, quote_usage = [], TokenUsage()
|
||||||
|
|
||||||
# 话题分析结果
|
# 话题分析结果
|
||||||
if isinstance(results[0], Exception):
|
if isinstance(results[0], Exception):
|
||||||
logger.error(f"话题分析失败: {results[0]}")
|
logger.error(f"话题分析失败: {results[0]}")
|
||||||
else:
|
else:
|
||||||
topics, topic_usage = results[0]
|
topics, topic_usage = results[0]
|
||||||
|
|
||||||
# 用户称号分析结果
|
# 用户称号分析结果
|
||||||
if isinstance(results[1], Exception):
|
if isinstance(results[1], Exception):
|
||||||
logger.error(f"用户称号分析失败: {results[1]}")
|
logger.error(f"用户称号分析失败: {results[1]}")
|
||||||
else:
|
else:
|
||||||
user_titles, title_usage = results[1]
|
user_titles, title_usage = results[1]
|
||||||
|
|
||||||
# 金句分析结果
|
# 金句分析结果
|
||||||
if isinstance(results[2], Exception):
|
if isinstance(results[2], Exception):
|
||||||
logger.error(f"金句分析失败: {results[2]}")
|
logger.error(f"金句分析失败: {results[2]}")
|
||||||
else:
|
else:
|
||||||
golden_quotes, quote_usage = results[2]
|
golden_quotes, quote_usage = results[2]
|
||||||
|
|
||||||
# 合并Token使用统计
|
# 合并Token使用统计
|
||||||
total_usage = TokenUsage(
|
total_usage = TokenUsage(
|
||||||
prompt_tokens=topic_usage.prompt_tokens + title_usage.prompt_tokens + quote_usage.prompt_tokens,
|
prompt_tokens=topic_usage.prompt_tokens
|
||||||
completion_tokens=topic_usage.completion_tokens + title_usage.completion_tokens + quote_usage.completion_tokens,
|
+ title_usage.prompt_tokens
|
||||||
total_tokens=topic_usage.total_tokens + title_usage.total_tokens + quote_usage.total_tokens
|
+ quote_usage.prompt_tokens,
|
||||||
|
completion_tokens=topic_usage.completion_tokens
|
||||||
|
+ title_usage.completion_tokens
|
||||||
|
+ quote_usage.completion_tokens,
|
||||||
|
total_tokens=topic_usage.total_tokens
|
||||||
|
+ title_usage.total_tokens
|
||||||
|
+ quote_usage.total_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}")
|
|
||||||
return topics, user_titles, golden_quotes, total_usage
|
return topics, user_titles, golden_quotes, total_usage
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"并发分析失败: {e}")
|
logger.error(f"并发分析失败: {e}")
|
||||||
return [], [], [], TokenUsage()
|
return [], [], [], TokenUsage()
|
||||||
|
|
||||||
# 向后兼容的方法,保持原有调用方式
|
# 向后兼容的方法,保持原有调用方式
|
||||||
async def _call_provider_with_retry(self, provider, prompt: str, max_tokens: int,
|
async def _call_provider_with_retry(
|
||||||
temperature: float, umo: str = None):
|
self,
|
||||||
|
provider,
|
||||||
|
prompt: str,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
umo: str = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
向后兼容的LLM调用方法
|
向后兼容的LLM调用方法
|
||||||
现在委托给llm_utils模块处理
|
现在委托给llm_utils模块处理
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: LLM服务商实例或None
|
provider: LLM服务商实例或None
|
||||||
prompt: 输入的提示语
|
prompt: 输入的提示语
|
||||||
max_tokens: 最大生成token数
|
max_tokens: 最大生成token数
|
||||||
temperature: 采样温度
|
temperature: 采样温度
|
||||||
umo: 指定使用的模型唯一标识符
|
umo: 指定使用的模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LLM生成的结果
|
LLM生成的结果
|
||||||
"""
|
"""
|
||||||
return await call_provider_with_retry(self.context, self.config_manager,
|
return await call_provider_with_retry(
|
||||||
prompt, max_tokens, temperature, umo)
|
self.context, self.config_manager, prompt, max_tokens, temperature, umo
|
||||||
|
)
|
||||||
|
|
||||||
def _fix_json(self, text: str) -> str:
|
def _fix_json(self, text: str) -> str:
|
||||||
"""
|
"""
|
||||||
向后兼容的JSON修复方法
|
向后兼容的JSON修复方法
|
||||||
现在委托给json_utils模块处理
|
现在委托给json_utils模块处理
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: 需要修复的JSON文本
|
text: 需要修复的JSON文本
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
修复后的JSON文本
|
修复后的JSON文本
|
||||||
"""
|
"""
|
||||||
return fix_json(text)
|
return fix_json(text)
|
||||||
|
|
||||||
+35
-26
@@ -19,24 +19,26 @@ class UserAnalyzer:
|
|||||||
"""分析用户活跃度"""
|
"""分析用户活跃度"""
|
||||||
# 获取机器人QQ号用于过滤
|
# 获取机器人QQ号用于过滤
|
||||||
bot_qq_id = self.config_manager.get_bot_qq_id()
|
bot_qq_id = self.config_manager.get_bot_qq_id()
|
||||||
|
|
||||||
user_stats = defaultdict(lambda: {
|
user_stats = defaultdict(
|
||||||
"message_count": 0,
|
lambda: {
|
||||||
"char_count": 0,
|
"message_count": 0,
|
||||||
"emoji_count": 0,
|
"char_count": 0,
|
||||||
"nickname": "",
|
"emoji_count": 0,
|
||||||
"hours": defaultdict(int),
|
"nickname": "",
|
||||||
"reply_count": 0
|
"hours": defaultdict(int),
|
||||||
})
|
"reply_count": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
sender = msg.get("sender", {})
|
sender = msg.get("sender", {})
|
||||||
user_id = str(sender.get("user_id", ""))
|
user_id = str(sender.get("user_id", ""))
|
||||||
|
|
||||||
# 跳过机器人自己的消息,避免进入统计
|
# 跳过机器人自己的消息,避免进入统计
|
||||||
if bot_qq_id and user_id == str(bot_qq_id):
|
if bot_qq_id and user_id == str(bot_qq_id):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||||
|
|
||||||
user_stats[user_id]["message_count"] += 1
|
user_stats[user_id]["message_count"] += 1
|
||||||
@@ -75,31 +77,37 @@ class UserAnalyzer:
|
|||||||
|
|
||||||
return dict(user_stats)
|
return dict(user_stats)
|
||||||
|
|
||||||
def get_top_users(self, user_analysis: Dict[str, Dict], limit: int = 10) -> List[Dict]:
|
def get_top_users(
|
||||||
|
self, user_analysis: Dict[str, Dict], limit: int = 10
|
||||||
|
) -> List[Dict]:
|
||||||
"""获取最活跃的用户"""
|
"""获取最活跃的用户"""
|
||||||
# 获取机器人QQ号用于过滤
|
# 获取机器人QQ号用于过滤
|
||||||
bot_qq_id = self.config_manager.get_bot_qq_id()
|
bot_qq_id = self.config_manager.get_bot_qq_id()
|
||||||
|
|
||||||
users = []
|
users = []
|
||||||
for user_id, stats in user_analysis.items():
|
for user_id, stats in user_analysis.items():
|
||||||
# 过滤机器人自己
|
# 过滤机器人自己
|
||||||
if bot_qq_id and str(user_id) == str(bot_qq_id):
|
if bot_qq_id and str(user_id) == str(bot_qq_id):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
users.append({
|
users.append(
|
||||||
"user_id": user_id,
|
{
|
||||||
"nickname": stats["nickname"],
|
"user_id": user_id,
|
||||||
"message_count": stats["message_count"],
|
"nickname": stats["nickname"],
|
||||||
"char_count": stats["char_count"],
|
"message_count": stats["message_count"],
|
||||||
"emoji_count": stats["emoji_count"],
|
"char_count": stats["char_count"],
|
||||||
"reply_count": stats["reply_count"]
|
"emoji_count": stats["emoji_count"],
|
||||||
})
|
"reply_count": stats["reply_count"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 按消息数量排序
|
# 按消息数量排序
|
||||||
users.sort(key=lambda x: x["message_count"], reverse=True)
|
users.sort(key=lambda x: x["message_count"], reverse=True)
|
||||||
return users[:limit]
|
return users[:limit]
|
||||||
|
|
||||||
def get_user_activity_pattern(self, user_analysis: Dict[str, Dict], user_id: str) -> Dict:
|
def get_user_activity_pattern(
|
||||||
|
self, user_analysis: Dict[str, Dict], user_id: str
|
||||||
|
) -> Dict:
|
||||||
"""获取用户活动模式"""
|
"""获取用户活动模式"""
|
||||||
if user_id not in user_analysis:
|
if user_id not in user_analysis:
|
||||||
return {}
|
return {}
|
||||||
@@ -112,11 +120,12 @@ class UserAnalyzer:
|
|||||||
|
|
||||||
# 计算夜间活跃度
|
# 计算夜间活跃度
|
||||||
night_messages = sum(hours[h] for h in range(0, 6))
|
night_messages = sum(hours[h] for h in range(0, 6))
|
||||||
night_ratio = night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
|
night_ratio = (
|
||||||
|
night_messages / stats["message_count"] if stats["message_count"] > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"most_active_hour": most_active_hour,
|
"most_active_hour": most_active_hour,
|
||||||
"night_ratio": night_ratio,
|
"night_ratio": night_ratio,
|
||||||
"hourly_distribution": dict(hours)
|
"hourly_distribution": dict(hours),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,30 +8,28 @@ from .json_utils import (
|
|||||||
parse_json_response,
|
parse_json_response,
|
||||||
extract_topics_with_regex,
|
extract_topics_with_regex,
|
||||||
extract_user_titles_with_regex,
|
extract_user_titles_with_regex,
|
||||||
extract_golden_quotes_with_regex
|
extract_golden_quotes_with_regex,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .llm_utils import (
|
from .llm_utils import (
|
||||||
call_provider_with_retry,
|
call_provider_with_retry,
|
||||||
extract_token_usage,
|
extract_token_usage,
|
||||||
extract_response_text
|
extract_response_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .info_utils import InfoUtils
|
from .info_utils import InfoUtils
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# JSON处理工具
|
# JSON处理工具
|
||||||
'fix_json',
|
"fix_json",
|
||||||
'parse_json_response',
|
"parse_json_response",
|
||||||
'extract_topics_with_regex',
|
"extract_topics_with_regex",
|
||||||
'extract_user_titles_with_regex',
|
"extract_user_titles_with_regex",
|
||||||
'extract_golden_quotes_with_regex',
|
"extract_golden_quotes_with_regex",
|
||||||
|
|
||||||
# LLM工具
|
# LLM工具
|
||||||
'call_provider_with_retry',
|
"call_provider_with_retry",
|
||||||
'extract_token_usage',
|
"extract_token_usage",
|
||||||
'extract_response_text',
|
"extract_response_text",
|
||||||
|
|
||||||
# 信息工具
|
# 信息工具
|
||||||
'InfoUtils'
|
"InfoUtils",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
JSON处理工具模块
|
JSON处理工具模块
|
||||||
提供JSON解析、修复和正则提取功能
|
提供JSON解析、修复和正则提取功能
|
||||||
@@ -6,41 +5,41 @@ JSON处理工具模块
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict, Tuple, Any, Optional
|
from typing import List, Dict, Tuple, Optional
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
def fix_json(text: str) -> str:
|
def fix_json(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
修复JSON格式问题,包括中文符号替换
|
修复JSON格式问题,包括中文符号替换
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: 需要修复的JSON文本
|
text: 需要修复的JSON文本
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
修复后的JSON文本
|
修复后的JSON文本
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1. 移除markdown代码块标记
|
# 1. 移除markdown代码块标记
|
||||||
text = re.sub(r'```json\s*', '', text)
|
text = re.sub(r"```json\s*", "", text)
|
||||||
text = re.sub(r'```\s*$', '', text)
|
text = re.sub(r"```\s*$", "", text)
|
||||||
|
|
||||||
# 2. 基础清理
|
# 2. 基础清理
|
||||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
text = text.replace("\n", " ").replace("\r", " ")
|
||||||
text = re.sub(r'\s+', ' ', text)
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
|
||||||
# 3. 替换中文符号为英文符号(修复)
|
# 3. 替换中文符号为英文符号(修复)
|
||||||
# 中文引号 -> 英文引号
|
# 中文引号 -> 英文引号
|
||||||
text = text.replace('“', '"').replace('”', '"')
|
text = text.replace("“", '"').replace("”", '"')
|
||||||
text = text.replace('‘', "'").replace('’', "'")
|
text = text.replace("‘", "'").replace("’", "'")
|
||||||
# 中文逗号 -> 英文逗号
|
# 中文逗号 -> 英文逗号
|
||||||
text = text.replace(',', ',')
|
text = text.replace(",", ",")
|
||||||
# 中文冒号 -> 英文冒号
|
# 中文冒号 -> 英文冒号
|
||||||
text = text.replace(':', ':')
|
text = text.replace(":", ":")
|
||||||
# 中文括号 -> 英文括号
|
# 中文括号 -> 英文括号
|
||||||
text = text.replace('(', '(').replace(')', ')')
|
text = text.replace("(", "(").replace(")", ")")
|
||||||
text = text.replace('【', '[').replace('】', ']')
|
text = text.replace("【", "[").replace("】", "]")
|
||||||
|
|
||||||
# 4. 处理字符串内容中的特殊字符
|
# 4. 处理字符串内容中的特殊字符
|
||||||
# 转义字符串内的双引号
|
# 转义字符串内的双引号
|
||||||
def escape_quotes_in_strings(match):
|
def escape_quotes_in_strings(match):
|
||||||
@@ -48,71 +47,73 @@ def fix_json(text: str) -> str:
|
|||||||
# 转义内部的双引号
|
# 转义内部的双引号
|
||||||
content = content.replace('"', '\\"')
|
content = content.replace('"', '\\"')
|
||||||
return f'"{content}"'
|
return f'"{content}"'
|
||||||
|
|
||||||
# 先处理字段值中的引号
|
# 先处理字段值中的引号
|
||||||
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
|
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
|
||||||
|
|
||||||
# 5. 修复截断的JSON
|
# 5. 修复截断的JSON
|
||||||
if not text.endswith(']'):
|
if not text.endswith("]"):
|
||||||
last_complete = text.rfind('}')
|
last_complete = text.rfind("}")
|
||||||
if last_complete > 0:
|
if last_complete > 0:
|
||||||
text = text[:last_complete + 1] + ']'
|
text = text[: last_complete + 1] + "]"
|
||||||
|
|
||||||
# 6. 修复常见的JSON格式问题
|
# 6. 修复常见的JSON格式问题
|
||||||
# 1. 修复缺失的逗号
|
# 1. 修复缺失的逗号
|
||||||
text = re.sub(r'}\s*{', '}, {', text)
|
text = re.sub(r"}\s*{", "}, {", text)
|
||||||
|
|
||||||
# 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值)
|
# 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值)
|
||||||
def quote_field_names(match):
|
def quote_field_names(match):
|
||||||
prefix = match.group(1)
|
prefix = match.group(1)
|
||||||
key = match.group(2)
|
key = match.group(2)
|
||||||
return f'{prefix}"{key}":'
|
return f'{prefix}"{key}":'
|
||||||
|
|
||||||
# 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配
|
# 只在 { 或 , 后面匹配字段名,避免在字符串值中误匹配
|
||||||
text = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', quote_field_names, text)
|
text = re.sub(r"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:", quote_field_names, text)
|
||||||
|
|
||||||
# 3. 移除多余的逗号
|
# 3. 移除多余的逗号
|
||||||
text = re.sub(r',\s*}', '}', text)
|
text = re.sub(r",\s*}", "}", text)
|
||||||
text = re.sub(r',\s*]', ']', text)
|
text = re.sub(r",\s*]", "]", text)
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"JSON修复失败: {e}")
|
logger.error(f"JSON修复失败: {e}")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def parse_json_response(result_text: str, data_type: str) -> Tuple[bool, Optional[List[Dict]], Optional[str]]:
|
def parse_json_response(
|
||||||
|
result_text: str, data_type: str
|
||||||
|
) -> Tuple[bool, Optional[List[Dict]], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
统一的JSON解析方法
|
统一的JSON解析方法
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: LLM返回的原始文本
|
result_text: LLM返回的原始文本
|
||||||
data_type: 数据类型 ('topics' | 'user_titles' | 'golden_quotes')
|
data_type: 数据类型 ('topics' | 'user_titles' | 'golden_quotes')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(成功标志, 解析后的数据列表, 错误消息)
|
(成功标志, 解析后的数据列表, 错误消息)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 1. 提取JSON部分
|
# 1. 提取JSON部分
|
||||||
json_match = re.search(r'\[.*?\]', result_text, re.DOTALL)
|
json_match = re.search(r"\[.*?\]", result_text, re.DOTALL)
|
||||||
if not json_match:
|
if not json_match:
|
||||||
error_msg = f"{data_type}响应中未找到JSON格式"
|
error_msg = f"{data_type}响应中未找到JSON格式"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
return False, None, error_msg
|
return False, None, error_msg
|
||||||
|
|
||||||
json_text = json_match.group()
|
json_text = json_match.group()
|
||||||
logger.debug(f"{data_type}分析JSON原文: {json_text[:500]}...")
|
logger.debug(f"{data_type}分析JSON原文: {json_text[:500]}...")
|
||||||
|
|
||||||
# 2. 修复JSON
|
# 2. 修复JSON
|
||||||
json_text = fix_json(json_text)
|
json_text = fix_json(json_text)
|
||||||
logger.debug(f"{data_type}修复后的JSON: {json_text[:300]}...")
|
logger.debug(f"{data_type}修复后的JSON: {json_text[:300]}...")
|
||||||
|
|
||||||
# 3. 解析JSON
|
# 3. 解析JSON
|
||||||
data = json.loads(json_text)
|
data = json.loads(json_text)
|
||||||
logger.info(f"{data_type}分析成功,解析到 {len(data)} 条数据")
|
logger.info(f"{data_type}分析成功,解析到 {len(data)} 条数据")
|
||||||
return True, data, None
|
return True, data, None
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
error_msg = f"{data_type}JSON解析失败: {e}"
|
error_msg = f"{data_type}JSON解析失败: {e}"
|
||||||
logger.warning(error_msg)
|
logger.warning(error_msg)
|
||||||
@@ -127,11 +128,11 @@ def parse_json_response(result_text: str, data_type: str) -> Tuple[bool, Optiona
|
|||||||
def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
|
def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取话题信息
|
使用正则表达式提取话题信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: 需要提取的文本
|
result_text: 需要提取的文本
|
||||||
max_topics: 最大话题数量
|
max_topics: 最大话题数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
话题数据列表
|
话题数据列表
|
||||||
"""
|
"""
|
||||||
@@ -140,33 +141,38 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
|
|||||||
# 匹配每个完整的话题对象
|
# 匹配每个完整的话题对象
|
||||||
topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
topic_pattern = r'\{\s*"topic":\s*"([^"]+)"\s*,\s*"contributors":\s*\[([^\]]+)\]\s*,\s*"detail":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
# 尝试更宽松的匹配
|
# 尝试更宽松的匹配
|
||||||
topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
|
topic_pattern = r'"topic":\s*"([^"]+)"[^}]*"contributors":\s*\[([^\]]+)\][^}]*"detail":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||||
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
matches = re.findall(topic_pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
topics = []
|
topics = []
|
||||||
for match in matches[:max_topics]:
|
for match in matches[:max_topics]:
|
||||||
topic_name = match[0].strip()
|
topic_name = match[0].strip()
|
||||||
contributors_str = match[1].strip()
|
contributors_str = match[1].strip()
|
||||||
detail = match[2].strip()
|
detail = match[2].strip()
|
||||||
|
|
||||||
# 清理detail中的转义字符
|
# 清理detail中的转义字符
|
||||||
detail = detail.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
detail = detail.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||||
|
|
||||||
# 解析参与者列表
|
# 解析参与者列表
|
||||||
contributors = [contrib.strip() for contrib in re.findall(r'"([^"]+)"', contributors_str)] or ["群友"]
|
contributors = [
|
||||||
|
contrib.strip()
|
||||||
topics.append({
|
for contrib in re.findall(r'"([^"]+)"', contributors_str)
|
||||||
"topic": topic_name,
|
] or ["群友"]
|
||||||
"contributors": contributors[:5], # 最多5个参与者
|
|
||||||
"detail": detail
|
topics.append(
|
||||||
})
|
{
|
||||||
|
"topic": topic_name,
|
||||||
|
"contributors": contributors[:5], # 最多5个参与者
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容")
|
logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容")
|
||||||
return topics
|
return topics
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"话题正则表达式提取失败: {e}")
|
logger.error(f"话题正则表达式提取失败: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -175,47 +181,43 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
|
|||||||
def extract_user_titles_with_regex(result_text: str, max_count: int) -> List[Dict]:
|
def extract_user_titles_with_regex(result_text: str, max_count: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取用户称号信息
|
使用正则表达式提取用户称号信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: 需要提取的文本
|
result_text: 需要提取的文本
|
||||||
max_count: 最大提取数量
|
max_count: 最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
用户称号数据列表
|
用户称号数据列表
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
titles = []
|
titles = []
|
||||||
|
|
||||||
# 正则模式:匹配完整的用户称号对象
|
# 正则模式:匹配完整的用户称号对象
|
||||||
pattern = r'\{\s*"name":\s*"([^"]+)"\s*,\s*"qq":\s*(\d+)\s*,\s*"title":\s*"([^"]+)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
pattern = r'\{\s*"name":\s*"([^"]+)"\s*,\s*"qq":\s*(\d+)\s*,\s*"title":\s*"([^"]+)"\s*,\s*"mbti":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
# 尝试更宽松的匹配(字段顺序可变)
|
# 尝试更宽松的匹配(字段顺序可变)
|
||||||
pattern = r'"name":\s*"([^"]+)"[^}]*"qq":\s*(\d+)[^}]*"title":\s*"([^"]+)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
pattern = r'"name":\s*"([^"]+)"[^}]*"qq":\s*(\d+)[^}]*"title":\s*"([^"]+)"[^}]*"mbti":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
for match in matches[:max_count]:
|
for match in matches[:max_count]:
|
||||||
name = match[0].strip()
|
name = match[0].strip()
|
||||||
qq = int(match[1])
|
qq = int(match[1])
|
||||||
title = match[2].strip()
|
title = match[2].strip()
|
||||||
mbti = match[3].strip()
|
mbti = match[3].strip()
|
||||||
reason = match[4].strip()
|
reason = match[4].strip()
|
||||||
|
|
||||||
# 清理转义字符
|
# 清理转义字符
|
||||||
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||||
|
|
||||||
titles.append({
|
titles.append(
|
||||||
"name": name,
|
{"name": name, "qq": qq, "title": title, "mbti": mbti, "reason": reason}
|
||||||
"qq": qq,
|
)
|
||||||
"title": title,
|
|
||||||
"mbti": mbti,
|
|
||||||
"reason": reason
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号")
|
logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号")
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"用户称号正则表达式提取失败: {e}")
|
logger.error(f"用户称号正则表达式提取失败: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -224,44 +226,42 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> List[Dic
|
|||||||
def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> List[Dict]:
|
def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
使用正则表达式提取金句信息
|
使用正则表达式提取金句信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result_text: 需要提取的文本
|
result_text: 需要提取的文本
|
||||||
max_count: 最大提取数量
|
max_count: 最大提取数量
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
金句数据列表
|
金句数据列表
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
quotes = []
|
quotes = []
|
||||||
|
|
||||||
# 正则模式:匹配完整的金句对象
|
# 正则模式:匹配完整的金句对象
|
||||||
pattern = r'\{\s*"content":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"sender":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
pattern = r'\{\s*"content":\s*"([^"]*(?:\\.[^"]*)*)"\s*,\s*"sender":\s*"([^"]+)"\s*,\s*"reason":\s*"([^"]*(?:\\.[^"]*)*)"\s*\}'
|
||||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
# 尝试更宽松的匹配(字段顺序可变)
|
# 尝试更宽松的匹配(字段顺序可变)
|
||||||
pattern = r'"content":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"sender":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
pattern = r'"content":\s*"([^"]*(?:\\.[^"]*)*)"[^}]*"sender":\s*"([^"]+)"[^}]*"reason":\s*"([^"]*(?:\\.[^"]*)*)"'
|
||||||
matches = re.findall(pattern, result_text, re.DOTALL)
|
matches = re.findall(pattern, result_text, re.DOTALL)
|
||||||
|
|
||||||
for match in matches[:max_count]:
|
for match in matches[:max_count]:
|
||||||
content = match[0].strip()
|
content = match[0].strip()
|
||||||
sender = match[1].strip()
|
sender = match[1].strip()
|
||||||
reason = match[2].strip()
|
reason = match[2].strip()
|
||||||
|
|
||||||
# 清理转义字符
|
# 清理转义字符
|
||||||
content = content.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
content = (
|
||||||
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
content.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||||
|
)
|
||||||
quotes.append({
|
reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||||
"content": content,
|
|
||||||
"sender": sender,
|
quotes.append({"content": content, "sender": sender, "reason": reason})
|
||||||
"reason": reason
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句")
|
logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句")
|
||||||
return quotes
|
return quotes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"金句正则表达式提取失败: {e}")
|
logger.error(f"金句正则表达式提取失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -9,11 +9,17 @@ from astrbot.api import logger
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
async def call_provider_with_retry(context, config_manager, prompt: str, max_tokens: int,
|
async def call_provider_with_retry(
|
||||||
temperature: float, umo: str = None) -> Optional[Any]:
|
context,
|
||||||
|
config_manager,
|
||||||
|
prompt: str,
|
||||||
|
max_tokens: int,
|
||||||
|
temperature: float,
|
||||||
|
umo: str = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
调用LLM提供者,带超时、重试与退避。支持自定义服务商。
|
调用LLM提供者,带超时、重试与退避。支持自定义服务商。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
context: AstrBot上下文对象
|
context: AstrBot上下文对象
|
||||||
config_manager: 配置管理器
|
config_manager: 配置管理器
|
||||||
@@ -21,59 +27,80 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
|||||||
max_tokens: 最大生成token数
|
max_tokens: 最大生成token数
|
||||||
temperature: 采样温度
|
temperature: 采样温度
|
||||||
umo: 指定使用的模型唯一标识符
|
umo: 指定使用的模型唯一标识符
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LLM生成的结果,失败时返回None
|
LLM生成的结果,失败时返回None
|
||||||
"""
|
"""
|
||||||
timeout = config_manager.get_llm_timeout()
|
timeout = config_manager.get_llm_timeout()
|
||||||
retries = config_manager.get_llm_retries()
|
retries = config_manager.get_llm_retries()
|
||||||
backoff = config_manager.get_llm_backoff()
|
backoff = config_manager.get_llm_backoff()
|
||||||
|
|
||||||
# 获取自定义服务商参数
|
# 获取自定义服务商参数
|
||||||
custom_api_key = config_manager.get_custom_api_key()
|
custom_api_key = config_manager.get_custom_api_key()
|
||||||
custom_api_base = config_manager.get_custom_api_base_url()
|
custom_api_base = config_manager.get_custom_api_base_url()
|
||||||
custom_model = config_manager.get_custom_model_name()
|
custom_model = config_manager.get_custom_model_name()
|
||||||
|
|
||||||
last_exc = None
|
last_exc = None
|
||||||
for attempt in range(1, retries + 1):
|
for attempt in range(1, retries + 1):
|
||||||
try:
|
try:
|
||||||
if custom_api_key and custom_api_base and custom_model:
|
if custom_api_key and custom_api_base and custom_model:
|
||||||
logger.info(f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}")
|
logger.info(
|
||||||
logger.debug(f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}")
|
f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}"
|
||||||
logger.debug(f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}...")
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||||
|
)
|
||||||
|
|
||||||
# 检查 prompt 是否为空
|
# 检查 prompt 是否为空
|
||||||
if not prompt or not prompt.strip():
|
if not prompt or not prompt.strip():
|
||||||
logger.error("自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求")
|
logger.error(
|
||||||
|
"自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {custom_api_key}",
|
"Authorization": f"Bearer {custom_api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
"model": custom_model,
|
"model": custom_model,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"temperature": temperature
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
aio_timeout = aiohttp.ClientTimeout(total=timeout)
|
aio_timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
async with session.post(custom_api_base, json=payload, headers=headers, timeout=aio_timeout) as resp:
|
async with session.post(
|
||||||
|
custom_api_base,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aio_timeout,
|
||||||
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
logger.error(f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}")
|
logger.error(
|
||||||
|
f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response_json = await resp.json()
|
response_json = await resp.json()
|
||||||
except Exception as json_err:
|
except Exception as json_err:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
logger.error(f"自定义LLM服务商响应JSON解析失败: {json_err}, 内容: {error_text}")
|
logger.error(
|
||||||
|
f"自定义LLM服务商响应JSON解析失败: {json_err}, 内容: {error_text}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
# 兼容 OpenAI 格式,安全访问嵌套字段
|
# 兼容 OpenAI 格式,安全访问嵌套字段
|
||||||
content = None
|
content = None
|
||||||
try:
|
try:
|
||||||
choices = response_json.get("choices")
|
choices = response_json.get("choices")
|
||||||
if choices and isinstance(choices, list) and len(choices) > 0:
|
if (
|
||||||
|
choices
|
||||||
|
and isinstance(choices, list)
|
||||||
|
and len(choices) > 0
|
||||||
|
):
|
||||||
message = choices[0].get("message")
|
message = choices[0].get("message")
|
||||||
if message and isinstance(message, dict):
|
if message and isinstance(message, dict):
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
@@ -81,17 +108,21 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
|||||||
logger.error(f"自定义LLM响应格式异常: {response_json}")
|
logger.error(f"自定义LLM响应格式异常: {response_json}")
|
||||||
return None
|
return None
|
||||||
except Exception as key_err:
|
except Exception as key_err:
|
||||||
logger.error(f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}")
|
logger.error(
|
||||||
|
f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 构造一个兼容原有逻辑的对象
|
# 构造一个兼容原有逻辑的对象
|
||||||
class CustomResponse:
|
class CustomResponse:
|
||||||
completion_text = content
|
completion_text = content
|
||||||
raw_completion = response_json
|
raw_completion = response_json
|
||||||
|
|
||||||
return CustomResponse()
|
return CustomResponse()
|
||||||
else:
|
else:
|
||||||
# 确保使用当前指定的模型
|
# 确保使用当前指定的模型
|
||||||
provider = context.get_using_provider(umo=umo)
|
provider = context.get_using_provider(umo=umo)
|
||||||
provider_id = 'unknown'
|
provider_id = "unknown"
|
||||||
if provider:
|
if provider:
|
||||||
try:
|
try:
|
||||||
meta = provider.meta()
|
meta = provider.meta()
|
||||||
@@ -99,23 +130,33 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"获取提供商ID失败: {e}")
|
logger.debug(f"获取提供商ID失败: {e}")
|
||||||
logger.info(f"获取到的 provider ID: {provider_id}")
|
logger.info(f"获取到的 provider ID: {provider_id}")
|
||||||
if not provider or provider_id == 'unknown':
|
if not provider or provider_id == "unknown":
|
||||||
logger.warning(f"获取的提供商不正确 (Provider ID: {provider_id})")
|
logger.warning(f"获取的提供商不正确 (Provider ID: {provider_id})")
|
||||||
|
|
||||||
logger.info(f"使用LLM provider: {provider}, max_tokens={max_tokens}, temperature={temperature}")
|
logger.info(
|
||||||
|
f"使用LLM provider: {provider}, max_tokens={max_tokens}, temperature={temperature}"
|
||||||
|
)
|
||||||
if not provider:
|
if not provider:
|
||||||
logger.error("provider 为空,无法调用 text_chat,直接返回 None")
|
logger.error("provider 为空,无法调用 text_chat,直接返回 None")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.debug(f"LLM provider prompt 长度: {len(prompt) if prompt else 0}")
|
logger.debug(
|
||||||
logger.debug(f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}...")
|
f"LLM provider prompt 长度: {len(prompt) if prompt else 0}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||||
|
)
|
||||||
|
|
||||||
# 检查 prompt 是否为空
|
# 检查 prompt 是否为空
|
||||||
if not prompt or not prompt.strip():
|
if not prompt or not prompt.strip():
|
||||||
logger.error("LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat")
|
logger.error(
|
||||||
|
"LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
coro = provider.text_chat(prompt=prompt, max_tokens=max_tokens, temperature=temperature)
|
coro = provider.text_chat(
|
||||||
|
prompt=prompt, max_tokens=max_tokens, temperature=temperature
|
||||||
|
)
|
||||||
return await asyncio.wait_for(coro, timeout=timeout)
|
return await asyncio.wait_for(coro, timeout=timeout)
|
||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
last_exc = e
|
last_exc = e
|
||||||
@@ -126,7 +167,7 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
|||||||
# 若非最后一次,等待退避后重试
|
# 若非最后一次,等待退避后重试
|
||||||
if attempt < retries:
|
if attempt < retries:
|
||||||
await asyncio.sleep(backoff * attempt)
|
await asyncio.sleep(backoff * attempt)
|
||||||
|
|
||||||
# 最终仍失败,记录错误并返回 None 由调用方处理降级,避免抛出异常
|
# 最终仍失败,记录错误并返回 None 由调用方处理降级,避免抛出异常
|
||||||
logger.error(f"LLM请求全部重试失败: {last_exc}")
|
logger.error(f"LLM请求全部重试失败: {last_exc}")
|
||||||
return None
|
return None
|
||||||
@@ -135,55 +176,49 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
|||||||
def extract_token_usage(response) -> Optional[dict]:
|
def extract_token_usage(response) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
从LLM响应中提取token使用统计
|
从LLM响应中提取token使用统计
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: LLM响应对象
|
response: LLM响应对象
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens
|
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
token_usage = {
|
token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 安全地提取 usage,避免 response.raw_completion.usage 为 None 导致的 AttributeError
|
# 安全地提取 usage,避免 response.raw_completion.usage 为 None 导致的 AttributeError
|
||||||
usage = None
|
usage = None
|
||||||
if getattr(response, 'raw_completion', None) is not None:
|
if getattr(response, "raw_completion", None) is not None:
|
||||||
usage = getattr(response.raw_completion, 'usage', None)
|
usage = getattr(response.raw_completion, "usage", None)
|
||||||
if usage:
|
if usage:
|
||||||
token_usage["prompt_tokens"] = getattr(usage, 'prompt_tokens', 0) or 0
|
token_usage["prompt_tokens"] = getattr(usage, "prompt_tokens", 0) or 0
|
||||||
token_usage["completion_tokens"] = getattr(usage, 'completion_tokens', 0) or 0
|
token_usage["completion_tokens"] = (
|
||||||
token_usage["total_tokens"] = getattr(usage, 'total_tokens', 0) or 0
|
getattr(usage, "completion_tokens", 0) or 0
|
||||||
|
)
|
||||||
|
token_usage["total_tokens"] = getattr(usage, "total_tokens", 0) or 0
|
||||||
|
|
||||||
return token_usage
|
return token_usage
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"提取token使用统计失败: {e}")
|
logger.error(f"提取token使用统计失败: {e}")
|
||||||
return {
|
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_response_text(response) -> str:
|
def extract_response_text(response) -> str:
|
||||||
"""
|
"""
|
||||||
从LLM响应中提取文本内容
|
从LLM响应中提取文本内容
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: LLM响应对象
|
response: LLM响应对象
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
响应文本内容
|
响应文本内容
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if hasattr(response, 'completion_text'):
|
if hasattr(response, "completion_text"):
|
||||||
return response.completion_text
|
return response.completion_text
|
||||||
else:
|
else:
|
||||||
return str(response)
|
return str(response)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"提取响应文本失败: {e}")
|
logger.error(f"提取响应文本失败: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -5,7 +5,4 @@
|
|||||||
from .config import ConfigManager
|
from .config import ConfigManager
|
||||||
from .message_handler import MessageHandler
|
from .message_handler import MessageHandler
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["ConfigManager", "MessageHandler"]
|
||||||
'ConfigManager',
|
|
||||||
'MessageHandler'
|
|
||||||
]
|
|
||||||
|
|||||||
+22
-23
@@ -5,20 +5,21 @@ Bot实例管理模块
|
|||||||
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
class BotManager:
|
class BotManager:
|
||||||
"""Bot实例管理器 - 统一管理所有bot相关操作"""
|
"""Bot实例管理器 - 统一管理所有bot相关操作"""
|
||||||
|
|
||||||
def __init__(self, config_manager):
|
def __init__(self, config_manager):
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self._bot_instance = None
|
self._bot_instance = None
|
||||||
self._bot_qq_id = None
|
self._bot_qq_id = None
|
||||||
self._context = None
|
self._context = None
|
||||||
self._is_initialized = False
|
self._is_initialized = False
|
||||||
|
|
||||||
def set_context(self, context):
|
def set_context(self, context):
|
||||||
"""设置AstrBot上下文"""
|
"""设置AstrBot上下文"""
|
||||||
self._context = context
|
self._context = context
|
||||||
|
|
||||||
def set_bot_instance(self, bot_instance):
|
def set_bot_instance(self, bot_instance):
|
||||||
"""设置bot实例"""
|
"""设置bot实例"""
|
||||||
if bot_instance:
|
if bot_instance:
|
||||||
@@ -29,48 +30,46 @@ class BotManager:
|
|||||||
if bot_qq_id:
|
if bot_qq_id:
|
||||||
self._bot_qq_id = str(bot_qq_id)
|
self._bot_qq_id = str(bot_qq_id)
|
||||||
|
|
||||||
|
|
||||||
def set_bot_qq_id(self, bot_qq_id: str):
|
def set_bot_qq_id(self, bot_qq_id: str):
|
||||||
"""设置bot QQ号"""
|
"""设置bot QQ号"""
|
||||||
if bot_qq_id:
|
if bot_qq_id:
|
||||||
self._bot_qq_id = str(bot_qq_id)
|
self._bot_qq_id = str(bot_qq_id)
|
||||||
|
|
||||||
|
|
||||||
def get_bot_instance(self):
|
def get_bot_instance(self):
|
||||||
"""获取当前bot实例"""
|
"""获取当前bot实例"""
|
||||||
return self._bot_instance
|
return self._bot_instance
|
||||||
|
|
||||||
def has_bot_instance(self) -> bool:
|
def has_bot_instance(self) -> bool:
|
||||||
"""检查是否有可用的bot实例"""
|
"""检查是否有可用的bot实例"""
|
||||||
return self._bot_instance is not None
|
return self._bot_instance is not None
|
||||||
|
|
||||||
def has_bot_qq_id(self) -> bool:
|
def has_bot_qq_id(self) -> bool:
|
||||||
"""检查是否有配置的bot QQ号"""
|
"""检查是否有配置的bot QQ号"""
|
||||||
return self._bot_qq_id is not None
|
return self._bot_qq_id is not None
|
||||||
|
|
||||||
def is_ready_for_auto_analysis(self) -> bool:
|
def is_ready_for_auto_analysis(self) -> bool:
|
||||||
"""检查是否准备好进行自动分析"""
|
"""检查是否准备好进行自动分析"""
|
||||||
return self.has_bot_instance() and self.has_bot_qq_id()
|
return self.has_bot_instance() and self.has_bot_qq_id()
|
||||||
|
|
||||||
async def auto_discover_bot_instance(self):
|
async def auto_discover_bot_instance(self):
|
||||||
"""自动发现可用的bot实例"""
|
"""自动发现可用的bot实例"""
|
||||||
if not self._context or not hasattr(self._context, 'platform_manager'):
|
if not self._context or not hasattr(self._context, "platform_manager"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
platforms = getattr(self._context.platform_manager, 'platform_insts', [])
|
platforms = getattr(self._context.platform_manager, "platform_insts", [])
|
||||||
for platform in platforms:
|
for platform in platforms:
|
||||||
# 获取bot实例
|
# 获取bot实例
|
||||||
bot_client = None
|
bot_client = None
|
||||||
if hasattr(platform, 'get_client'):
|
if hasattr(platform, "get_client"):
|
||||||
bot_client = platform.get_client()
|
bot_client = platform.get_client()
|
||||||
elif hasattr(platform, 'bot'):
|
elif hasattr(platform, "bot"):
|
||||||
bot_client = platform.bot
|
bot_client = platform.bot
|
||||||
|
|
||||||
if bot_client:
|
if bot_client:
|
||||||
self.set_bot_instance(bot_client)
|
self.set_bot_instance(bot_client)
|
||||||
return bot_client
|
return bot_client
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def initialize_from_config(self):
|
async def initialize_from_config(self):
|
||||||
"""从配置初始化bot管理器"""
|
"""从配置初始化bot管理器"""
|
||||||
# 设置配置的bot QQ号
|
# 设置配置的bot QQ号
|
||||||
@@ -84,19 +83,19 @@ class BotManager:
|
|||||||
|
|
||||||
# 返回是否成功初始化(至少有bot实例)
|
# 返回是否成功初始化(至少有bot实例)
|
||||||
return self.has_bot_instance()
|
return self.has_bot_instance()
|
||||||
|
|
||||||
def get_status_info(self) -> Dict[str, Any]:
|
def get_status_info(self) -> Dict[str, Any]:
|
||||||
"""获取bot管理器状态信息"""
|
"""获取bot管理器状态信息"""
|
||||||
return {
|
return {
|
||||||
"has_bot_instance": self.has_bot_instance(),
|
"has_bot_instance": self.has_bot_instance(),
|
||||||
"has_bot_qq_id": self.has_bot_qq_id(),
|
"has_bot_qq_id": self.has_bot_qq_id(),
|
||||||
"bot_qq_id": self._bot_qq_id,
|
"bot_qq_id": self._bot_qq_id,
|
||||||
"ready_for_auto_analysis": self.is_ready_for_auto_analysis()
|
"ready_for_auto_analysis": self.is_ready_for_auto_analysis(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_from_event(self, event):
|
def update_from_event(self, event):
|
||||||
"""从事件更新bot实例(用于手动命令)"""
|
"""从事件更新bot实例(用于手动命令)"""
|
||||||
if hasattr(event, 'bot') and event.bot:
|
if hasattr(event, "bot") and event.bot:
|
||||||
self.set_bot_instance(event.bot)
|
self.set_bot_instance(event.bot)
|
||||||
# 每次都尝试从bot实例提取QQ号
|
# 每次都尝试从bot实例提取QQ号
|
||||||
bot_qq_id = self._extract_bot_qq_id(event.bot)
|
bot_qq_id = self._extract_bot_qq_id(event.bot)
|
||||||
@@ -113,18 +112,18 @@ class BotManager:
|
|||||||
def _extract_bot_qq_id(self, bot_instance):
|
def _extract_bot_qq_id(self, bot_instance):
|
||||||
"""从bot实例中提取QQ号"""
|
"""从bot实例中提取QQ号"""
|
||||||
# 尝试多种方式获取bot QQ号
|
# 尝试多种方式获取bot QQ号
|
||||||
if hasattr(bot_instance, 'self_id') and bot_instance.self_id:
|
if hasattr(bot_instance, "self_id") and bot_instance.self_id:
|
||||||
return str(bot_instance.self_id)
|
return str(bot_instance.self_id)
|
||||||
elif hasattr(bot_instance, 'qq') and bot_instance.qq:
|
elif hasattr(bot_instance, "qq") and bot_instance.qq:
|
||||||
return str(bot_instance.qq)
|
return str(bot_instance.qq)
|
||||||
elif hasattr(bot_instance, 'user_id') and bot_instance.user_id:
|
elif hasattr(bot_instance, "user_id") and bot_instance.user_id:
|
||||||
return str(bot_instance.user_id)
|
return str(bot_instance.user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def validate_for_message_fetching(self, group_id: str) -> bool:
|
def validate_for_message_fetching(self, group_id: str) -> bool:
|
||||||
"""验证是否可以进行消息获取"""
|
"""验证是否可以进行消息获取"""
|
||||||
return self.has_bot_instance() and bool(group_id)
|
return self.has_bot_instance() and bool(group_id)
|
||||||
|
|
||||||
def should_filter_bot_message(self, sender_id: str) -> bool:
|
def should_filter_bot_message(self, sender_id: str) -> bool:
|
||||||
"""判断是否应该过滤bot自己的消息"""
|
"""判断是否应该过滤bot自己的消息"""
|
||||||
if not self._bot_qq_id:
|
if not self._bot_qq_id:
|
||||||
|
|||||||
+38
-24
@@ -4,8 +4,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import importlib
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from astrbot.api import logger, AstrBotConfig
|
from astrbot.api import logger, AstrBotConfig
|
||||||
|
|
||||||
@@ -110,25 +108,30 @@ class ConfigManager:
|
|||||||
def get_custom_model_name(self) -> str:
|
def get_custom_model_name(self) -> str:
|
||||||
"""获取自定义 LLM 服务的模型名称"""
|
"""获取自定义 LLM 服务的模型名称"""
|
||||||
return self.config.get("custom_model_name", "")
|
return self.config.get("custom_model_name", "")
|
||||||
|
|
||||||
def get_pdf_output_dir(self) -> str:
|
def get_pdf_output_dir(self) -> str:
|
||||||
"""获取PDF输出目录"""
|
"""获取PDF输出目录"""
|
||||||
return self.config.get("pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports")
|
return self.config.get(
|
||||||
|
"pdf_output_dir", "data/plugins/astrbot-qq-group-daily-analysis/reports"
|
||||||
|
)
|
||||||
|
|
||||||
def get_bot_qq_id(self) -> str:
|
def get_bot_qq_id(self) -> str:
|
||||||
"""获取bot QQ号"""
|
"""获取bot QQ号"""
|
||||||
return str(self.config.get("bot_qq_id", ""))
|
return str(self.config.get("bot_qq_id", ""))
|
||||||
|
|
||||||
def get_pdf_filename_format(self) -> str:
|
def get_pdf_filename_format(self) -> str:
|
||||||
"""获取PDF文件名格式"""
|
"""获取PDF文件名格式"""
|
||||||
return self.config.get("pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf")
|
return self.config.get(
|
||||||
|
"pdf_filename_format", "群聊分析报告_{group_id}_{date}.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str:
|
def get_topic_analysis_prompt(self, style: str = "topic_prompt") -> str:
|
||||||
"""
|
"""
|
||||||
获取话题分析提示词模板
|
获取话题分析提示词模板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style: 提示词风格,默认为 "topic_prompt"
|
style: 提示词风格,默认为 "topic_prompt"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词模板字符串
|
提示词模板字符串
|
||||||
"""
|
"""
|
||||||
@@ -144,10 +147,10 @@ class ConfigManager:
|
|||||||
def get_user_title_analysis_prompt(self, style: str = "user_title_prompt") -> str:
|
def get_user_title_analysis_prompt(self, style: str = "user_title_prompt") -> str:
|
||||||
"""
|
"""
|
||||||
获取用户称号分析提示词模板
|
获取用户称号分析提示词模板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style: 提示词风格,默认为 "user_title_prompt"
|
style: 提示词风格,默认为 "user_title_prompt"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词模板字符串
|
提示词模板字符串
|
||||||
"""
|
"""
|
||||||
@@ -160,13 +163,15 @@ class ConfigManager:
|
|||||||
# 兼容旧配置
|
# 兼容旧配置
|
||||||
return self.config.get("user_title_analysis_prompt", "")
|
return self.config.get("user_title_analysis_prompt", "")
|
||||||
|
|
||||||
def get_golden_quote_analysis_prompt(self, style: str = "golden_quote_prompt") -> str:
|
def get_golden_quote_analysis_prompt(
|
||||||
|
self, style: str = "golden_quote_prompt"
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
获取金句分析提示词模板
|
获取金句分析提示词模板
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style: 提示词风格,默认为 "golden_quote_prompt"
|
style: 提示词风格,默认为 "golden_quote_prompt"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
提示词模板字符串
|
提示词模板字符串
|
||||||
"""
|
"""
|
||||||
@@ -308,7 +313,7 @@ class ConfigManager:
|
|||||||
"""检查 pyppeteer 可用性"""
|
"""检查 pyppeteer 可用性"""
|
||||||
try:
|
try:
|
||||||
import pyppeteer
|
import pyppeteer
|
||||||
from pyppeteer import launch
|
|
||||||
self._pyppeteer_available = True
|
self._pyppeteer_available = True
|
||||||
|
|
||||||
# 检查版本
|
# 检查版本
|
||||||
@@ -322,7 +327,9 @@ class ConfigManager:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
self._pyppeteer_available = False
|
self._pyppeteer_available = False
|
||||||
self._pyppeteer_version = None
|
self._pyppeteer_version = None
|
||||||
logger.warning("pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2")
|
logger.warning(
|
||||||
|
"pyppeteer 未安装,PDF 功能将不可用。请使用 /安装PDF 命令安装 pyppeteer==1.0.2"
|
||||||
|
)
|
||||||
|
|
||||||
def reload_pyppeteer(self) -> bool:
|
def reload_pyppeteer(self) -> bool:
|
||||||
"""重新加载 pyppeteer 模块"""
|
"""重新加载 pyppeteer 模块"""
|
||||||
@@ -330,7 +337,9 @@ class ConfigManager:
|
|||||||
logger.info("开始重新加载 pyppeteer 模块...")
|
logger.info("开始重新加载 pyppeteer 模块...")
|
||||||
|
|
||||||
# 移除所有 pyppeteer 相关模块
|
# 移除所有 pyppeteer 相关模块
|
||||||
modules_to_remove = [mod for mod in sys.modules.keys() if mod.startswith('pyppeteer')]
|
modules_to_remove = [
|
||||||
|
mod for mod in sys.modules.keys() if mod.startswith("pyppeteer")
|
||||||
|
]
|
||||||
logger.info(f"移除模块: {modules_to_remove}")
|
logger.info(f"移除模块: {modules_to_remove}")
|
||||||
for mod in modules_to_remove:
|
for mod in modules_to_remove:
|
||||||
del sys.modules[mod]
|
del sys.modules[mod]
|
||||||
@@ -338,28 +347,33 @@ class ConfigManager:
|
|||||||
# 强制重新导入
|
# 强制重新导入
|
||||||
try:
|
try:
|
||||||
import pyppeteer
|
import pyppeteer
|
||||||
from pyppeteer import launch
|
|
||||||
|
|
||||||
# 更新全局变量
|
# 更新全局变量
|
||||||
self._pyppeteer_available = True
|
self._pyppeteer_available = True
|
||||||
try:
|
try:
|
||||||
self._pyppeteer_version = pyppeteer.__version__
|
self._pyppeteer_version = pyppeteer.__version__
|
||||||
logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}")
|
logger.info(
|
||||||
|
f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}"
|
||||||
|
)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
self._pyppeteer_version = "unknown"
|
self._pyppeteer_version = "unknown"
|
||||||
logger.info("重新加载成功,pyppeteer 版本未知")
|
logger.info("重新加载成功,pyppeteer 版本未知")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError:
|
||||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
logger.info(
|
||||||
|
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
|
||||||
|
)
|
||||||
self._pyppeteer_available = False
|
self._pyppeteer_available = False
|
||||||
self._pyppeteer_version = None
|
self._pyppeteer_version = None
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
logger.info(
|
||||||
|
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
|
||||||
|
)
|
||||||
self._pyppeteer_available = False
|
self._pyppeteer_available = False
|
||||||
self._pyppeteer_version = None
|
self._pyppeteer_version = None
|
||||||
return False
|
return False
|
||||||
@@ -384,4 +398,4 @@ class ConfigManager:
|
|||||||
# 配置会自动从self.config中重新读取
|
# 配置会自动从self.config中重新读取
|
||||||
logger.info("配置重载完成")
|
logger.info("配置重载完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"重新加载配置失败: {e}")
|
logger.error(f"重新加载配置失败: {e}")
|
||||||
|
|||||||
+115
-139
@@ -3,12 +3,11 @@
|
|||||||
负责群聊消息的获取、过滤和预处理
|
负责群聊消息的获取、过滤和预处理
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics, ActivityVisualization
|
from ...src.models.data_models import GroupStatistics, TokenUsage, EmojiStatistics
|
||||||
from ...src.visualization.activity_charts import ActivityVisualizer
|
from ...src.visualization.activity_charts import ActivityVisualizer
|
||||||
|
|
||||||
|
|
||||||
@@ -35,15 +34,17 @@ class MessageHandler:
|
|||||||
|
|
||||||
def _extract_bot_qq_id_from_instance(self, bot_instance):
|
def _extract_bot_qq_id_from_instance(self, bot_instance):
|
||||||
"""从bot实例中提取QQ号"""
|
"""从bot实例中提取QQ号"""
|
||||||
if hasattr(bot_instance, 'self_id') and bot_instance.self_id:
|
if hasattr(bot_instance, "self_id") and bot_instance.self_id:
|
||||||
return str(bot_instance.self_id)
|
return str(bot_instance.self_id)
|
||||||
elif hasattr(bot_instance, 'qq') and bot_instance.qq:
|
elif hasattr(bot_instance, "qq") and bot_instance.qq:
|
||||||
return str(bot_instance.qq)
|
return str(bot_instance.qq)
|
||||||
elif hasattr(bot_instance, 'user_id') and bot_instance.user_id:
|
elif hasattr(bot_instance, "user_id") and bot_instance.user_id:
|
||||||
return str(bot_instance.user_id)
|
return str(bot_instance.user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fetch_group_messages(self, bot_instance, group_id: str, days: int) -> List[Dict]:
|
async def fetch_group_messages(
|
||||||
|
self, bot_instance, group_id: str, days: int
|
||||||
|
) -> List[Dict]:
|
||||||
"""获取群聊消息记录"""
|
"""获取群聊消息记录"""
|
||||||
try:
|
try:
|
||||||
# 验证参数
|
# 验证参数
|
||||||
@@ -63,151 +64,104 @@ class MessageHandler:
|
|||||||
start_time = end_time - timedelta(days=days)
|
start_time = end_time - timedelta(days=days)
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
message_seq = 0
|
# 一次性获取,移除分页与多轮查询
|
||||||
query_rounds = 0
|
|
||||||
max_rounds = self.config_manager.get_max_query_rounds()
|
|
||||||
max_messages = self.config_manager.get_max_messages()
|
max_messages = self.config_manager.get_max_messages()
|
||||||
consecutive_failures = 0
|
query_rounds = 0
|
||||||
max_failures = 3
|
|
||||||
|
|
||||||
logger.info(f"开始获取群 {group_id} 近 {days} 天的消息记录")
|
logger.info(f"开始获取群 {group_id} 近 {days} 天的消息记录")
|
||||||
logger.info(f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')} 到 {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
logger.info(
|
||||||
|
f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M:%S')} 到 {end_time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
while query_rounds < max_rounds:
|
# 单次请求,按配置的 max_messages 作为 count
|
||||||
try:
|
try:
|
||||||
# 构造请求参数
|
payloads = {
|
||||||
payloads = {
|
"group_id": int(group_id) if group_id.isdigit() else group_id,
|
||||||
"group_id": int(group_id) if group_id.isdigit() else group_id,
|
"count": int(max_messages),
|
||||||
"count": 200,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# 添加 message_seq(如果不是第一轮)
|
|
||||||
if message_seq:
|
|
||||||
payloads["message_seq"] = message_seq
|
|
||||||
|
|
||||||
# 尝试调用 API
|
result = None
|
||||||
result = None
|
if hasattr(bot_instance, "call_action"):
|
||||||
api_error = None
|
try:
|
||||||
|
# aiocqhttp (CQHttp) 方式
|
||||||
if hasattr(bot_instance, 'call_action'):
|
result = await bot_instance.call_action(
|
||||||
try:
|
"get_group_msg_history", **payloads
|
||||||
# aiocqhttp (CQHttp) 方式
|
)
|
||||||
result = await bot_instance.call_action("get_group_msg_history", **payloads)
|
query_rounds = 1
|
||||||
except Exception as api_err:
|
except Exception as api_err:
|
||||||
api_error = api_err
|
logger.error(f"群 {group_id} API 调用失败: {api_err}")
|
||||||
logger.error(f"群 {group_id} API 调用失败: {api_err}")
|
logger.error(
|
||||||
|
f"群 {group_id} 当前 OneBot 实现可能不支持 get_group_msg_history API"
|
||||||
# 第一次失败就放弃(该 API 不支持)
|
)
|
||||||
if query_rounds == 0:
|
|
||||||
logger.error(f"群 {group_id} 当前 OneBot 实现不支持 get_group_msg_history API")
|
|
||||||
return []
|
|
||||||
elif hasattr(bot_instance, 'api'):
|
|
||||||
# QQ官方 bot (botClient) 方式 - 官方API不支持历史消息
|
|
||||||
logger.error(f"群 {group_id} 检测到 QQ 官方 Bot,官方 API 不支持获取历史消息")
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
logger.error(f"群 {group_id} 未知的 bot_instance 类型,无法调用 API")
|
|
||||||
logger.error(f"bot_instance 类型: {type(bot_instance)}")
|
|
||||||
return []
|
return []
|
||||||
|
elif hasattr(bot_instance, "api"):
|
||||||
|
# QQ 官方 bot (botClient) 不支持历史消息
|
||||||
|
logger.error(
|
||||||
|
f"群 {group_id} 检测到 QQ 官方 Bot,官方 API 不支持获取历史消息"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"群 {group_id} 未知的 bot_instance 类型,无法调用 API,类型: {type(bot_instance)}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
if not result or "messages" not in result:
|
if not result or "messages" not in result:
|
||||||
logger.warning(f"群 {group_id} API返回无效结果: {result}")
|
logger.warning(f"群 {group_id} API返回无效结果: {result}")
|
||||||
consecutive_failures += 1
|
return []
|
||||||
if consecutive_failures >= max_failures:
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
|
|
||||||
round_messages = result.get("messages", [])
|
round_messages = result.get("messages", [])
|
||||||
|
if not round_messages:
|
||||||
|
logger.info(f"群 {group_id} 未获取到消息")
|
||||||
|
|
||||||
if not round_messages:
|
# 过滤时间范围内的消息并过滤机器人自身消息
|
||||||
logger.info(f"群 {group_id} 没有更多消息,结束获取")
|
for msg in round_messages:
|
||||||
break
|
try:
|
||||||
|
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
||||||
# 重置失败计数
|
if not (start_time <= msg_time <= end_time):
|
||||||
consecutive_failures = 0
|
|
||||||
|
|
||||||
# 过滤时间范围内的消息
|
|
||||||
valid_messages_in_round = 0
|
|
||||||
oldest_msg_time = None
|
|
||||||
has_out_of_range_message = False
|
|
||||||
|
|
||||||
for msg in round_messages:
|
|
||||||
try:
|
|
||||||
msg_time = datetime.fromtimestamp(msg.get("time", 0))
|
|
||||||
|
|
||||||
# 记录本轮最老的消息时间
|
|
||||||
if oldest_msg_time is None or msg_time < oldest_msg_time:
|
|
||||||
oldest_msg_time = msg_time
|
|
||||||
|
|
||||||
# 检查是否已经超出时间范围
|
|
||||||
if msg_time < start_time:
|
|
||||||
has_out_of_range_message = True
|
|
||||||
# 继续处理本轮剩余消息,但不再查询下一轮
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 过滤掉机器人自己的消息
|
|
||||||
sender_id = str(msg.get("sender", {}).get("user_id", ""))
|
|
||||||
if self.bot_manager and self.bot_manager.should_filter_bot_message(sender_id):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg_time >= start_time and msg_time <= end_time:
|
|
||||||
messages.append(msg)
|
|
||||||
valid_messages_in_round += 1
|
|
||||||
except Exception as msg_error:
|
|
||||||
logger.warning(f"群 {group_id} 处理单条消息失败: {msg_error}")
|
|
||||||
continue
|
continue
|
||||||
|
sender_id = str(msg.get("sender", {}).get("user_id", ""))
|
||||||
# 如果本轮有消息已经超出时间范围,立即停止获取
|
if (
|
||||||
if has_out_of_range_message:
|
self.bot_manager
|
||||||
logger.info(f"群 {group_id} 已获取到时间范围外的消息(最老消息时间: {oldest_msg_time.strftime('%Y-%m-%d %H:%M:%S')}),停止获取。共获取 {len(messages)} 条消息")
|
and self.bot_manager.should_filter_bot_message(sender_id)
|
||||||
break
|
):
|
||||||
|
continue
|
||||||
# 如果本轮没有获取到任何有效消息,停止获取
|
messages.append(msg)
|
||||||
if valid_messages_in_round == 0:
|
except Exception as msg_error:
|
||||||
logger.warning(f"群 {group_id} 本轮未获取到有效消息,停止获取")
|
logger.warning(f"群 {group_id} 处理单条消息失败: {msg_error}")
|
||||||
break
|
continue
|
||||||
|
except Exception as e:
|
||||||
# 如果已经获取到足够的消息(达到 max_messages),停止获取
|
logger.error(f"群 {group_id} 获取消息失败: {e}")
|
||||||
if len(messages) >= max_messages:
|
return []
|
||||||
logger.info(f"群 {group_id} 已达到消息数量限制({len(messages)} 条,限制 {max_messages} 条),停止获取")
|
|
||||||
break
|
|
||||||
|
|
||||||
message_seq = round_messages[0]["message_id"]
|
|
||||||
query_rounds += 1
|
|
||||||
|
|
||||||
# 添加延迟避免请求过快
|
|
||||||
if query_rounds % 5 == 0:
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"群 {group_id} 获取消息失败 (第{query_rounds+1}轮): {e}")
|
|
||||||
consecutive_failures += 1
|
|
||||||
if consecutive_failures >= max_failures:
|
|
||||||
logger.error(f"群 {group_id} 连续失败 {max_failures} 次,停止获取")
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
# ========== 最终清理步骤:严格过滤和限制 ==========
|
# ========== 最终清理步骤:严格过滤和限制 ==========
|
||||||
original_count = len(messages)
|
original_count = len(messages)
|
||||||
|
|
||||||
# 1. 严格过滤时间范围外的消息
|
# 1. 严格过滤时间范围外的消息
|
||||||
messages = [
|
messages = [
|
||||||
msg for msg in messages
|
msg
|
||||||
|
for msg in messages
|
||||||
if start_time <= datetime.fromtimestamp(msg.get("time", 0)) <= end_time
|
if start_time <= datetime.fromtimestamp(msg.get("time", 0)) <= end_time
|
||||||
]
|
]
|
||||||
time_filtered_count = len(messages)
|
time_filtered_count = len(messages)
|
||||||
|
|
||||||
# 2. 严格限制消息数量
|
# 2. 严格限制消息数量
|
||||||
if len(messages) > max_messages:
|
if len(messages) > max_messages:
|
||||||
# 保留最新的消息(假设messages已按时间排序,从新到旧)
|
# 保留最新的消息(假设messages已按时间排序,从新到旧)
|
||||||
messages = messages[:max_messages]
|
messages = messages[:max_messages]
|
||||||
logger.info(f"群 {group_id} 消息数量超过限制,已截断: {time_filtered_count} -> {max_messages} 条")
|
logger.info(
|
||||||
|
f"群 {group_id} 消息数量超过限制,已截断: {time_filtered_count} -> {max_messages} 条"
|
||||||
|
)
|
||||||
|
|
||||||
# 记录清理结果
|
# 记录清理结果
|
||||||
if original_count != len(messages):
|
if original_count != len(messages):
|
||||||
logger.info(f"群 {group_id} 最终清理: 原始 {original_count} 条 -> 时间过滤 {time_filtered_count} 条 -> 最终 {len(messages)} 条")
|
logger.info(
|
||||||
|
f"群 {group_id} 最终清理: 原始 {original_count} 条 -> 时间过滤 {time_filtered_count} 条 -> 最终 {len(messages)} 条"
|
||||||
logger.info(f"群 {group_id} 消息获取完成,共获取到 {len(messages)} 条有效消息(时间范围: 近{days}天),查询轮数: {query_rounds}")
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"群 {group_id} 消息获取完成,共获取到 {len(messages)} 条有效消息(时间范围: 近{days}天),查询轮数: {query_rounds}"
|
||||||
|
)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -238,22 +192,30 @@ class MessageHandler:
|
|||||||
# QQ基础表情
|
# QQ基础表情
|
||||||
emoji_statistics.face_count += 1
|
emoji_statistics.face_count += 1
|
||||||
face_id = content.get("data", {}).get("id", "unknown")
|
face_id = content.get("data", {}).get("id", "unknown")
|
||||||
emoji_statistics.face_details[f"face_{face_id}"] = emoji_statistics.face_details.get(f"face_{face_id}", 0) + 1
|
emoji_statistics.face_details[f"face_{face_id}"] = (
|
||||||
|
emoji_statistics.face_details.get(f"face_{face_id}", 0) + 1
|
||||||
|
)
|
||||||
elif content.get("type") == "mface":
|
elif content.get("type") == "mface":
|
||||||
# 动画表情/魔法表情
|
# 动画表情/魔法表情
|
||||||
emoji_statistics.mface_count += 1
|
emoji_statistics.mface_count += 1
|
||||||
emoji_id = content.get("data", {}).get("emoji_id", "unknown")
|
emoji_id = content.get("data", {}).get("emoji_id", "unknown")
|
||||||
emoji_statistics.face_details[f"mface_{emoji_id}"] = emoji_statistics.face_details.get(f"mface_{emoji_id}", 0) + 1
|
emoji_statistics.face_details[f"mface_{emoji_id}"] = (
|
||||||
|
emoji_statistics.face_details.get(f"mface_{emoji_id}", 0) + 1
|
||||||
|
)
|
||||||
elif content.get("type") == "bface":
|
elif content.get("type") == "bface":
|
||||||
# 超级表情
|
# 超级表情
|
||||||
emoji_statistics.bface_count += 1
|
emoji_statistics.bface_count += 1
|
||||||
emoji_id = content.get("data", {}).get("p", "unknown")
|
emoji_id = content.get("data", {}).get("p", "unknown")
|
||||||
emoji_statistics.face_details[f"bface_{emoji_id}"] = emoji_statistics.face_details.get(f"bface_{emoji_id}", 0) + 1
|
emoji_statistics.face_details[f"bface_{emoji_id}"] = (
|
||||||
|
emoji_statistics.face_details.get(f"bface_{emoji_id}", 0) + 1
|
||||||
|
)
|
||||||
elif content.get("type") == "sface":
|
elif content.get("type") == "sface":
|
||||||
# 小表情
|
# 小表情
|
||||||
emoji_statistics.sface_count += 1
|
emoji_statistics.sface_count += 1
|
||||||
emoji_id = content.get("data", {}).get("id", "unknown")
|
emoji_id = content.get("data", {}).get("id", "unknown")
|
||||||
emoji_statistics.face_details[f"sface_{emoji_id}"] = emoji_statistics.face_details.get(f"sface_{emoji_id}", 0) + 1
|
emoji_statistics.face_details[f"sface_{emoji_id}"] = (
|
||||||
|
emoji_statistics.face_details.get(f"sface_{emoji_id}", 0) + 1
|
||||||
|
)
|
||||||
elif content.get("type") == "image":
|
elif content.get("type") == "image":
|
||||||
# 检查是否是动画表情(通过summary字段判断)
|
# 检查是否是动画表情(通过summary字段判断)
|
||||||
data = content.get("data", {})
|
data = content.get("data", {})
|
||||||
@@ -262,20 +224,34 @@ class MessageHandler:
|
|||||||
# 动画表情(以image形式发送)
|
# 动画表情(以image形式发送)
|
||||||
emoji_statistics.mface_count += 1
|
emoji_statistics.mface_count += 1
|
||||||
file_name = data.get("file", "unknown")
|
file_name = data.get("file", "unknown")
|
||||||
emoji_statistics.face_details[f"animated_{file_name}"] = emoji_statistics.face_details.get(f"animated_{file_name}", 0) + 1
|
emoji_statistics.face_details[f"animated_{file_name}"] = (
|
||||||
|
emoji_statistics.face_details.get(
|
||||||
|
f"animated_{file_name}", 0
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 普通图片,不计入表情统计
|
# 普通图片,不计入表情统计
|
||||||
pass
|
pass
|
||||||
elif content.get("type") in ["record", "video"] and "emoji" in str(content.get("data", {})).lower():
|
elif (
|
||||||
|
content.get("type") in ["record", "video"]
|
||||||
|
and "emoji" in str(content.get("data", {})).lower()
|
||||||
|
):
|
||||||
# 其他可能的表情类型
|
# 其他可能的表情类型
|
||||||
emoji_statistics.other_emoji_count += 1
|
emoji_statistics.other_emoji_count += 1
|
||||||
|
|
||||||
# 找出最活跃时段
|
# 找出最活跃时段
|
||||||
most_active_hour = max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
|
most_active_hour = (
|
||||||
most_active_period = f"{most_active_hour:02d}:00-{(most_active_hour+1)%24:02d}:00"
|
max(hour_counts.items(), key=lambda x: x[1])[0] if hour_counts else 0
|
||||||
|
)
|
||||||
|
most_active_period = (
|
||||||
|
f"{most_active_hour:02d}:00-{(most_active_hour + 1) % 24:02d}:00"
|
||||||
|
)
|
||||||
|
|
||||||
# 生成活跃度可视化数据
|
# 生成活跃度可视化数据
|
||||||
activity_visualization = self.activity_visualizer.generate_activity_visualization(messages)
|
activity_visualization = (
|
||||||
|
self.activity_visualizer.generate_activity_visualization(messages)
|
||||||
|
)
|
||||||
|
|
||||||
return GroupStatistics(
|
return GroupStatistics(
|
||||||
message_count=len(messages),
|
message_count=len(messages),
|
||||||
@@ -286,5 +262,5 @@ class MessageHandler:
|
|||||||
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
|
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
|
||||||
emoji_statistics=emoji_statistics,
|
emoji_statistics=emoji_statistics,
|
||||||
activity_visualization=activity_visualization,
|
activity_visualization=activity_visualization,
|
||||||
token_usage=TokenUsage()
|
token_usage=TokenUsage(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ from .data_models import (
|
|||||||
UserTitle,
|
UserTitle,
|
||||||
GoldenQuote,
|
GoldenQuote,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
GroupStatistics
|
GroupStatistics,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["SummaryTopic", "UserTitle", "GoldenQuote", "TokenUsage", "GroupStatistics"]
|
||||||
'SummaryTopic',
|
|
||||||
'UserTitle',
|
|
||||||
'GoldenQuote',
|
|
||||||
'TokenUsage',
|
|
||||||
'GroupStatistics'
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import List
|
|||||||
@dataclass
|
@dataclass
|
||||||
class SummaryTopic:
|
class SummaryTopic:
|
||||||
"""话题总结数据结构"""
|
"""话题总结数据结构"""
|
||||||
|
|
||||||
topic: str
|
topic: str
|
||||||
contributors: List[str]
|
contributors: List[str]
|
||||||
detail: str
|
detail: str
|
||||||
@@ -18,6 +19,7 @@ class SummaryTopic:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class UserTitle:
|
class UserTitle:
|
||||||
"""用户称号数据结构"""
|
"""用户称号数据结构"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
qq: int
|
qq: int
|
||||||
title: str
|
title: str
|
||||||
@@ -28,6 +30,7 @@ class UserTitle:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class GoldenQuote:
|
class GoldenQuote:
|
||||||
"""群聊金句数据结构"""
|
"""群聊金句数据结构"""
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
sender: str
|
sender: str
|
||||||
reason: str
|
reason: str
|
||||||
@@ -36,6 +39,7 @@ class GoldenQuote:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class TokenUsage:
|
class TokenUsage:
|
||||||
"""Token使用统计"""
|
"""Token使用统计"""
|
||||||
|
|
||||||
prompt_tokens: int = 0
|
prompt_tokens: int = 0
|
||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
total_tokens: int = 0
|
total_tokens: int = 0
|
||||||
@@ -44,6 +48,7 @@ class TokenUsage:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class EmojiStatistics:
|
class EmojiStatistics:
|
||||||
"""表情统计数据结构"""
|
"""表情统计数据结构"""
|
||||||
|
|
||||||
face_count: int = 0 # QQ基础表情数量
|
face_count: int = 0 # QQ基础表情数量
|
||||||
mface_count: int = 0 # 动画表情数量
|
mface_count: int = 0 # 动画表情数量
|
||||||
bface_count: int = 0 # 超级表情数量
|
bface_count: int = 0 # 超级表情数量
|
||||||
@@ -54,14 +59,21 @@ class EmojiStatistics:
|
|||||||
@property
|
@property
|
||||||
def total_emoji_count(self) -> int:
|
def total_emoji_count(self) -> int:
|
||||||
"""总表情数量"""
|
"""总表情数量"""
|
||||||
return self.face_count + self.mface_count + self.bface_count + self.sface_count + self.other_emoji_count
|
return (
|
||||||
|
self.face_count
|
||||||
|
+ self.mface_count
|
||||||
|
+ self.bface_count
|
||||||
|
+ self.sface_count
|
||||||
|
+ self.other_emoji_count
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ActivityVisualization:
|
class ActivityVisualization:
|
||||||
"""活跃度可视化数据结构"""
|
"""活跃度可视化数据结构"""
|
||||||
|
|
||||||
hourly_activity: dict = field(default_factory=dict) # {hour: count}
|
hourly_activity: dict = field(default_factory=dict) # {hour: count}
|
||||||
daily_activity: dict = field(default_factory=dict) # {date: count}
|
daily_activity: dict = field(default_factory=dict) # {date: count}
|
||||||
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
|
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
|
||||||
peak_hours: list = field(default_factory=list) # 高峰时段
|
peak_hours: list = field(default_factory=list) # 高峰时段
|
||||||
activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据
|
activity_heatmap_data: dict = field(default_factory=dict) # 热力图数据
|
||||||
@@ -70,6 +82,7 @@ class ActivityVisualization:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class GroupStatistics:
|
class GroupStatistics:
|
||||||
"""群聊统计数据结构"""
|
"""群聊统计数据结构"""
|
||||||
|
|
||||||
message_count: int
|
message_count: int
|
||||||
total_characters: int
|
total_characters: int
|
||||||
participant_count: int
|
participant_count: int
|
||||||
@@ -77,5 +90,7 @@ class GroupStatistics:
|
|||||||
golden_quotes: List[GoldenQuote]
|
golden_quotes: List[GoldenQuote]
|
||||||
emoji_count: int # 保持向后兼容
|
emoji_count: int # 保持向后兼容
|
||||||
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
|
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
|
||||||
activity_visualization: ActivityVisualization = field(default_factory=ActivityVisualization)
|
activity_visualization: ActivityVisualization = field(
|
||||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
default_factory=ActivityVisualization
|
||||||
|
)
|
||||||
|
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||||
|
|||||||
@@ -6,7 +6,4 @@
|
|||||||
from .generators import ReportGenerator
|
from .generators import ReportGenerator
|
||||||
from .templates import HTMLTemplates
|
from .templates import HTMLTemplates
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["ReportGenerator", "HTMLTemplates"]
|
||||||
'ReportGenerator',
|
|
||||||
'HTMLTemplates'
|
|
||||||
]
|
|
||||||
|
|||||||
+145
-113
@@ -21,7 +21,9 @@ class ReportGenerator:
|
|||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.activity_visualizer = ActivityVisualizer()
|
self.activity_visualizer = ActivityVisualizer()
|
||||||
|
|
||||||
async def generate_image_report(self, analysis_result: Dict, group_id: str, html_render_func) -> Optional[str]:
|
async def generate_image_report(
|
||||||
|
self, analysis_result: Dict, group_id: str, html_render_func
|
||||||
|
) -> Optional[str]:
|
||||||
"""生成图片格式的分析报告"""
|
"""生成图片格式的分析报告"""
|
||||||
try:
|
try:
|
||||||
# 准备渲染数据
|
# 准备渲染数据
|
||||||
@@ -31,13 +33,13 @@ class ReportGenerator:
|
|||||||
image_options = {
|
image_options = {
|
||||||
"full_page": True,
|
"full_page": True,
|
||||||
"type": "jpeg", # 使用默认的jpeg格式提高兼容性
|
"type": "jpeg", # 使用默认的jpeg格式提高兼容性
|
||||||
"quality": 95, # 设置合理的质量
|
"quality": 95, # 设置合理的质量
|
||||||
}
|
}
|
||||||
image_url = await html_render_func(
|
image_url = await html_render_func(
|
||||||
HTMLTemplates.get_image_template(),
|
HTMLTemplates.get_image_template(),
|
||||||
render_payload,
|
render_payload,
|
||||||
True, # return_url=True,返回URL而不是下载文件
|
True, # return_url=True,返回URL而不是下载文件
|
||||||
image_options
|
image_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"图片生成成功: {image_url}")
|
logger.info(f"图片生成成功: {image_url}")
|
||||||
@@ -51,13 +53,13 @@ class ReportGenerator:
|
|||||||
simple_options = {
|
simple_options = {
|
||||||
"full_page": True,
|
"full_page": True,
|
||||||
"type": "jpeg",
|
"type": "jpeg",
|
||||||
"quality": 70 # 降低质量以提高兼容性
|
"quality": 70, # 降低质量以提高兼容性
|
||||||
}
|
}
|
||||||
image_url = await html_render_func(
|
image_url = await html_render_func(
|
||||||
HTMLTemplates.get_image_template(),
|
HTMLTemplates.get_image_template(),
|
||||||
render_payload,
|
render_payload,
|
||||||
True,
|
True,
|
||||||
simple_options
|
simple_options,
|
||||||
)
|
)
|
||||||
logger.info(f"使用低质量选项生成成功: {image_url}")
|
logger.info(f"使用低质量选项生成成功: {image_url}")
|
||||||
return image_url
|
return image_url
|
||||||
@@ -65,9 +67,9 @@ class ReportGenerator:
|
|||||||
logger.error(f"后备低质量方案也失败: {fallback_e}")
|
logger.error(f"后备低质量方案也失败: {fallback_e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def generate_pdf_report(
|
||||||
|
self, analysis_result: Dict, group_id: str
|
||||||
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""生成PDF格式的分析报告"""
|
"""生成PDF格式的分析报告"""
|
||||||
try:
|
try:
|
||||||
# 确保输出目录存在
|
# 确保输出目录存在
|
||||||
@@ -75,10 +77,9 @@ class ReportGenerator:
|
|||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 生成文件名
|
# 生成文件名
|
||||||
current_date = datetime.now().strftime('%Y%m%d')
|
current_date = datetime.now().strftime("%Y%m%d")
|
||||||
filename = self.config_manager.get_pdf_filename_format().format(
|
filename = self.config_manager.get_pdf_filename_format().format(
|
||||||
group_id=group_id,
|
group_id=group_id, date=current_date
|
||||||
date=current_date
|
|
||||||
)
|
)
|
||||||
pdf_path = output_dir / filename
|
pdf_path = output_dir / filename
|
||||||
|
|
||||||
@@ -87,7 +88,9 @@ class ReportGenerator:
|
|||||||
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||||
|
|
||||||
# 生成 HTML 内容(PDF模板使用{}占位符)
|
# 生成 HTML 内容(PDF模板使用{}占位符)
|
||||||
html_content = self._render_html_template(HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False)
|
html_content = self._render_html_template(
|
||||||
|
HTMLTemplates.get_pdf_template(), render_data, use_jinja_style=False
|
||||||
|
)
|
||||||
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
|
logger.info(f"HTML 内容生成完成,长度: {len(html_content)} 字符")
|
||||||
|
|
||||||
# 转换为 PDF
|
# 转换为 PDF
|
||||||
@@ -110,7 +113,7 @@ class ReportGenerator:
|
|||||||
|
|
||||||
report = f"""
|
report = f"""
|
||||||
🎯 群聊日常分析报告
|
🎯 群聊日常分析报告
|
||||||
📅 {datetime.now().strftime('%Y年%m月%d日')}
|
📅 {datetime.now().strftime("%Y年%m月%d日")}
|
||||||
|
|
||||||
📊 基础统计
|
📊 基础统计
|
||||||
• 消息总数: {stats.message_count}
|
• 消息总数: {stats.message_count}
|
||||||
@@ -138,7 +141,7 @@ class ReportGenerator:
|
|||||||
report += "💬 群圣经\n"
|
report += "💬 群圣经\n"
|
||||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||||
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
|
for i, quote in enumerate(stats.golden_quotes[:max_golden_quotes], 1):
|
||||||
report += f"{i}. \"{quote.content}\" —— {quote.sender}\n"
|
report += f'{i}. "{quote.content}" —— {quote.sender}\n'
|
||||||
report += f" {quote.reason}\n\n"
|
report += f" {quote.reason}\n\n"
|
||||||
|
|
||||||
return report
|
return report
|
||||||
@@ -172,7 +175,11 @@ class ReportGenerator:
|
|||||||
for title in user_titles[:max_user_titles]:
|
for title in user_titles[:max_user_titles]:
|
||||||
# 获取用户头像
|
# 获取用户头像
|
||||||
avatar_data = await self._get_user_avatar(str(title.qq))
|
avatar_data = await self._get_user_avatar(str(title.qq))
|
||||||
avatar_html = f'<img src="{avatar_data}" class="user-avatar" alt="头像">' if avatar_data else '<div class="user-avatar-placeholder">👤</div>'
|
avatar_html = (
|
||||||
|
f'<img src="{avatar_data}" class="user-avatar" alt="头像">'
|
||||||
|
if avatar_data
|
||||||
|
else '<div class="user-avatar-placeholder">👤</div>'
|
||||||
|
)
|
||||||
|
|
||||||
titles_html += f"""
|
titles_html += f"""
|
||||||
<div class="user-title">
|
<div class="user-title">
|
||||||
@@ -209,8 +216,8 @@ class ReportGenerator:
|
|||||||
|
|
||||||
# 返回扁平化的渲染数据
|
# 返回扁平化的渲染数据
|
||||||
return {
|
return {
|
||||||
"current_date": datetime.now().strftime('%Y年%m月%d日'),
|
"current_date": datetime.now().strftime("%Y年%m月%d日"),
|
||||||
"current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
"current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"message_count": stats.message_count,
|
"message_count": stats.message_count,
|
||||||
"participant_count": stats.participant_count,
|
"participant_count": stats.participant_count,
|
||||||
"total_characters": stats.total_characters,
|
"total_characters": stats.total_characters,
|
||||||
@@ -220,15 +227,20 @@ class ReportGenerator:
|
|||||||
"titles_html": titles_html,
|
"titles_html": titles_html,
|
||||||
"quotes_html": quotes_html,
|
"quotes_html": quotes_html,
|
||||||
"hourly_chart_html": hourly_chart_html,
|
"hourly_chart_html": hourly_chart_html,
|
||||||
"total_tokens": stats.token_usage.total_tokens if stats.token_usage.total_tokens else 0,
|
"total_tokens": stats.token_usage.total_tokens
|
||||||
"prompt_tokens": stats.token_usage.prompt_tokens if stats.token_usage.prompt_tokens else 0,
|
if stats.token_usage.total_tokens
|
||||||
"completion_tokens": stats.token_usage.completion_tokens if stats.token_usage.completion_tokens else 0
|
else 0,
|
||||||
|
"prompt_tokens": stats.token_usage.prompt_tokens
|
||||||
|
if stats.token_usage.prompt_tokens
|
||||||
|
else 0,
|
||||||
|
"completion_tokens": stats.token_usage.completion_tokens
|
||||||
|
if stats.token_usage.completion_tokens
|
||||||
|
else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _render_html_template(
|
||||||
|
self, template: str, data: Dict, use_jinja_style: bool = False
|
||||||
|
) -> str:
|
||||||
def _render_html_template(self, template: str, data: Dict, use_jinja_style: bool = False) -> str:
|
|
||||||
"""HTML模板渲染,支持两种占位符格式
|
"""HTML模板渲染,支持两种占位符格式
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -239,7 +251,9 @@ class ReportGenerator:
|
|||||||
result = template
|
result = template
|
||||||
|
|
||||||
# 调试:记录渲染数据
|
# 调试:记录渲染数据
|
||||||
logger.info(f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}")
|
logger.info(
|
||||||
|
f"渲染数据键: {list(data.keys())}, 使用Jinja风格: {use_jinja_style}"
|
||||||
|
)
|
||||||
|
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if use_jinja_style:
|
if use_jinja_style:
|
||||||
@@ -256,10 +270,11 @@ class ReportGenerator:
|
|||||||
|
|
||||||
# 检查是否还有未替换的占位符
|
# 检查是否还有未替换的占位符
|
||||||
import re
|
import re
|
||||||
|
|
||||||
if use_jinja_style:
|
if use_jinja_style:
|
||||||
remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result)
|
remaining_placeholders = re.findall(r"\{\{[^}]+\}\}", result)
|
||||||
else:
|
else:
|
||||||
remaining_placeholders = re.findall(r'\{[^}]+\}', result)
|
remaining_placeholders = re.findall(r"\{[^}]+\}", result)
|
||||||
|
|
||||||
if remaining_placeholders:
|
if remaining_placeholders:
|
||||||
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
|
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
|
||||||
@@ -275,7 +290,7 @@ class ReportGenerator:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
avatar_data = await response.read()
|
avatar_data = await response.read()
|
||||||
# 转换为base64编码
|
# 转换为base64编码
|
||||||
avatar_base64 = base64.b64encode(avatar_data).decode('utf-8')
|
avatar_base64 = base64.b64encode(avatar_data).decode("utf-8")
|
||||||
return f"data:image/jpeg;base64,{avatar_base64}"
|
return f"data:image/jpeg;base64,{avatar_base64}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取用户头像失败 {user_id}: {e}")
|
logger.error(f"获取用户头像失败 {user_id}: {e}")
|
||||||
@@ -300,91 +315,98 @@ class ReportGenerator:
|
|||||||
|
|
||||||
# 配置浏览器启动参数,解决Docker环境中的沙盒问题
|
# 配置浏览器启动参数,解决Docker环境中的沙盒问题
|
||||||
launch_options = {
|
launch_options = {
|
||||||
'headless': True,
|
"headless": True,
|
||||||
'args': [
|
"args": [
|
||||||
'--no-sandbox', # Docker环境必需 - 禁用沙盒
|
"--no-sandbox", # Docker环境必需 - 禁用沙盒
|
||||||
'--disable-setuid-sandbox', # Docker环境必需 - 禁用setuid沙盒
|
"--disable-setuid-sandbox", # Docker环境必需 - 禁用setuid沙盒
|
||||||
'--disable-dev-shm-usage', # 避免共享内存问题
|
"--disable-dev-shm-usage", # 避免共享内存问题
|
||||||
'--disable-gpu', # 禁用GPU加速
|
"--disable-gpu", # 禁用GPU加速
|
||||||
'--no-first-run',
|
"--no-first-run",
|
||||||
'--disable-extensions',
|
"--disable-extensions",
|
||||||
'--disable-default-apps',
|
"--disable-default-apps",
|
||||||
'--disable-background-timer-throttling',
|
"--disable-background-timer-throttling",
|
||||||
'--disable-backgrounding-occluded-windows',
|
"--disable-backgrounding-occluded-windows",
|
||||||
'--disable-renderer-backgrounding',
|
"--disable-renderer-backgrounding",
|
||||||
'--disable-features=TranslateUI',
|
"--disable-features=TranslateUI",
|
||||||
'--disable-ipc-flooding-protection',
|
"--disable-ipc-flooding-protection",
|
||||||
'--disable-background-networking',
|
"--disable-background-networking",
|
||||||
'--enable-features=NetworkService,NetworkServiceInProcess',
|
"--enable-features=NetworkService,NetworkServiceInProcess",
|
||||||
'--force-color-profile=srgb',
|
"--force-color-profile=srgb",
|
||||||
'--metrics-recording-only',
|
"--metrics-recording-only",
|
||||||
'--disable-breakpad',
|
"--disable-breakpad",
|
||||||
'--disable-component-extensions-with-background-pages',
|
"--disable-component-extensions-with-background-pages",
|
||||||
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync',
|
"--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync",
|
||||||
'--enable-automation',
|
"--enable-automation",
|
||||||
'--password-store=basic',
|
"--password-store=basic",
|
||||||
'--use-mock-keychain',
|
"--use-mock-keychain",
|
||||||
'--export-tagged-pdf',
|
"--export-tagged-pdf",
|
||||||
'--disable-web-security',
|
"--disable-web-security",
|
||||||
'--disable-features=VizDisplayCompositor',
|
"--disable-features=VizDisplayCompositor",
|
||||||
'--disable-blink-features=AutomationControlled', # 隐藏自动化特征
|
"--disable-blink-features=AutomationControlled", # 隐藏自动化特征
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检测系统 Chrome/Chromium 路径
|
# 检测系统 Chrome/Chromium 路径
|
||||||
chrome_paths = []
|
chrome_paths = []
|
||||||
|
|
||||||
if sys.platform.startswith('win'):
|
if sys.platform.startswith("win"):
|
||||||
# Windows 系统 Chrome 安装路径
|
# Windows 系统 Chrome 安装路径
|
||||||
username = os.environ.get('USERNAME', '')
|
username = os.environ.get("USERNAME", "")
|
||||||
chrome_paths = [
|
chrome_paths = [
|
||||||
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
rf"C:\Users\{username}\AppData\Local\Google\Chrome\Application\chrome.exe",
|
rf"C:\Users\{username}\AppData\Local\Google\Chrome\Application\chrome.exe",
|
||||||
r"C:\Program Files\Chromium\Application\chrome.exe",
|
r"C:\Program Files\Chromium\Application\chrome.exe",
|
||||||
]
|
]
|
||||||
elif sys.platform.startswith('linux'):
|
elif sys.platform.startswith("linux"):
|
||||||
# Linux 系统 Chrome/Chromium 路径
|
# Linux 系统 Chrome/Chromium 路径
|
||||||
chrome_paths = [
|
chrome_paths = [
|
||||||
'/usr/bin/google-chrome',
|
"/usr/bin/google-chrome",
|
||||||
'/usr/bin/google-chrome-stable',
|
"/usr/bin/google-chrome-stable",
|
||||||
'/usr/bin/chromium',
|
"/usr/bin/chromium",
|
||||||
'/usr/bin/chromium-browser',
|
"/usr/bin/chromium-browser",
|
||||||
'/snap/bin/chromium',
|
"/snap/bin/chromium",
|
||||||
'/usr/bin/chromium-freeworld',
|
"/usr/bin/chromium-freeworld",
|
||||||
]
|
]
|
||||||
elif sys.platform.startswith('darwin'):
|
elif sys.platform.startswith("darwin"):
|
||||||
# macOS 系统 Chrome 路径
|
# macOS 系统 Chrome 路径
|
||||||
chrome_paths = [
|
chrome_paths = [
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 查找可用的浏览器
|
# 查找可用的浏览器
|
||||||
found_browser = False
|
found_browser = False
|
||||||
for chrome_path in chrome_paths:
|
for chrome_path in chrome_paths:
|
||||||
if Path(chrome_path).exists():
|
if Path(chrome_path).exists():
|
||||||
launch_options['executablePath'] = chrome_path
|
launch_options["executablePath"] = chrome_path
|
||||||
logger.info(f"使用系统浏览器: {chrome_path}")
|
logger.info(f"使用系统浏览器: {chrome_path}")
|
||||||
found_browser = True
|
found_browser = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not found_browser:
|
if not found_browser:
|
||||||
logger.info("未找到系统浏览器,将使用 pyppeteer 默认下载的 Chromium")
|
logger.info("未找到系统浏览器,将使用 pyppeteer 默认下载的 Chromium")
|
||||||
# 先尝试确保 Chromium 已下载
|
# 先尝试确保 Chromium 已下载
|
||||||
try:
|
try:
|
||||||
from pyppeteer import connection, browser, launcher
|
from pyppeteer import browser, launcher
|
||||||
|
|
||||||
launcher_instance = launcher.Launcher(
|
launcher_instance = launcher.Launcher(
|
||||||
headless=True,
|
headless=True,
|
||||||
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
|
args=[
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
await launcher_instance._get_chromium_revision()
|
await launcher_instance._get_chromium_revision()
|
||||||
await launcher_instance._download_chromium()
|
await launcher_instance._download_chromium()
|
||||||
chromium_path = pyppeteer.executablePath()
|
chromium_path = pyppeteer.executablePath()
|
||||||
launch_options['executablePath'] = chromium_path
|
launch_options["executablePath"] = chromium_path
|
||||||
logger.info(f"使用 pyppeteer 下载的 Chromium: {chromium_path}")
|
logger.info(f"使用 pyppeteer 下载的 Chromium: {chromium_path}")
|
||||||
except Exception as pre_download_err:
|
except Exception as pre_download_err:
|
||||||
logger.warning(f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}")
|
logger.warning(
|
||||||
|
f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}"
|
||||||
|
)
|
||||||
|
|
||||||
# 尝试启动浏览器
|
# 尝试启动浏览器
|
||||||
try:
|
try:
|
||||||
@@ -398,49 +420,53 @@ class ReportGenerator:
|
|||||||
try:
|
try:
|
||||||
# 创建新页面,设置更合理的超时时间
|
# 创建新页面,设置更合理的超时时间
|
||||||
page = await browser.newPage()
|
page = await browser.newPage()
|
||||||
|
|
||||||
# 设置页面视口,减少内存占用
|
# 设置页面视口,减少内存占用
|
||||||
await page.setViewport({
|
await page.setViewport(
|
||||||
'width': 1024,
|
{
|
||||||
'height': 768,
|
"width": 1024,
|
||||||
'deviceScaleFactor': 1,
|
"height": 768,
|
||||||
'isMobile': False,
|
"deviceScaleFactor": 1,
|
||||||
'hasTouch': False,
|
"isMobile": False,
|
||||||
'isLandscape': False
|
"hasTouch": False,
|
||||||
})
|
"isLandscape": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 设置页面内容,使用更安全的加载方式
|
# 设置页面内容,使用更安全的加载方式
|
||||||
logger.info("开始设置页面内容...")
|
logger.info("开始设置页面内容...")
|
||||||
await page.setContent(html_content, {'waitUntil': 'domcontentloaded', 'timeout': 30000})
|
await page.setContent(
|
||||||
|
html_content, {"waitUntil": "domcontentloaded", "timeout": 30000}
|
||||||
|
)
|
||||||
|
|
||||||
# 等待页面基本加载完成,但不要太长时间
|
# 等待页面基本加载完成,但不要太长时间
|
||||||
try:
|
try:
|
||||||
await page.waitForSelector('body', {'timeout': 5000})
|
await page.waitForSelector("body", {"timeout": 5000})
|
||||||
logger.info("页面基本加载完成")
|
logger.info("页面基本加载完成")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("等待页面加载超时,继续执行")
|
logger.warning("等待页面加载超时,继续执行")
|
||||||
|
|
||||||
# 减少等待时间,避免内存累积
|
# 减少等待时间,避免内存累积
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# 导出 PDF,使用更保守的设置
|
# 导出 PDF,使用更保守的设置
|
||||||
logger.info("开始生成PDF...")
|
logger.info("开始生成PDF...")
|
||||||
pdf_options = {
|
pdf_options = {
|
||||||
'path': output_path,
|
"path": output_path,
|
||||||
'format': 'A4',
|
"format": "A4",
|
||||||
'printBackground': True,
|
"printBackground": True,
|
||||||
'margin': {
|
"margin": {
|
||||||
'top': '10mm',
|
"top": "10mm",
|
||||||
'right': '10mm',
|
"right": "10mm",
|
||||||
'bottom': '10mm',
|
"bottom": "10mm",
|
||||||
'left': '10mm'
|
"left": "10mm",
|
||||||
},
|
},
|
||||||
'scale': 0.8,
|
"scale": 0.8,
|
||||||
'displayHeaderFooter': False,
|
"displayHeaderFooter": False,
|
||||||
'preferCSSPageSize': True,
|
"preferCSSPageSize": True,
|
||||||
'timeout': 60000 # 增加PDF生成超时时间到60秒
|
"timeout": 60000, # 增加PDF生成超时时间到60秒
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.pdf(pdf_options)
|
await page.pdf(pdf_options)
|
||||||
logger.info(f"PDF 生成成功: {output_path}")
|
logger.info(f"PDF 生成成功: {output_path}")
|
||||||
return True
|
return True
|
||||||
@@ -448,7 +474,7 @@ class ReportGenerator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"PDF生成过程中出错: {e}")
|
logger.error(f"PDF生成过程中出错: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 确保浏览器被正确关闭
|
# 确保浏览器被正确关闭
|
||||||
if browser:
|
if browser:
|
||||||
@@ -459,12 +485,12 @@ class ReportGenerator:
|
|||||||
for page in pages:
|
for page in pages:
|
||||||
try:
|
try:
|
||||||
await page.close()
|
await page.close()
|
||||||
except:
|
except Exception as close_err:
|
||||||
pass
|
logger.debug(f"关闭页面时忽略的异常: {close_err}")
|
||||||
|
|
||||||
# 等待一小段时间让资源释放
|
# 等待一小段时间让资源释放
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# 关闭浏览器
|
# 关闭浏览器
|
||||||
await browser.close()
|
await browser.close()
|
||||||
logger.info("浏览器已关闭")
|
logger.info("浏览器已关闭")
|
||||||
@@ -473,21 +499,25 @@ class ReportGenerator:
|
|||||||
# 强制清理
|
# 强制清理
|
||||||
try:
|
try:
|
||||||
await browser.disconnect()
|
await browser.disconnect()
|
||||||
except:
|
except Exception as disc_err:
|
||||||
pass
|
logger.debug(f"断开浏览器连接时忽略的异常: {disc_err}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "Chromium downloadable not found" in error_msg:
|
if "Chromium downloadable not found" in error_msg:
|
||||||
logger.error("Chromium 下载失败,建议安装系统 Chrome/Chromium")
|
logger.error("Chromium 下载失败,建议安装系统 Chrome/Chromium")
|
||||||
logger.info("💡 Linux 系统建议: sudo apt-get install chromium-browser 或 sudo yum install chromium")
|
logger.info(
|
||||||
|
"💡 Linux 系统建议: sudo apt-get install chromium-browser 或 sudo yum install chromium"
|
||||||
|
)
|
||||||
elif "No usable sandbox" in error_msg:
|
elif "No usable sandbox" in error_msg:
|
||||||
logger.error("沙盒权限问题,已尝试禁用沙盒")
|
logger.error("沙盒权限问题,已尝试禁用沙盒")
|
||||||
elif "Connection refused" in error_msg or "connect" in error_msg.lower():
|
elif "Connection refused" in error_msg or "connect" in error_msg.lower():
|
||||||
logger.error("浏览器连接失败,请检查系统资源或尝试重启")
|
logger.error("浏览器连接失败,请检查系统资源或尝试重启")
|
||||||
elif "executablePath" in error_msg and "not found" in error_msg:
|
elif "executablePath" in error_msg and "not found" in error_msg:
|
||||||
logger.error("未找到系统浏览器,请安装 Chrome 或 Chromium")
|
logger.error("未找到系统浏览器,请安装 Chrome 或 Chromium")
|
||||||
logger.info("💡 安装建议: sudo apt-get install chromium-browser (Ubuntu/Debian) 或 sudo yum install chromium (CentOS/RHEL)")
|
logger.info(
|
||||||
|
"💡 安装建议: sudo apt-get install chromium-browser (Ubuntu/Debian) 或 sudo yum install chromium (CentOS/RHEL)"
|
||||||
|
)
|
||||||
elif "Browser closed unexpectedly" in error_msg:
|
elif "Browser closed unexpectedly" in error_msg:
|
||||||
logger.error("浏览器意外关闭,可能是由于内存不足或系统资源限制")
|
logger.error("浏览器意外关闭,可能是由于内存不足或系统资源限制")
|
||||||
logger.info("💡 建议: 检查系统内存,或重启 AstrBot 后重试")
|
logger.info("💡 建议: 检查系统内存,或重启 AstrBot 后重试")
|
||||||
@@ -497,5 +527,7 @@ class ReportGenerator:
|
|||||||
logger.info(" 3. 考虑使用其他 PDF 生成方案")
|
logger.info(" 3. 考虑使用其他 PDF 生成方案")
|
||||||
else:
|
else:
|
||||||
logger.error(f"HTML 转 PDF 失败: {e}")
|
logger.error(f"HTML 转 PDF 失败: {e}")
|
||||||
logger.info("💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息")
|
logger.info(
|
||||||
return False
|
"💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ HTML模板模块
|
|||||||
|
|
||||||
class HTMLTemplates:
|
class HTMLTemplates:
|
||||||
"""HTML模板管理类"""
|
"""HTML模板管理类"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_image_template() -> str:
|
def get_image_template() -> str:
|
||||||
"""获取图片报告的HTML模板(使用{{ }}占位符)"""
|
"""获取图片报告的HTML模板(使用{{ }}占位符)"""
|
||||||
@@ -594,7 +594,7 @@ class HTMLTemplates:
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_pdf_template() -> str:
|
def get_pdf_template() -> str:
|
||||||
"""获取PDF报告的HTML模板(使用{}占位符)"""
|
"""获取PDF报告的HTML模板(使用{}占位符)"""
|
||||||
|
|||||||
@@ -5,6 +5,4 @@
|
|||||||
|
|
||||||
from .auto_scheduler import AutoScheduler
|
from .auto_scheduler import AutoScheduler
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["AutoScheduler"]
|
||||||
'AutoScheduler'
|
|
||||||
]
|
|
||||||
|
|||||||
+139
-74
@@ -5,14 +5,21 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
class AutoScheduler:
|
class AutoScheduler:
|
||||||
"""自动调度器"""
|
"""自动调度器"""
|
||||||
|
|
||||||
def __init__(self, config_manager, message_handler, analyzer, report_generator, bot_manager, html_render_func=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_manager,
|
||||||
|
message_handler,
|
||||||
|
analyzer,
|
||||||
|
report_generator,
|
||||||
|
bot_manager,
|
||||||
|
html_render_func=None,
|
||||||
|
):
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.message_handler = message_handler
|
self.message_handler = message_handler
|
||||||
self.analyzer = analyzer
|
self.analyzer = analyzer
|
||||||
@@ -33,16 +40,20 @@ class AutoScheduler:
|
|||||||
def _get_platform_id(self):
|
def _get_platform_id(self):
|
||||||
"""获取平台ID"""
|
"""获取平台ID"""
|
||||||
try:
|
try:
|
||||||
if hasattr(self.bot_manager, '_context') and self.bot_manager._context:
|
if hasattr(self.bot_manager, "_context") and self.bot_manager._context:
|
||||||
context = self.bot_manager._context
|
context = self.bot_manager._context
|
||||||
if hasattr(context, 'platform_manager') and hasattr(context.platform_manager, 'platform_insts'):
|
if hasattr(context, "platform_manager") and hasattr(
|
||||||
|
context.platform_manager, "platform_insts"
|
||||||
|
):
|
||||||
platforms = context.platform_manager.platform_insts
|
platforms = context.platform_manager.platform_insts
|
||||||
for platform in platforms:
|
for platform in platforms:
|
||||||
if hasattr(platform, 'metadata') and hasattr(platform.metadata, 'id'):
|
if hasattr(platform, "metadata") and hasattr(
|
||||||
|
platform.metadata, "id"
|
||||||
|
):
|
||||||
platform_id = platform.metadata.id
|
platform_id = platform.metadata.id
|
||||||
return platform_id
|
return platform_id
|
||||||
return "aiocqhttp" # 默认值
|
return "aiocqhttp" # 默认值
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return "aiocqhttp" # 默认值
|
return "aiocqhttp" # 默认值
|
||||||
|
|
||||||
async def start_scheduler(self):
|
async def start_scheduler(self):
|
||||||
@@ -54,7 +65,9 @@ class AutoScheduler:
|
|||||||
# 延迟启动,给系统时间初始化
|
# 延迟启动,给系统时间初始化
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
logger.info(f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}")
|
logger.info(
|
||||||
|
f"启动定时任务调度器,自动分析时间: {self.config_manager.get_auto_analysis_time()}"
|
||||||
|
)
|
||||||
|
|
||||||
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
self.scheduler_task = asyncio.create_task(self._scheduler_loop())
|
||||||
|
|
||||||
@@ -75,9 +88,9 @@ class AutoScheduler:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace(
|
target_time = datetime.strptime(
|
||||||
year=now.year, month=now.month, day=now.day
|
self.config_manager.get_auto_analysis_time(), "%H:%M"
|
||||||
)
|
).replace(year=now.year, month=now.month, day=now.day)
|
||||||
|
|
||||||
# 如果今天的目标时间已过,设置为明天
|
# 如果今天的目标时间已过,设置为明天
|
||||||
if now >= target_time:
|
if now >= target_time:
|
||||||
@@ -85,7 +98,9 @@ class AutoScheduler:
|
|||||||
|
|
||||||
# 计算等待时间
|
# 计算等待时间
|
||||||
wait_seconds = (target_time - now).total_seconds()
|
wait_seconds = (target_time - now).total_seconds()
|
||||||
logger.info(f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒")
|
logger.info(
|
||||||
|
f"定时分析将在 {target_time.strftime('%Y-%m-%d %H:%M:%S')} 执行,等待 {wait_seconds:.0f} 秒"
|
||||||
|
)
|
||||||
|
|
||||||
# 等待到目标时间
|
# 等待到目标时间
|
||||||
await asyncio.sleep(wait_seconds)
|
await asyncio.sleep(wait_seconds)
|
||||||
@@ -94,7 +109,9 @@ class AutoScheduler:
|
|||||||
if self.config_manager.get_enable_auto_analysis():
|
if self.config_manager.get_enable_auto_analysis():
|
||||||
# 检查今天是否已经执行过,防止重复执行
|
# 检查今天是否已经执行过,防止重复执行
|
||||||
if self.last_execution_date == target_time.date():
|
if self.last_execution_date == target_time.date():
|
||||||
logger.info(f"今天 {target_time.date()} 已经执行过自动分析,跳过执行")
|
logger.info(
|
||||||
|
f"今天 {target_time.date()} 已经执行过自动分析,跳过执行"
|
||||||
|
)
|
||||||
# 等待到明天再检查
|
# 等待到明天再检查
|
||||||
await asyncio.sleep(3600) # 等待1小时后再检查
|
await asyncio.sleep(3600) # 等待1小时后再检查
|
||||||
continue
|
continue
|
||||||
@@ -102,7 +119,9 @@ class AutoScheduler:
|
|||||||
logger.info("开始执行定时分析")
|
logger.info("开始执行定时分析")
|
||||||
await self._run_auto_analysis()
|
await self._run_auto_analysis()
|
||||||
self.last_execution_date = target_time.date() # 记录执行日期
|
self.last_execution_date = target_time.date() # 记录执行日期
|
||||||
logger.info(f"定时分析执行完成,记录执行日期: {self.last_execution_date}")
|
logger.info(
|
||||||
|
f"定时分析执行完成,记录执行日期: {self.last_execution_date}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("自动分析已禁用,跳过执行")
|
logger.info("自动分析已禁用,跳过执行")
|
||||||
break
|
break
|
||||||
@@ -122,24 +141,26 @@ class AutoScheduler:
|
|||||||
logger.info("没有启用的群聊需要分析")
|
logger.info("没有启用的群聊需要分析")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}")
|
logger.info(
|
||||||
|
f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}"
|
||||||
|
)
|
||||||
|
|
||||||
# 创建并发任务 - 为每个群聊创建独立的分析任务
|
# 创建并发任务 - 为每个群聊创建独立的分析任务
|
||||||
analysis_tasks = []
|
analysis_tasks = []
|
||||||
for group_id in enabled_groups:
|
for group_id in enabled_groups:
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self._perform_auto_analysis_for_group_with_timeout(group_id),
|
self._perform_auto_analysis_for_group_with_timeout(group_id),
|
||||||
name=f"analysis_group_{group_id}"
|
name=f"analysis_group_{group_id}",
|
||||||
)
|
)
|
||||||
analysis_tasks.append(task)
|
analysis_tasks.append(task)
|
||||||
|
|
||||||
# 并发执行所有分析任务,使用 return_exceptions=True 确保单个任务失败不影响其他任务
|
# 并发执行所有分析任务,使用 return_exceptions=True 确保单个任务失败不影响其他任务
|
||||||
results = await asyncio.gather(*analysis_tasks, return_exceptions=True)
|
results = await asyncio.gather(*analysis_tasks, return_exceptions=True)
|
||||||
|
|
||||||
# 统计执行结果
|
# 统计执行结果
|
||||||
success_count = 0
|
success_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
|
|
||||||
for i, result in enumerate(results):
|
for i, result in enumerate(results):
|
||||||
group_id = enabled_groups[i]
|
group_id = enabled_groups[i]
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -147,8 +168,10 @@ class AutoScheduler:
|
|||||||
error_count += 1
|
error_count += 1
|
||||||
else:
|
else:
|
||||||
success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
logger.info(f"并发分析完成 - 成功: {success_count}, 失败: {error_count}, 总计: {len(enabled_groups)}")
|
logger.info(
|
||||||
|
f"并发分析完成 - 成功: {success_count}, 失败: {error_count}, 总计: {len(enabled_groups)}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"自动分析执行失败: {e}", exc_info=True)
|
logger.error(f"自动分析执行失败: {e}", exc_info=True)
|
||||||
@@ -157,30 +180,34 @@ class AutoScheduler:
|
|||||||
"""为指定群执行自动分析(带超时控制)"""
|
"""为指定群执行自动分析(带超时控制)"""
|
||||||
try:
|
try:
|
||||||
# 为每个群聊设置独立的超时时间(20分钟)- 使用 asyncio.wait_for 兼容所有 Python 版本
|
# 为每个群聊设置独立的超时时间(20分钟)- 使用 asyncio.wait_for 兼容所有 Python 版本
|
||||||
await asyncio.wait_for(self._perform_auto_analysis_for_group(group_id), timeout=1200)
|
await asyncio.wait_for(
|
||||||
|
self._perform_auto_analysis_for_group(group_id), timeout=1200
|
||||||
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"群 {group_id} 分析超时(20分钟),跳过该群分析")
|
logger.error(f"群 {group_id} 分析超时(20分钟),跳过该群分析")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"群 {group_id} 分析任务执行失败: {e}")
|
logger.error(f"群 {group_id} 分析任务执行失败: {e}")
|
||||||
|
|
||||||
async def _perform_auto_analysis_for_group(self, group_id: str):
|
async def _perform_auto_analysis_for_group(self, group_id: str):
|
||||||
"""为指定群执行自动分析(核心逻辑)"""
|
"""为指定群执行自动分析(核心逻辑)"""
|
||||||
# 为每个群聊使用独立的锁,避免全局锁导致串行化
|
# 为每个群聊使用独立的锁,避免全局锁导致串行化
|
||||||
group_lock_key = f"analysis_{group_id}"
|
group_lock_key = f"analysis_{group_id}"
|
||||||
if not hasattr(self, '_group_locks'):
|
if not hasattr(self, "_group_locks"):
|
||||||
self._group_locks = {}
|
self._group_locks = {}
|
||||||
|
|
||||||
if group_lock_key not in self._group_locks:
|
if group_lock_key not in self._group_locks:
|
||||||
self._group_locks[group_lock_key] = asyncio.Lock()
|
self._group_locks[group_lock_key] = asyncio.Lock()
|
||||||
|
|
||||||
async with self._group_locks[group_lock_key]:
|
async with self._group_locks[group_lock_key]:
|
||||||
try:
|
try:
|
||||||
start_time = asyncio.get_event_loop().time()
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
# 检查bot管理器状态
|
# 检查bot管理器状态
|
||||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||||
status = self.bot_manager.get_status_info()
|
status = self.bot_manager.get_status_info()
|
||||||
logger.warning(f"群 {group_id} 自动分析跳过:bot管理器未就绪 - {status}")
|
logger.warning(
|
||||||
|
f"群 {group_id} 自动分析跳过:bot管理器未就绪 - {status}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"开始为群 {group_id} 执行自动分析(并发任务)")
|
logger.info(f"开始为群 {group_id} 执行自动分析(并发任务)")
|
||||||
@@ -189,8 +216,10 @@ class AutoScheduler:
|
|||||||
analysis_days = self.config_manager.get_analysis_days()
|
analysis_days = self.config_manager.get_analysis_days()
|
||||||
bot_instance = self.bot_manager.get_bot_instance()
|
bot_instance = self.bot_manager.get_bot_instance()
|
||||||
|
|
||||||
messages = await self.message_handler.fetch_group_messages(bot_instance, group_id, analysis_days)
|
messages = await self.message_handler.fetch_group_messages(
|
||||||
|
bot_instance, group_id, analysis_days
|
||||||
|
)
|
||||||
|
|
||||||
if not messages:
|
if not messages:
|
||||||
logger.warning(f"群 {group_id} 未获取到足够的消息记录")
|
logger.warning(f"群 {group_id} 未获取到足够的消息记录")
|
||||||
return
|
return
|
||||||
@@ -198,7 +227,9 @@ class AutoScheduler:
|
|||||||
# 检查消息数量
|
# 检查消息数量
|
||||||
min_threshold = self.config_manager.get_min_messages_threshold()
|
min_threshold = self.config_manager.get_min_messages_threshold()
|
||||||
if len(messages) < min_threshold:
|
if len(messages) < min_threshold:
|
||||||
logger.warning(f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析")
|
logger.warning(
|
||||||
|
f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析")
|
logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析")
|
||||||
@@ -206,14 +237,16 @@ class AutoScheduler:
|
|||||||
# 进行分析 - 构造正确的 unified_msg_origin
|
# 进行分析 - 构造正确的 unified_msg_origin
|
||||||
platform_id = self._get_platform_id()
|
platform_id = self._get_platform_id()
|
||||||
umo = f"{platform_id}:GroupMessage:{group_id}" if platform_id else None
|
umo = f"{platform_id}:GroupMessage:{group_id}" if platform_id else None
|
||||||
analysis_result = await self.analyzer.analyze_messages(messages, group_id, umo)
|
analysis_result = await self.analyzer.analyze_messages(
|
||||||
|
messages, group_id, umo
|
||||||
|
)
|
||||||
if not analysis_result:
|
if not analysis_result:
|
||||||
logger.error(f"群 {group_id} 分析失败")
|
logger.error(f"群 {group_id} 分析失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 生成并发送报告
|
# 生成并发送报告
|
||||||
await self._send_analysis_report(group_id, analysis_result)
|
await self._send_analysis_report(group_id, analysis_result)
|
||||||
|
|
||||||
# 记录执行时间
|
# 记录执行时间
|
||||||
end_time = asyncio.get_event_loop().time()
|
end_time = asyncio.get_event_loop().time()
|
||||||
execution_time = end_time - start_time
|
execution_time = end_time - start_time
|
||||||
@@ -221,10 +254,10 @@ class AutoScheduler:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True)
|
logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 清理群聊锁资源(可选,防止内存泄漏)
|
# 清理群聊锁资源(可选,防止内存泄漏)
|
||||||
if hasattr(self, '_group_locks') and len(self._group_locks) > 50:
|
if hasattr(self, "_group_locks") and len(self._group_locks) > 50:
|
||||||
old_locks = list(self._group_locks.keys())[:10]
|
old_locks = list(self._group_locks.keys())[:10]
|
||||||
for lock_key in old_locks:
|
for lock_key in old_locks:
|
||||||
if not self._group_locks[lock_key].locked():
|
if not self._group_locks[lock_key].locked():
|
||||||
@@ -240,47 +273,87 @@ class AutoScheduler:
|
|||||||
# 使用图片格式
|
# 使用图片格式
|
||||||
logger.info(f"群 {group_id} 自动分析使用图片报告格式")
|
logger.info(f"群 {group_id} 自动分析使用图片报告格式")
|
||||||
try:
|
try:
|
||||||
image_url = await self.report_generator.generate_image_report(analysis_result, group_id, self.html_render_func)
|
image_url = await self.report_generator.generate_image_report(
|
||||||
|
analysis_result, group_id, self.html_render_func
|
||||||
|
)
|
||||||
if image_url:
|
if image_url:
|
||||||
await self._send_image_message(group_id, image_url)
|
await self._send_image_message(group_id, image_url)
|
||||||
logger.info(f"群 {group_id} 图片报告发送成功")
|
logger.info(f"群 {group_id} 图片报告发送成功")
|
||||||
else:
|
else:
|
||||||
# 图片生成失败,回退到文本
|
# 图片生成失败,回退到文本
|
||||||
logger.warning(f"群 {group_id} 图片报告生成失败(返回None),回退到文本报告")
|
logger.warning(
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
f"群 {group_id} 图片报告生成失败(返回None),回退到文本报告"
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
)
|
||||||
|
text_report = self.report_generator.generate_text_report(
|
||||||
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
except Exception as img_e:
|
except Exception as img_e:
|
||||||
logger.error(f"群 {group_id} 图片报告生成异常: {img_e},回退到文本报告")
|
logger.error(
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
f"群 {group_id} 图片报告生成异常: {img_e},回退到文本报告"
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
)
|
||||||
|
text_report = self.report_generator.generate_text_report(
|
||||||
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 没有html_render函数,回退到文本报告
|
# 没有html_render函数,回退到文本报告
|
||||||
logger.warning(f"群 {group_id} 缺少html_render函数,回退到文本报告")
|
logger.warning(f"群 {group_id} 缺少html_render函数,回退到文本报告")
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
text_report = self.report_generator.generate_text_report(
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
|
|
||||||
elif output_format == "pdf":
|
elif output_format == "pdf":
|
||||||
if not self.config_manager.pyppeteer_available:
|
if not self.config_manager.pyppeteer_available:
|
||||||
logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告")
|
logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告")
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
text_report = self.report_generator.generate_text_report(
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
pdf_path = await self.report_generator.generate_pdf_report(analysis_result, group_id)
|
pdf_path = await self.report_generator.generate_pdf_report(
|
||||||
|
analysis_result, group_id
|
||||||
|
)
|
||||||
if pdf_path:
|
if pdf_path:
|
||||||
await self._send_pdf_file(group_id, pdf_path)
|
await self._send_pdf_file(group_id, pdf_path)
|
||||||
logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告")
|
logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告")
|
||||||
else:
|
else:
|
||||||
logger.error(f"群 {group_id} PDF报告生成失败(返回None),回退到文本报告")
|
logger.error(
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
f"群 {group_id} PDF报告生成失败(返回None),回退到文本报告"
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
)
|
||||||
|
text_report = self.report_generator.generate_text_report(
|
||||||
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
except Exception as pdf_e:
|
except Exception as pdf_e:
|
||||||
logger.error(f"群 {group_id} PDF报告生成异常: {pdf_e},回退到文本报告")
|
logger.error(
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
f"群 {group_id} PDF报告生成异常: {pdf_e},回退到文本报告"
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
)
|
||||||
|
text_report = self.report_generator.generate_text_report(
|
||||||
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
text_report = self.report_generator.generate_text_report(
|
||||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
analysis_result
|
||||||
|
)
|
||||||
|
await self._send_text_message(
|
||||||
|
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"群 {group_id} 自动分析完成,已发送报告")
|
logger.info(f"群 {group_id} 自动分析完成,已发送报告")
|
||||||
|
|
||||||
@@ -299,13 +372,10 @@ class AutoScheduler:
|
|||||||
await bot_instance.api.call_action(
|
await bot_instance.api.call_action(
|
||||||
"send_group_msg",
|
"send_group_msg",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
message=[{
|
message=[
|
||||||
"type": "text",
|
{"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
|
||||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
{"type": "image", "data": {"url": image_url}},
|
||||||
}, {
|
],
|
||||||
"type": "image",
|
|
||||||
"data": {"url": image_url}
|
|
||||||
}]
|
|
||||||
)
|
)
|
||||||
logger.info(f"群 {group_id} 图片消息发送成功")
|
logger.info(f"群 {group_id} 图片消息发送成功")
|
||||||
|
|
||||||
@@ -322,9 +392,7 @@ class AutoScheduler:
|
|||||||
|
|
||||||
# 发送文本消息到群
|
# 发送文本消息到群
|
||||||
await bot_instance.api.call_action(
|
await bot_instance.api.call_action(
|
||||||
"send_group_msg",
|
"send_group_msg", group_id=group_id, message=text_content
|
||||||
group_id=group_id,
|
|
||||||
message=text_content
|
|
||||||
)
|
)
|
||||||
logger.info(f"群 {group_id} 文本消息发送成功")
|
logger.info(f"群 {group_id} 文本消息发送成功")
|
||||||
|
|
||||||
@@ -343,13 +411,10 @@ class AutoScheduler:
|
|||||||
await bot_instance.api.call_action(
|
await bot_instance.api.call_action(
|
||||||
"send_group_msg",
|
"send_group_msg",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
message=[{
|
message=[
|
||||||
"type": "text",
|
{"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
|
||||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
{"type": "file", "data": {"file": pdf_path}},
|
||||||
}, {
|
],
|
||||||
"type": "file",
|
|
||||||
"data": {"file": pdf_path}
|
|
||||||
}]
|
|
||||||
)
|
)
|
||||||
logger.info(f"群 {group_id} PDF文件发送成功")
|
logger.info(f"群 {group_id} PDF文件发送成功")
|
||||||
|
|
||||||
@@ -360,7 +425,7 @@ class AutoScheduler:
|
|||||||
await bot_instance.api.call_action(
|
await bot_instance.api.call_action(
|
||||||
"send_group_msg",
|
"send_group_msg",
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
|
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}",
|
||||||
)
|
)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
|
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
|
||||||
|
|||||||
@@ -6,7 +6,4 @@
|
|||||||
from .pdf_utils import PDFInstaller
|
from .pdf_utils import PDFInstaller
|
||||||
from .helpers import MessageAnalyzer
|
from .helpers import MessageAnalyzer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["PDFInstaller", "MessageAnalyzer"]
|
||||||
'PDFInstaller',
|
|
||||||
'MessageAnalyzer'
|
|
||||||
]
|
|
||||||
|
|||||||
+49
-16
@@ -4,12 +4,13 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
from ...src.models.data_models import GroupStatistics, SummaryTopic, UserTitle, GoldenQuote, TokenUsage
|
from ...src.models.data_models import TokenUsage
|
||||||
from ...src.core.message_handler import MessageHandler
|
from ...src.core.message_handler import MessageHandler
|
||||||
from ...src.analysis.llm_analyzer import LLMAnalyzer
|
from ...src.analysis.llm_analyzer import LLMAnalyzer
|
||||||
from ...src.analysis.statistics import UserAnalyzer
|
from ...src.analysis.statistics import UserAnalyzer
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
class MessageAnalyzer:
|
class MessageAnalyzer:
|
||||||
"""消息分析器 - 整合所有分析功能"""
|
"""消息分析器 - 整合所有分析功能"""
|
||||||
|
|
||||||
@@ -28,7 +29,9 @@ class MessageAnalyzer:
|
|||||||
else:
|
else:
|
||||||
await self.message_handler.set_bot_qq_id(bot_instance)
|
await self.message_handler.set_bot_qq_id(bot_instance)
|
||||||
|
|
||||||
async def analyze_messages(self, messages: List[Dict], group_id: str, unified_msg_origin: str = None) -> Dict:
|
async def analyze_messages(
|
||||||
|
self, messages: List[Dict], group_id: str, unified_msg_origin: str = None
|
||||||
|
) -> Dict:
|
||||||
"""完整的消息分析流程"""
|
"""完整的消息分析流程"""
|
||||||
try:
|
try:
|
||||||
# 基础统计
|
# 基础统计
|
||||||
@@ -36,11 +39,15 @@ class MessageAnalyzer:
|
|||||||
|
|
||||||
# 用户分析
|
# 用户分析
|
||||||
user_analysis = self.user_analyzer.analyze_users(messages)
|
user_analysis = self.user_analyzer.analyze_users(messages)
|
||||||
|
|
||||||
# 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取
|
# 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取
|
||||||
max_user_titles = self.config_manager.get_max_user_titles()
|
max_user_titles = self.config_manager.get_max_user_titles()
|
||||||
top_users = self.user_analyzer.get_top_users(user_analysis, limit=max_user_titles)
|
top_users = self.user_analyzer.get_top_users(
|
||||||
logger.info(f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})")
|
user_analysis, limit=max_user_titles
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})"
|
||||||
|
)
|
||||||
|
|
||||||
# LLM分析 - 使用并发方式
|
# LLM分析 - 使用并发方式
|
||||||
topics = []
|
topics = []
|
||||||
@@ -51,35 +58,61 @@ class MessageAnalyzer:
|
|||||||
# 检查各个分析功能是否启用
|
# 检查各个分析功能是否启用
|
||||||
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
||||||
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
||||||
golden_quote_enabled = self.config_manager.get_golden_quote_analysis_enabled()
|
golden_quote_enabled = (
|
||||||
|
self.config_manager.get_golden_quote_analysis_enabled()
|
||||||
|
)
|
||||||
|
|
||||||
# 如果三个分析都启用,使用并发执行
|
# 如果三个分析都启用,使用并发执行
|
||||||
if topic_enabled and user_title_enabled and golden_quote_enabled:
|
if topic_enabled and user_title_enabled and golden_quote_enabled:
|
||||||
# 并发执行所有三个分析任务,传入活跃用户列表
|
# 并发执行所有三个分析任务,传入活跃用户列表
|
||||||
topics, user_titles, golden_quotes, total_token_usage = await self.llm_analyzer.analyze_all_concurrent(
|
(
|
||||||
|
topics,
|
||||||
|
user_titles,
|
||||||
|
golden_quotes,
|
||||||
|
total_token_usage,
|
||||||
|
) = await self.llm_analyzer.analyze_all_concurrent(
|
||||||
messages, user_analysis, umo=unified_msg_origin, top_users=top_users
|
messages, user_analysis, umo=unified_msg_origin, top_users=top_users
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 如果只启用部分分析,则按需执行
|
# 如果只启用部分分析,则按需执行
|
||||||
if topic_enabled:
|
if topic_enabled:
|
||||||
topics, topic_tokens = await self.llm_analyzer.analyze_topics(messages, umo=unified_msg_origin)
|
topics, topic_tokens = await self.llm_analyzer.analyze_topics(
|
||||||
|
messages, umo=unified_msg_origin
|
||||||
|
)
|
||||||
total_token_usage.prompt_tokens += topic_tokens.prompt_tokens
|
total_token_usage.prompt_tokens += topic_tokens.prompt_tokens
|
||||||
total_token_usage.completion_tokens += topic_tokens.completion_tokens
|
total_token_usage.completion_tokens += (
|
||||||
|
topic_tokens.completion_tokens
|
||||||
|
)
|
||||||
total_token_usage.total_tokens += topic_tokens.total_tokens
|
total_token_usage.total_tokens += topic_tokens.total_tokens
|
||||||
|
|
||||||
if user_title_enabled:
|
if user_title_enabled:
|
||||||
# 传入活跃用户列表
|
# 传入活跃用户列表
|
||||||
user_titles, title_tokens = await self.llm_analyzer.analyze_user_titles(
|
(
|
||||||
messages, user_analysis, umo=unified_msg_origin, top_users=top_users
|
user_titles,
|
||||||
|
title_tokens,
|
||||||
|
) = await self.llm_analyzer.analyze_user_titles(
|
||||||
|
messages,
|
||||||
|
user_analysis,
|
||||||
|
umo=unified_msg_origin,
|
||||||
|
top_users=top_users,
|
||||||
)
|
)
|
||||||
total_token_usage.prompt_tokens += title_tokens.prompt_tokens
|
total_token_usage.prompt_tokens += title_tokens.prompt_tokens
|
||||||
total_token_usage.completion_tokens += title_tokens.completion_tokens
|
total_token_usage.completion_tokens += (
|
||||||
|
title_tokens.completion_tokens
|
||||||
|
)
|
||||||
total_token_usage.total_tokens += title_tokens.total_tokens
|
total_token_usage.total_tokens += title_tokens.total_tokens
|
||||||
|
|
||||||
if golden_quote_enabled:
|
if golden_quote_enabled:
|
||||||
golden_quotes, quote_tokens = await self.llm_analyzer.analyze_golden_quotes(messages, umo=unified_msg_origin)
|
(
|
||||||
|
golden_quotes,
|
||||||
|
quote_tokens,
|
||||||
|
) = await self.llm_analyzer.analyze_golden_quotes(
|
||||||
|
messages, umo=unified_msg_origin
|
||||||
|
)
|
||||||
total_token_usage.prompt_tokens += quote_tokens.prompt_tokens
|
total_token_usage.prompt_tokens += quote_tokens.prompt_tokens
|
||||||
total_token_usage.completion_tokens += quote_tokens.completion_tokens
|
total_token_usage.completion_tokens += (
|
||||||
|
quote_tokens.completion_tokens
|
||||||
|
)
|
||||||
total_token_usage.total_tokens += quote_tokens.total_tokens
|
total_token_usage.total_tokens += quote_tokens.total_tokens
|
||||||
|
|
||||||
# 更新统计数据
|
# 更新统计数据
|
||||||
@@ -90,7 +123,7 @@ class MessageAnalyzer:
|
|||||||
"statistics": statistics,
|
"statistics": statistics,
|
||||||
"topics": topics,
|
"topics": topics,
|
||||||
"user_titles": user_titles,
|
"user_titles": user_titles,
|
||||||
"user_analysis": user_analysis
|
"user_analysis": user_analysis,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+85
-67
@@ -5,21 +5,22 @@ PDF工具模块
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from astrbot.api import logger
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
class PDFInstaller:
|
class PDFInstaller:
|
||||||
"""PDF功能安装器"""
|
"""PDF功能安装器"""
|
||||||
|
|
||||||
# 类级别的线程池,用于异步下载任务
|
# 类级别的线程池,用于异步下载任务
|
||||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="chromium_download")
|
_executor = ThreadPoolExecutor(
|
||||||
|
max_workers=1, thread_name_prefix="chromium_download"
|
||||||
|
)
|
||||||
_download_status = {
|
_download_status = {
|
||||||
"in_progress": False,
|
"in_progress": False,
|
||||||
"completed": False,
|
"completed": False,
|
||||||
"failed": False,
|
"failed": False,
|
||||||
"error_message": None
|
"error_message": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -31,10 +32,14 @@ class PDFInstaller:
|
|||||||
# 使用asyncio安装pyppeteer和兼容的websockets版本
|
# 使用asyncio安装pyppeteer和兼容的websockets版本
|
||||||
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
|
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
sys.executable, "-m", "pip", "install",
|
sys.executable,
|
||||||
"pyppeteer==1.0.2", "websockets==10.4",
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"pyppeteer==1.0.2",
|
||||||
|
"websockets==10.4",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
@@ -63,20 +68,20 @@ class PDFInstaller:
|
|||||||
"""通过 pyppeteer 自动安装 Chromium(异步非阻塞方式)"""
|
"""通过 pyppeteer 自动安装 Chromium(异步非阻塞方式)"""
|
||||||
try:
|
try:
|
||||||
logger.info("正在通过 pyppeteer 自动安装 Chromium...")
|
logger.info("正在通过 pyppeteer 自动安装 Chromium...")
|
||||||
|
|
||||||
# 检查是否已经在下载中
|
# 检查是否已经在下载中
|
||||||
if PDFInstaller._download_status["in_progress"]:
|
if PDFInstaller._download_status["in_progress"]:
|
||||||
return "⏳ Chromium 正在后台下载中,请稍候..."
|
return "⏳ Chromium 正在后台下载中,请稍候..."
|
||||||
|
|
||||||
# 启动异步下载任务
|
# 启动异步下载任务
|
||||||
PDFInstaller._download_status["in_progress"] = True
|
PDFInstaller._download_status["in_progress"] = True
|
||||||
PDFInstaller._download_status["completed"] = False
|
PDFInstaller._download_status["completed"] = False
|
||||||
PDFInstaller._download_status["failed"] = False
|
PDFInstaller._download_status["failed"] = False
|
||||||
PDFInstaller._download_status["error_message"] = None
|
PDFInstaller._download_status["error_message"] = None
|
||||||
|
|
||||||
# 在后台线程中启动下载
|
# 在后台线程中启动下载
|
||||||
asyncio.create_task(PDFInstaller._background_chromium_download())
|
asyncio.create_task(PDFInstaller._background_chromium_download())
|
||||||
|
|
||||||
return """⏳ Chromium 下载已在后台启动
|
return """⏳ Chromium 下载已在后台启动
|
||||||
|
|
||||||
这可能需要几分钟时间,请稍候...
|
这可能需要几分钟时间,请稍候...
|
||||||
@@ -96,31 +101,35 @@ class PDFInstaller:
|
|||||||
"""后台下载 Chromium,带超时控制"""
|
"""后台下载 Chromium,带超时控制"""
|
||||||
try:
|
try:
|
||||||
logger.info("后台 Chromium 下载任务开始")
|
logger.info("后台 Chromium 下载任务开始")
|
||||||
|
|
||||||
# 设置10分钟超时
|
# 设置10分钟超时
|
||||||
timeout_seconds = 600
|
timeout_seconds = 600
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 使用 asyncio.wait_for 实现超时控制
|
# 使用 asyncio.wait_for 实现超时控制
|
||||||
success = await asyncio.wait_for(
|
success = await asyncio.wait_for(
|
||||||
PDFInstaller._download_chromium_via_pyppeteer(),
|
PDFInstaller._download_chromium_via_pyppeteer(),
|
||||||
timeout=timeout_seconds
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
PDFInstaller._download_status["completed"] = True
|
PDFInstaller._download_status["completed"] = True
|
||||||
PDFInstaller._download_status["failed"] = False
|
PDFInstaller._download_status["failed"] = False
|
||||||
logger.info("✅ Chromium 后台下载完成!")
|
logger.info("✅ Chromium 后台下载完成!")
|
||||||
else:
|
else:
|
||||||
PDFInstaller._download_status["failed"] = True
|
PDFInstaller._download_status["failed"] = True
|
||||||
PDFInstaller._download_status["error_message"] = "下载失败,请检查网络连接"
|
PDFInstaller._download_status["error_message"] = (
|
||||||
|
"下载失败,请检查网络连接"
|
||||||
|
)
|
||||||
logger.error("❌ Chromium 下载失败")
|
logger.error("❌ Chromium 下载失败")
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
PDFInstaller._download_status["failed"] = True
|
PDFInstaller._download_status["failed"] = True
|
||||||
PDFInstaller._download_status["error_message"] = f"下载超时({timeout_seconds}秒)"
|
PDFInstaller._download_status["error_message"] = (
|
||||||
|
f"下载超时({timeout_seconds}秒)"
|
||||||
|
)
|
||||||
logger.error(f"❌ Chromium 下载超时({timeout_seconds}秒)")
|
logger.error(f"❌ Chromium 下载超时({timeout_seconds}秒)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
PDFInstaller._download_status["failed"] = True
|
PDFInstaller._download_status["failed"] = True
|
||||||
PDFInstaller._download_status["error_message"] = str(e)
|
PDFInstaller._download_status["error_message"] = str(e)
|
||||||
@@ -133,96 +142,101 @@ class PDFInstaller:
|
|||||||
"""通过 pyppeteer 自动下载 Chromium(带重试机制)"""
|
"""通过 pyppeteer 自动下载 Chromium(带重试机制)"""
|
||||||
max_retries = 2
|
max_retries = 2
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
|
|
||||||
while retry_count <= max_retries:
|
while retry_count <= max_retries:
|
||||||
try:
|
try:
|
||||||
if retry_count > 0:
|
if retry_count > 0:
|
||||||
logger.info(f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)...")
|
logger.info(
|
||||||
|
f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)..."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("通过 pyppeteer 自动下载 Chromium...")
|
logger.info("通过 pyppeteer 自动下载 Chromium...")
|
||||||
|
|
||||||
# 导入 pyppeteer 并尝试下载
|
# 导入 pyppeteer 并尝试下载
|
||||||
try:
|
try:
|
||||||
import pyppeteer
|
import pyppeteer
|
||||||
from pyppeteer import launch
|
from pyppeteer import launch
|
||||||
from pyppeteer.errors import BrowserError
|
from pyppeteer.errors import BrowserError
|
||||||
|
|
||||||
# 方法1: 尝试直接下载 Chromium 而不启动浏览器
|
# 方法1: 尝试直接下载 Chromium 而不启动浏览器
|
||||||
logger.info("尝试直接下载 Chromium...")
|
logger.info("尝试直接下载 Chromium...")
|
||||||
try:
|
try:
|
||||||
from pyppeteer.launcher import Launcher
|
from pyppeteer.launcher import Launcher
|
||||||
|
|
||||||
# 创建 Launcher 实例但不启动浏览器
|
# 创建 Launcher 实例但不启动浏览器
|
||||||
launcher = Launcher(
|
launcher = Launcher(
|
||||||
headless=True,
|
headless=True,
|
||||||
args=['--no-sandbox', '--disable-setuid-sandbox']
|
args=["--no-sandbox", "--disable-setuid-sandbox"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 只下载 Chromium
|
# 只下载 Chromium
|
||||||
chromium_revision = launcher._get_chromium_revision()
|
launcher._get_chromium_revision()
|
||||||
await launcher._download_chromium()
|
await launcher._download_chromium()
|
||||||
|
|
||||||
logger.info("✅ Chromium 下载完成")
|
logger.info("✅ Chromium 下载完成")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as download_error:
|
except Exception as download_error:
|
||||||
logger.warning(f"直接下载 Chromium 失败: {download_error}")
|
logger.warning(f"直接下载 Chromium 失败: {download_error}")
|
||||||
logger.info("尝试通过启动浏览器来下载...")
|
logger.info("尝试通过启动浏览器来下载...")
|
||||||
|
|
||||||
# 方法2: 通过启动浏览器触发自动下载
|
# 方法2: 通过启动浏览器触发自动下载
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
|
|
||||||
if system == "linux":
|
if system == "linux":
|
||||||
browser_args = [
|
browser_args = [
|
||||||
'--no-sandbox',
|
"--no-sandbox",
|
||||||
'--disable-setuid-sandbox',
|
"--disable-setuid-sandbox",
|
||||||
'--disable-dev-shm-usage',
|
"--disable-dev-shm-usage",
|
||||||
'--disable-accelerated-2d-canvas',
|
"--disable-accelerated-2d-canvas",
|
||||||
'--no-first-run',
|
"--no-first-run",
|
||||||
'--no-zygote',
|
"--no-zygote",
|
||||||
'--disable-gpu',
|
"--disable-gpu",
|
||||||
'--disable-background-timer-throttling',
|
"--disable-background-timer-throttling",
|
||||||
'--disable-backgrounding-occluded-windows',
|
"--disable-backgrounding-occluded-windows",
|
||||||
'--disable-renderer-backgrounding',
|
"--disable-renderer-backgrounding",
|
||||||
'--disable-features=TranslateUI',
|
"--disable-features=TranslateUI",
|
||||||
'--disable-ipc-flooding-protection'
|
"--disable-ipc-flooding-protection",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
browser_args = [
|
browser_args = [
|
||||||
'--no-sandbox',
|
"--no-sandbox",
|
||||||
'--disable-setuid-sandbox',
|
"--disable-setuid-sandbox",
|
||||||
'--disable-gpu'
|
"--disable-gpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("启动 pyppeteer 浏览器以触发 Chromium 自动下载...")
|
logger.info("启动 pyppeteer 浏览器以触发 Chromium 自动下载...")
|
||||||
browser = await launch(
|
browser = await launch(
|
||||||
headless=True,
|
headless=True,
|
||||||
args=browser_args,
|
args=browser_args,
|
||||||
ignoreHTTPSErrors=True,
|
ignoreHTTPSErrors=True,
|
||||||
dumpio=False # 关闭浏览器日志输出以减少干扰
|
dumpio=False, # 关闭浏览器日志输出以减少干扰
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取 Chromium 路径
|
# 获取 Chromium 路径
|
||||||
chromium_path = pyppeteer.executablePath()
|
chromium_path = pyppeteer.executablePath()
|
||||||
logger.info(f"✅ Chromium 自动下载完成,路径: {chromium_path}")
|
logger.info(f"✅ Chromium 自动下载完成,路径: {chromium_path}")
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except BrowserError as e:
|
except BrowserError as e:
|
||||||
logger.error(f"浏览器错误: {e}")
|
logger.error(f"浏览器错误: {e}")
|
||||||
|
|
||||||
# 方法3: 使用子进程命令行触发下载
|
# 方法3: 使用子进程命令行触发下载
|
||||||
try:
|
try:
|
||||||
logger.info("尝试使用命令行触发 Chromium 自动下载...")
|
logger.info("尝试使用命令行触发 Chromium 自动下载...")
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
|
|
||||||
if system == "linux":
|
if system == "linux":
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-c",
|
sys.executable,
|
||||||
|
"-c",
|
||||||
"""
|
"""
|
||||||
import pyppeteer
|
import pyppeteer
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -246,33 +260,34 @@ async def download_chrome():
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
asyncio.run(download_chrome())
|
asyncio.run(download_chrome())
|
||||||
"""
|
""",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-c",
|
sys.executable,
|
||||||
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())"
|
"-c",
|
||||||
|
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())",
|
||||||
]
|
]
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
if process.returncode == 0:
|
if process.returncode == 0:
|
||||||
logger.info("✅ 成功通过命令行触发 Chromium 自动下载")
|
logger.info("✅ 成功通过命令行触发 Chromium 自动下载")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"命令行触发自动下载失败: {stderr.decode()}")
|
logger.error(f"命令行触发自动下载失败: {stderr.decode()}")
|
||||||
raise Exception(f"命令行下载失败: {stderr.decode()}")
|
raise Exception(f"命令行下载失败: {stderr.decode()}")
|
||||||
|
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error(f"命令行触发自动下载失败: {e2}")
|
logger.error(f"命令行触发自动下载失败: {e2}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
if retry_count <= max_retries:
|
if retry_count <= max_retries:
|
||||||
@@ -280,9 +295,12 @@ asyncio.run(download_chrome())
|
|||||||
logger.warning(f"下载失败,{wait_time}秒后重试... 错误: {e}")
|
logger.warning(f"下载失败,{wait_time}秒后重试... 错误: {e}")
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
else:
|
else:
|
||||||
logger.error(f"通过 pyppeteer 自动下载 Chromium 失败(已重试{max_retries}次): {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"通过 pyppeteer 自动下载 Chromium 失败(已重试{max_retries}次): {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -292,4 +310,4 @@ asyncio.run(download_chrome())
|
|||||||
version = config_manager.pyppeteer_version or "未知版本"
|
version = config_manager.pyppeteer_version or "未知版本"
|
||||||
return f"✅ PDF 功能可用 (pyppeteer {version})"
|
return f"✅ PDF 功能可用 (pyppeteer {version})"
|
||||||
else:
|
else:
|
||||||
return "❌ PDF 功能不可用 - 需要安装 pyppeteer"
|
return "❌ PDF 功能不可用 - 需要安装 pyppeteer"
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
|
|
||||||
from .activity_charts import ActivityVisualizer
|
from .activity_charts import ActivityVisualizer
|
||||||
|
|
||||||
__all__ = ['ActivityVisualizer']
|
__all__ = ["ActivityVisualizer"]
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
参考 astrbot_plugin_github_analyzer 的实现方式
|
参考 astrbot_plugin_github_analyzer 的实现方式
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List
|
||||||
from ..models.data_models import ActivityVisualization
|
from ..models.data_models import ActivityVisualization
|
||||||
|
|
||||||
|
|
||||||
class ActivityVisualizer:
|
class ActivityVisualizer:
|
||||||
"""活跃度可视化器"""
|
"""活跃度可视化器"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def generate_activity_visualization(self, messages: List[Dict]) -> ActivityVisualization:
|
def generate_activity_visualization(
|
||||||
|
self, messages: List[Dict]
|
||||||
|
) -> ActivityVisualization:
|
||||||
"""生成活跃度可视化数据 - 专注于小时级别分析"""
|
"""生成活跃度可视化数据 - 专注于小时级别分析"""
|
||||||
hourly_activity = defaultdict(int)
|
hourly_activity = defaultdict(int)
|
||||||
user_activity = defaultdict(int)
|
user_activity = defaultdict(int)
|
||||||
@@ -54,15 +56,19 @@ class ActivityVisualizer:
|
|||||||
# 生成用户活跃度排行
|
# 生成用户活跃度排行
|
||||||
user_ranking = []
|
user_ranking = []
|
||||||
for user_id, data in user_activity.items():
|
for user_id, data in user_activity.items():
|
||||||
user_ranking.append({
|
user_ranking.append(
|
||||||
"user_id": user_id,
|
{
|
||||||
"nickname": data["nickname"],
|
"user_id": user_id,
|
||||||
"message_count": data["count"]
|
"nickname": data["nickname"],
|
||||||
})
|
"message_count": data["count"],
|
||||||
|
}
|
||||||
|
)
|
||||||
user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
|
user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
|
||||||
|
|
||||||
# 找出高峰时段(活跃度最高的3个小时)
|
# 找出高峰时段(活跃度最高的3个小时)
|
||||||
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[:3]
|
peak_hours = sorted(hourly_activity.items(), key=lambda x: x[1], reverse=True)[
|
||||||
|
:3
|
||||||
|
]
|
||||||
peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours]
|
peak_hours = [{"hour": hour, "count": count} for hour, count in peak_hours]
|
||||||
|
|
||||||
return ActivityVisualization(
|
return ActivityVisualization(
|
||||||
@@ -70,10 +76,14 @@ class ActivityVisualizer:
|
|||||||
daily_activity={}, # 不使用日期分析
|
daily_activity={}, # 不使用日期分析
|
||||||
user_activity_ranking=user_ranking[:10], # 前10名
|
user_activity_ranking=user_ranking[:10], # 前10名
|
||||||
peak_hours=peak_hours,
|
peak_hours=peak_hours,
|
||||||
activity_heatmap_data=self._generate_hourly_heatmap_data(hourly_activity, emoji_activity)
|
activity_heatmap_data=self._generate_hourly_heatmap_data(
|
||||||
|
hourly_activity, emoji_activity
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _generate_hourly_heatmap_data(self, hourly_activity: dict, emoji_activity: dict) -> dict:
|
def _generate_hourly_heatmap_data(
|
||||||
|
self, hourly_activity: dict, emoji_activity: dict
|
||||||
|
) -> dict:
|
||||||
"""生成小时级热力图数据"""
|
"""生成小时级热力图数据"""
|
||||||
# 计算活跃度等级
|
# 计算活跃度等级
|
||||||
max_hourly = max(hourly_activity.values()) if hourly_activity else 1
|
max_hourly = max(hourly_activity.values()) if hourly_activity else 1
|
||||||
@@ -90,7 +100,7 @@ class ActivityVisualizer:
|
|||||||
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
|
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
|
||||||
for hour in range(24)
|
for hour in range(24)
|
||||||
},
|
},
|
||||||
"activity_levels": self._calculate_activity_levels(hourly_activity)
|
"activity_levels": self._calculate_activity_levels(hourly_activity),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
|
def _calculate_activity_levels(self, hourly_activity: dict) -> dict:
|
||||||
@@ -114,17 +124,17 @@ class ActivityVisualizer:
|
|||||||
levels[hour] = level
|
levels[hour] = level
|
||||||
|
|
||||||
return levels
|
return levels
|
||||||
|
|
||||||
def generate_hourly_chart_html(self, hourly_activity: dict) -> str:
|
def generate_hourly_chart_html(self, hourly_activity: dict) -> str:
|
||||||
"""生成每小时活动分布的HTML图表"""
|
"""生成每小时活动分布的HTML图表"""
|
||||||
html_parts = []
|
html_parts = []
|
||||||
max_activity = max(hourly_activity.values()) if hourly_activity else 1
|
max_activity = max(hourly_activity.values()) if hourly_activity else 1
|
||||||
threshold_percentage = 20 # 数值显示阈值
|
threshold_percentage = 20 # 数值显示阈值
|
||||||
|
|
||||||
for hour in range(24):
|
for hour in range(24):
|
||||||
count = hourly_activity.get(hour, 0)
|
count = hourly_activity.get(hour, 0)
|
||||||
percentage = (count / max_activity) * 100 if max_activity > 0 else 0
|
percentage = (count / max_activity) * 100 if max_activity > 0 else 0
|
||||||
|
|
||||||
if count > 0 and percentage >= threshold_percentage:
|
if count > 0 and percentage >= threshold_percentage:
|
||||||
# 活动数较多,数值显示在条形图内部
|
# 活动数较多,数值显示在条形图内部
|
||||||
html_segment = f"""
|
html_segment = f"""
|
||||||
@@ -159,7 +169,5 @@ class ActivityVisualizer:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
html_parts.append(html_segment)
|
html_parts.append(html_segment)
|
||||||
|
|
||||||
return "".join(html_parts)
|
return "".join(html_parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user