diff --git a/README.md b/README.md index 4d3d664..52866f1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # QQ群日常分析插件 -[![Plugin Version](https://img.shields.io/badge/Latest_Version-v3.6.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) +[![Plugin Version](https://img.shields.io/badge/Latest_Version-v3.7.0-blue.svg?style=for-the-badge&color=76bad9)](https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis) [![AstrBot](https://img.shields.io/badge/AstrBot-Plugin-ff69b4?style=for-the-badge)](https://github.com/AstrBotDevs/AstrBot) [![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](LICENSE) @@ -92,6 +92,19 @@ _✨ 一个基于AstrBot的智能群聊分析插件,能够生成精美的群
📋 点击展开查看完整更新日志 +### v3.7.0 +- feat(provider): 根据配置键获取 Provider,支持多级回退: + + 1. 尝试从配置获取指定的 provider_id(如 topic_provider_id) + + 2. 回退到主 LLM provider_id(llm_provider_id) + + 3. 回退到当前会话的 Provider(通过 umo) + + 4. 回退到第一个可用的 Provider + + + ### v3.6.0 - 由于 napcat 存在的问题 [NapNeko/NapCatQQ#441](https://github.com/NapNeko/NapCatQQ/issues/441) 选择取消分页拉取和多次轮询获取逻辑,解决获取消息数量异常的情况 @exynos967 diff --git a/_conf_schema.json b/_conf_schema.json index 91236bf..8ecd019 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -93,12 +93,6 @@ "default": 5, "hint": "分析报告中显示的最大金句数量,依赖于 LLM 输出的格式化信息质量,模型越好结果越好,可能出现数量不匹配。" }, - "max_query_rounds": { - "type": "int", - "description": "最大消息平台历史消息查询轮数", - "default": 15, - "hint": "有时候平台接口返回的历史消息不完整,导致获取的消息数量不足,可以适当增加查询轮数以获取更多消息,但会增加请求时间和资源消耗。" - }, "llm_timeout": { "type": "int", "description": "LLM 请求超时时间(秒)", @@ -135,23 +129,33 @@ "default": 4096, "hint": "(兼容部分提供商,实测大部分模型调整后没有明显效果)用户称号分析时 LLM 能生成的最大 token 数量。当分析内容较多或者分析提示词复杂时,建议适当调大此值以保证输出质量。" }, - "custom_api_key": { + "llm_provider_id": { "type": "string", - "description": "自定义 LLM 服务 API Key (选填)", + "description": "LLM Provider ID(用于所有分析任务)", + "_special": "select_provider", "default": "", - "hint": "若使用自建或第三方的 LLM 服务,可在此填写 API Key;留空则使用 Astrbot 统一内置提供商。" + "hint": "选择用于群聊分析的 LLM Provider。留空则使用当前会话的默认 Provider" }, - "custom_api_base_url": { + "topic_provider_id": { "type": "string", - "description": "自定义 LLM 服务 Base URL (选填)", + "description": "话题分析专用 Provider ID", + "_special": "select_provider", "default": "", - "hint": "自定义 LLM 服务的基础请求地址,例如 https://openrouter.ai/api/v1/chat/completions 。留空则使用 Astrbot 统一内置提供商。" + "hint": "专门用于话题分析的 Provider。留空则使用主 LLM Provider" }, - "custom_model_name": { + "user_title_provider_id": { "type": "string", - "description": "自定义 LLM 模型名称 (选填)", + "description": "用户称号分析专用 Provider ID", + "_special": "select_provider", "default": "", - "hint": "自定义服务所使用的模型名称,例如 gpt-4 、deepseek/deepseek-r1:free 或自定义模型标识,由于自定义情况复杂,无法给出有效的参数参考,需要根据实际情况例如日志报错判断。留空则使用 Astrbot 统一内置提供商。" + "hint": "专门用于用户称号分析的 Provider。留空则使用主 LLM Provider" + }, + "golden_quote_provider_id": { + "type": "string", + "description": "金句分析专用 Provider ID", + "_special": "select_provider", + "default": "", + "hint": "专门用于金句分析的 Provider。留空则使用主 LLM Provider" }, "pdf_output_dir": { "type": "string", diff --git a/main.py b/main.py index 6ee69fa..0c95072 100644 --- a/main.py +++ b/main.py @@ -412,7 +412,6 @@ class QQGroupDailyAnalysis(Star): pdf_status = PDFInstaller.get_pdf_status(config_manager) output_format = config_manager.get_output_format() min_threshold = config_manager.get_min_messages_threshold() - max_rounds = config_manager.get_max_query_rounds() yield event.plain_result(f"""📊 当前群分析功能状态: • 群分析功能: {status} diff --git a/metadata.yaml b/metadata.yaml index 7bfb5a1..8bd4e30 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -14,6 +14,6 @@ help: | # 插件的帮助信息 命令: /群分析 [天数] - 分析群聊活动 /分析设置 [操作] - 管理设置(enable/disable/status/test) -version: v3.6.0 # 插件版本号。格式:v1.1.1 或者 v1.1 +version: v3.7.0 # 插件版本号。格式:v1.1.1 或者 v1.1 author: SXP-Simon # 作者 repo: https://github.com/SXP-Simon/astrbot-qq-group-daily-analysis # 插件的仓库地址 diff --git a/src/analysis/analyzers/base_analyzer.py b/src/analysis/analyzers/base_analyzer.py index 261cb09..407e115 100644 --- a/src/analysis/analyzers/base_analyzer.py +++ b/src/analysis/analyzers/base_analyzer.py @@ -32,6 +32,16 @@ class BaseAnalyzer(ABC): self.context = context self.config_manager = config_manager + def get_provider_id_key(self) -> str: + """ + 获取 Provider ID 配置键名 + 子类可重写以指定特定的 provider,默认返回 None(使用主 LLM Provider) + + Returns: + Provider ID 配置键名,如 'topic_provider_id' + """ + return None + @abstractmethod def get_data_type(self) -> str: """ @@ -128,12 +138,19 @@ class BaseAnalyzer(ABC): ) return [], TokenUsage() - # 2. 调用LLM + # 2. 调用LLM(使用配置的 provider) max_tokens = self.get_max_tokens() temperature = self.get_temperature() + provider_id_key = self.get_provider_id_key() response = await call_provider_with_retry( - self.context, self.config_manager, prompt, max_tokens, temperature, umo + self.context, + self.config_manager, + prompt, + max_tokens, + temperature, + umo, + provider_id_key, ) if response is None: diff --git a/src/analysis/analyzers/golden_quote_analyzer.py b/src/analysis/analyzers/golden_quote_analyzer.py index 5921547..9f832d4 100644 --- a/src/analysis/analyzers/golden_quote_analyzer.py +++ b/src/analysis/analyzers/golden_quote_analyzer.py @@ -18,6 +18,10 @@ class GoldenQuoteAnalyzer(BaseAnalyzer): 专门处理群聊金句的提取和分析 """ + def get_provider_id_key(self) -> str: + """获取 Provider ID 配置键名""" + return "golden_quote_provider_id" + def get_data_type(self) -> str: """获取数据类型标识""" return "金句" diff --git a/src/analysis/analyzers/topic_analyzer.py b/src/analysis/analyzers/topic_analyzer.py index 27ac694..a04711f 100644 --- a/src/analysis/analyzers/topic_analyzer.py +++ b/src/analysis/analyzers/topic_analyzer.py @@ -19,6 +19,10 @@ class TopicAnalyzer(BaseAnalyzer): 专门处理群聊话题的提取和分析 """ + def get_provider_id_key(self) -> str: + """获取 Provider ID 配置键名""" + return "topic_provider_id" + def get_data_type(self) -> str: """获取数据类型标识""" return "话题" diff --git a/src/analysis/analyzers/user_title_analyzer.py b/src/analysis/analyzers/user_title_analyzer.py index e7c33b9..e16e149 100644 --- a/src/analysis/analyzers/user_title_analyzer.py +++ b/src/analysis/analyzers/user_title_analyzer.py @@ -16,6 +16,10 @@ class UserTitleAnalyzer(BaseAnalyzer): 专门处理用户称号分配和MBTI类型分析 """ + def get_provider_id_key(self) -> str: + """获取 Provider ID 配置键名""" + return "user_title_provider_id" + def get_data_type(self) -> str: """获取数据类型标识""" return "用户称号" diff --git a/src/analysis/llm_analyzer.py b/src/analysis/llm_analyzer.py index c5a3583..abe528a 100644 --- a/src/analysis/llm_analyzer.py +++ b/src/analysis/llm_analyzer.py @@ -193,23 +193,31 @@ class LLMAnalyzer: max_tokens: int, temperature: float, umo: str = None, + provider_id_key: str = None, ): """ 向后兼容的LLM调用方法 现在委托给llm_utils模块处理 Args: - provider: LLM服务商实例或None + provider: LLM服务商实例或None(已弃用,现在使用 provider_id_key) prompt: 输入的提示语 max_tokens: 最大生成token数 temperature: 采样温度 umo: 指定使用的模型唯一标识符 + provider_id_key: 配置中的 provider_id 键名(可选) Returns: LLM生成的结果 """ return await call_provider_with_retry( - self.context, self.config_manager, prompt, max_tokens, temperature, umo + self.context, + self.config_manager, + prompt, + max_tokens, + temperature, + umo, + provider_id_key, ) def _fix_json(self, text: str) -> str: diff --git a/src/analysis/utils/llm_utils.py b/src/analysis/utils/llm_utils.py index aba04e2..08b6e12 100644 --- a/src/analysis/utils/llm_utils.py +++ b/src/analysis/utils/llm_utils.py @@ -9,6 +9,171 @@ from astrbot.api import logger import aiohttp +def _try_get_provider_by_id( + context, provider_id: str, description: str +) -> Optional[Any]: + """ + 尝试通过 ID 获取 Provider 的辅助函数 + + Args: + context: AstrBot上下文对象 + provider_id: Provider ID + description: 描述信息,用于日志 + + Returns: + Provider 实例或 None + """ + if not provider_id or not isinstance(provider_id, str) or not provider_id.strip(): + return None + + provider_id = provider_id.strip() + logger.info(f"尝试使用{description}: {provider_id}") + try: + provider = context.get_provider_by_id(provider_id=provider_id) + if provider: + logger.info(f"✓ 使用{description}: {provider_id}") + return provider + except Exception as e: + logger.warning(f"无法找到{description} '{provider_id}': {e}") + return None + + +def _try_get_session_provider(context, umo: str) -> Optional[Any]: + """ + 尝试获取会话 Provider 的辅助函数 + + Args: + context: AstrBot上下文对象 + umo: unified_msg_origin + + Returns: + Provider 实例或 None + """ + try: + provider = context.get_using_provider(umo=umo) + if provider: + try: + meta = provider.meta() + provider_id = meta.id + logger.info(f"✓ 使用当前会话的 Provider: {provider_id}") + except Exception: + logger.info("✓ 使用当前会话的默认 Provider") + return provider + except Exception as e: + logger.warning(f"无法获取会话 Provider: {e}") + return None + + +def _try_get_first_available_provider(context) -> Optional[Any]: + """ + 尝试获取第一个可用 Provider 的辅助函数 + + Args: + context: AstrBot上下文对象 + + Returns: + Provider 实例或 None + """ + try: + all_providers = context.get_all_providers() + if all_providers and len(all_providers) > 0: + provider = all_providers[0] + logger.info(f"✓ 使用第一个可用 Provider: {type(provider).__name__}") + return provider + except Exception as e: + logger.warning(f"无法获取任何 Provider: {e}") + return None + + +def get_provider_with_fallback( + context, config_manager, provider_id_key: str, umo: str = None +) -> Optional[Any]: + """ + 根据配置键获取 Provider,支持多级回退 + + 回退顺序: + 1. 尝试从配置获取指定的 provider_id(如 topic_provider_id) + 2. 回退到主 LLM provider_id(llm_provider_id) + 3. 回退到当前会话的 Provider(通过 umo) + 4. 回退到第一个可用的 Provider + + Args: + context: AstrBot上下文对象 + config_manager: 配置管理器 + provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id') + umo: unified_msg_origin,用于获取会话默认 Provider + + Returns: + Provider 实例或 None + """ + try: + # 输出Provider选择开始日志 + task_desc = provider_id_key if provider_id_key else "默认任务" + logger.info(f"[Provider 选择] 开始为 {task_desc} 选择 Provider...") + + # 定义回退策略列表 + strategies = [] + strategy_names = [] + + # 1. 特定任务的 provider_id + if provider_id_key: + getter_method = f"get_{provider_id_key}" + if hasattr(config_manager, getter_method): + specific_provider_id = getattr(config_manager, getter_method)() + if specific_provider_id: + strategies.append( + lambda pid=specific_provider_id: _try_get_provider_by_id( + context, pid, f"配置的 {provider_id_key}" + ) + ) + strategy_names.append(f"1. 配置的 {provider_id_key}") + + # 2. 主 LLM provider_id + main_provider_id = config_manager.get_llm_provider_id() + if main_provider_id: + strategies.append( + lambda pid=main_provider_id: _try_get_provider_by_id( + context, pid, "主 LLM Provider" + ) + ) + strategy_names.append("2. 主 LLM Provider") + + # 3. 当前会话的 Provider + strategies.append(lambda: _try_get_session_provider(context, umo)) + strategy_names.append("3. 当前会话 Provider") + + # 4. 第一个可用的 Provider + strategies.append(lambda: _try_get_first_available_provider(context)) + strategy_names.append("4. 第一个可用 Provider") + + # 输出回退策略列表 + logger.info(f"[Provider 选择] 回退策略顺序:{' -> '.join(strategy_names)}") + + # 依次尝试每个策略 + for idx, strategy in enumerate(strategies): + provider = strategy() + if provider: + # 获取最终的 provider ID 用于日志 + final_provider_id = "unknown" + try: + meta = provider.meta() + final_provider_id = meta.id + except Exception: + final_provider_id = type(provider).__name__ + + logger.info( + f"[Provider 选择] ✓ 成功!使用策略 #{idx + 1},Provider ID: {final_provider_id}" + ) + return provider + + logger.error("[Provider 选择] ✗ 失败:所有回退策略均无法获取可用 Provider") + return None + + except Exception as e: + logger.error(f"[Provider 选择] ✗ 异常:Provider 选择过程出错: {e}") + return None + + async def call_provider_with_retry( context, config_manager, @@ -16,9 +181,10 @@ async def call_provider_with_retry( max_tokens: int, temperature: float, umo: str = None, + provider_id_key: str = None, ) -> Optional[Any]: """ - 调用LLM提供者,带超时、重试与退避。支持自定义服务商。 + 调用LLM提供者,带超时、重试与退避。支持自定义服务商和配置化 Provider 选择。 Args: context: AstrBot上下文对象 @@ -27,6 +193,7 @@ async def call_provider_with_retry( max_tokens: 最大生成token数 temperature: 采样温度 umo: 指定使用的模型唯一标识符 + provider_id_key: 配置中的 provider_id 键名(如 'topic_provider_id'),用于选择特定的 Provider Returns: LLM生成的结果,失败时返回None @@ -35,129 +202,47 @@ async def call_provider_with_retry( retries = config_manager.get_llm_retries() backoff = config_manager.get_llm_backoff() - # 获取自定义服务商参数 - custom_api_key = config_manager.get_custom_api_key() - custom_api_base = config_manager.get_custom_api_base_url() - custom_model = config_manager.get_custom_model_name() - last_exc = None for attempt in range(1, retries + 1): try: - if custom_api_key and custom_api_base and custom_model: - logger.info( - f"使用自定义LLM提供商: {custom_api_base} model={custom_model}, max_tokens={max_tokens}, temperature={temperature}" - ) - logger.debug( - f"自定义LLM提供商 prompt 长度: {len(prompt) if prompt else 0}" - ) - logger.debug( - f"自定义LLM提供商 prompt 前100字符: {prompt[:100] if prompt else 'None'}..." + # 使用新的 provider 选择逻辑,支持配置化选择和多级回退 + provider = get_provider_with_fallback( + context, config_manager, provider_id_key, umo + ) + + provider_id = "unknown" + if provider: + try: + meta = provider.meta() + provider_id = meta.id + except Exception as e: + logger.debug(f"获取提供商ID失败: {e}") + + if not provider: + logger.error("provider 为空,无法调用 text_chat,直接返回 None") + return None + + logger.info( + f"[LLM 调用] 使用 Provider: {provider_id} | " + f"max_tokens={max_tokens} | temperature={temperature} | " + f"prompt长度={len(prompt) if prompt else 0}字符" + ) + + logger.debug( + f"[LLM 调用] Prompt 前100字符: {prompt[:100] if prompt else 'None'}..." + ) + + # 检查 prompt 是否为空 + if not prompt or not prompt.strip(): + logger.error( + "LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat" ) + return None - # 检查 prompt 是否为空 - if not prompt or not prompt.strip(): - logger.error( - "自定义LLM提供商: prompt 为空或只包含空白字符,无法发送请求" - ) - return None - - async with aiohttp.ClientSession() as session: - headers = { - "Authorization": f"Bearer {custom_api_key}", - "Content-Type": "application/json", - } - payload = { - "model": custom_model, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": max_tokens, - "temperature": temperature, - } - aio_timeout = aiohttp.ClientTimeout(total=timeout) - async with session.post( - custom_api_base, - json=payload, - headers=headers, - timeout=aio_timeout, - ) as resp: - if resp.status != 200: - error_text = await resp.text() - logger.error( - f"自定义LLM服务商请求失败: HTTP {resp.status}, 内容: {error_text}" - ) - try: - response_json = await resp.json() - except Exception as json_err: - error_text = await resp.text() - logger.error( - f"自定义LLM服务商响应JSON解析失败: {json_err}, 内容: {error_text}" - ) - return None - # 兼容 OpenAI 格式,安全访问嵌套字段 - content = None - try: - choices = response_json.get("choices") - if ( - choices - and isinstance(choices, list) - and len(choices) > 0 - ): - message = choices[0].get("message") - if message and isinstance(message, dict): - content = message.get("content") - if content is None: - logger.error(f"自定义LLM响应格式异常: {response_json}") - return None - except Exception as key_err: - logger.error( - f"自定义LLM响应结构解析失败: {key_err}, 响应内容: {response_json}" - ) - return None - - # 构造一个兼容原有逻辑的对象 - class CustomResponse: - completion_text = content - raw_completion = response_json - - return CustomResponse() - else: - # 确保使用当前指定的模型 - provider = context.get_using_provider(umo=umo) - provider_id = "unknown" - if provider: - try: - meta = provider.meta() - provider_id = meta.id - except Exception as e: - logger.debug(f"获取提供商ID失败: {e}") - logger.info(f"获取到的 provider ID: {provider_id}") - if not provider or provider_id == "unknown": - logger.warning(f"获取的提供商不正确 (Provider ID: {provider_id})") - - logger.info( - f"使用LLM provider: {provider}, max_tokens={max_tokens}, temperature={temperature}" - ) - if not provider: - logger.error("provider 为空,无法调用 text_chat,直接返回 None") - return None - - logger.debug( - f"LLM provider prompt 长度: {len(prompt) if prompt else 0}" - ) - logger.debug( - f"LLM provider prompt 前100字符: {prompt[:100] if prompt else 'None'}..." - ) - - # 检查 prompt 是否为空 - if not prompt or not prompt.strip(): - logger.error( - "LLM provider: prompt 为空或只包含空白字符,无法调用 text_chat" - ) - return None - - coro = provider.text_chat( - prompt=prompt, max_tokens=max_tokens, temperature=temperature - ) - return await asyncio.wait_for(coro, timeout=timeout) + coro = provider.text_chat( + prompt=prompt, max_tokens=max_tokens, temperature=temperature + ) + return await asyncio.wait_for(coro, timeout=timeout) except asyncio.TimeoutError as e: last_exc = e logger.warning(f"LLM请求超时: 第{attempt}次, timeout={timeout}s") diff --git a/src/core/config.py b/src/core/config.py index 2e6f6b1..88f0dc1 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -69,10 +69,6 @@ class ConfigManager: """获取最大金句数量""" return self.config.get("max_golden_quotes", 5) - def get_max_query_rounds(self) -> int: - """获取最大查询轮数""" - return self.config.get("max_query_rounds", 35) - def get_llm_timeout(self) -> int: """获取LLM请求超时时间(秒)""" return self.config.get("llm_timeout", 30) @@ -97,17 +93,21 @@ class ConfigManager: """获取用户称号分析最大token数""" return self.config.get("user_title_max_tokens", 4096) - def get_custom_api_key(self) -> str: - """获取自定义 LLM 服务的 API Key""" - return self.config.get("custom_api_key", "") + def get_llm_provider_id(self) -> str: + """获取主 LLM Provider ID""" + return self.config.get("llm_provider_id", "") - def get_custom_api_base_url(self) -> str: - """获取自定义 LLM 服务的 Base URL""" - return self.config.get("custom_api_base_url", "") + def get_topic_provider_id(self) -> str: + """获取话题分析专用 Provider ID""" + return self.config.get("topic_provider_id", "") - def get_custom_model_name(self) -> str: - """获取自定义 LLM 服务的模型名称""" - return self.config.get("custom_model_name", "") + def get_user_title_provider_id(self) -> str: + """获取用户称号分析专用 Provider ID""" + return self.config.get("user_title_provider_id", "") + + def get_golden_quote_provider_id(self) -> str: + """获取金句分析专用 Provider ID""" + return self.config.get("golden_quote_provider_id", "") def get_pdf_output_dir(self) -> str: """获取PDF输出目录""" @@ -264,11 +264,6 @@ class ConfigManager: self.config["max_golden_quotes"] = count self.config.save_config() - def set_max_query_rounds(self, rounds: int): - """设置最大查询轮数""" - self.config["max_query_rounds"] = rounds - self.config.save_config() - def set_pdf_output_dir(self, directory: str): """设置PDF输出目录""" self.config["pdf_output_dir"] = directory