mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 20:01:04 +00:00
feat: 实现群聊分析任务锁及全局资源并发限流控制
1. 任务锁定机制 (Group Task Locking): - 在应用服务层实现基于群组和任务类型的异步锁,使用 asynccontextmanager 生成器函数确保同一群聊在同一时间只能执行一个分析任务。 - 使用弱引用字典 (WeakValueDictionary) 管理锁实例,防止长期的内存占用。 - 在指令入口增加对 CancelledError 的捕获,当任务冲突时向用户反馈“任务正在执行中”的友好提示。 2. 全局并发限流重构 (Resource-level Throttling): - 资源层限流下沉:将并发控制中心从调度层迁移至具体的资源处理层(T2I/LLM)。 - T2I 渲染保护:在报告生成器中引入全局信号量,严格控制 T2I 引擎的瞬时负载。 - LLM API 保护:在应用服务层引入全局信号量,平滑所有分析请求对 LLM 提供商接口的请求频率。 - 调度逻辑简化:移除调度器内部冗余的信号量,使其仅专注于分发逻辑与 Stagger(交错启动)平滑。 3. 调度与反馈优化: - 自动分析调度器增加对“跳过”任务的独立统计,并在执行报告中清晰展示“成功、跳过、失败”的具体数量。 - 优化了管理测试指令和增量分析指令的异常处理逻辑。 4. 消息重复发送防护: - 在重试组件中增加基于消息历史记录的探活逻辑。在重试发送前先核实记录,确认是否已因网络抖动导致“假失败”但已实达,有效拦截重复消息。
This commit is contained in:
@@ -7,7 +7,9 @@
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import time as time_mod
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from ...domain.entities.incremental_state import IncrementalBatch
|
||||
@@ -46,6 +48,37 @@ class AnalysisApplicationService:
|
||||
self.analysis_domain_service = analysis_domain_service
|
||||
self.incremental_store = incremental_store
|
||||
self.incremental_merge_service = incremental_merge_service
|
||||
self._locks = weakref.WeakValueDictionary()
|
||||
# 全局 LLM 分析信号量,控制对外 API 的并发压力
|
||||
max_concurrent = self.config_manager.get_max_concurrent_tasks()
|
||||
self.llm_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
@asynccontextmanager
|
||||
async def group_lock(self, group_id: str, task_type: str = "analysis"):
|
||||
"""
|
||||
同一时间、同一个群、同一种任务只能有一个在执行
|
||||
锁将在退出上下文时自动释放。
|
||||
"""
|
||||
lock_key = f"{task_type}:{group_id}"
|
||||
|
||||
# 获取或创建该群专属的异步锁
|
||||
lock = self._locks.get(lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[lock_key] = lock
|
||||
|
||||
# 检查是否已经锁定(防止并发)
|
||||
if lock.locked():
|
||||
logger.warning(f"群 {group_id} 的 {task_type} 任务已在运行,跳过本次请求")
|
||||
# 这里抛出异常以便上层识别并优雅跳过
|
||||
raise asyncio.CancelledError(f"Duplicate task for {lock_key}")
|
||||
|
||||
async with lock:
|
||||
logger.debug(f"[Lock] 已获取群 {group_id} 的 {task_type} 排他锁")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
logger.debug(f"[Lock] 已释放群 {group_id} 的 {task_type} 排他锁")
|
||||
|
||||
async def execute_daily_analysis(
|
||||
self, group_id: str, platform_id: str | None = None, manual: bool = False
|
||||
@@ -63,121 +96,129 @@ class AnalysisApplicationService:
|
||||
7. 持久化摘要 (Persistence)
|
||||
8. 返回结果
|
||||
"""
|
||||
logger.info(f"开始执行分析用例: 群 {group_id}, 平台 {platform_id or '默认'}")
|
||||
|
||||
# 1. 获取适配器
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
# 2. 拉取消息
|
||||
days = self.config_manager.get_analysis_days()
|
||||
max_count = self.config_manager.get_max_messages()
|
||||
|
||||
raw_messages = await adapter.fetch_messages(
|
||||
group_id=group_id, days=days, max_count=max_count
|
||||
)
|
||||
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取")
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
|
||||
# 3. 清理消息 (Filter commands, bot messages, noise)
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
|
||||
cleaner = MessageCleanerService()
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
|
||||
# 对于自动任务,强制过滤指令;对于手动任务,也建议过滤以保持报告纯净
|
||||
unified_messages = cleaner.clean_messages(
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
|
||||
# 4. 检查最小消息阈值 (在清理后进行)
|
||||
threshold = self.config_manager.get_min_messages_threshold()
|
||||
if len(unified_messages) < threshold and not manual:
|
||||
async with self.group_lock(group_id, "daily"):
|
||||
logger.info(
|
||||
f"群 {group_id} 有效消息数 ({len(unified_messages)}) 未达到自动分析阈值 ({threshold})"
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 5. 基础统计 (Domain Service)
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
|
||||
# 4. 用户分析 (Domain Service)
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
top_users = self.analysis_domain_service.get_top_users(
|
||||
user_activity, limit=max_user_titles
|
||||
)
|
||||
|
||||
# 5. LLM 语义分析 (为了保持兼容,目前直接传 UnifiedMessage,后续如需传 raw dict 再加转换)
|
||||
# LLMAnalyzer 内部可能已经处理了转换(见之前代码)
|
||||
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()
|
||||
|
||||
topics = []
|
||||
user_titles = []
|
||||
golden_quotes = []
|
||||
total_token_usage = TokenUsage()
|
||||
|
||||
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配
|
||||
# 暂时转换回 legacy 格式以确保稳定性,直到 LLMAnalyzer 被重构
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
|
||||
)
|
||||
|
||||
if topic_enabled or user_title_enabled or golden_quote_enabled:
|
||||
(
|
||||
topics,
|
||||
user_titles,
|
||||
golden_quotes,
|
||||
total_token_usage,
|
||||
) = await self.llm_analyzer.analyze_all_concurrent(
|
||||
legacy_messages,
|
||||
user_activity,
|
||||
umo=unified_msg_origin,
|
||||
top_users=top_users,
|
||||
topic_enabled=topic_enabled,
|
||||
user_title_enabled=user_title_enabled,
|
||||
golden_quote_enabled=golden_quote_enabled,
|
||||
f"开始执行分析用例: 群 {group_id}, platform_id={platform_id or '默认'}"
|
||||
)
|
||||
|
||||
# 回填结果
|
||||
statistics.golden_quotes = golden_quotes
|
||||
statistics.token_usage = total_token_usage
|
||||
# 1. 获取适配器
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": topics,
|
||||
"user_titles": user_titles,
|
||||
"user_analysis": user_activity,
|
||||
}
|
||||
# 2. 拉取消息
|
||||
days = self.config_manager.get_analysis_days()
|
||||
max_count = self.config_manager.get_max_messages()
|
||||
|
||||
# 6. 持久化摘要 (Persistence)
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
raw_messages = await adapter.fetch_messages(
|
||||
group_id=group_id, days=days, max_count=max_count
|
||||
)
|
||||
|
||||
# 7. 生成报告并发送 (应用层编排发送动作)
|
||||
# 这里由调用方处理发送,本服务只返回分析结果和可能的视觉产物
|
||||
return {
|
||||
"success": True,
|
||||
"analysis_result": analysis_result,
|
||||
"messages_count": len(unified_messages),
|
||||
"adapter": adapter,
|
||||
}
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 在最近 {days} 天内无消息或无法获取")
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
|
||||
# 3. 清理消息 (Filter commands, bot messages, noise)
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
|
||||
cleaner = MessageCleanerService()
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
|
||||
# 对于自动任务,强制过滤指令;对于手动任务,也建议过滤以保持报告纯净
|
||||
unified_messages = cleaner.clean_messages(
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
|
||||
# 4. 检查最小消息阈值 (在清理后进行)
|
||||
threshold = self.config_manager.get_min_messages_threshold()
|
||||
if len(unified_messages) < threshold and not manual:
|
||||
logger.info(
|
||||
f"群 {group_id} 有效消息数 ({len(unified_messages)}) 未达到自动分析阈值 ({threshold})"
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 5. 基础统计 (Domain Service)
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
|
||||
# 4. 用户分析 (Domain Service)
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
top_users = self.analysis_domain_service.get_top_users(
|
||||
user_activity, limit=max_user_titles
|
||||
)
|
||||
|
||||
# 5. LLM 语义分析 (为了保持兼容,目前直接传 UnifiedMessage,后续如需传 raw dict 再加转换)
|
||||
# LLMAnalyzer 内部可能已经处理了转换(见之前代码)
|
||||
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()
|
||||
)
|
||||
|
||||
topics = []
|
||||
user_titles = []
|
||||
golden_quotes = []
|
||||
total_token_usage = TokenUsage()
|
||||
|
||||
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配
|
||||
# 暂时转换回 legacy 格式以确保稳定性,直到 LLMAnalyzer 被重构
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
|
||||
)
|
||||
|
||||
if topic_enabled or user_title_enabled or golden_quote_enabled:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})")
|
||||
(
|
||||
topics,
|
||||
user_titles,
|
||||
golden_quotes,
|
||||
total_token_usage,
|
||||
) = await self.llm_analyzer.analyze_all_concurrent(
|
||||
legacy_messages,
|
||||
user_activity,
|
||||
umo=unified_msg_origin,
|
||||
top_users=top_users,
|
||||
topic_enabled=topic_enabled,
|
||||
user_title_enabled=user_title_enabled,
|
||||
golden_quote_enabled=golden_quote_enabled,
|
||||
)
|
||||
|
||||
# 回填结果
|
||||
statistics.golden_quotes = golden_quotes
|
||||
statistics.token_usage = total_token_usage
|
||||
|
||||
analysis_result = {
|
||||
"statistics": statistics,
|
||||
"topics": topics,
|
||||
"user_titles": user_titles,
|
||||
"user_analysis": user_activity,
|
||||
}
|
||||
|
||||
# 6. 持久化摘要 (Persistence)
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
|
||||
# 7. 生成报告并发送 (应用层编排发送动作)
|
||||
# 这里由调用方处理发送,本服务只返回分析结果和可能的视觉产物
|
||||
return {
|
||||
"success": True,
|
||||
"analysis_result": analysis_result,
|
||||
"messages_count": len(unified_messages),
|
||||
"adapter": adapter,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 增量分析用例
|
||||
@@ -212,188 +253,195 @@ class AnalysisApplicationService:
|
||||
Returns:
|
||||
dict: 包含 success、batch_summary 等信息
|
||||
"""
|
||||
if not self.incremental_store:
|
||||
raise RuntimeError("增量分析未初始化:缺少 IncrementalStore")
|
||||
async with self.group_lock(group_id, "incremental"):
|
||||
if not self.incremental_store:
|
||||
raise RuntimeError("增量分析未初始化:缺少 IncrementalStore")
|
||||
|
||||
logger.info(f"开始增量分析用例: 群 {group_id}, 平台 {platform_id or '默认'}")
|
||||
logger.info(
|
||||
f"开始增量分析用例: 群 {group_id}, 平台 {platform_id or '默认'}"
|
||||
)
|
||||
|
||||
# 1. 获取适配器
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
# 1. 获取适配器
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
# 2. 拉取消息(使用增量配置的消息数量上限)
|
||||
days = self.config_manager.get_analysis_days()
|
||||
max_count = self.config_manager.get_incremental_max_messages()
|
||||
# 2. 拉取消息(使用增量配置的消息数量上限)
|
||||
days = self.config_manager.get_analysis_days()
|
||||
max_count = self.config_manager.get_incremental_max_messages()
|
||||
|
||||
raw_messages = await adapter.fetch_messages(
|
||||
group_id=group_id, days=days, max_count=max_count
|
||||
)
|
||||
raw_messages = await adapter.fetch_messages(
|
||||
group_id=group_id, days=days, max_count=max_count
|
||||
)
|
||||
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 增量分析:无法获取消息")
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
if not raw_messages:
|
||||
logger.warning(f"群 {group_id} 增量分析:无法获取消息")
|
||||
return {"success": False, "reason": "no_messages"}
|
||||
|
||||
# 3. 清理消息
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
# 3. 清理消息
|
||||
from ...domain.services.message_cleaner_service import MessageCleanerService
|
||||
|
||||
cleaner = MessageCleanerService()
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
unified_messages = cleaner.clean_messages(
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
cleaner = MessageCleanerService()
|
||||
bot_self_ids = self.config_manager.get_bot_self_ids()
|
||||
unified_messages = cleaner.clean_messages(
|
||||
raw_messages, bot_self_ids=bot_self_ids, filter_commands=True
|
||||
)
|
||||
|
||||
# 4. 按时间戳去重:获取最后分析消息时间戳
|
||||
last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp(
|
||||
group_id
|
||||
)
|
||||
# 4. 按时间戳去重:获取最后分析消息时间戳
|
||||
last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp(
|
||||
group_id
|
||||
)
|
||||
|
||||
if last_analyzed_ts > 0:
|
||||
unified_messages = [
|
||||
msg for msg in unified_messages if msg.timestamp > last_analyzed_ts
|
||||
if last_analyzed_ts > 0:
|
||||
unified_messages = [
|
||||
msg for msg in unified_messages if msg.timestamp > last_analyzed_ts
|
||||
]
|
||||
|
||||
# 5. 检查最小消息阈值
|
||||
min_messages = self.config_manager.get_incremental_min_messages()
|
||||
if len(unified_messages) < min_messages:
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析:新消息数 ({len(unified_messages)}) "
|
||||
f"未达到阈值 ({min_messages}),跳过本次分析"
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 6. 计算基础统计
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
# 计算本批次的小时分布
|
||||
hourly_msg_counts, hourly_char_counts = self._compute_hourly_counts(
|
||||
unified_messages
|
||||
)
|
||||
|
||||
# 7. LLM 增量分析(仅话题 + 金句)
|
||||
topics_per_batch = self.config_manager.get_incremental_topics_per_batch()
|
||||
quotes_per_batch = self.config_manager.get_incremental_quotes_per_batch()
|
||||
|
||||
# 获取功能开关状态
|
||||
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
||||
golden_quote_enabled = (
|
||||
self.config_manager.get_golden_quote_analysis_enabled()
|
||||
)
|
||||
|
||||
# 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
|
||||
)
|
||||
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})")
|
||||
(
|
||||
topics,
|
||||
golden_quotes,
|
||||
token_usage,
|
||||
) = await self.llm_analyzer.analyze_incremental_concurrent(
|
||||
legacy_messages,
|
||||
umo=unified_msg_origin,
|
||||
topics_per_batch=topics_per_batch,
|
||||
quotes_per_batch=quotes_per_batch,
|
||||
topic_enabled=topic_enabled,
|
||||
golden_quote_enabled=golden_quote_enabled,
|
||||
)
|
||||
|
||||
# 8. 构建 IncrementalBatch
|
||||
# 8a. 转换话题: SummaryTopic -> dict
|
||||
new_topics = [
|
||||
{
|
||||
"topic": t.topic,
|
||||
"contributors": t.contributors,
|
||||
"detail": t.detail,
|
||||
"contributor_ids": t.contributor_ids,
|
||||
}
|
||||
for t in topics
|
||||
]
|
||||
|
||||
# 5. 检查最小消息阈值
|
||||
min_messages = self.config_manager.get_incremental_min_messages()
|
||||
if len(unified_messages) < min_messages:
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析:新消息数 ({len(unified_messages)}) "
|
||||
f"未达到阈值 ({min_messages}),跳过本次分析"
|
||||
# 8b. 转换金句: GoldenQuote -> dict
|
||||
new_quotes = [
|
||||
{
|
||||
"content": q.content,
|
||||
"sender": q.sender,
|
||||
"reason": q.reason,
|
||||
"user_id": q.user_id,
|
||||
}
|
||||
for q in golden_quotes
|
||||
]
|
||||
|
||||
# 8c. 转换 token 消耗: TokenUsage -> dict
|
||||
token_usage_dict = {
|
||||
"prompt_tokens": token_usage.prompt_tokens,
|
||||
"completion_tokens": token_usage.completion_tokens,
|
||||
"total_tokens": token_usage.total_tokens,
|
||||
}
|
||||
|
||||
# 8d. 转换用户统计: AnalysisDomainService 格式 -> IncrementalBatch 格式
|
||||
user_stats = self._convert_user_activity_for_merge(
|
||||
user_activity, unified_messages
|
||||
)
|
||||
return {"success": False, "reason": "below_threshold"}
|
||||
|
||||
# 6. 计算基础统计
|
||||
statistics = await asyncio.to_thread(
|
||||
self.statistics_service.calculate_group_statistics, unified_messages
|
||||
)
|
||||
user_activity = await asyncio.to_thread(
|
||||
self.analysis_domain_service.analyze_user_activity,
|
||||
unified_messages,
|
||||
bot_self_ids,
|
||||
)
|
||||
|
||||
# 计算本批次的小时分布
|
||||
hourly_msg_counts, hourly_char_counts = self._compute_hourly_counts(
|
||||
unified_messages
|
||||
)
|
||||
|
||||
# 7. LLM 增量分析(仅话题 + 金句)
|
||||
topics_per_batch = self.config_manager.get_incremental_topics_per_batch()
|
||||
quotes_per_batch = self.config_manager.get_incremental_quotes_per_batch()
|
||||
|
||||
# 获取功能开关状态
|
||||
topic_enabled = self.config_manager.get_topic_analysis_enabled()
|
||||
golden_quote_enabled = self.config_manager.get_golden_quote_analysis_enabled()
|
||||
|
||||
# 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用
|
||||
legacy_messages = self.statistics_service._convert_to_legacy_dict(
|
||||
unified_messages
|
||||
)
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
|
||||
)
|
||||
|
||||
(
|
||||
topics,
|
||||
golden_quotes,
|
||||
token_usage,
|
||||
) = await self.llm_analyzer.analyze_incremental_concurrent(
|
||||
legacy_messages,
|
||||
umo=unified_msg_origin,
|
||||
topics_per_batch=topics_per_batch,
|
||||
quotes_per_batch=quotes_per_batch,
|
||||
topic_enabled=topic_enabled,
|
||||
golden_quote_enabled=golden_quote_enabled,
|
||||
)
|
||||
|
||||
# 8. 构建 IncrementalBatch
|
||||
# 8a. 转换话题: SummaryTopic -> dict
|
||||
new_topics = [
|
||||
{
|
||||
"topic": t.topic,
|
||||
"contributors": t.contributors,
|
||||
"detail": t.detail,
|
||||
"contributor_ids": t.contributor_ids,
|
||||
# 8e. 转换表情统计: EmojiStatistics -> dict
|
||||
emoji_stats = {
|
||||
"face_count": statistics.emoji_statistics.face_count,
|
||||
"mface_count": statistics.emoji_statistics.mface_count,
|
||||
"bface_count": statistics.emoji_statistics.bface_count,
|
||||
"sface_count": statistics.emoji_statistics.sface_count,
|
||||
"other_emoji_count": statistics.emoji_statistics.other_emoji_count,
|
||||
"face_details": statistics.emoji_statistics.face_details,
|
||||
}
|
||||
for t in topics
|
||||
]
|
||||
|
||||
# 8b. 转换金句: GoldenQuote -> dict
|
||||
new_quotes = [
|
||||
{
|
||||
"content": q.content,
|
||||
"sender": q.sender,
|
||||
"reason": q.reason,
|
||||
"user_id": q.user_id,
|
||||
# 8f. 获取参与者 ID 和最后消息时间戳
|
||||
participant_ids = list({msg.sender_id for msg in unified_messages})
|
||||
last_message_timestamp = max(
|
||||
(msg.timestamp for msg in unified_messages), default=0
|
||||
)
|
||||
|
||||
# 8g. 计算本批次总字符数
|
||||
characters_count = sum(msg.get_text_length() for msg in unified_messages)
|
||||
|
||||
# 构建批次对象
|
||||
batch = IncrementalBatch(
|
||||
group_id=group_id,
|
||||
timestamp=time_mod.time(),
|
||||
messages_count=len(unified_messages),
|
||||
characters_count=characters_count,
|
||||
hourly_msg_counts={str(k): v for k, v in hourly_msg_counts.items()},
|
||||
hourly_char_counts={str(k): v for k, v in hourly_char_counts.items()},
|
||||
user_stats=user_stats,
|
||||
emoji_stats=emoji_stats,
|
||||
topics=new_topics,
|
||||
golden_quotes=new_quotes,
|
||||
token_usage=token_usage_dict,
|
||||
last_message_timestamp=last_message_timestamp,
|
||||
participant_ids=participant_ids,
|
||||
)
|
||||
|
||||
# 9. 保存批次并更新最后分析时间戳
|
||||
await self.incremental_store.save_batch(batch)
|
||||
await self.incremental_store.update_last_analyzed_timestamp(
|
||||
group_id, last_message_timestamp
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"本批次消息={len(unified_messages)}, "
|
||||
f"新话题={len(new_topics)}, 新金句={len(new_quotes)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batch_summary": batch.get_summary(),
|
||||
"messages_count": len(unified_messages),
|
||||
}
|
||||
for q in golden_quotes
|
||||
]
|
||||
|
||||
# 8c. 转换 token 消耗: TokenUsage -> dict
|
||||
token_usage_dict = {
|
||||
"prompt_tokens": token_usage.prompt_tokens,
|
||||
"completion_tokens": token_usage.completion_tokens,
|
||||
"total_tokens": token_usage.total_tokens,
|
||||
}
|
||||
|
||||
# 8d. 转换用户统计: AnalysisDomainService 格式 -> IncrementalBatch 格式
|
||||
user_stats = self._convert_user_activity_for_merge(
|
||||
user_activity, unified_messages
|
||||
)
|
||||
|
||||
# 8e. 转换表情统计: EmojiStatistics -> dict
|
||||
emoji_stats = {
|
||||
"face_count": statistics.emoji_statistics.face_count,
|
||||
"mface_count": statistics.emoji_statistics.mface_count,
|
||||
"bface_count": statistics.emoji_statistics.bface_count,
|
||||
"sface_count": statistics.emoji_statistics.sface_count,
|
||||
"other_emoji_count": statistics.emoji_statistics.other_emoji_count,
|
||||
"face_details": statistics.emoji_statistics.face_details,
|
||||
}
|
||||
|
||||
# 8f. 获取参与者 ID 和最后消息时间戳
|
||||
participant_ids = list({msg.sender_id for msg in unified_messages})
|
||||
last_message_timestamp = max(
|
||||
(msg.timestamp for msg in unified_messages), default=0
|
||||
)
|
||||
|
||||
# 8g. 计算本批次总字符数
|
||||
characters_count = sum(msg.get_text_length() for msg in unified_messages)
|
||||
|
||||
# 构建批次对象
|
||||
batch = IncrementalBatch(
|
||||
group_id=group_id,
|
||||
timestamp=time_mod.time(),
|
||||
messages_count=len(unified_messages),
|
||||
characters_count=characters_count,
|
||||
hourly_msg_counts={str(k): v for k, v in hourly_msg_counts.items()},
|
||||
hourly_char_counts={str(k): v for k, v in hourly_char_counts.items()},
|
||||
user_stats=user_stats,
|
||||
emoji_stats=emoji_stats,
|
||||
topics=new_topics,
|
||||
golden_quotes=new_quotes,
|
||||
token_usage=token_usage_dict,
|
||||
last_message_timestamp=last_message_timestamp,
|
||||
participant_ids=participant_ids,
|
||||
)
|
||||
|
||||
# 9. 保存批次并更新最后分析时间戳
|
||||
await self.incremental_store.save_batch(batch)
|
||||
await self.incremental_store.update_last_analyzed_timestamp(
|
||||
group_id, last_message_timestamp
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"本批次消息={len(unified_messages)}, "
|
||||
f"新话题={len(new_topics)}, 新金句={len(new_quotes)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"batch_summary": batch.get_summary(),
|
||||
"messages_count": len(unified_messages),
|
||||
}
|
||||
|
||||
async def execute_incremental_final_report(
|
||||
self, group_id: str, platform_id: str | None = None
|
||||
@@ -422,101 +470,110 @@ class AnalysisApplicationService:
|
||||
Returns:
|
||||
dict: 包含 success、analysis_result、adapter 等信息
|
||||
"""
|
||||
if not self.incremental_store or not self.incremental_merge_service:
|
||||
raise RuntimeError(
|
||||
"增量分析未初始化:缺少 IncrementalStore 或 IncrementalMergeService"
|
||||
async with self.group_lock(group_id, "final"):
|
||||
if not self.incremental_store or not self.incremental_merge_service:
|
||||
raise RuntimeError(
|
||||
"增量分析未初始化:缺少 IncrementalStore 或 IncrementalMergeService"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"开始增量最终报告: 群 {group_id}, 平台 {platform_id or '默认'}"
|
||||
)
|
||||
|
||||
logger.info(f"开始增量最终报告: 群 {group_id}, 平台 {platform_id or '默认'}")
|
||||
# 1. 计算滑动窗口范围
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
window_end = time_mod.time()
|
||||
window_start = window_end - (analysis_days * 24 * 3600)
|
||||
|
||||
# 1. 计算滑动窗口范围
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
window_end = time_mod.time()
|
||||
window_start = window_end - (analysis_days * 24 * 3600)
|
||||
|
||||
# 2. 查询窗口内的所有批次
|
||||
batches = await self.incremental_store.query_batches(
|
||||
group_id, window_start, window_end
|
||||
)
|
||||
|
||||
# 3. 检查批次有效性
|
||||
if not batches:
|
||||
logger.warning(f"群 {group_id} 滑动窗口内无增量分析数据,无法生成最终报告")
|
||||
return {"success": False, "reason": "no_incremental_data"}
|
||||
|
||||
# 4. 合并批次为 IncrementalState
|
||||
state = self.incremental_merge_service.merge_batches(
|
||||
batches, window_start, window_end
|
||||
)
|
||||
|
||||
# 5. 获取适配器(报告发送需要)
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
# 6. 执行用户称号 LLM 分析
|
||||
user_titles = []
|
||||
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
||||
|
||||
if user_title_enabled and state.user_activities:
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
# 从合并后的 user_activities 中取出 top 用户
|
||||
top_users = state.get_user_activity_ranking(max_user_titles)
|
||||
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
|
||||
# 2. 查询窗口内的所有批次
|
||||
batches = await self.incremental_store.query_batches(
|
||||
group_id, window_start, window_end
|
||||
)
|
||||
|
||||
try:
|
||||
(
|
||||
user_titles_result,
|
||||
title_token_usage,
|
||||
) = await self.llm_analyzer.analyze_user_titles(
|
||||
messages=[], # 增量模式下不传原始消息
|
||||
user_activity=state.user_activities,
|
||||
umo=unified_msg_origin,
|
||||
top_users=top_users,
|
||||
# 3. 检查批次有效性
|
||||
if not batches:
|
||||
logger.warning(
|
||||
f"群 {group_id} 滑动窗口内无增量分析数据,无法生成最终报告"
|
||||
)
|
||||
user_titles = user_titles_result
|
||||
return {"success": False, "reason": "no_incremental_data"}
|
||||
|
||||
# 将称号分析的 token 消耗追加到状态中
|
||||
state.total_token_usage["prompt_tokens"] = (
|
||||
state.total_token_usage.get("prompt_tokens", 0)
|
||||
+ title_token_usage.prompt_tokens
|
||||
# 4. 合并批次为 IncrementalState
|
||||
state = self.incremental_merge_service.merge_batches(
|
||||
batches, window_start, window_end
|
||||
)
|
||||
|
||||
# 5. 获取适配器(报告发送需要)
|
||||
adapter = self.bot_manager.get_adapter(platform_id)
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
# 6. 执行用户称号 LLM 分析
|
||||
user_titles = []
|
||||
user_title_enabled = self.config_manager.get_user_title_analysis_enabled()
|
||||
|
||||
if user_title_enabled and state.user_activities:
|
||||
max_user_titles = self.config_manager.get_max_user_titles()
|
||||
# 从合并后的 user_activities 中取出 top 用户
|
||||
top_users = state.get_user_activity_ranking(max_user_titles)
|
||||
|
||||
unified_msg_origin = (
|
||||
f"{platform_id}:GroupMessage:{group_id}"
|
||||
if platform_id
|
||||
else group_id
|
||||
)
|
||||
state.total_token_usage["completion_tokens"] = (
|
||||
state.total_token_usage.get("completion_tokens", 0)
|
||||
+ title_token_usage.completion_tokens
|
||||
)
|
||||
state.total_token_usage["total_tokens"] = (
|
||||
state.total_token_usage.get("total_tokens", 0)
|
||||
+ title_token_usage.total_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"增量最终报告用户称号分析失败: {e}", exc_info=True)
|
||||
|
||||
# 7. 构建 analysis_result
|
||||
analysis_result = self.incremental_merge_service.build_analysis_result(
|
||||
state, user_titles
|
||||
)
|
||||
try:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入称号分析队列 (群: {group_id})")
|
||||
(
|
||||
user_titles_result,
|
||||
title_token_usage,
|
||||
) = await self.llm_analyzer.analyze_user_titles(
|
||||
messages=[], # 增量模式下不传原始消息
|
||||
user_activity=state.user_activities,
|
||||
umo=unified_msg_origin,
|
||||
top_users=top_users,
|
||||
)
|
||||
user_titles = user_titles_result
|
||||
|
||||
# 8. 持久化到 history_manager
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
# 将称号分析的 token 消耗追加到状态中
|
||||
state.total_token_usage["prompt_tokens"] = (
|
||||
state.total_token_usage.get("prompt_tokens", 0)
|
||||
+ title_token_usage.prompt_tokens
|
||||
)
|
||||
state.total_token_usage["completion_tokens"] = (
|
||||
state.total_token_usage.get("completion_tokens", 0)
|
||||
+ title_token_usage.completion_tokens
|
||||
)
|
||||
state.total_token_usage["total_tokens"] = (
|
||||
state.total_token_usage.get("total_tokens", 0)
|
||||
+ title_token_usage.total_tokens
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"增量最终报告用户称号分析失败: {e}", exc_info=True)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量最终报告完成: "
|
||||
f"窗口={state.get_window_date_str()}, "
|
||||
f"累计消息={state.total_message_count}, "
|
||||
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}, "
|
||||
f"批次={state.total_analysis_count}"
|
||||
)
|
||||
# 7. 构建 analysis_result
|
||||
analysis_result = self.incremental_merge_service.build_analysis_result(
|
||||
state, user_titles
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"analysis_result": analysis_result,
|
||||
"messages_count": state.total_message_count,
|
||||
"adapter": adapter,
|
||||
}
|
||||
# 8. 持久化到 history_manager
|
||||
await self.history_manager.save_analysis(group_id, analysis_result)
|
||||
|
||||
logger.info(
|
||||
f"群 {group_id} 增量最终报告完成: "
|
||||
f"窗口={state.get_window_date_str()}, "
|
||||
f"累计消息={state.total_message_count}, "
|
||||
f"话题={len(state.topics)}, 金句={len(state.golden_quotes)}, "
|
||||
f"批次={state.total_analysis_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"analysis_result": analysis_result,
|
||||
"messages_count": state.total_message_count,
|
||||
"adapter": adapter,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 辅助方法
|
||||
|
||||
@@ -533,21 +533,28 @@ class OneBotAdapter(PlatformAdapter):
|
||||
logger.error(f"OneBot 图片发送最终失败: {e}")
|
||||
return False
|
||||
|
||||
async def was_image_sent_recently(self, group_id: str, seconds: int = 60) -> bool:
|
||||
async def was_image_sent_recently(
|
||||
self, group_id: str, seconds: int = 60, token: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
[真相检查] 检查最近 X 秒内,机器人是否已经向该群发送过图片。
|
||||
用于判断之前的“超时/1200”错误是否其实已经在后台发送成功。
|
||||
"""
|
||||
try:
|
||||
# 1. 获取最近的消息历史 (OneBot 标准 API)
|
||||
history = await self.bot.call_action(
|
||||
"get_group_msg_history",
|
||||
group_id=int(group_id),
|
||||
count=100, # [针对重复检查优化] 提高扫描深度,覆盖大群高频刷屏的情况
|
||||
)
|
||||
try:
|
||||
history = await self.bot.call_action(
|
||||
"get_group_msg_history",
|
||||
group_id=int(group_id),
|
||||
count=50, # 适度缩减扫描深度以提高成功率
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[OneBot] was_image_sent_recently: get_group_msg_history 失败 (可能 API 繁忙): {e}"
|
||||
)
|
||||
return False # API 失败时,我们保持谨慎,但不阻止重试
|
||||
|
||||
if not history or "messages" not in history:
|
||||
# 某些 OneBot 实现返回值结构不同
|
||||
messages = history if isinstance(history, list) else []
|
||||
else:
|
||||
messages = history["messages"]
|
||||
@@ -559,16 +566,16 @@ class OneBotAdapter(PlatformAdapter):
|
||||
# 1. 优先从内存缓存中获取机器人 ID
|
||||
self_id = self.bot_self_ids[0] if self.bot_self_ids else ""
|
||||
|
||||
# 2. 如果列表为空,尝试反射实例属性
|
||||
if not self_id:
|
||||
# 尝试从 bot 实例中获取多个可能的 ID 属性
|
||||
self_id = (
|
||||
str(getattr(self.bot, "self_id", ""))
|
||||
or str(getattr(self.bot, "uin", ""))
|
||||
or str(getattr(self.bot, "user_id", ""))
|
||||
)
|
||||
|
||||
# 3. [兜底方案] 仍未获取到,通过 API 向 OneBot 服务端请求
|
||||
if not self_id:
|
||||
# 最后的 API 兜底:尝试从 login_info 获取
|
||||
try:
|
||||
login_info = await self.bot.call_action("get_login_info")
|
||||
if login_info and "user_id" in login_info:
|
||||
@@ -587,6 +594,15 @@ class OneBotAdapter(PlatformAdapter):
|
||||
"[OneBot] was_image_sent_recently: 无法确定机器人 ID,历史回显校验可能不准确"
|
||||
)
|
||||
|
||||
# [优化] 如果提供了 token,我们也尝试从 caption 中提取 ID 部分进行更精准匹配
|
||||
search_token = None
|
||||
if token and "[ID: " in token:
|
||||
import re
|
||||
|
||||
match = re.search(r"\[ID: ([^\]]+)\]", token)
|
||||
if match:
|
||||
search_token = match.group(0) # 例如 "[ID: report_XXXX]"
|
||||
|
||||
for msg in reversed(messages):
|
||||
msg_time = msg.get("time", 0)
|
||||
# 只检查约定时间范围内的消息
|
||||
@@ -604,15 +620,27 @@ class OneBotAdapter(PlatformAdapter):
|
||||
raw_message = msg.get("message", [])
|
||||
# 适配字符串形式或列表形式的消息
|
||||
msg_str = str(raw_message)
|
||||
if "[CQ:image" in msg_str or '"type": "image"' in msg_str:
|
||||
logger.debug(
|
||||
f"自检发现群 {group_id} 已有成功发送的图片回显,无需重试。"
|
||||
)
|
||||
return True
|
||||
|
||||
has_image = "[CQ:image" in msg_str or '"type": "image"' in msg_str
|
||||
|
||||
if has_image:
|
||||
if search_token:
|
||||
# 精确匹配 TraceID
|
||||
if search_token in msg_str:
|
||||
logger.debug(
|
||||
f"自检发现群 {group_id} 已有匹配 ID ({search_token}) 的图片回显,拦截重复发送。"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# 广义匹配(回退模式)
|
||||
logger.debug(
|
||||
f"自检发现群 {group_id} 已有成功发送的图片回显,无需重试。"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"回显自检失败 (可能不支持 get_group_msg_history): {e}")
|
||||
logger.debug(f"回显自检失败: {e}")
|
||||
return False
|
||||
|
||||
async def send_file(
|
||||
|
||||
@@ -94,8 +94,9 @@ class ReportDispatcher:
|
||||
|
||||
# 3. 发送图片
|
||||
if image_url:
|
||||
caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]"
|
||||
sent = await self.message_sender.send_image_smart(
|
||||
group_id, image_url, "📊 每日群聊分析报告已生成:", platform_id
|
||||
group_id, image_url, caption, platform_id
|
||||
)
|
||||
if sent:
|
||||
# 4. 发送成功后,尝试上传到群文件/群相册(静默处理)
|
||||
@@ -119,7 +120,7 @@ class ReportDispatcher:
|
||||
analysis_result,
|
||||
group_id,
|
||||
platform_id,
|
||||
caption="📊 每日群聊分析报告已生成:",
|
||||
caption=f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]",
|
||||
)
|
||||
return True # 已加入队列视作处理成功 (不在此处报错)
|
||||
else:
|
||||
|
||||
@@ -25,6 +25,9 @@ class ReportGenerator(IReportGenerator):
|
||||
self.config_manager = config_manager
|
||||
self.activity_visualizer = ActivityVisualizer()
|
||||
self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器
|
||||
# 全局 T2I 渲染信号量,保护本地资源
|
||||
max_concurrent = self.config_manager.get_max_concurrent_tasks()
|
||||
self._render_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def generate_image_report(
|
||||
self,
|
||||
@@ -67,106 +70,112 @@ class ReportGenerator(IReportGenerator):
|
||||
|
||||
logger.info(f"图片报告HTML渲染完成,长度: {len(html_content)} 字符")
|
||||
|
||||
# 定义渲染策略
|
||||
render_strategies = [
|
||||
# 1. 第一策略: PNG, Ultra quality, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "png",
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "ultra",
|
||||
},
|
||||
# 2. 第二策略: JPEG, ultra, quality 100%, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 100,
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "ultra",
|
||||
},
|
||||
# 3. 第三策略: JPEG, high, quality 80%, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 95,
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "high", # 尝试高分辨率
|
||||
},
|
||||
# 4. 第四策略: JPEG, normal quality, Device scale (后备)
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 80,
|
||||
"scale": "device",
|
||||
# normal quality
|
||||
},
|
||||
]
|
||||
# 使用信号量控制并发进入渲染引擎
|
||||
async with self._render_semaphore:
|
||||
logger.debug(f"[T2I] 已进入渲染队列 (群: {group_id})")
|
||||
|
||||
last_exception = None
|
||||
# 定义渲染策略
|
||||
render_strategies = [
|
||||
# 1. 第一策略: PNG, Ultra quality, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "png",
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "ultra",
|
||||
},
|
||||
# 2. 第二策略: JPEG, ultra, quality 100%, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 100,
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "ultra",
|
||||
},
|
||||
# 3. 第三策略: JPEG, high, quality 80%, Device scale
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 95,
|
||||
"scale": "device",
|
||||
"device_scale_factor_level": "high", # 尝试高分辨率
|
||||
},
|
||||
# 4. 第四策略: JPEG, normal quality, Device scale (后备)
|
||||
{
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 80,
|
||||
"scale": "device",
|
||||
# normal quality
|
||||
},
|
||||
]
|
||||
|
||||
for image_options in render_strategies:
|
||||
try:
|
||||
# Cleanse options
|
||||
if image_options.get("type") == "png":
|
||||
image_options["quality"] = None
|
||||
last_exception = None
|
||||
|
||||
logger.info(f"正在尝试渲染策略: {image_options}")
|
||||
# 改为获取 bytes 数据,避免 OneBot 无法访问内部 URL
|
||||
image_data = await html_render_func(
|
||||
html_content, # 渲染后的HTML内容
|
||||
{}, # 空数据字典,因为数据已包含在HTML中
|
||||
False, # return_url=False,直接获取图片数据
|
||||
image_options,
|
||||
)
|
||||
for image_options in render_strategies:
|
||||
try:
|
||||
# Cleanse options
|
||||
if image_options.get("type") == "png":
|
||||
image_options["quality"] = None
|
||||
|
||||
if image_data:
|
||||
# 校验是否为合法图片(防止 T2I 返回 500 错误 HTML 字符流)
|
||||
is_valid = False
|
||||
actual_data_head = None
|
||||
logger.info(f"正在尝试渲染策略: {image_options}")
|
||||
# 改为获取 bytes 数据,避免 OneBot 无法访问内部 URL
|
||||
image_data = await html_render_func(
|
||||
html_content, # 渲染后的HTML内容
|
||||
{}, # 空数据字典,因为数据已包含在HTML中
|
||||
False, # return_url=False,直接获取图片数据
|
||||
image_options,
|
||||
)
|
||||
|
||||
if isinstance(image_data, bytes):
|
||||
actual_data_head = image_data[:10]
|
||||
elif isinstance(image_data, str) and os.path.exists(image_data):
|
||||
try:
|
||||
with open(image_data, "rb") as f:
|
||||
actual_data_head = f.read(10)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取图片临时文件失败: {e}")
|
||||
if image_data:
|
||||
# 校验是否为合法图片(防止 T2I 返回 500 错误 HTML 字符流)
|
||||
is_valid = False
|
||||
actual_data_head = None
|
||||
|
||||
if actual_data_head:
|
||||
# 检查 magic numbers (JPEG: FF D8, PNG: 89 50 4E 47)
|
||||
if actual_data_head.startswith(
|
||||
b"\xff\xd8"
|
||||
) or actual_data_head.startswith(b"\x89PNG"):
|
||||
is_valid = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})"
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
if isinstance(image_data, bytes):
|
||||
b64 = base64.b64encode(image_data).decode("utf-8")
|
||||
image_url = f"base64://{b64}"
|
||||
logger.info(
|
||||
f"图片生成成功 ({image_options}): [Base64 Data {len(image_data)} bytes]"
|
||||
)
|
||||
return image_url, html_content
|
||||
elif isinstance(image_data, str):
|
||||
logger.info(f"图片生成成功 (String): {image_data}")
|
||||
return image_data, html_content
|
||||
actual_data_head = image_data[:10]
|
||||
elif isinstance(image_data, str) and os.path.exists(
|
||||
image_data
|
||||
):
|
||||
try:
|
||||
with open(image_data, "rb") as f:
|
||||
actual_data_head = f.read(10)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取图片临时文件失败: {e}")
|
||||
|
||||
logger.warning(f"渲染策略 {image_options} 返回了无效或空数据")
|
||||
if actual_data_head:
|
||||
# 检查 magic numbers (JPEG: FF D8, PNG: 89 50 4E 47)
|
||||
if actual_data_head.startswith(
|
||||
b"\xff\xd8"
|
||||
) or actual_data_head.startswith(b"\x89PNG"):
|
||||
is_valid = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"渲染结果似乎不是有效的图片数据 (头部: {actual_data_head.hex()})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"渲染策略 {image_options} 失败: {e}")
|
||||
last_exception = e
|
||||
logger.warning("尝试下一个策略")
|
||||
continue
|
||||
if is_valid:
|
||||
if isinstance(image_data, bytes):
|
||||
b64 = base64.b64encode(image_data).decode("utf-8")
|
||||
image_url = f"base64://{b64}"
|
||||
logger.info(
|
||||
f"图片生成成功 ({image_options}): [Base64 Data {len(image_data)} bytes]"
|
||||
)
|
||||
return image_url, html_content
|
||||
elif isinstance(image_data, str):
|
||||
logger.info(f"图片生成成功 (String): {image_data}")
|
||||
return image_data, html_content
|
||||
|
||||
# 如果所有策略都失败
|
||||
logger.error(f"所有渲染策略都失败。最后一个错误: {last_exception}")
|
||||
return None, html_content
|
||||
logger.warning(f"渲染策略 {image_options} 返回了无效或空数据")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"渲染策略 {image_options} 失败: {e}")
|
||||
last_exception = e
|
||||
logger.warning("尝试下一个策略")
|
||||
continue
|
||||
|
||||
# 如果所有策略都失败
|
||||
logger.error(f"所有渲染策略都失败。最后一个错误: {last_exception}")
|
||||
return None, html_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成图片报告过程发生严重错误: {e}", exc_info=True)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import asyncio
|
||||
import time as time_mod
|
||||
import weakref
|
||||
from typing import Any
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
@@ -319,21 +318,10 @@ class AutoScheduler:
|
||||
|
||||
logger.info(f"将为 {len(target_list)} 个群聊并发执行分析")
|
||||
|
||||
# 创建并发任务,限制最大并发数
|
||||
max_concurrent = self.config_manager.get_max_concurrent_tasks()
|
||||
logger.info(f"自动分析并发数限制: {max_concurrent}")
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def safe_perform_analysis(gid, pid):
|
||||
async with sem:
|
||||
return await self._perform_auto_analysis_for_group_with_timeout(
|
||||
gid, pid
|
||||
)
|
||||
|
||||
analysis_tasks = []
|
||||
for gid, pid in target_list:
|
||||
task = asyncio.create_task(
|
||||
safe_perform_analysis(gid, pid),
|
||||
self._perform_auto_analysis_for_group_with_timeout(gid, pid),
|
||||
name=f"analysis_group_{gid}",
|
||||
)
|
||||
analysis_tasks.append(task)
|
||||
@@ -343,18 +331,22 @@ class AutoScheduler:
|
||||
|
||||
# 统计执行结果
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
error_count = 0
|
||||
|
||||
for i, result in enumerate(results):
|
||||
gid, _ = target_list[i]
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, asyncio.CancelledError):
|
||||
# 锁冲突导致的跳过
|
||||
skip_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(f"群 {gid} 分析任务异常: {result}")
|
||||
error_count += 1
|
||||
else:
|
||||
success_count += 1
|
||||
|
||||
logger.info(
|
||||
f"并发分析完成 - 成功: {success_count}, 失败: {error_count}, 总计: {len(target_list)}"
|
||||
f"并发分析完成 - 成功: {success_count}, 跳过: {skip_count}, 失败: {error_count}, 总计: {len(target_list)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -379,60 +371,54 @@ class AutoScheduler:
|
||||
self, group_id: str, target_platform_id: str | None = None
|
||||
):
|
||||
"""为指定群执行自动分析(业务逻辑委派给 AnalysisApplicationService)"""
|
||||
# 为每个群聊使用独立的锁
|
||||
group_lock_key = f"analysis_{group_id}"
|
||||
if not hasattr(self, "_group_locks"):
|
||||
self._group_locks = weakref.WeakValueDictionary()
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"group_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
|
||||
lock = self._group_locks.get(group_lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._group_locks[group_lock_key] = lock
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
|
||||
async with lock:
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"group_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
# 检查平台状态 (BotManager 为基础设施层,用于获取平台就绪状态)
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 自动分析跳过:bot管理器未就绪")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
# 委派给应用层服务执行核心用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
result = await self.analysis_service.execute_daily_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id, manual=False
|
||||
)
|
||||
|
||||
# 检查平台状态 (BotManager 为基础设施层,用于获取平台就绪状态)
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 自动分析跳过:bot管理器未就绪")
|
||||
return
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason")
|
||||
logger.info(f"群 {group_id} 自动分析跳过: {reason}")
|
||||
return
|
||||
|
||||
# 委派给应用层服务执行核心用例
|
||||
result = await self.analysis_service.execute_daily_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id, manual=False
|
||||
)
|
||||
# 获取分析结果及适配器
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason")
|
||||
logger.info(f"群 {group_id} 自动分析跳过: {reason}")
|
||||
return
|
||||
# 调度导出并发送报告
|
||||
await self.report_dispatcher.dispatch(
|
||||
group_id,
|
||||
analysis_result,
|
||||
adapter.platform_id
|
||||
if hasattr(adapter, "platform_id")
|
||||
else target_platform_id,
|
||||
)
|
||||
|
||||
# 获取分析结果及适配器
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
logger.info(f"群 {group_id} 自动分析任务执行成功")
|
||||
|
||||
# 调度导出并发送报告(由 ReportDispatcher 协调)
|
||||
await self.report_dispatcher.dispatch(
|
||||
group_id,
|
||||
analysis_result,
|
||||
adapter.platform_id
|
||||
if hasattr(adapter, "platform_id")
|
||||
else target_platform_id,
|
||||
)
|
||||
|
||||
logger.info(f"群 {group_id} 自动分析任务执行成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True)
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 自动分析流程结束")
|
||||
except asyncio.CancelledError:
|
||||
# group_lock 抛出的 CancelledError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 任务因并发锁冲突而跳过(已在运行)")
|
||||
raise # 重新抛出,让上层知道任务并没真正执行而是跳过了
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 自动分析执行失败: {e}", exc_info=True)
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 自动分析流程结束")
|
||||
|
||||
# ================================================================
|
||||
# 增量模式:增量分析
|
||||
@@ -458,32 +444,31 @@ class AutoScheduler:
|
||||
f"(并发限制: {max_concurrent}, 交错间隔: {stagger}秒)"
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
# 资源限制现在由 Application Service 全局控制,此处仅保留交错逻辑
|
||||
|
||||
async def staggered_incremental(idx, gid, pid):
|
||||
async with sem:
|
||||
# 按索引交错延迟,均匀分散 API 压力
|
||||
if idx > 0 and stagger > 0:
|
||||
await asyncio.sleep(stagger * idx)
|
||||
# 按索引交错延迟,均匀分散 API 压力
|
||||
if idx > 0 and stagger > 0:
|
||||
await asyncio.sleep(stagger * idx)
|
||||
|
||||
result = (
|
||||
await self._perform_incremental_analysis_for_group_with_timeout(
|
||||
result = (
|
||||
await self._perform_incremental_analysis_for_group_with_timeout(
|
||||
gid, pid
|
||||
)
|
||||
)
|
||||
|
||||
# 检查是否需要立即发送报告(调试模式)
|
||||
if self.config_manager.get_incremental_report_immediately():
|
||||
if isinstance(result, dict) and result.get("success"):
|
||||
logger.info(
|
||||
f"增量分析立即报告模式生效,正在为群 {gid} 生成报告..."
|
||||
)
|
||||
# 立即生成最终报告
|
||||
await self._perform_incremental_final_report_for_group_with_timeout(
|
||||
gid, pid
|
||||
)
|
||||
)
|
||||
|
||||
# 检查是否需要立即发送报告(调试模式)
|
||||
if self.config_manager.get_incremental_report_immediately():
|
||||
if isinstance(result, dict) and result.get("success"):
|
||||
logger.info(
|
||||
f"增量分析立即报告模式生效,正在为群 {gid} 生成报告..."
|
||||
)
|
||||
# 立即生成最终报告
|
||||
await self._perform_incremental_final_report_for_group_with_timeout(
|
||||
gid, pid
|
||||
)
|
||||
|
||||
return result
|
||||
return result
|
||||
|
||||
analysis_tasks = []
|
||||
for idx, (gid, pid) in enumerate(target_list):
|
||||
@@ -542,57 +527,51 @@ class AutoScheduler:
|
||||
self, group_id: str, target_platform_id: str | None = None
|
||||
):
|
||||
"""为指定群执行增量分析(业务逻辑委派给 AnalysisApplicationService)"""
|
||||
# 为每个群聊使用独立的锁
|
||||
group_lock_key = f"incremental_{group_id}"
|
||||
if not hasattr(self, "_group_locks"):
|
||||
self._group_locks = weakref.WeakValueDictionary()
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"incr_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
|
||||
lock = self._group_locks.get(group_lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._group_locks[group_lock_key] = lock
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 执行增量分析 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
|
||||
async with lock:
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"incr_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
# 检查平台状态
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 增量分析跳过:bot管理器未就绪")
|
||||
return {"success": False, "reason": "bot_not_ready"}
|
||||
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 执行增量分析 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
# 委派给应用层服务执行增量分析用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
result = await self.analysis_service.execute_incremental_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
# 检查平台状态
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 增量分析跳过:bot管理器未就绪")
|
||||
return {"success": False, "reason": "bot_not_ready"}
|
||||
|
||||
# 委派给应用层服务执行增量分析用例
|
||||
result = await self.analysis_service.execute_incremental_analysis(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason", "unknown")
|
||||
logger.info(f"群 {group_id} 增量分析跳过: {reason}")
|
||||
return result
|
||||
|
||||
# 增量分析只累积数据,不发送报告
|
||||
batch_summary = result.get("batch_summary", {})
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"消息数={result.get('messages_count', 0)}, "
|
||||
f"话题={batch_summary.get('topics_count', 0)}, "
|
||||
f"金句={batch_summary.get('quotes_count', 0)}"
|
||||
)
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason", "unknown")
|
||||
logger.info(f"群 {group_id} 增量分析跳过: {reason}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 增量分析执行失败: {e}", exc_info=True)
|
||||
return {"success": False, "reason": str(e)}
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 增量分析流程结束")
|
||||
# 增量分析只累积数据,不发送报告
|
||||
batch_summary = result.get("batch_summary", {})
|
||||
logger.info(
|
||||
f"群 {group_id} 增量分析完成: "
|
||||
f"消息数={result.get('messages_count', 0)}, "
|
||||
f"话题={batch_summary.get('topics_count', 0)}, "
|
||||
f"金句={batch_summary.get('quotes_count', 0)}"
|
||||
)
|
||||
return result
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# group_lock 抛出的 CancelledError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 增量分析因并发锁冲突而跳过(已在运行)")
|
||||
return {"success": False, "reason": "already_running"}
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 增量分析执行失败: {e}", exc_info=True)
|
||||
return {"success": False, "reason": str(e)}
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 增量分析流程结束")
|
||||
|
||||
# ================================================================
|
||||
# 增量模式:最终报告生成
|
||||
@@ -618,15 +597,14 @@ class AutoScheduler:
|
||||
f"(并发限制: {max_concurrent}, 交错间隔: {stagger}秒)"
|
||||
)
|
||||
|
||||
sem = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def staggered_final_report(idx, gid, pid):
|
||||
async with sem:
|
||||
if idx > 0 and stagger > 0:
|
||||
await asyncio.sleep(stagger * idx)
|
||||
return await self._perform_incremental_final_report_for_group_with_timeout(
|
||||
if idx > 0 and stagger > 0:
|
||||
await asyncio.sleep(stagger * idx)
|
||||
return (
|
||||
await self._perform_incremental_final_report_for_group_with_timeout(
|
||||
gid, pid
|
||||
)
|
||||
)
|
||||
|
||||
report_tasks = []
|
||||
for idx, (gid, pid) in enumerate(target_list):
|
||||
@@ -685,80 +663,74 @@ class AutoScheduler:
|
||||
self, group_id: str, target_platform_id: str | None = None
|
||||
):
|
||||
"""为指定群生成增量最终报告(业务逻辑委派给 AnalysisApplicationService)"""
|
||||
# 为每个群聊使用独立的锁
|
||||
group_lock_key = f"final_report_{group_id}"
|
||||
if not hasattr(self, "_group_locks"):
|
||||
self._group_locks = weakref.WeakValueDictionary()
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"report_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
|
||||
lock = self._group_locks.get(group_lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._group_locks[group_lock_key] = lock
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 生成增量最终报告 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
|
||||
async with lock:
|
||||
try:
|
||||
# 设置 TraceID
|
||||
trace_id = TraceContext.generate(prefix=f"report_{group_id}")
|
||||
TraceContext.set(trace_id)
|
||||
# 检查平台状态
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 最终报告跳过:bot管理器未就绪")
|
||||
return {"success": False, "reason": "bot_not_ready"}
|
||||
|
||||
logger.info(
|
||||
f"开始为群 {group_id} 生成增量最终报告 "
|
||||
f"(Platform: {target_platform_id or 'Auto'})"
|
||||
)
|
||||
# 委派给应用层服务执行最终报告用例
|
||||
# AnalysisApplicationService 内部已处理群锁 (group_lock)
|
||||
result = await self.analysis_service.execute_incremental_final_report(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
# 检查平台状态
|
||||
if not self.bot_manager.is_ready_for_auto_analysis():
|
||||
logger.warning(f"群 {group_id} 最终报告跳过:bot管理器未就绪")
|
||||
return {"success": False, "reason": "bot_not_ready"}
|
||||
|
||||
# 委派给应用层服务执行最终报告用例
|
||||
result = await self.analysis_service.execute_incremental_final_report(
|
||||
group_id=group_id, platform_id=target_platform_id
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason", "unknown")
|
||||
logger.info(f"群 {group_id} 最终报告跳过: {reason}")
|
||||
return result
|
||||
|
||||
# 获取分析结果及适配器,分发报告
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
|
||||
await self.report_dispatcher.dispatch(
|
||||
group_id,
|
||||
analysis_result,
|
||||
adapter.platform_id
|
||||
if hasattr(adapter, "platform_id")
|
||||
else target_platform_id,
|
||||
)
|
||||
|
||||
# 清理过期批次(保留 2 倍窗口范围的数据作为缓冲)
|
||||
try:
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
before_ts = time_mod.time() - (analysis_days * 2 * 24 * 3600)
|
||||
incremental_store = self.analysis_service.incremental_store
|
||||
if incremental_store:
|
||||
cleaned = await incremental_store.cleanup_old_batches(
|
||||
group_id, before_ts
|
||||
)
|
||||
if cleaned > 0:
|
||||
logger.info(
|
||||
f"群 {group_id} 报告发送后清理了 {cleaned} 个过期批次"
|
||||
)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(
|
||||
f"群 {group_id} 过期批次清理失败(不影响报告): {cleanup_err}"
|
||||
)
|
||||
|
||||
logger.info(f"群 {group_id} 增量最终报告发送成功")
|
||||
if not result.get("success"):
|
||||
reason = result.get("reason", "unknown")
|
||||
logger.info(f"群 {group_id} 最终报告跳过: {reason}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 最终报告执行失败: {e}", exc_info=True)
|
||||
return {"success": False, "reason": str(e)}
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 最终报告流程结束")
|
||||
# 获取分析结果及适配器,分发报告
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
|
||||
await self.report_dispatcher.dispatch(
|
||||
group_id,
|
||||
analysis_result,
|
||||
adapter.platform_id
|
||||
if hasattr(adapter, "platform_id")
|
||||
else target_platform_id,
|
||||
)
|
||||
|
||||
# 清理过期批次(保留 2 倍窗口范围的数据作为缓冲)
|
||||
try:
|
||||
analysis_days = self.config_manager.get_analysis_days()
|
||||
before_ts = time_mod.time() - (analysis_days * 2 * 24 * 3600)
|
||||
incremental_store = self.analysis_service.incremental_store
|
||||
if incremental_store:
|
||||
cleaned = await incremental_store.cleanup_old_batches(
|
||||
group_id, before_ts
|
||||
)
|
||||
if cleaned > 0:
|
||||
logger.info(
|
||||
f"群 {group_id} 报告发送后清理了 {cleaned} 个过期批次"
|
||||
)
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(
|
||||
f"群 {group_id} 过期批次清理失败(不影响报告): {cleanup_err}"
|
||||
)
|
||||
|
||||
logger.info(f"群 {group_id} 增量最终报告发送成功")
|
||||
return result
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# group_lock 抛出的 CancelledError 表示任务正在运行,优雅跳过
|
||||
logger.debug(f"群 {group_id} 最终报告因并发锁冲突而跳过(已在运行)")
|
||||
return {"success": False, "reason": "already_running"}
|
||||
except Exception as e:
|
||||
logger.error(f"群 {group_id} 最终报告执行失败: {e}", exc_info=True)
|
||||
return {"success": False, "reason": str(e)}
|
||||
finally:
|
||||
logger.debug(f"群 {group_id} 最终报告流程结束")
|
||||
|
||||
# ================================================================
|
||||
# 群列表获取(基础设施层)
|
||||
|
||||
@@ -157,7 +157,9 @@ class RetryManager:
|
||||
adapter = self.bot_manager.get_adapter(task.platform_id)
|
||||
if adapter and hasattr(adapter, "was_image_sent_recently"):
|
||||
# 检查过去 5 分钟内的消息回显 (覆盖初发和之前的重试)
|
||||
if await adapter.was_image_sent_recently(task.group_id, seconds=300):
|
||||
if await adapter.was_image_sent_recently(
|
||||
task.group_id, seconds=300, token=task.caption
|
||||
):
|
||||
logger.info(
|
||||
f"[RetryManager] [拦截] 根据历史回显,群 {task.group_id} 的图片已成功送达。取消本次重试。"
|
||||
)
|
||||
@@ -287,7 +289,9 @@ class RetryManager:
|
||||
# 3. 【临界检查 2】发送图片前最后一次复核 (针对渲染耗时极长产生的盲窗)
|
||||
# 例如渲染 10s 期间图片出来了,这里可以最后贴身拦截一次
|
||||
if adapter and hasattr(adapter, "was_image_sent_recently"):
|
||||
if await adapter.was_image_sent_recently(task.group_id, seconds=120):
|
||||
if await adapter.was_image_sent_recently(
|
||||
task.group_id, seconds=120, token=task.caption
|
||||
):
|
||||
logger.info(
|
||||
f"[RetryManager] [临界拦截] 渲染完成后检测到群 {task.group_id} 已有报告。拦截重复发送。"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user