mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
fix(message_handler): 取消分页拉取、Ruff 自动修复
Merge pull request #39 from exynos967/master
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
"""
|
||||
QQ群日常分析插件
|
||||
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
||||
|
||||
@@ -7,12 +7,13 @@ QQ群日常分析插件
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api.event import filter
|
||||
from astrbot.api.star import Context, Star
|
||||
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.star.filter.permission import PermissionType
|
||||
|
||||
@@ -39,7 +40,12 @@ class QQGroupDailyAnalysis(Star):
|
||||
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)
|
||||
bot_manager = BotManager(config_manager)
|
||||
@@ -52,7 +58,7 @@ class QQGroupDailyAnalysis(Star):
|
||||
message_analyzer,
|
||||
report_generator,
|
||||
bot_manager,
|
||||
self.html_render # 传入html_render函数
|
||||
self.html_render, # 传入html_render函数
|
||||
)
|
||||
|
||||
# 延迟启动自动调度器,给系统时间初始化
|
||||
@@ -86,7 +92,12 @@ class QQGroupDailyAnalysis(Star):
|
||||
try:
|
||||
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:
|
||||
@@ -123,7 +134,9 @@ class QQGroupDailyAnalysis(Star):
|
||||
|
||||
@filter.command("群分析")
|
||||
@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
|
||||
|
||||
# 设置分析天数
|
||||
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}天的活动,请稍候...")
|
||||
|
||||
@@ -156,21 +171,31 @@ class QQGroupDailyAnalysis(Star):
|
||||
|
||||
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:
|
||||
yield event.plain_result("❌ 未找到足够的群聊记录,请确保群内有足够的消息历史")
|
||||
yield event.plain_result(
|
||||
"❌ 未找到足够的群聊记录,请确保群内有足够的消息历史"
|
||||
)
|
||||
return
|
||||
|
||||
# 检查消息数量是否足够分析
|
||||
min_threshold = config_manager.get_min_messages_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
|
||||
|
||||
yield event.plain_result(f"📊 已获取{len(messages)}条消息,正在进行智能分析...")
|
||||
yield event.plain_result(
|
||||
f"📊 已获取{len(messages)}条消息,正在进行智能分析..."
|
||||
)
|
||||
|
||||
# 进行分析 - 传递 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"):
|
||||
@@ -180,23 +205,32 @@ class QQGroupDailyAnalysis(Star):
|
||||
# 生成报告
|
||||
output_format = config_manager.get_output_format()
|
||||
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:
|
||||
yield event.image_result(image_url)
|
||||
else:
|
||||
# 如果图片生成失败,回退到文本报告
|
||||
logger.warning("图片报告生成失败,回退到文本报告")
|
||||
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":
|
||||
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
|
||||
|
||||
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:
|
||||
# 发送 PDF 文件
|
||||
from pathlib import Path
|
||||
|
||||
pdf_file = File(name=Path(pdf_path).name, file=pdf_path)
|
||||
result = event.make_result()
|
||||
result.chain.append(pdf_file)
|
||||
@@ -212,20 +246,24 @@ class QQGroupDailyAnalysis(Star):
|
||||
# 回退到文本报告
|
||||
logger.warning("PDF 报告生成失败,回退到文本报告")
|
||||
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:
|
||||
text_report = report_generator.generate_text_report(analysis_result)
|
||||
yield event.plain_result(text_report)
|
||||
|
||||
except Exception as e:
|
||||
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.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]
|
||||
@@ -241,7 +279,9 @@ class QQGroupDailyAnalysis(Star):
|
||||
|
||||
if not format_type:
|
||||
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}
|
||||
|
||||
可用格式:
|
||||
@@ -258,7 +298,9 @@ class QQGroupDailyAnalysis(Star):
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
config_manager.set_output_format(format_type)
|
||||
@@ -292,7 +334,9 @@ class QQGroupDailyAnalysis(Star):
|
||||
|
||||
@filter.command("分析设置")
|
||||
@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]
|
||||
@@ -360,7 +404,9 @@ class QQGroupDailyAnalysis(Star):
|
||||
else: # status
|
||||
enabled_groups = config_manager.get_enabled_groups()
|
||||
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()
|
||||
|
||||
pdf_status = PDFInstaller.get_pdf_status(config_manager)
|
||||
@@ -379,5 +425,3 @@ class QQGroupDailyAnalysis(Star):
|
||||
💡 可用命令: enable, disable, status, reload, test
|
||||
💡 支持的输出格式: image, text, pdf (图片和PDF包含活跃度可视化)
|
||||
💡 其他命令: /设置格式, /安装PDF""")
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,4 @@
|
||||
from .llm_analyzer import LLMAnalyzer
|
||||
from .statistics import UserAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'LLMAnalyzer',
|
||||
'UserAnalyzer'
|
||||
]
|
||||
__all__ = ["LLMAnalyzer", "UserAnalyzer"]
|
||||
|
||||
@@ -8,9 +8,4 @@ from .topic_analyzer import TopicAnalyzer
|
||||
from .user_title_analyzer import UserTitleAnalyzer
|
||||
from .golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'BaseAnalyzer',
|
||||
'TopicAnalyzer',
|
||||
'UserTitleAnalyzer',
|
||||
'GoldenQuoteAnalyzer'
|
||||
]
|
||||
__all__ = ["BaseAnalyzer", "TopicAnalyzer", "UserTitleAnalyzer", "GoldenQuoteAnalyzer"]
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Tuple, Any, Optional
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Tuple, Any
|
||||
from astrbot.api import logger
|
||||
from ...models.data_models import TokenUsage
|
||||
from ..utils.json_utils import parse_json_response
|
||||
from ..utils.llm_utils import call_provider_with_retry, extract_token_usage, extract_response_text
|
||||
import re
|
||||
from ..utils.llm_utils import (
|
||||
call_provider_with_retry,
|
||||
extract_token_usage,
|
||||
extract_response_text,
|
||||
)
|
||||
|
||||
|
||||
class BaseAnalyzer(ABC):
|
||||
"""
|
||||
@@ -102,17 +105,27 @@ class BaseAnalyzer(ABC):
|
||||
"""
|
||||
try:
|
||||
# 1. 构建提示词
|
||||
logger.debug(f"{self.get_data_type()}分析开始构建prompt,输入数据类型: {type(data)}")
|
||||
logger.debug(f"{self.get_data_type()}分析输入数据长度: {len(data) if hasattr(data, '__len__') else 'N/A'}")
|
||||
logger.debug(
|
||||
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)
|
||||
logger.info(f"开始{self.get_data_type()}分析,构建提示词完成")
|
||||
logger.debug(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'}...")
|
||||
logger.debug(
|
||||
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 是否为空
|
||||
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()
|
||||
|
||||
# 2. 调用LLM
|
||||
@@ -120,12 +133,13 @@ class BaseAnalyzer(ABC):
|
||||
temperature = self.get_temperature()
|
||||
|
||||
response = await call_provider_with_retry(
|
||||
self.context, self.config_manager, prompt,
|
||||
max_tokens, temperature, umo
|
||||
self.context, self.config_manager, prompt, max_tokens, temperature, umo
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
# 3. 提取token使用统计
|
||||
@@ -133,7 +147,7 @@ class BaseAnalyzer(ABC):
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=token_usage_dict["prompt_tokens"],
|
||||
completion_tokens=token_usage_dict["completion_tokens"],
|
||||
total_tokens=token_usage_dict["total_tokens"]
|
||||
total_tokens=token_usage_dict["total_tokens"],
|
||||
)
|
||||
|
||||
# 4. 提取响应文本
|
||||
@@ -141,25 +155,35 @@ class BaseAnalyzer(ABC):
|
||||
logger.debug(f"{self.get_data_type()}分析原始响应: {result_text[:500]}...")
|
||||
|
||||
# 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:
|
||||
# JSON解析成功,创建数据对象
|
||||
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
|
||||
|
||||
# 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())
|
||||
|
||||
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)
|
||||
return data_objects, token_usage
|
||||
else:
|
||||
# 最后的降级方案 - 两种方法都失败
|
||||
logger.error(f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表")
|
||||
logger.error(
|
||||
f"{self.get_data_type()}分析失败: JSON解析和正则表达式提取均未成功,返回空列表"
|
||||
)
|
||||
return [], token_usage
|
||||
|
||||
except Exception as e:
|
||||
@@ -183,5 +207,3 @@ class BaseAnalyzer(ABC):
|
||||
温度参数
|
||||
"""
|
||||
return 0.6
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from ..utils.json_utils import extract_golden_quotes_with_regex
|
||||
from ..utils import InfoUtils
|
||||
|
||||
|
||||
|
||||
class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
"""
|
||||
金句分析器
|
||||
@@ -49,10 +48,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
return ""
|
||||
|
||||
# 构建消息文本
|
||||
messages_text = "\n".join([
|
||||
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
||||
for msg in messages
|
||||
])
|
||||
messages_text = "\n".join(
|
||||
[f"[{msg['time']}] {msg['sender']}: {msg['content']}" for msg in messages]
|
||||
)
|
||||
|
||||
max_golden_quotes = self.get_max_count()
|
||||
|
||||
@@ -63,8 +61,7 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
# 使用配置中的 prompt 并替换变量
|
||||
try:
|
||||
prompt = prompt_template.format(
|
||||
max_golden_quotes=max_golden_quotes,
|
||||
messages_text=messages_text
|
||||
max_golden_quotes=max_golden_quotes, messages_text=messages_text
|
||||
)
|
||||
logger.info("使用配置中的金句分析提示词")
|
||||
return prompt
|
||||
@@ -114,11 +111,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
logger.warning(f"金句数据格式不完整,跳过: {quote_data}")
|
||||
continue
|
||||
|
||||
quotes.append(GoldenQuote(
|
||||
content=content,
|
||||
sender=sender,
|
||||
reason=reason
|
||||
))
|
||||
quotes.append(
|
||||
GoldenQuote(content=content, sender=sender, reason=reason)
|
||||
)
|
||||
|
||||
return quotes
|
||||
|
||||
@@ -148,12 +143,12 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
if content.get("type") == "text":
|
||||
text = content.get("data", {}).get("text", "").strip()
|
||||
# 过滤长度适中、可能圣经的消息
|
||||
if 5 <= len(text) <= 100 and not text.startswith(("http", "www", "/")):
|
||||
interesting_messages.append({
|
||||
"sender": nickname,
|
||||
"time": msg_time,
|
||||
"content": text
|
||||
})
|
||||
if 5 <= len(text) <= 100 and not text.startswith(
|
||||
("http", "www", "/")
|
||||
):
|
||||
interesting_messages.append(
|
||||
{"sender": nickname, "time": msg_time, "content": text}
|
||||
)
|
||||
|
||||
return interesting_messages
|
||||
|
||||
@@ -161,7 +156,9 @@ class GoldenQuoteAnalyzer(BaseAnalyzer):
|
||||
logger.error(f"提取圣经消息失败: {e}")
|
||||
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]:
|
||||
"""
|
||||
分析群聊金句
|
||||
|
||||
|
||||
@@ -58,32 +58,42 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
# 提取文本消息
|
||||
text_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'错误
|
||||
if not isinstance(msg, dict):
|
||||
logger.warning(f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}")
|
||||
logger.warning(
|
||||
f"build_prompt 跳过非字典类型的消息: {type(msg)} - {msg}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
sender = msg.get("sender", {})
|
||||
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
||||
if not isinstance(sender, dict):
|
||||
logger.warning(f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}")
|
||||
logger.warning(
|
||||
f"build_prompt 跳过sender非字典类型的消息: {type(sender)} - {sender}"
|
||||
)
|
||||
continue
|
||||
|
||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
|
||||
|
||||
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 中
|
||||
text_parts = []
|
||||
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):
|
||||
logger.warning(f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}")
|
||||
logger.warning(
|
||||
f"build_prompt 跳过非字典类型的内容: {type(content)} - {content}"
|
||||
)
|
||||
continue
|
||||
|
||||
content_type = content.get("type", "")
|
||||
@@ -91,7 +101,9 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
|
||||
if content_type == "text":
|
||||
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:
|
||||
text_parts.append(text)
|
||||
elif content_type == "at":
|
||||
@@ -111,27 +123,35 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
|
||||
# 合并所有文本部分
|
||||
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 = cleaned_text.replace('‘', "'").replace('’', "'")
|
||||
cleaned_text = cleaned_text.replace('\n', ' ').replace('\r', ' ')
|
||||
cleaned_text = cleaned_text.replace('\t', ' ')
|
||||
cleaned_text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', cleaned_text)
|
||||
cleaned_text = combined_text.replace("“", '"').replace("”", '"')
|
||||
cleaned_text = cleaned_text.replace("‘", "'").replace("’", "'")
|
||||
cleaned_text = cleaned_text.replace("\n", " ").replace("\r", " ")
|
||||
cleaned_text = cleaned_text.replace("\t", " ")
|
||||
cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text)
|
||||
|
||||
logger.debug(f"build_prompt 清理后的文本: '{cleaned_text}'")
|
||||
|
||||
text_messages.append({
|
||||
"sender": nickname,
|
||||
"time": msg_time,
|
||||
"content": cleaned_text
|
||||
})
|
||||
text_messages.append(
|
||||
{"sender": nickname, "time": msg_time, "content": cleaned_text}
|
||||
)
|
||||
else:
|
||||
logger.debug(f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)")
|
||||
logger.debug(
|
||||
f"build_prompt 跳过文本: '{combined_text}' (长度不足或以/开头)"
|
||||
)
|
||||
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
|
||||
|
||||
if not text_messages:
|
||||
@@ -139,10 +159,12 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
return ""
|
||||
|
||||
# 构建消息文本
|
||||
messages_text = "\n".join([
|
||||
messages_text = "\n".join(
|
||||
[
|
||||
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
|
||||
for msg in text_messages
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
max_topics = self.get_max_count()
|
||||
|
||||
@@ -153,8 +175,7 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
# 使用配置中的 prompt 并替换变量
|
||||
try:
|
||||
prompt = prompt_template.format(
|
||||
max_topics=max_topics,
|
||||
messages_text=messages_text
|
||||
max_topics=max_topics, messages_text=messages_text
|
||||
)
|
||||
logger.info("使用配置中的话题分析提示词")
|
||||
return prompt
|
||||
@@ -189,7 +210,9 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
Returns:
|
||||
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)}")
|
||||
|
||||
try:
|
||||
@@ -199,11 +222,13 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
logger.debug(f"处理前 {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'错误
|
||||
if not isinstance(topic_data, dict):
|
||||
logger.warning(f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}")
|
||||
logger.warning(
|
||||
f"跳过非字典类型的话题数据: {type(topic_data)} - {topic_data}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -212,7 +237,9 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
contributors = topic_data.get("contributors", [])
|
||||
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:
|
||||
@@ -224,15 +251,19 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
contributors = ["群友"]
|
||||
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()
|
||||
] or ["群友"]
|
||||
|
||||
topics.append(SummaryTopic(
|
||||
topics.append(
|
||||
SummaryTopic(
|
||||
topic=topic_name,
|
||||
contributors=contributors[:5], # 最多5个参与者
|
||||
detail=detail
|
||||
))
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理第 {i+1} 条话题数据时出错: {e}", exc_info=True)
|
||||
logger.error(f"处理第 {i + 1} 条话题数据时出错: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
logger.debug(f"create_data_objects 完成,创建了 {len(topics)} 个话题对象")
|
||||
@@ -252,7 +283,9 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
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)}")
|
||||
|
||||
if not messages:
|
||||
@@ -262,7 +295,7 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
text_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'错误
|
||||
if not isinstance(msg, dict):
|
||||
logger.warning(f"跳过非字典类型的消息: {type(msg)} - {msg}")
|
||||
@@ -272,7 +305,9 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
sender = msg.get("sender", {})
|
||||
# 确保sender是字典类型,避免'str' object has no attribute 'get'错误
|
||||
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
|
||||
|
||||
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
|
||||
@@ -284,25 +319,31 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
if text and len(text) > 2 and not text.startswith("/"):
|
||||
# 清理消息内容
|
||||
text = text.replace('""', '"').replace('""', '"')
|
||||
text = text.replace(''', "'").replace(''', "'")
|
||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
||||
text = text.replace('\t', ' ')
|
||||
text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', text)
|
||||
text_messages.append({
|
||||
text = text.replace(""", "'").replace(""", "'")
|
||||
text = text.replace("\n", " ").replace("\r", " ")
|
||||
text = text.replace("\t", " ")
|
||||
text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", text)
|
||||
text_messages.append(
|
||||
{
|
||||
"sender": nickname,
|
||||
"time": msg_time,
|
||||
"content": text.strip()
|
||||
})
|
||||
"content": text.strip(),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理第 {i+1} 条消息时出错: {e}", exc_info=True)
|
||||
logger.error(f"处理第 {i + 1} 条消息时出错: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
logger.debug(f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息")
|
||||
logger.debug(
|
||||
f"extract_text_messages 完成,提取到 {len(text_messages)} 条文本消息"
|
||||
)
|
||||
if text_messages:
|
||||
logger.debug(f"extract_text_messages 第一条文本消息: {text_messages[0]}")
|
||||
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]:
|
||||
"""
|
||||
分析群聊话题
|
||||
|
||||
@@ -314,10 +355,14 @@ class TopicAnalyzer(BaseAnalyzer):
|
||||
(话题列表, Token使用统计)
|
||||
"""
|
||||
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)}")
|
||||
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 '无'}")
|
||||
|
||||
# 检查是否有有效的文本消息
|
||||
|
||||
@@ -48,13 +48,15 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
return ""
|
||||
|
||||
# 构建用户数据文本
|
||||
users_text = "\n".join([
|
||||
users_text = "\n".join(
|
||||
[
|
||||
f"- {user['name']} (QQ:{user['qq']}): "
|
||||
f"发言{user['message_count']}条, 平均{user['avg_chars']}字, "
|
||||
f"表情比例{user['emoji_ratio']}, 夜间发言比例{user['night_ratio']}, "
|
||||
f"回复比例{user['reply_ratio']}"
|
||||
for user in user_summaries
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
# 从配置读取 prompt 模板(默认使用 "default" 风格)
|
||||
prompt_template = self.config_manager.get_user_title_analysis_prompt()
|
||||
@@ -62,9 +64,7 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
if prompt_template:
|
||||
# 使用配置中的 prompt 并替换变量
|
||||
try:
|
||||
prompt = prompt_template.format(
|
||||
users_text=users_text
|
||||
)
|
||||
prompt = prompt_template.format(users_text=users_text)
|
||||
logger.info("使用配置中的用户称号分析提示词")
|
||||
return prompt
|
||||
except KeyError as e:
|
||||
@@ -122,13 +122,9 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
logger.warning(f"QQ号格式无效,跳过: {qq}")
|
||||
continue
|
||||
|
||||
titles.append(UserTitle(
|
||||
name=name,
|
||||
qq=qq,
|
||||
title=title,
|
||||
mbti=mbti,
|
||||
reason=reason
|
||||
))
|
||||
titles.append(
|
||||
UserTitle(name=name, qq=qq, title=title, mbti=mbti, reason=reason)
|
||||
)
|
||||
|
||||
return titles
|
||||
|
||||
@@ -136,7 +132,9 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
logger.error(f"创建用户称号对象失败: {e}")
|
||||
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:
|
||||
"""
|
||||
准备用户数据
|
||||
|
||||
@@ -156,13 +154,18 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
|
||||
# 如果提供了top_users列表,只分析这些活跃用户
|
||||
if top_users:
|
||||
logger.info(f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析")
|
||||
target_user_ids = {str(user['user_id']) for user in top_users}
|
||||
logger.info(
|
||||
f"使用get_top_users筛选出的 {len(top_users)} 个活跃用户进行称号分析"
|
||||
)
|
||||
target_user_ids = {str(user["user_id"]) for user in top_users}
|
||||
else:
|
||||
# 兼容旧逻辑:如果没有提供top_users,则使用所有消息数>=5的用户
|
||||
logger.info("未提供活跃用户列表,使用消息数>=5的用户")
|
||||
target_user_ids = {user_id for user_id, stats in user_analysis.items()
|
||||
if stats["message_count"] >= 5}
|
||||
target_user_ids = {
|
||||
user_id
|
||||
for user_id, stats in user_analysis.items()
|
||||
if stats["message_count"] >= 5
|
||||
}
|
||||
|
||||
for user_id, stats in user_analysis.items():
|
||||
# 过滤机器人自己的消息
|
||||
@@ -176,18 +179,33 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
|
||||
# 分析用户特征
|
||||
night_messages = sum(stats["hours"][h] for h in range(6))
|
||||
day_messages = stats["message_count"] - night_messages
|
||||
avg_chars = stats["char_count"] / stats["message_count"] if stats["message_count"] > 0 else 0
|
||||
avg_chars = (
|
||||
stats["char_count"] / stats["message_count"]
|
||||
if stats["message_count"] > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
user_summaries.append({
|
||||
user_summaries.append(
|
||||
{
|
||||
"name": stats["nickname"],
|
||||
"qq": int(user_id),
|
||||
"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
|
||||
})
|
||||
"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:
|
||||
return {"user_summaries": []}
|
||||
@@ -201,7 +219,13 @@ class UserTitleAnalyzer(BaseAnalyzer):
|
||||
logger.error(f"准备用户数据失败: {e}")
|
||||
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]:
|
||||
"""
|
||||
分析用户称号
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from .analyzers.user_title_analyzer import UserTitleAnalyzer
|
||||
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||
from .utils.llm_utils import call_provider_with_retry
|
||||
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:
|
||||
@@ -38,7 +37,9 @@ class LLMAnalyzer:
|
||||
self.user_title_analyzer = UserTitleAnalyzer(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分析话题
|
||||
保持原有接口,委托给专门的TopicAnalyzer处理
|
||||
@@ -57,7 +58,13 @@ class LLMAnalyzer:
|
||||
logger.error(f"话题分析失败: {e}")
|
||||
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分析用户称号
|
||||
保持原有接口,委托给专门的UserTitleAnalyzer处理
|
||||
@@ -73,12 +80,16 @@ class LLMAnalyzer:
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
logger.error(f"用户称号分析失败: {e}")
|
||||
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分析群聊金句
|
||||
保持原有接口,委托给专门的GoldenQuoteAnalyzer处理
|
||||
@@ -97,7 +108,13 @@ class LLMAnalyzer:
|
||||
logger.error(f"金句分析失败: {e}")
|
||||
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]:
|
||||
"""
|
||||
并发执行所有分析任务(话题、用户称号、金句)
|
||||
|
||||
@@ -116,9 +133,11 @@ class LLMAnalyzer:
|
||||
# 并发执行三个分析任务
|
||||
results = await asyncio.gather(
|
||||
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),
|
||||
return_exceptions=True
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 处理结果
|
||||
@@ -146,12 +165,20 @@ class LLMAnalyzer:
|
||||
|
||||
# 合并Token使用统计
|
||||
total_usage = TokenUsage(
|
||||
prompt_tokens=topic_usage.prompt_tokens + title_usage.prompt_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
|
||||
prompt_tokens=topic_usage.prompt_tokens
|
||||
+ title_usage.prompt_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
|
||||
|
||||
except Exception as e:
|
||||
@@ -159,8 +186,14 @@ class LLMAnalyzer:
|
||||
return [], [], [], TokenUsage()
|
||||
|
||||
# 向后兼容的方法,保持原有调用方式
|
||||
async def _call_provider_with_retry(self, provider, prompt: str, max_tokens: int,
|
||||
temperature: float, umo: str = None):
|
||||
async def _call_provider_with_retry(
|
||||
self,
|
||||
provider,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
umo: str = None,
|
||||
):
|
||||
"""
|
||||
向后兼容的LLM调用方法
|
||||
现在委托给llm_utils模块处理
|
||||
@@ -175,8 +208,9 @@ class LLMAnalyzer:
|
||||
Returns:
|
||||
LLM生成的结果
|
||||
"""
|
||||
return await call_provider_with_retry(self.context, self.config_manager,
|
||||
prompt, max_tokens, temperature, umo)
|
||||
return await call_provider_with_retry(
|
||||
self.context, self.config_manager, prompt, max_tokens, temperature, umo
|
||||
)
|
||||
|
||||
def _fix_json(self, text: str) -> str:
|
||||
"""
|
||||
@@ -190,4 +224,3 @@ class LLMAnalyzer:
|
||||
修复后的JSON文本
|
||||
"""
|
||||
return fix_json(text)
|
||||
|
||||
+20
-11
@@ -20,14 +20,16 @@ class UserAnalyzer:
|
||||
# 获取机器人QQ号用于过滤
|
||||
bot_qq_id = self.config_manager.get_bot_qq_id()
|
||||
|
||||
user_stats = defaultdict(lambda: {
|
||||
user_stats = defaultdict(
|
||||
lambda: {
|
||||
"message_count": 0,
|
||||
"char_count": 0,
|
||||
"emoji_count": 0,
|
||||
"nickname": "",
|
||||
"hours": defaultdict(int),
|
||||
"reply_count": 0
|
||||
})
|
||||
"reply_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
sender = msg.get("sender", {})
|
||||
@@ -75,7 +77,9 @@ class UserAnalyzer:
|
||||
|
||||
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号用于过滤
|
||||
bot_qq_id = self.config_manager.get_bot_qq_id()
|
||||
@@ -86,20 +90,24 @@ class UserAnalyzer:
|
||||
if bot_qq_id and str(user_id) == str(bot_qq_id):
|
||||
continue
|
||||
|
||||
users.append({
|
||||
users.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"nickname": stats["nickname"],
|
||||
"message_count": stats["message_count"],
|
||||
"char_count": stats["char_count"],
|
||||
"emoji_count": stats["emoji_count"],
|
||||
"reply_count": stats["reply_count"]
|
||||
})
|
||||
"reply_count": stats["reply_count"],
|
||||
}
|
||||
)
|
||||
|
||||
# 按消息数量排序
|
||||
users.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
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:
|
||||
return {}
|
||||
@@ -112,11 +120,12 @@ class UserAnalyzer:
|
||||
|
||||
# 计算夜间活跃度
|
||||
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 {
|
||||
"most_active_hour": most_active_hour,
|
||||
"night_ratio": night_ratio,
|
||||
"hourly_distribution": dict(hours)
|
||||
"hourly_distribution": dict(hours),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,30 +8,28 @@ from .json_utils import (
|
||||
parse_json_response,
|
||||
extract_topics_with_regex,
|
||||
extract_user_titles_with_regex,
|
||||
extract_golden_quotes_with_regex
|
||||
extract_golden_quotes_with_regex,
|
||||
)
|
||||
|
||||
from .llm_utils import (
|
||||
call_provider_with_retry,
|
||||
extract_token_usage,
|
||||
extract_response_text
|
||||
extract_response_text,
|
||||
)
|
||||
|
||||
from .info_utils import InfoUtils
|
||||
|
||||
__all__ = [
|
||||
# JSON处理工具
|
||||
'fix_json',
|
||||
'parse_json_response',
|
||||
'extract_topics_with_regex',
|
||||
'extract_user_titles_with_regex',
|
||||
'extract_golden_quotes_with_regex',
|
||||
|
||||
"fix_json",
|
||||
"parse_json_response",
|
||||
"extract_topics_with_regex",
|
||||
"extract_user_titles_with_regex",
|
||||
"extract_golden_quotes_with_regex",
|
||||
# LLM工具
|
||||
'call_provider_with_retry',
|
||||
'extract_token_usage',
|
||||
'extract_response_text',
|
||||
|
||||
"call_provider_with_retry",
|
||||
"extract_token_usage",
|
||||
"extract_response_text",
|
||||
# 信息工具
|
||||
'InfoUtils'
|
||||
"InfoUtils",
|
||||
]
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"""
|
||||
JSON处理工具模块
|
||||
提供JSON解析、修复和正则提取功能
|
||||
@@ -6,7 +5,7 @@ JSON处理工具模块
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Any, Optional
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
@@ -22,24 +21,24 @@ def fix_json(text: str) -> str:
|
||||
"""
|
||||
try:
|
||||
# 1. 移除markdown代码块标记
|
||||
text = re.sub(r'```json\s*', '', text)
|
||||
text = re.sub(r'```\s*$', '', text)
|
||||
text = re.sub(r"```json\s*", "", text)
|
||||
text = re.sub(r"```\s*$", "", text)
|
||||
|
||||
# 2. 基础清理
|
||||
text = text.replace('\n', ' ').replace('\r', ' ')
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = text.replace("\n", " ").replace("\r", " ")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
|
||||
# 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. 处理字符串内容中的特殊字符
|
||||
# 转义字符串内的双引号
|
||||
@@ -53,14 +52,14 @@ def fix_json(text: str) -> str:
|
||||
text = re.sub(r'"([^"]*(?:"[^"]*)*)"', escape_quotes_in_strings, text)
|
||||
|
||||
# 5. 修复截断的JSON
|
||||
if not text.endswith(']'):
|
||||
last_complete = text.rfind('}')
|
||||
if not text.endswith("]"):
|
||||
last_complete = text.rfind("}")
|
||||
if last_complete > 0:
|
||||
text = text[:last_complete + 1] + ']'
|
||||
text = text[: last_complete + 1] + "]"
|
||||
|
||||
# 6. 修复常见的JSON格式问题
|
||||
# 1. 修复缺失的逗号
|
||||
text = re.sub(r'}\s*{', '}, {', text)
|
||||
text = re.sub(r"}\s*{", "}, {", text)
|
||||
|
||||
# 2. 确保字段名有引号(仅在对象开始或逗号后,避免破坏字符串值)
|
||||
def quote_field_names(match):
|
||||
@@ -69,11 +68,11 @@ def fix_json(text: str) -> str:
|
||||
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. 移除多余的逗号
|
||||
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()
|
||||
|
||||
@@ -82,7 +81,9 @@ def fix_json(text: str) -> str:
|
||||
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解析方法
|
||||
|
||||
@@ -95,7 +96,7 @@ def parse_json_response(result_text: str, data_type: str) -> Tuple[bool, Optiona
|
||||
"""
|
||||
try:
|
||||
# 1. 提取JSON部分
|
||||
json_match = re.search(r'\[.*?\]', result_text, re.DOTALL)
|
||||
json_match = re.search(r"\[.*?\]", result_text, re.DOTALL)
|
||||
if not json_match:
|
||||
error_msg = f"{data_type}响应中未找到JSON格式"
|
||||
logger.warning(error_msg)
|
||||
@@ -153,16 +154,21 @@ def extract_topics_with_regex(result_text: str, max_topics: int) -> List[Dict]:
|
||||
detail = match[2].strip()
|
||||
|
||||
# 清理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()
|
||||
for contrib in re.findall(r'"([^"]+)"', contributors_str)
|
||||
] or ["群友"]
|
||||
|
||||
topics.append({
|
||||
topics.append(
|
||||
{
|
||||
"topic": topic_name,
|
||||
"contributors": contributors[:5], # 最多5个参与者
|
||||
"detail": detail
|
||||
})
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"话题正则表达式提取成功,提取到 {len(topics)} 条有效话题内容")
|
||||
return topics
|
||||
@@ -203,15 +209,11 @@ def extract_user_titles_with_regex(result_text: str, max_count: int) -> List[Dic
|
||||
reason = match[4].strip()
|
||||
|
||||
# 清理转义字符
|
||||
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
||||
reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||
|
||||
titles.append({
|
||||
"name": name,
|
||||
"qq": qq,
|
||||
"title": title,
|
||||
"mbti": mbti,
|
||||
"reason": reason
|
||||
})
|
||||
titles.append(
|
||||
{"name": name, "qq": qq, "title": title, "mbti": mbti, "reason": reason}
|
||||
)
|
||||
|
||||
logger.info(f"用户称号正则表达式提取成功,提取到 {len(titles)} 条有效用户称号")
|
||||
return titles
|
||||
@@ -250,14 +252,12 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> List[D
|
||||
reason = match[2].strip()
|
||||
|
||||
# 清理转义字符
|
||||
content = content.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
||||
reason = reason.replace('\\"', '"').replace('\\n', ' ').replace('\\t', ' ')
|
||||
content = (
|
||||
content.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||
)
|
||||
reason = reason.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
|
||||
|
||||
quotes.append({
|
||||
"content": content,
|
||||
"sender": sender,
|
||||
"reason": reason
|
||||
})
|
||||
quotes.append({"content": content, "sender": sender, "reason": reason})
|
||||
|
||||
logger.info(f"金句正则表达式提取成功,提取到 {len(quotes)} 条有效金句")
|
||||
return quotes
|
||||
|
||||
@@ -9,8 +9,14 @@ from astrbot.api import logger
|
||||
import aiohttp
|
||||
|
||||
|
||||
async def call_provider_with_retry(context, config_manager, prompt: str, max_tokens: int,
|
||||
temperature: float, umo: str = None) -> Optional[Any]:
|
||||
async def call_provider_with_retry(
|
||||
context,
|
||||
config_manager,
|
||||
prompt: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
umo: str = None,
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
调用LLM提供者,带超时、重试与退避。支持自定义服务商。
|
||||
|
||||
@@ -38,42 +44,63 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
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.debug(f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}")
|
||||
logger.debug(f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}...")
|
||||
logger.info(
|
||||
f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}"
|
||||
)
|
||||
logger.debug(
|
||||
f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}"
|
||||
)
|
||||
logger.debug(
|
||||
f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||
)
|
||||
|
||||
# 检查 prompt 是否为空
|
||||
if not prompt or not prompt.strip():
|
||||
logger.error("自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求")
|
||||
logger.error(
|
||||
"自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求"
|
||||
)
|
||||
return None
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {custom_api_key}",
|
||||
"Content-Type": "application/json"
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": custom_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature
|
||||
"temperature": temperature,
|
||||
}
|
||||
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:
|
||||
error_text = await resp.text()
|
||||
logger.error(f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}")
|
||||
logger.error(
|
||||
f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}"
|
||||
)
|
||||
try:
|
||||
response_json = await resp.json()
|
||||
except Exception as json_err:
|
||||
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
|
||||
# 兼容 OpenAI 格式,安全访问嵌套字段
|
||||
content = None
|
||||
try:
|
||||
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")
|
||||
if message and isinstance(message, dict):
|
||||
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}")
|
||||
return None
|
||||
except Exception as key_err:
|
||||
logger.error(f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}")
|
||||
logger.error(
|
||||
f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 构造一个兼容原有逻辑的对象
|
||||
class CustomResponse:
|
||||
completion_text = content
|
||||
raw_completion = response_json
|
||||
|
||||
return CustomResponse()
|
||||
else:
|
||||
# 确保使用当前指定的模型
|
||||
provider = context.get_using_provider(umo=umo)
|
||||
provider_id = 'unknown'
|
||||
provider_id = "unknown"
|
||||
if provider:
|
||||
try:
|
||||
meta = provider.meta()
|
||||
@@ -99,23 +130,33 @@ async def call_provider_with_retry(context, config_manager, prompt: str, max_tok
|
||||
except Exception as e:
|
||||
logger.debug(f"获取提供商ID失败: {e}")
|
||||
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.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:
|
||||
logger.error("provider 为空,无法调用 text_chat,直接返回 None")
|
||||
return None
|
||||
|
||||
logger.debug(f"LLM provider prompt 长度: {len(prompt) if prompt else 0}")
|
||||
logger.debug(f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}...")
|
||||
logger.debug(
|
||||
f"LLM provider prompt 长度: {len(prompt) if prompt else 0}"
|
||||
)
|
||||
logger.debug(
|
||||
f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}..."
|
||||
)
|
||||
|
||||
# 检查 prompt 是否为空
|
||||
if not prompt or not prompt.strip():
|
||||
logger.error("LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat")
|
||||
logger.error(
|
||||
"LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat"
|
||||
)
|
||||
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)
|
||||
except asyncio.TimeoutError as e:
|
||||
last_exc = e
|
||||
@@ -143,30 +184,24 @@ def extract_token_usage(response) -> Optional[dict]:
|
||||
Token使用统计字典,包含prompt_tokens, completion_tokens, total_tokens
|
||||
"""
|
||||
try:
|
||||
token_usage = {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
# 安全地提取 usage,避免 response.raw_completion.usage 为 None 导致的 AttributeError
|
||||
usage = None
|
||||
if getattr(response, 'raw_completion', None) is not None:
|
||||
usage = getattr(response.raw_completion, 'usage', None)
|
||||
if getattr(response, "raw_completion", None) is not None:
|
||||
usage = getattr(response.raw_completion, "usage", None)
|
||||
if usage:
|
||||
token_usage["prompt_tokens"] = getattr(usage, 'prompt_tokens', 0) or 0
|
||||
token_usage["completion_tokens"] = getattr(usage, 'completion_tokens', 0) or 0
|
||||
token_usage["total_tokens"] = getattr(usage, 'total_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["total_tokens"] = getattr(usage, "total_tokens", 0) or 0
|
||||
|
||||
return token_usage
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"提取token使用统计失败: {e}")
|
||||
return {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
|
||||
def extract_response_text(response) -> str:
|
||||
@@ -180,7 +215,7 @@ def extract_response_text(response) -> str:
|
||||
响应文本内容
|
||||
"""
|
||||
try:
|
||||
if hasattr(response, 'completion_text'):
|
||||
if hasattr(response, "completion_text"):
|
||||
return response.completion_text
|
||||
else:
|
||||
return str(response)
|
||||
|
||||
@@ -5,7 +5,4 @@
|
||||
from .config import ConfigManager
|
||||
from .message_handler import MessageHandler
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'MessageHandler'
|
||||
]
|
||||
__all__ = ["ConfigManager", "MessageHandler"]
|
||||
|
||||
+10
-11
@@ -5,6 +5,7 @@ Bot实例管理模块
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class BotManager:
|
||||
"""Bot实例管理器 - 统一管理所有bot相关操作"""
|
||||
|
||||
@@ -29,13 +30,11 @@ class BotManager:
|
||||
if bot_qq_id:
|
||||
self._bot_qq_id = str(bot_qq_id)
|
||||
|
||||
|
||||
def set_bot_qq_id(self, bot_qq_id: str):
|
||||
"""设置bot QQ号"""
|
||||
if bot_qq_id:
|
||||
self._bot_qq_id = str(bot_qq_id)
|
||||
|
||||
|
||||
def get_bot_instance(self):
|
||||
"""获取当前bot实例"""
|
||||
return self._bot_instance
|
||||
@@ -54,16 +53,16 @@ class BotManager:
|
||||
|
||||
async def auto_discover_bot_instance(self):
|
||||
"""自动发现可用的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
|
||||
|
||||
platforms = getattr(self._context.platform_manager, 'platform_insts', [])
|
||||
platforms = getattr(self._context.platform_manager, "platform_insts", [])
|
||||
for platform in platforms:
|
||||
# 获取bot实例
|
||||
bot_client = None
|
||||
if hasattr(platform, 'get_client'):
|
||||
if hasattr(platform, "get_client"):
|
||||
bot_client = platform.get_client()
|
||||
elif hasattr(platform, 'bot'):
|
||||
elif hasattr(platform, "bot"):
|
||||
bot_client = platform.bot
|
||||
|
||||
if bot_client:
|
||||
@@ -91,12 +90,12 @@ class BotManager:
|
||||
"has_bot_instance": self.has_bot_instance(),
|
||||
"has_bot_qq_id": self.has_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):
|
||||
"""从事件更新bot实例(用于手动命令)"""
|
||||
if hasattr(event, 'bot') and event.bot:
|
||||
if hasattr(event, "bot") and event.bot:
|
||||
self.set_bot_instance(event.bot)
|
||||
# 每次都尝试从bot实例提取QQ号
|
||||
bot_qq_id = self._extract_bot_qq_id(event.bot)
|
||||
@@ -113,11 +112,11 @@ class BotManager:
|
||||
def _extract_bot_qq_id(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)
|
||||
elif hasattr(bot_instance, 'qq') and bot_instance.qq:
|
||||
elif hasattr(bot_instance, "qq") and 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 None
|
||||
|
||||
|
||||
+30
-16
@@ -4,8 +4,6 @@
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from astrbot.api import logger, AstrBotConfig
|
||||
|
||||
@@ -110,9 +108,12 @@ class ConfigManager:
|
||||
def get_custom_model_name(self) -> str:
|
||||
"""获取自定义 LLM 服务的模型名称"""
|
||||
return self.config.get("custom_model_name", "")
|
||||
|
||||
def get_pdf_output_dir(self) -> str:
|
||||
"""获取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:
|
||||
"""获取bot QQ号"""
|
||||
@@ -120,7 +121,9 @@ class ConfigManager:
|
||||
|
||||
def get_pdf_filename_format(self) -> str:
|
||||
"""获取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:
|
||||
"""
|
||||
@@ -160,7 +163,9 @@ class ConfigManager:
|
||||
# 兼容旧配置
|
||||
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:
|
||||
"""
|
||||
获取金句分析提示词模板
|
||||
|
||||
@@ -308,7 +313,7 @@ class ConfigManager:
|
||||
"""检查 pyppeteer 可用性"""
|
||||
try:
|
||||
import pyppeteer
|
||||
from pyppeteer import launch
|
||||
|
||||
self._pyppeteer_available = True
|
||||
|
||||
# 检查版本
|
||||
@@ -322,7 +327,9 @@ class ConfigManager:
|
||||
except ImportError:
|
||||
self._pyppeteer_available = False
|
||||
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:
|
||||
"""重新加载 pyppeteer 模块"""
|
||||
@@ -330,7 +337,9 @@ class ConfigManager:
|
||||
logger.info("开始重新加载 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}")
|
||||
for mod in modules_to_remove:
|
||||
del sys.modules[mod]
|
||||
@@ -338,28 +347,33 @@ class ConfigManager:
|
||||
# 强制重新导入
|
||||
try:
|
||||
import pyppeteer
|
||||
from pyppeteer import launch
|
||||
|
||||
# 更新全局变量
|
||||
self._pyppeteer_available = True
|
||||
try:
|
||||
self._pyppeteer_version = pyppeteer.__version__
|
||||
logger.info(f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}")
|
||||
logger.info(
|
||||
f"重新加载成功,pyppeteer 版本: {self._pyppeteer_version}"
|
||||
)
|
||||
except AttributeError:
|
||||
self._pyppeteer_version = "unknown"
|
||||
logger.info("重新加载成功,pyppeteer 版本未知")
|
||||
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
||||
except ImportError:
|
||||
logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info(
|
||||
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
|
||||
)
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.info(f"pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info("💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能")
|
||||
except Exception:
|
||||
logger.info("pyppeteer 重新导入需要重启 AstrBot 才能生效")
|
||||
logger.info(
|
||||
"💡 提示:pyppeteer 安装成功,但需要重启 AstrBot 后才能使用 PDF 功能"
|
||||
)
|
||||
self._pyppeteer_available = False
|
||||
self._pyppeteer_version = None
|
||||
return False
|
||||
|
||||
+84
-108
@@ -3,12 +3,11 @@
|
||||
负责群聊消息的获取、过滤和预处理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict
|
||||
from collections import defaultdict
|
||||
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
|
||||
|
||||
|
||||
@@ -35,15 +34,17 @@ class MessageHandler:
|
||||
|
||||
def _extract_bot_qq_id_from_instance(self, bot_instance):
|
||||
"""从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)
|
||||
elif hasattr(bot_instance, 'qq') and bot_instance.qq:
|
||||
elif hasattr(bot_instance, "qq") and 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 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:
|
||||
# 验证参数
|
||||
@@ -63,136 +64,83 @@ class MessageHandler:
|
||||
start_time = end_time - timedelta(days=days)
|
||||
|
||||
messages = []
|
||||
message_seq = 0
|
||||
query_rounds = 0
|
||||
max_rounds = self.config_manager.get_max_query_rounds()
|
||||
# 一次性获取,移除分页与多轮查询
|
||||
max_messages = self.config_manager.get_max_messages()
|
||||
consecutive_failures = 0
|
||||
max_failures = 3
|
||||
query_rounds = 0
|
||||
|
||||
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:
|
||||
# 构造请求参数
|
||||
payloads = {
|
||||
"group_id": int(group_id) if group_id.isdigit() else group_id,
|
||||
"count": 200,
|
||||
"count": int(max_messages),
|
||||
}
|
||||
|
||||
# 添加 message_seq(如果不是第一轮)
|
||||
if message_seq:
|
||||
payloads["message_seq"] = message_seq
|
||||
|
||||
# 尝试调用 API
|
||||
result = None
|
||||
api_error = None
|
||||
|
||||
if hasattr(bot_instance, 'call_action'):
|
||||
if hasattr(bot_instance, "call_action"):
|
||||
try:
|
||||
# aiocqhttp (CQHttp) 方式
|
||||
result = await bot_instance.call_action("get_group_msg_history", **payloads)
|
||||
result = await bot_instance.call_action(
|
||||
"get_group_msg_history", **payloads
|
||||
)
|
||||
query_rounds = 1
|
||||
except Exception as api_err:
|
||||
api_error = api_err
|
||||
logger.error(f"群 {group_id} API 调用失败: {api_err}")
|
||||
|
||||
# 第一次失败就放弃(该 API 不支持)
|
||||
if query_rounds == 0:
|
||||
logger.error(f"群 {group_id} 当前 OneBot 实现不支持 get_group_msg_history API")
|
||||
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 不支持获取历史消息")
|
||||
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")
|
||||
logger.error(f"bot_instance 类型: {type(bot_instance)}")
|
||||
logger.error(
|
||||
f"群 {group_id} 未知的 bot_instance 类型,无法调用 API,类型: {type(bot_instance)}"
|
||||
)
|
||||
return []
|
||||
|
||||
if not result or "messages" not in result:
|
||||
logger.warning(f"群 {group_id} API返回无效结果: {result}")
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= max_failures:
|
||||
break
|
||||
continue
|
||||
return []
|
||||
|
||||
round_messages = result.get("messages", [])
|
||||
|
||||
if not round_messages:
|
||||
logger.info(f"群 {group_id} 没有更多消息,结束获取")
|
||||
break
|
||||
|
||||
# 重置失败计数
|
||||
consecutive_failures = 0
|
||||
|
||||
# 过滤时间范围内的消息
|
||||
valid_messages_in_round = 0
|
||||
oldest_msg_time = None
|
||||
has_out_of_range_message = False
|
||||
logger.info(f"群 {group_id} 未获取到消息")
|
||||
|
||||
# 过滤时间范围内的消息并过滤机器人自身消息
|
||||
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
|
||||
# 继续处理本轮剩余消息,但不再查询下一轮
|
||||
if not (start_time <= msg_time <= end_time):
|
||||
continue
|
||||
|
||||
# 过滤掉机器人自己的消息
|
||||
sender_id = str(msg.get("sender", {}).get("user_id", ""))
|
||||
if self.bot_manager and self.bot_manager.should_filter_bot_message(sender_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
|
||||
|
||||
# 如果本轮有消息已经超出时间范围,立即停止获取
|
||||
if has_out_of_range_message:
|
||||
logger.info(f"群 {group_id} 已获取到时间范围外的消息(最老消息时间: {oldest_msg_time.strftime('%Y-%m-%d %H:%M:%S')}),停止获取。共获取 {len(messages)} 条消息")
|
||||
break
|
||||
|
||||
# 如果本轮没有获取到任何有效消息,停止获取
|
||||
if valid_messages_in_round == 0:
|
||||
logger.warning(f"群 {group_id} 本轮未获取到有效消息,停止获取")
|
||||
break
|
||||
|
||||
# 如果已经获取到足够的消息(达到 max_messages),停止获取
|
||||
if len(messages) >= max_messages:
|
||||
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)
|
||||
logger.error(f"群 {group_id} 获取消息失败: {e}")
|
||||
return []
|
||||
|
||||
# ========== 最终清理步骤:严格过滤和限制 ==========
|
||||
original_count = len(messages)
|
||||
|
||||
# 1. 严格过滤时间范围外的消息
|
||||
messages = [
|
||||
msg for msg in messages
|
||||
msg
|
||||
for msg in messages
|
||||
if start_time <= datetime.fromtimestamp(msg.get("time", 0)) <= end_time
|
||||
]
|
||||
time_filtered_count = len(messages)
|
||||
@@ -201,13 +149,19 @@ class MessageHandler:
|
||||
if len(messages) > max_messages:
|
||||
# 保留最新的消息(假设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):
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
@@ -238,22 +192,30 @@ class MessageHandler:
|
||||
# QQ基础表情
|
||||
emoji_statistics.face_count += 1
|
||||
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":
|
||||
# 动画表情/魔法表情
|
||||
emoji_statistics.mface_count += 1
|
||||
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":
|
||||
# 超级表情
|
||||
emoji_statistics.bface_count += 1
|
||||
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":
|
||||
# 小表情
|
||||
emoji_statistics.sface_count += 1
|
||||
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":
|
||||
# 检查是否是动画表情(通过summary字段判断)
|
||||
data = content.get("data", {})
|
||||
@@ -262,20 +224,34 @@ class MessageHandler:
|
||||
# 动画表情(以image形式发送)
|
||||
emoji_statistics.mface_count += 1
|
||||
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:
|
||||
# 普通图片,不计入表情统计
|
||||
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
|
||||
|
||||
# 找出最活跃时段
|
||||
most_active_hour = 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"
|
||||
most_active_hour = (
|
||||
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(
|
||||
message_count=len(messages),
|
||||
@@ -286,5 +262,5 @@ class MessageHandler:
|
||||
emoji_count=emoji_statistics.total_emoji_count, # 保持向后兼容
|
||||
emoji_statistics=emoji_statistics,
|
||||
activity_visualization=activity_visualization,
|
||||
token_usage=TokenUsage()
|
||||
token_usage=TokenUsage(),
|
||||
)
|
||||
@@ -7,13 +7,7 @@ from .data_models import (
|
||||
UserTitle,
|
||||
GoldenQuote,
|
||||
TokenUsage,
|
||||
GroupStatistics
|
||||
GroupStatistics,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'SummaryTopic',
|
||||
'UserTitle',
|
||||
'GoldenQuote',
|
||||
'TokenUsage',
|
||||
'GroupStatistics'
|
||||
]
|
||||
__all__ = ["SummaryTopic", "UserTitle", "GoldenQuote", "TokenUsage", "GroupStatistics"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import List
|
||||
@dataclass
|
||||
class SummaryTopic:
|
||||
"""话题总结数据结构"""
|
||||
|
||||
topic: str
|
||||
contributors: List[str]
|
||||
detail: str
|
||||
@@ -18,6 +19,7 @@ class SummaryTopic:
|
||||
@dataclass
|
||||
class UserTitle:
|
||||
"""用户称号数据结构"""
|
||||
|
||||
name: str
|
||||
qq: int
|
||||
title: str
|
||||
@@ -28,6 +30,7 @@ class UserTitle:
|
||||
@dataclass
|
||||
class GoldenQuote:
|
||||
"""群聊金句数据结构"""
|
||||
|
||||
content: str
|
||||
sender: str
|
||||
reason: str
|
||||
@@ -36,6 +39,7 @@ class GoldenQuote:
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
"""Token使用统计"""
|
||||
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
@@ -44,6 +48,7 @@ class TokenUsage:
|
||||
@dataclass
|
||||
class EmojiStatistics:
|
||||
"""表情统计数据结构"""
|
||||
|
||||
face_count: int = 0 # QQ基础表情数量
|
||||
mface_count: int = 0 # 动画表情数量
|
||||
bface_count: int = 0 # 超级表情数量
|
||||
@@ -54,12 +59,19 @@ class EmojiStatistics:
|
||||
@property
|
||||
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
|
||||
class ActivityVisualization:
|
||||
"""活跃度可视化数据结构"""
|
||||
|
||||
hourly_activity: dict = field(default_factory=dict) # {hour: count}
|
||||
daily_activity: dict = field(default_factory=dict) # {date: count}
|
||||
user_activity_ranking: list = field(default_factory=list) # 用户活跃度排行
|
||||
@@ -70,6 +82,7 @@ class ActivityVisualization:
|
||||
@dataclass
|
||||
class GroupStatistics:
|
||||
"""群聊统计数据结构"""
|
||||
|
||||
message_count: int
|
||||
total_characters: int
|
||||
participant_count: int
|
||||
@@ -77,5 +90,7 @@ class GroupStatistics:
|
||||
golden_quotes: List[GoldenQuote]
|
||||
emoji_count: int # 保持向后兼容
|
||||
emoji_statistics: EmojiStatistics = field(default_factory=EmojiStatistics)
|
||||
activity_visualization: ActivityVisualization = field(default_factory=ActivityVisualization)
|
||||
activity_visualization: ActivityVisualization = field(
|
||||
default_factory=ActivityVisualization
|
||||
)
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
@@ -6,7 +6,4 @@
|
||||
from .generators import ReportGenerator
|
||||
from .templates import HTMLTemplates
|
||||
|
||||
__all__ = [
|
||||
'ReportGenerator',
|
||||
'HTMLTemplates'
|
||||
]
|
||||
__all__ = ["ReportGenerator", "HTMLTemplates"]
|
||||
|
||||
+134
-102
@@ -21,7 +21,9 @@ class ReportGenerator:
|
||||
self.config_manager = config_manager
|
||||
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:
|
||||
# 准备渲染数据
|
||||
@@ -37,7 +39,7 @@ class ReportGenerator:
|
||||
HTMLTemplates.get_image_template(),
|
||||
render_payload,
|
||||
True, # return_url=True,返回URL而不是下载文件
|
||||
image_options
|
||||
image_options,
|
||||
)
|
||||
|
||||
logger.info(f"图片生成成功: {image_url}")
|
||||
@@ -51,13 +53,13 @@ class ReportGenerator:
|
||||
simple_options = {
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 70 # 降低质量以提高兼容性
|
||||
"quality": 70, # 降低质量以提高兼容性
|
||||
}
|
||||
image_url = await html_render_func(
|
||||
HTMLTemplates.get_image_template(),
|
||||
render_payload,
|
||||
True,
|
||||
simple_options
|
||||
simple_options,
|
||||
)
|
||||
logger.info(f"使用低质量选项生成成功: {image_url}")
|
||||
return image_url
|
||||
@@ -65,9 +67,9 @@ class ReportGenerator:
|
||||
logger.error(f"后备低质量方案也失败: {fallback_e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
async def generate_pdf_report(self, analysis_result: Dict, group_id: str) -> Optional[str]:
|
||||
async def generate_pdf_report(
|
||||
self, analysis_result: Dict, group_id: str
|
||||
) -> Optional[str]:
|
||||
"""生成PDF格式的分析报告"""
|
||||
try:
|
||||
# 确保输出目录存在
|
||||
@@ -75,10 +77,9 @@ class ReportGenerator:
|
||||
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(
|
||||
group_id=group_id,
|
||||
date=current_date
|
||||
group_id=group_id, date=current_date
|
||||
)
|
||||
pdf_path = output_dir / filename
|
||||
|
||||
@@ -87,7 +88,9 @@ class ReportGenerator:
|
||||
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
|
||||
# 生成 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)} 字符")
|
||||
|
||||
# 转换为 PDF
|
||||
@@ -110,7 +113,7 @@ class ReportGenerator:
|
||||
|
||||
report = f"""
|
||||
🎯 群聊日常分析报告
|
||||
📅 {datetime.now().strftime('%Y年%m月%d日')}
|
||||
📅 {datetime.now().strftime("%Y年%m月%d日")}
|
||||
|
||||
📊 基础统计
|
||||
• 消息总数: {stats.message_count}
|
||||
@@ -138,7 +141,7 @@ class ReportGenerator:
|
||||
report += "💬 群圣经\n"
|
||||
max_golden_quotes = self.config_manager.get_max_golden_quotes()
|
||||
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"
|
||||
|
||||
return report
|
||||
@@ -172,7 +175,11 @@ class ReportGenerator:
|
||||
for title in user_titles[:max_user_titles]:
|
||||
# 获取用户头像
|
||||
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"""
|
||||
<div class="user-title">
|
||||
@@ -209,8 +216,8 @@ class ReportGenerator:
|
||||
|
||||
# 返回扁平化的渲染数据
|
||||
return {
|
||||
"current_date": datetime.now().strftime('%Y年%m月%d日'),
|
||||
"current_datetime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"current_date": datetime.now().strftime("%Y年%m月%d日"),
|
||||
"current_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"message_count": stats.message_count,
|
||||
"participant_count": stats.participant_count,
|
||||
"total_characters": stats.total_characters,
|
||||
@@ -220,15 +227,20 @@ class ReportGenerator:
|
||||
"titles_html": titles_html,
|
||||
"quotes_html": quotes_html,
|
||||
"hourly_chart_html": hourly_chart_html,
|
||||
"total_tokens": stats.token_usage.total_tokens if stats.token_usage.total_tokens 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
|
||||
"total_tokens": stats.token_usage.total_tokens
|
||||
if stats.token_usage.total_tokens
|
||||
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模板渲染,支持两种占位符格式
|
||||
|
||||
Args:
|
||||
@@ -239,7 +251,9 @@ class ReportGenerator:
|
||||
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():
|
||||
if use_jinja_style:
|
||||
@@ -256,10 +270,11 @@ class ReportGenerator:
|
||||
|
||||
# 检查是否还有未替换的占位符
|
||||
import re
|
||||
|
||||
if use_jinja_style:
|
||||
remaining_placeholders = re.findall(r'\{\{[^}]+\}\}', result)
|
||||
remaining_placeholders = re.findall(r"\{\{[^}]+\}\}", result)
|
||||
else:
|
||||
remaining_placeholders = re.findall(r'\{[^}]+\}', result)
|
||||
remaining_placeholders = re.findall(r"\{[^}]+\}", result)
|
||||
|
||||
if remaining_placeholders:
|
||||
logger.warning(f"未替换的占位符: {remaining_placeholders[:10]}")
|
||||
@@ -275,7 +290,7 @@ class ReportGenerator:
|
||||
response.raise_for_status()
|
||||
avatar_data = await response.read()
|
||||
# 转换为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}"
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户头像失败 {user_id}: {e}")
|
||||
@@ -300,71 +315,71 @@ class ReportGenerator:
|
||||
|
||||
# 配置浏览器启动参数,解决Docker环境中的沙盒问题
|
||||
launch_options = {
|
||||
'headless': True,
|
||||
'args': [
|
||||
'--no-sandbox', # Docker环境必需 - 禁用沙盒
|
||||
'--disable-setuid-sandbox', # Docker环境必需 - 禁用setuid沙盒
|
||||
'--disable-dev-shm-usage', # 避免共享内存问题
|
||||
'--disable-gpu', # 禁用GPU加速
|
||||
'--no-first-run',
|
||||
'--disable-extensions',
|
||||
'--disable-default-apps',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-features=TranslateUI',
|
||||
'--disable-ipc-flooding-protection',
|
||||
'--disable-background-networking',
|
||||
'--enable-features=NetworkService,NetworkServiceInProcess',
|
||||
'--force-color-profile=srgb',
|
||||
'--metrics-recording-only',
|
||||
'--disable-breakpad',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync',
|
||||
'--enable-automation',
|
||||
'--password-store=basic',
|
||||
'--use-mock-keychain',
|
||||
'--export-tagged-pdf',
|
||||
'--disable-web-security',
|
||||
'--disable-features=VizDisplayCompositor',
|
||||
'--disable-blink-features=AutomationControlled', # 隐藏自动化特征
|
||||
]
|
||||
"headless": True,
|
||||
"args": [
|
||||
"--no-sandbox", # Docker环境必需 - 禁用沙盒
|
||||
"--disable-setuid-sandbox", # Docker环境必需 - 禁用setuid沙盒
|
||||
"--disable-dev-shm-usage", # 避免共享内存问题
|
||||
"--disable-gpu", # 禁用GPU加速
|
||||
"--no-first-run",
|
||||
"--disable-extensions",
|
||||
"--disable-default-apps",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=TranslateUI",
|
||||
"--disable-ipc-flooding-protection",
|
||||
"--disable-background-networking",
|
||||
"--enable-features=NetworkService,NetworkServiceInProcess",
|
||||
"--force-color-profile=srgb",
|
||||
"--metrics-recording-only",
|
||||
"--disable-breakpad",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync",
|
||||
"--enable-automation",
|
||||
"--password-store=basic",
|
||||
"--use-mock-keychain",
|
||||
"--export-tagged-pdf",
|
||||
"--disable-web-security",
|
||||
"--disable-features=VizDisplayCompositor",
|
||||
"--disable-blink-features=AutomationControlled", # 隐藏自动化特征
|
||||
],
|
||||
}
|
||||
|
||||
# 检测系统 Chrome/Chromium 路径
|
||||
chrome_paths = []
|
||||
|
||||
if sys.platform.startswith('win'):
|
||||
if sys.platform.startswith("win"):
|
||||
# Windows 系统 Chrome 安装路径
|
||||
username = os.environ.get('USERNAME', '')
|
||||
username = os.environ.get("USERNAME", "")
|
||||
chrome_paths = [
|
||||
r"C:\Program Files\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",
|
||||
r"C:\Program Files\Chromium\Application\chrome.exe",
|
||||
]
|
||||
elif sys.platform.startswith('linux'):
|
||||
elif sys.platform.startswith("linux"):
|
||||
# Linux 系统 Chrome/Chromium 路径
|
||||
chrome_paths = [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/chromium-freeworld',
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium-freeworld",
|
||||
]
|
||||
elif sys.platform.startswith('darwin'):
|
||||
elif sys.platform.startswith("darwin"):
|
||||
# macOS 系统 Chrome 路径
|
||||
chrome_paths = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
]
|
||||
|
||||
# 查找可用的浏览器
|
||||
found_browser = False
|
||||
for chrome_path in chrome_paths:
|
||||
if Path(chrome_path).exists():
|
||||
launch_options['executablePath'] = chrome_path
|
||||
launch_options["executablePath"] = chrome_path
|
||||
logger.info(f"使用系统浏览器: {chrome_path}")
|
||||
found_browser = True
|
||||
break
|
||||
@@ -373,18 +388,25 @@ class ReportGenerator:
|
||||
logger.info("未找到系统浏览器,将使用 pyppeteer 默认下载的 Chromium")
|
||||
# 先尝试确保 Chromium 已下载
|
||||
try:
|
||||
from pyppeteer import connection, browser, launcher
|
||||
from pyppeteer import browser, launcher
|
||||
|
||||
launcher_instance = launcher.Launcher(
|
||||
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._download_chromium()
|
||||
chromium_path = pyppeteer.executablePath()
|
||||
launch_options['executablePath'] = chromium_path
|
||||
launch_options["executablePath"] = chromium_path
|
||||
logger.info(f"使用 pyppeteer 下载的 Chromium: {chromium_path}")
|
||||
except Exception as pre_download_err:
|
||||
logger.warning(f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}")
|
||||
logger.warning(
|
||||
f"预下载 Chromium 失败,继续尝试直接启动: {pre_download_err}"
|
||||
)
|
||||
|
||||
# 尝试启动浏览器
|
||||
try:
|
||||
@@ -400,22 +422,26 @@ class ReportGenerator:
|
||||
page = await browser.newPage()
|
||||
|
||||
# 设置页面视口,减少内存占用
|
||||
await page.setViewport({
|
||||
'width': 1024,
|
||||
'height': 768,
|
||||
'deviceScaleFactor': 1,
|
||||
'isMobile': False,
|
||||
'hasTouch': False,
|
||||
'isLandscape': False
|
||||
})
|
||||
await page.setViewport(
|
||||
{
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"deviceScaleFactor": 1,
|
||||
"isMobile": False,
|
||||
"hasTouch": False,
|
||||
"isLandscape": False,
|
||||
}
|
||||
)
|
||||
|
||||
# 设置页面内容,使用更安全的加载方式
|
||||
logger.info("开始设置页面内容...")
|
||||
await page.setContent(html_content, {'waitUntil': 'domcontentloaded', 'timeout': 30000})
|
||||
await page.setContent(
|
||||
html_content, {"waitUntil": "domcontentloaded", "timeout": 30000}
|
||||
)
|
||||
|
||||
# 等待页面基本加载完成,但不要太长时间
|
||||
try:
|
||||
await page.waitForSelector('body', {'timeout': 5000})
|
||||
await page.waitForSelector("body", {"timeout": 5000})
|
||||
logger.info("页面基本加载完成")
|
||||
except Exception:
|
||||
logger.warning("等待页面加载超时,继续执行")
|
||||
@@ -426,19 +452,19 @@ class ReportGenerator:
|
||||
# 导出 PDF,使用更保守的设置
|
||||
logger.info("开始生成PDF...")
|
||||
pdf_options = {
|
||||
'path': output_path,
|
||||
'format': 'A4',
|
||||
'printBackground': True,
|
||||
'margin': {
|
||||
'top': '10mm',
|
||||
'right': '10mm',
|
||||
'bottom': '10mm',
|
||||
'left': '10mm'
|
||||
"path": output_path,
|
||||
"format": "A4",
|
||||
"printBackground": True,
|
||||
"margin": {
|
||||
"top": "10mm",
|
||||
"right": "10mm",
|
||||
"bottom": "10mm",
|
||||
"left": "10mm",
|
||||
},
|
||||
'scale': 0.8,
|
||||
'displayHeaderFooter': False,
|
||||
'preferCSSPageSize': True,
|
||||
'timeout': 60000 # 增加PDF生成超时时间到60秒
|
||||
"scale": 0.8,
|
||||
"displayHeaderFooter": False,
|
||||
"preferCSSPageSize": True,
|
||||
"timeout": 60000, # 增加PDF生成超时时间到60秒
|
||||
}
|
||||
|
||||
await page.pdf(pdf_options)
|
||||
@@ -459,8 +485,8 @@ class ReportGenerator:
|
||||
for page in pages:
|
||||
try:
|
||||
await page.close()
|
||||
except:
|
||||
pass
|
||||
except Exception as close_err:
|
||||
logger.debug(f"关闭页面时忽略的异常: {close_err}")
|
||||
|
||||
# 等待一小段时间让资源释放
|
||||
await asyncio.sleep(0.5)
|
||||
@@ -473,21 +499,25 @@ class ReportGenerator:
|
||||
# 强制清理
|
||||
try:
|
||||
await browser.disconnect()
|
||||
except:
|
||||
pass
|
||||
except Exception as disc_err:
|
||||
logger.debug(f"断开浏览器连接时忽略的异常: {disc_err}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "Chromium downloadable not found" in error_msg:
|
||||
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:
|
||||
logger.error("沙盒权限问题,已尝试禁用沙盒")
|
||||
elif "Connection refused" in error_msg or "connect" in error_msg.lower():
|
||||
logger.error("浏览器连接失败,请检查系统资源或尝试重启")
|
||||
elif "executablePath" in error_msg and "not found" in error_msg:
|
||||
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:
|
||||
logger.error("浏览器意外关闭,可能是由于内存不足或系统资源限制")
|
||||
logger.info("💡 建议: 检查系统内存,或重启 AstrBot 后重试")
|
||||
@@ -497,5 +527,7 @@ class ReportGenerator:
|
||||
logger.info(" 3. 考虑使用其他 PDF 生成方案")
|
||||
else:
|
||||
logger.error(f"HTML 转 PDF 失败: {e}")
|
||||
logger.info("💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息")
|
||||
logger.info(
|
||||
"💡 可以尝试使用 /安装PDF 命令重新安装依赖,或检查系统日志获取更多信息"
|
||||
)
|
||||
return False
|
||||
@@ -5,6 +5,4 @@
|
||||
|
||||
from .auto_scheduler import AutoScheduler
|
||||
|
||||
__all__ = [
|
||||
'AutoScheduler'
|
||||
]
|
||||
__all__ = ["AutoScheduler"]
|
||||
|
||||
+126
-61
@@ -5,14 +5,21 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
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.message_handler = message_handler
|
||||
self.analyzer = analyzer
|
||||
@@ -33,16 +40,20 @@ class AutoScheduler:
|
||||
def _get_platform_id(self):
|
||||
"""获取平台ID"""
|
||||
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
|
||||
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
|
||||
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
|
||||
return platform_id
|
||||
return "aiocqhttp" # 默认值
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return "aiocqhttp" # 默认值
|
||||
|
||||
async def start_scheduler(self):
|
||||
@@ -54,7 +65,9 @@ class AutoScheduler:
|
||||
# 延迟启动,给系统时间初始化
|
||||
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())
|
||||
|
||||
@@ -75,9 +88,9 @@ class AutoScheduler:
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now()
|
||||
target_time = datetime.strptime(self.config_manager.get_auto_analysis_time(), "%H:%M").replace(
|
||||
year=now.year, month=now.month, day=now.day
|
||||
)
|
||||
target_time = datetime.strptime(
|
||||
self.config_manager.get_auto_analysis_time(), "%H:%M"
|
||||
).replace(year=now.year, month=now.month, day=now.day)
|
||||
|
||||
# 如果今天的目标时间已过,设置为明天
|
||||
if now >= target_time:
|
||||
@@ -85,7 +98,9 @@ class AutoScheduler:
|
||||
|
||||
# 计算等待时间
|
||||
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)
|
||||
@@ -94,7 +109,9 @@ class AutoScheduler:
|
||||
if self.config_manager.get_enable_auto_analysis():
|
||||
# 检查今天是否已经执行过,防止重复执行
|
||||
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小时后再检查
|
||||
continue
|
||||
@@ -102,7 +119,9 @@ class AutoScheduler:
|
||||
logger.info("开始执行定时分析")
|
||||
await self._run_auto_analysis()
|
||||
self.last_execution_date = target_time.date() # 记录执行日期
|
||||
logger.info(f"定时分析执行完成,记录执行日期: {self.last_execution_date}")
|
||||
logger.info(
|
||||
f"定时分析执行完成,记录执行日期: {self.last_execution_date}"
|
||||
)
|
||||
else:
|
||||
logger.info("自动分析已禁用,跳过执行")
|
||||
break
|
||||
@@ -122,14 +141,16 @@ class AutoScheduler:
|
||||
logger.info("没有启用的群聊需要分析")
|
||||
return
|
||||
|
||||
logger.info(f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}")
|
||||
logger.info(
|
||||
f"将为 {len(enabled_groups)} 个群聊并发执行分析: {enabled_groups}"
|
||||
)
|
||||
|
||||
# 创建并发任务 - 为每个群聊创建独立的分析任务
|
||||
analysis_tasks = []
|
||||
for group_id in enabled_groups:
|
||||
task = asyncio.create_task(
|
||||
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)
|
||||
|
||||
@@ -148,7 +169,9 @@ class AutoScheduler:
|
||||
else:
|
||||
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:
|
||||
logger.error(f"自动分析执行失败: {e}", exc_info=True)
|
||||
@@ -157,7 +180,9 @@ class AutoScheduler:
|
||||
"""为指定群执行自动分析(带超时控制)"""
|
||||
try:
|
||||
# 为每个群聊设置独立的超时时间(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:
|
||||
logger.error(f"群 {group_id} 分析超时(20分钟),跳过该群分析")
|
||||
except Exception as e:
|
||||
@@ -167,7 +192,7 @@ class AutoScheduler:
|
||||
"""为指定群执行自动分析(核心逻辑)"""
|
||||
# 为每个群聊使用独立的锁,避免全局锁导致串行化
|
||||
group_lock_key = f"analysis_{group_id}"
|
||||
if not hasattr(self, '_group_locks'):
|
||||
if not hasattr(self, "_group_locks"):
|
||||
self._group_locks = {}
|
||||
|
||||
if group_lock_key not in self._group_locks:
|
||||
@@ -180,7 +205,9 @@ class AutoScheduler:
|
||||
# 检查bot管理器状态
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
status = self.bot_manager.get_status_info()
|
||||
logger.warning(f"群 {group_id} 自动分析跳过:bot管理器未就绪 - {status}")
|
||||
logger.warning(
|
||||
f"群 {group_id} 自动分析跳过:bot管理器未就绪 - {status}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"开始为群 {group_id} 执行自动分析(并发任务)")
|
||||
@@ -189,7 +216,9 @@ class AutoScheduler:
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
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:
|
||||
logger.warning(f"群 {group_id} 未获取到足够的消息记录")
|
||||
@@ -198,7 +227,9 @@ class AutoScheduler:
|
||||
# 检查消息数量
|
||||
min_threshold = self.config_manager.get_min_messages_threshold()
|
||||
if len(messages) < min_threshold:
|
||||
logger.warning(f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析")
|
||||
logger.warning(
|
||||
f"群 {group_id} 消息数量不足({len(messages)}条),跳过分析"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"群 {group_id} 获取到 {len(messages)} 条消息,开始分析")
|
||||
@@ -206,7 +237,9 @@ class AutoScheduler:
|
||||
# 进行分析 - 构造正确的 unified_msg_origin
|
||||
platform_id = self._get_platform_id()
|
||||
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:
|
||||
logger.error(f"群 {group_id} 分析失败")
|
||||
return
|
||||
@@ -224,7 +257,7 @@ class AutoScheduler:
|
||||
|
||||
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]
|
||||
for lock_key in old_locks:
|
||||
if not self._group_locks[lock_key].locked():
|
||||
@@ -240,47 +273,87 @@ class AutoScheduler:
|
||||
# 使用图片格式
|
||||
logger.info(f"群 {group_id} 自动分析使用图片报告格式")
|
||||
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:
|
||||
await self._send_image_message(group_id, image_url)
|
||||
logger.info(f"群 {group_id} 图片报告发送成功")
|
||||
else:
|
||||
# 图片生成失败,回退到文本
|
||||
logger.warning(f"群 {group_id} 图片报告生成失败(返回None),回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
logger.warning(
|
||||
f"群 {group_id} 图片报告生成失败(返回None),回退到文本报告"
|
||||
)
|
||||
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:
|
||||
logger.error(f"群 {group_id} 图片报告生成异常: {img_e},回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
logger.error(
|
||||
f"群 {group_id} 图片报告生成异常: {img_e},回退到文本报告"
|
||||
)
|
||||
text_report = self.report_generator.generate_text_report(
|
||||
analysis_result
|
||||
)
|
||||
await self._send_text_message(
|
||||
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||
)
|
||||
else:
|
||||
# 没有html_render函数,回退到文本报告
|
||||
logger.warning(f"群 {group_id} 缺少html_render函数,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
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}"
|
||||
)
|
||||
|
||||
elif output_format == "pdf":
|
||||
if not self.config_manager.pyppeteer_available:
|
||||
logger.warning(f"群 {group_id} PDF功能不可用,回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
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:
|
||||
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:
|
||||
await self._send_pdf_file(group_id, pdf_path)
|
||||
logger.info(f"群 {group_id} 自动分析完成,已发送PDF报告")
|
||||
else:
|
||||
logger.error(f"群 {group_id} PDF报告生成失败(返回None),回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
logger.error(
|
||||
f"群 {group_id} PDF报告生成失败(返回None),回退到文本报告"
|
||||
)
|
||||
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:
|
||||
logger.error(f"群 {group_id} PDF报告生成异常: {pdf_e},回退到文本报告")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await self._send_text_message(group_id, f"📊 每日群聊分析报告:\n\n{text_report}")
|
||||
logger.error(
|
||||
f"群 {group_id} PDF报告生成异常: {pdf_e},回退到文本报告"
|
||||
)
|
||||
text_report = self.report_generator.generate_text_report(
|
||||
analysis_result
|
||||
)
|
||||
await self._send_text_message(
|
||||
group_id, f"📊 每日群聊分析报告:\n\n{text_report}"
|
||||
)
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
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}"
|
||||
)
|
||||
|
||||
logger.info(f"群 {group_id} 自动分析完成,已发送报告")
|
||||
|
||||
@@ -299,13 +372,10 @@ class AutoScheduler:
|
||||
await bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=[{
|
||||
"type": "text",
|
||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
||||
}, {
|
||||
"type": "image",
|
||||
"data": {"url": image_url}
|
||||
}]
|
||||
message=[
|
||||
{"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
|
||||
{"type": "image", "data": {"url": image_url}},
|
||||
],
|
||||
)
|
||||
logger.info(f"群 {group_id} 图片消息发送成功")
|
||||
|
||||
@@ -322,9 +392,7 @@ class AutoScheduler:
|
||||
|
||||
# 发送文本消息到群
|
||||
await bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=text_content
|
||||
"send_group_msg", group_id=group_id, message=text_content
|
||||
)
|
||||
logger.info(f"群 {group_id} 文本消息发送成功")
|
||||
|
||||
@@ -343,13 +411,10 @@ class AutoScheduler:
|
||||
await bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=[{
|
||||
"type": "text",
|
||||
"data": {"text": "📊 每日群聊分析报告已生成:"}
|
||||
}, {
|
||||
"type": "file",
|
||||
"data": {"file": pdf_path}
|
||||
}]
|
||||
message=[
|
||||
{"type": "text", "data": {"text": "📊 每日群聊分析报告已生成:"}},
|
||||
{"type": "file", "data": {"file": pdf_path}},
|
||||
],
|
||||
)
|
||||
logger.info(f"群 {group_id} PDF文件发送成功")
|
||||
|
||||
@@ -360,7 +425,7 @@ class AutoScheduler:
|
||||
await bot_instance.api.call_action(
|
||||
"send_group_msg",
|
||||
group_id=group_id,
|
||||
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}"
|
||||
message=f"📊 每日群聊分析报告已生成,但发送PDF文件失败。PDF文件路径:{pdf_path}",
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error(f"发送PDF失败提示到群 {group_id} 也失败: {e2}")
|
||||
@@ -6,7 +6,4 @@
|
||||
from .pdf_utils import PDFInstaller
|
||||
from .helpers import MessageAnalyzer
|
||||
|
||||
__all__ = [
|
||||
'PDFInstaller',
|
||||
'MessageAnalyzer'
|
||||
]
|
||||
__all__ = ["PDFInstaller", "MessageAnalyzer"]
|
||||
|
||||
+47
-14
@@ -4,12 +4,13 @@
|
||||
"""
|
||||
|
||||
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.analysis.llm_analyzer import LLMAnalyzer
|
||||
from ...src.analysis.statistics import UserAnalyzer
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class MessageAnalyzer:
|
||||
"""消息分析器 - 整合所有分析功能"""
|
||||
|
||||
@@ -28,7 +29,9 @@ class MessageAnalyzer:
|
||||
else:
|
||||
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:
|
||||
# 基础统计
|
||||
@@ -39,8 +42,12 @@ class MessageAnalyzer:
|
||||
|
||||
# 获取活跃用户列表 - 使用get_top_users方法,limit从配置中读取
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
top_users = self.user_analyzer.get_top_users(user_analysis, limit=max_user_titles)
|
||||
logger.info(f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})")
|
||||
top_users = self.user_analyzer.get_top_users(
|
||||
user_analysis, limit=max_user_titles
|
||||
)
|
||||
logger.info(
|
||||
f"获取到 {len(top_users)} 个活跃用户用于称号分析(配置上限: {max_user_titles})"
|
||||
)
|
||||
|
||||
# LLM分析 - 使用并发方式
|
||||
topics = []
|
||||
@@ -51,35 +58,61 @@ class MessageAnalyzer:
|
||||
# 检查各个分析功能是否启用
|
||||
topic_enabled = self.config_manager.get_topic_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:
|
||||
# 并发执行所有三个分析任务,传入活跃用户列表
|
||||
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
|
||||
)
|
||||
else:
|
||||
# 如果只启用部分分析,则按需执行
|
||||
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.completion_tokens += topic_tokens.completion_tokens
|
||||
total_token_usage.completion_tokens += (
|
||||
topic_tokens.completion_tokens
|
||||
)
|
||||
total_token_usage.total_tokens += topic_tokens.total_tokens
|
||||
|
||||
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.completion_tokens += title_tokens.completion_tokens
|
||||
total_token_usage.completion_tokens += (
|
||||
title_tokens.completion_tokens
|
||||
)
|
||||
total_token_usage.total_tokens += title_tokens.total_tokens
|
||||
|
||||
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.completion_tokens += quote_tokens.completion_tokens
|
||||
total_token_usage.completion_tokens += (
|
||||
quote_tokens.completion_tokens
|
||||
)
|
||||
total_token_usage.total_tokens += quote_tokens.total_tokens
|
||||
|
||||
# 更新统计数据
|
||||
@@ -90,7 +123,7 @@ class MessageAnalyzer:
|
||||
"statistics": statistics,
|
||||
"topics": topics,
|
||||
"user_titles": user_titles,
|
||||
"user_analysis": user_analysis
|
||||
"user_analysis": user_analysis,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+52
-34
@@ -5,7 +5,6 @@ PDF工具模块
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from astrbot.api import logger
|
||||
|
||||
@@ -14,12 +13,14 @@ class PDFInstaller:
|
||||
"""PDF功能安装器"""
|
||||
|
||||
# 类级别的线程池,用于异步下载任务
|
||||
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="chromium_download")
|
||||
_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="chromium_download"
|
||||
)
|
||||
_download_status = {
|
||||
"in_progress": False,
|
||||
"completed": False,
|
||||
"failed": False,
|
||||
"error_message": None
|
||||
"error_message": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -31,10 +32,14 @@ class PDFInstaller:
|
||||
# 使用asyncio安装pyppeteer和兼容的websockets版本
|
||||
logger.info("安装 pyppeteer==1.0.2 和兼容的依赖...")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"pyppeteer==1.0.2", "websockets==10.4",
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"pyppeteer==1.0.2",
|
||||
"websockets==10.4",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
@@ -104,7 +109,7 @@ class PDFInstaller:
|
||||
# 使用 asyncio.wait_for 实现超时控制
|
||||
success = await asyncio.wait_for(
|
||||
PDFInstaller._download_chromium_via_pyppeteer(),
|
||||
timeout=timeout_seconds
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -113,12 +118,16 @@ class PDFInstaller:
|
||||
logger.info("✅ Chromium 后台下载完成!")
|
||||
else:
|
||||
PDFInstaller._download_status["failed"] = True
|
||||
PDFInstaller._download_status["error_message"] = "下载失败,请检查网络连接"
|
||||
PDFInstaller._download_status["error_message"] = (
|
||||
"下载失败,请检查网络连接"
|
||||
)
|
||||
logger.error("❌ Chromium 下载失败")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
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}秒)")
|
||||
|
||||
except Exception as e:
|
||||
@@ -137,7 +146,9 @@ class PDFInstaller:
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
if retry_count > 0:
|
||||
logger.info(f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)...")
|
||||
logger.info(
|
||||
f"正在重试下载 Chromium(第 {retry_count}/{max_retries} 次)..."
|
||||
)
|
||||
else:
|
||||
logger.info("通过 pyppeteer 自动下载 Chromium...")
|
||||
|
||||
@@ -155,11 +166,11 @@ class PDFInstaller:
|
||||
# 创建 Launcher 实例但不启动浏览器
|
||||
launcher = Launcher(
|
||||
headless=True,
|
||||
args=['--no-sandbox', '--disable-setuid-sandbox']
|
||||
args=["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
)
|
||||
|
||||
# 只下载 Chromium
|
||||
chromium_revision = launcher._get_chromium_revision()
|
||||
launcher._get_chromium_revision()
|
||||
await launcher._download_chromium()
|
||||
|
||||
logger.info("✅ Chromium 下载完成")
|
||||
@@ -171,28 +182,29 @@ class PDFInstaller:
|
||||
|
||||
# 方法2: 通过启动浏览器触发自动下载
|
||||
import platform
|
||||
|
||||
system = platform.system().lower()
|
||||
|
||||
if system == "linux":
|
||||
browser_args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-features=TranslateUI',
|
||||
'--disable-ipc-flooding-protection'
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-accelerated-2d-canvas",
|
||||
"--no-first-run",
|
||||
"--no-zygote",
|
||||
"--disable-gpu",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-features=TranslateUI",
|
||||
"--disable-ipc-flooding-protection",
|
||||
]
|
||||
else:
|
||||
browser_args = [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-gpu'
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-gpu",
|
||||
]
|
||||
|
||||
logger.info("启动 pyppeteer 浏览器以触发 Chromium 自动下载...")
|
||||
@@ -200,7 +212,7 @@ class PDFInstaller:
|
||||
headless=True,
|
||||
args=browser_args,
|
||||
ignoreHTTPSErrors=True,
|
||||
dumpio=False # 关闭浏览器日志输出以减少干扰
|
||||
dumpio=False, # 关闭浏览器日志输出以减少干扰
|
||||
)
|
||||
|
||||
# 获取 Chromium 路径
|
||||
@@ -218,11 +230,13 @@ class PDFInstaller:
|
||||
logger.info("尝试使用命令行触发 Chromium 自动下载...")
|
||||
|
||||
import platform
|
||||
|
||||
system = platform.system().lower()
|
||||
|
||||
if system == "linux":
|
||||
cmd = [
|
||||
sys.executable, "-c",
|
||||
sys.executable,
|
||||
"-c",
|
||||
"""
|
||||
import pyppeteer
|
||||
import asyncio
|
||||
@@ -246,18 +260,19 @@ async def download_chrome():
|
||||
raise
|
||||
|
||||
asyncio.run(download_chrome())
|
||||
"""
|
||||
""",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
sys.executable, "-c",
|
||||
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())"
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import pyppeteer; import asyncio; asyncio.run(pyppeteer.launch())",
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
@@ -280,7 +295,10 @@ asyncio.run(download_chrome())
|
||||
logger.warning(f"下载失败,{wait_time}秒后重试... 错误: {e}")
|
||||
await asyncio.sleep(wait_time)
|
||||
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
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
|
||||
from .activity_charts import ActivityVisualizer
|
||||
|
||||
__all__ = ['ActivityVisualizer']
|
||||
__all__ = ["ActivityVisualizer"]
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
参考 astrbot_plugin_github_analyzer 的实现方式
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List
|
||||
from ..models.data_models import ActivityVisualization
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ class ActivityVisualizer:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def generate_activity_visualization(self, messages: List[Dict]) -> ActivityVisualization:
|
||||
def generate_activity_visualization(
|
||||
self, messages: List[Dict]
|
||||
) -> ActivityVisualization:
|
||||
"""生成活跃度可视化数据 - 专注于小时级别分析"""
|
||||
hourly_activity = defaultdict(int)
|
||||
user_activity = defaultdict(int)
|
||||
@@ -54,15 +56,19 @@ class ActivityVisualizer:
|
||||
# 生成用户活跃度排行
|
||||
user_ranking = []
|
||||
for user_id, data in user_activity.items():
|
||||
user_ranking.append({
|
||||
user_ranking.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"nickname": data["nickname"],
|
||||
"message_count": data["count"]
|
||||
})
|
||||
"message_count": data["count"],
|
||||
}
|
||||
)
|
||||
user_ranking.sort(key=lambda x: x["message_count"], reverse=True)
|
||||
|
||||
# 找出高峰时段(活跃度最高的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]
|
||||
|
||||
return ActivityVisualization(
|
||||
@@ -70,10 +76,14 @@ class ActivityVisualizer:
|
||||
daily_activity={}, # 不使用日期分析
|
||||
user_activity_ranking=user_ranking[:10], # 前10名
|
||||
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
|
||||
@@ -90,7 +100,7 @@ class ActivityVisualizer:
|
||||
hour: (emoji_activity.get(hour, 0) / max_emoji) * 100
|
||||
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:
|
||||
@@ -161,5 +171,3 @@ class ActivityVisualizer:
|
||||
html_parts.append(html_segment)
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user