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:
SXP-Simon
2026-03-03 21:07:51 +08:00
committed by Helian Nuits
parent 3956939837
commit ee0bf037b8
8 changed files with 742 additions and 667 deletions
+1 -1
View File
@@ -116,7 +116,7 @@
"max_concurrent_tasks": {
"type": "int",
"description": "自动分析最大并发数",
"default": 3,
"default": 1,
"hint": "同时进行的群聊分析任务数量,建议根据机器性能和服务商情况调整,过高可能导致LLM API RPM 超出限制,卡顿或被风控"
}
}
+4
View File
@@ -515,6 +515,8 @@ class GroupDailyAnalysis(Star):
if not await adapter.send_text(group_id, text_report):
yield event.plain_result(text_report)
except asyncio.CancelledError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(
@@ -787,6 +789,8 @@ class GroupDailyAnalysis(Star):
try:
await self.auto_scheduler._perform_auto_analysis_for_group(group_id)
yield event.plain_result("✅ 自动分析测试完成,请查看群消息")
except asyncio.CancelledError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except Exception as e:
yield event.plain_result(f"❌ 自动分析测试失败: {str(e)}")
@@ -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(
+3 -2
View 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:
+99 -90
View File
@@ -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)
+172 -200
View File
@@ -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} 最终报告流程结束")
# ================================================================
# 群列表获取(基础设施层)
+6 -2
View File
@@ -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} 已有报告。拦截重复发送。"
)