From 80fc5b86a7d24d254f15e96b7f9c63f3fb52223c Mon Sep 17 00:00:00 2001 From: Rumio <32546670+webjoin111@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:53:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat!(llm):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B9=B6=E5=8D=87=E7=BA=A7=E5=A4=A7=E8=AF=AD=E8=A8=80=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=9C=8D=E5=8A=A1=E4=B8=BA=E5=85=A8=E6=96=B0=20AI=20?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E6=A1=86=E6=9E=B6=20(#2146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * :rotating_light: auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .gitignore | 4 +- envs/pydantic-v1/pyproject.toml | 3 + envs/pydantic-v1/uv.lock | 24 +- envs/pydantic-v2/pyproject.toml | 4 + envs/pydantic-v2/uv.lock | 460 ++++++- pyproject.toml | 4 + requirements.txt | 3 + uv.lock | 455 ++++++- zhenxun/builtin_plugins/help/data_source.py | 18 +- .../builtin_plugins/llm_manager/__init__.py | 159 ++- .../llm_manager/data_source.py | 229 +++- .../builtin_plugins/llm_manager/presenters.py | 103 +- .../superuser/reload_setting.py | 6 +- zhenxun/services/__init__.py | 42 +- zhenxun/services/ai/__init__.py | 19 + zhenxun/services/ai/capabilities/__init__.py | 19 + zhenxun/services/ai/capabilities/base.py | 153 +++ zhenxun/services/ai/capabilities/builtin.py | 461 +++++++ zhenxun/services/ai/capabilities/wrappers.py | 341 +++++ zhenxun/services/ai/config/__init__.py | 19 + zhenxun/services/ai/config/manager.py | 274 ++++ zhenxun/services/ai/config/models.py | 213 +++ zhenxun/services/ai/context/__init__.py | 20 + .../services/ai/context/knowledge/__init__.py | 7 + zhenxun/services/ai/context/knowledge/base.py | 10 + .../ai/context/knowledge/filesystem.py | 131 ++ .../services/ai/context/knowledge/readers.py | 74 + .../services/ai/context/knowledge/vector.py | 319 +++++ .../services/ai/context/memory/__init__.py | 23 + zhenxun/services/ai/context/memory/builder.py | 270 ++++ .../ai/context/memory/capabilities.py | 52 + .../services/ai/context/memory/compression.py | 688 ++++++++++ zhenxun/services/ai/context/memory/engine.py | 260 ++++ zhenxun/services/ai/context/memory/facades.py | 128 ++ zhenxun/services/ai/context/memory/manager.py | 211 +++ zhenxun/services/ai/context/memory/models.py | 173 +++ .../ai/context/memory/storage/__init__.py | 21 + .../ai/context/memory/storage/backends.py | 465 +++++++ .../ai/context/memory/storage/interfaces.py | 110 ++ zhenxun/services/ai/context/memory/types.py | 103 ++ zhenxun/services/ai/context/rag/__init__.py | 26 + .../ai/context/rag/backends/__init__.py | 20 + .../ai/context/rag/backends/embedders.py | 176 +++ .../ai/context/rag/backends/storages.py | 628 +++++++++ zhenxun/services/ai/context/rag/builder.py | 272 ++++ zhenxun/services/ai/context/rag/configs.py | 86 ++ zhenxun/services/ai/context/rag/engine.py | 96 ++ zhenxun/services/ai/context/rag/ingestion.py | 521 +++++++ zhenxun/services/ai/context/rag/models.py | 47 + zhenxun/services/ai/context/rag/retrieval.py | 439 ++++++ zhenxun/services/ai/context/rag/utils.py | 12 + zhenxun/services/ai/core/__init__.py | 25 + .../services/ai/core/engine/append_only.py | 178 +++ .../ai/core/engine/context_renderer.py | 52 + .../ai/core/engine/structured_parser.py | 214 +++ .../services/ai/core/engine/token_counter.py | 191 +++ zhenxun/services/ai/core/exceptions.py | 349 +++++ zhenxun/services/ai/core/messages/__init__.py | 131 ++ .../ai/core/messages/context_events.py | 73 + zhenxun/services/ai/core/messages/models.py | 366 +++++ zhenxun/services/ai/core/messages/parts.py | 341 +++++ zhenxun/services/ai/core/messages/requests.py | 121 ++ .../services/ai/core/messages/responses.py | 189 +++ zhenxun/services/ai/core/messages/shared.py | 121 ++ zhenxun/services/ai/core/messages/types.py | 83 ++ zhenxun/services/ai/core/models.py | 213 +++ zhenxun/services/ai/core/options.py | 408 ++++++ .../services/ai/core/protocols/__init__.py | 11 + zhenxun/services/ai/core/protocols/llm.py | 76 ++ .../services/ai/core/protocols/middleware.py | 23 + zhenxun/services/ai/core/protocols/tool.py | 84 ++ zhenxun/services/ai/core/stream_events.py | 176 +++ zhenxun/services/ai/core/templates.py | 103 ++ zhenxun/services/ai/flow/__init__.py | 18 + zhenxun/services/ai/flow/agent/__init__.py | 11 + zhenxun/services/ai/flow/agent/agent.py | 1069 +++++++++++++++ zhenxun/services/ai/flow/agent/bridge.py | 142 ++ .../services/ai/flow/agent/capabilities.py | 156 +++ .../services/ai/flow/agent/engine/__init__.py | 20 + .../services/ai/flow/agent/engine/builders.py | 434 ++++++ .../ai/flow/agent/engine/directive.py | 115 ++ .../services/ai/flow/agent/engine/executor.py | 691 ++++++++++ zhenxun/services/ai/flow/agent/models.py | 178 +++ zhenxun/services/ai/flow/base.py | 149 ++ zhenxun/services/ai/flow/concurrency.py | 111 ++ zhenxun/services/ai/flow/team/__init__.py | 7 + zhenxun/services/ai/flow/team/capabilities.py | 131 ++ zhenxun/services/ai/flow/team/models.py | 265 ++++ zhenxun/services/ai/flow/team/router.py | 247 ++++ zhenxun/services/ai/flow/team/runner.py | 235 ++++ zhenxun/services/ai/flow/team/strategy.py | 641 +++++++++ zhenxun/services/ai/flow/team/task_tools.py | 196 +++ zhenxun/services/ai/flow/team/team.py | 377 +++++ zhenxun/services/ai/flow/workflow/__init__.py | 27 + zhenxun/services/ai/flow/workflow/auto.py | 107 ++ zhenxun/services/ai/flow/workflow/base.py | 253 ++++ .../services/ai/flow/workflow/decorators.py | 91 ++ zhenxun/services/ai/flow/workflow/engine.py | 329 +++++ zhenxun/services/ai/flow/workflow/nodes.py | 628 +++++++++ zhenxun/services/ai/flow/workflow/types.py | 253 ++++ zhenxun/services/ai/guardrails.py | 431 ++++++ zhenxun/services/ai/llm/__init__.py | 48 + .../{ => ai}/llm/adapters/__init__.py | 18 +- zhenxun/services/ai/llm/adapters/base.py | 702 ++++++++++ zhenxun/services/ai/llm/adapters/deepseek.py | 140 ++ zhenxun/services/ai/llm/adapters/doubao.py | 31 + .../services/{ => ai}/llm/adapters/factory.py | 108 +- zhenxun/services/ai/llm/adapters/gemini.py | 59 + zhenxun/services/ai/llm/adapters/glm.py | 78 ++ .../llm/adapters/handlers}/__init__.py | 0 .../services/ai/llm/adapters/handlers/base.py | 223 +++ .../llm/adapters/handlers/gemini_handlers.py | 1066 +++++++++++++++ .../ai/llm/adapters/handlers/mimo_handlers.py | 199 +++ .../llm/adapters/handlers/openai_handlers.py | 1209 +++++++++++++++++ zhenxun/services/ai/llm/adapters/jina.py | 129 ++ zhenxun/services/ai/llm/adapters/mimo.py | 30 + zhenxun/services/ai/llm/adapters/minimax.py | 243 ++++ zhenxun/services/ai/llm/adapters/openai.py | 122 ++ .../services/ai/llm/adapters/openrouter.py | 202 +++ zhenxun/services/ai/llm/api.py | 526 +++++++ zhenxun/services/ai/llm/builder.py | 228 ++++ zhenxun/services/ai/llm/engine/__init__.py | 3 + zhenxun/services/ai/llm/engine/middlewares.py | 641 +++++++++ zhenxun/services/ai/llm/engine/router.py | 162 +++ .../ai/llm/engine/schema_transformer.py | 356 +++++ zhenxun/services/ai/llm/engine/service.py | 280 ++++ zhenxun/services/ai/llm/manager.py | 311 +++++ zhenxun/services/ai/llm/system/__init__.py | 3 + zhenxun/services/ai/llm/system/cache.py | 173 +++ .../services/ai/llm/system/capabilities.py | 352 +++++ zhenxun/services/ai/llm/system/models.py | 112 ++ zhenxun/services/ai/llm/system/network.py | 594 ++++++++ zhenxun/services/ai/message_builder.py | 640 +++++++++ zhenxun/services/ai/run/__init__.py | 38 + zhenxun/services/ai/run/blackboard.py | 75 + zhenxun/services/ai/run/capabilities.py | 40 + zhenxun/services/ai/run/context.py | 410 ++++++ zhenxun/services/ai/run/di.py | 387 ++++++ zhenxun/services/ai/run/hitl.py | 144 ++ zhenxun/services/ai/run/hooks.py | 705 ++++++++++ zhenxun/services/ai/run/models.py | 279 ++++ zhenxun/services/ai/run/session.py | 169 +++ zhenxun/services/ai/run/subscribers.py | 138 ++ zhenxun/services/ai/run/ui.py | 83 ++ zhenxun/services/ai/sandbox/__init__.py | 31 + .../services/ai/sandbox/addons/__init__.py | 4 + zhenxun/services/ai/sandbox/addons/base.py | 37 + .../services/ai/sandbox/addons/mcp_proxy.py | 94 ++ .../services/ai/sandbox/drivers/__init__.py | 0 zhenxun/services/ai/sandbox/drivers/base.py | 173 +++ zhenxun/services/ai/sandbox/drivers/docker.py | 632 +++++++++ zhenxun/services/ai/sandbox/environments.py | 126 ++ zhenxun/services/ai/sandbox/manager.py | 209 +++ zhenxun/services/ai/sandbox/models.py | 467 +++++++ zhenxun/services/ai/sandbox/protocols.py | 189 +++ zhenxun/services/ai/sandbox/registry.py | 45 + zhenxun/services/ai/sandbox/runtimes.py | 562 ++++++++ zhenxun/services/ai/sandbox/storage.py | 131 ++ zhenxun/services/ai/tools/__init__.py | 32 + zhenxun/services/ai/tools/bridges/__init__.py | 9 + zhenxun/services/ai/tools/bridges/delegate.py | 156 +++ zhenxun/services/ai/tools/bridges/handoff.py | 99 ++ .../ai/tools/bridges/matcher_bridge.py | 464 +++++++ zhenxun/services/ai/tools/core/__init__.py | 23 + .../services/ai/tools/core/capabilities.py | 398 ++++++ zhenxun/services/ai/tools/core/decorators.py | 423 ++++++ zhenxun/services/ai/tools/core/schema.py | 226 +++ zhenxun/services/ai/tools/core/tool.py | 441 ++++++ zhenxun/services/ai/tools/core/toolkit.py | 272 ++++ zhenxun/services/ai/tools/engine/__init__.py | 7 + zhenxun/services/ai/tools/engine/executor.py | 547 ++++++++ zhenxun/services/ai/tools/engine/registry.py | 575 ++++++++ zhenxun/services/ai/tools/models.py | 329 +++++ .../services/ai/tools/providers/__init__.py | 0 .../ai/tools/providers/builtin/__init__.py | 15 + .../ai/tools/providers/builtin/blackboard.py | 72 + .../ai/tools/providers/builtin/hitl.py | 56 + .../ai/tools/providers/builtin/memory.py | 324 +++++ .../ai/tools/providers/builtin/native.py | 129 ++ .../ai/tools/providers/builtin/rest_api.py | 132 ++ .../ai/tools/providers/builtin/sandbox.py | 425 ++++++ .../ai/tools/providers/builtin/slots.py | 368 +++++ .../ai/tools/providers/mcp/__init__.py | 3 + .../ai/tools/providers/mcp/provider.py | 366 +++++ .../ai/tools/providers/mcp/toolkit.py | 612 +++++++++ .../ai/tools/providers/skills/__init__.py | 21 + .../ai/tools/providers/skills/capabilities.py | 33 + .../ai/tools/providers/skills/manager.py | 401 ++++++ .../ai/tools/providers/skills/models.py | 260 ++++ .../ai/tools/providers/skills/toolkit.py | 320 +++++ zhenxun/services/ai/utils/__init__.py | 3 + zhenxun/services/ai/utils/runtime.py | 166 +++ zhenxun/services/ai/utils/scope.py | 226 +++ zhenxun/services/ai/utils/utils.py | 57 + zhenxun/services/db_context/base_model.py | 6 +- zhenxun/services/llm/__init__.py | 451 ++++-- zhenxun/services/llm/adapters/base.py | 595 -------- .../adapters/components/gemini_components.py | 606 --------- .../llm/adapters/components/interfaces.py | 43 - .../adapters/components/openai_components.py | 347 ----- zhenxun/services/llm/adapters/gemini.py | 326 ----- zhenxun/services/llm/adapters/openai.py | 599 -------- zhenxun/services/llm/api.py | 407 ------ zhenxun/services/llm/config/__init__.py | 35 - zhenxun/services/llm/config/generation.py | 523 ------- zhenxun/services/llm/config/providers.py | 451 ------ zhenxun/services/llm/core.py | 596 -------- zhenxun/services/llm/manager.py | 474 ------- zhenxun/services/llm/memory.py | 243 ---- zhenxun/services/llm/service.py | 830 ----------- zhenxun/services/llm/session.py | 793 ----------- zhenxun/services/llm/tools.py | 851 ------------ zhenxun/services/llm/types/__init__.py | 76 -- zhenxun/services/llm/types/capabilities.py | 273 ---- zhenxun/services/llm/types/exceptions.py | 113 -- zhenxun/services/llm/types/models.py | 672 --------- zhenxun/services/llm/types/protocols.py | 156 --- zhenxun/services/llm/utils.py | 593 -------- zhenxun/services/log.py | 25 +- zhenxun/utils/lifespan.py | 181 +++ zhenxun/utils/log_sanitizer.py | 159 ++- zhenxun/utils/pydantic_compat.py | 33 + zhenxun/utils/utils.py | 47 + 223 files changed, 41885 insertions(+), 10006 deletions(-) create mode 100644 zhenxun/services/ai/__init__.py create mode 100644 zhenxun/services/ai/capabilities/__init__.py create mode 100644 zhenxun/services/ai/capabilities/base.py create mode 100644 zhenxun/services/ai/capabilities/builtin.py create mode 100644 zhenxun/services/ai/capabilities/wrappers.py create mode 100644 zhenxun/services/ai/config/__init__.py create mode 100644 zhenxun/services/ai/config/manager.py create mode 100644 zhenxun/services/ai/config/models.py create mode 100644 zhenxun/services/ai/context/__init__.py create mode 100644 zhenxun/services/ai/context/knowledge/__init__.py create mode 100644 zhenxun/services/ai/context/knowledge/base.py create mode 100644 zhenxun/services/ai/context/knowledge/filesystem.py create mode 100644 zhenxun/services/ai/context/knowledge/readers.py create mode 100644 zhenxun/services/ai/context/knowledge/vector.py create mode 100644 zhenxun/services/ai/context/memory/__init__.py create mode 100644 zhenxun/services/ai/context/memory/builder.py create mode 100644 zhenxun/services/ai/context/memory/capabilities.py create mode 100644 zhenxun/services/ai/context/memory/compression.py create mode 100644 zhenxun/services/ai/context/memory/engine.py create mode 100644 zhenxun/services/ai/context/memory/facades.py create mode 100644 zhenxun/services/ai/context/memory/manager.py create mode 100644 zhenxun/services/ai/context/memory/models.py create mode 100644 zhenxun/services/ai/context/memory/storage/__init__.py create mode 100644 zhenxun/services/ai/context/memory/storage/backends.py create mode 100644 zhenxun/services/ai/context/memory/storage/interfaces.py create mode 100644 zhenxun/services/ai/context/memory/types.py create mode 100644 zhenxun/services/ai/context/rag/__init__.py create mode 100644 zhenxun/services/ai/context/rag/backends/__init__.py create mode 100644 zhenxun/services/ai/context/rag/backends/embedders.py create mode 100644 zhenxun/services/ai/context/rag/backends/storages.py create mode 100644 zhenxun/services/ai/context/rag/builder.py create mode 100644 zhenxun/services/ai/context/rag/configs.py create mode 100644 zhenxun/services/ai/context/rag/engine.py create mode 100644 zhenxun/services/ai/context/rag/ingestion.py create mode 100644 zhenxun/services/ai/context/rag/models.py create mode 100644 zhenxun/services/ai/context/rag/retrieval.py create mode 100644 zhenxun/services/ai/context/rag/utils.py create mode 100644 zhenxun/services/ai/core/__init__.py create mode 100644 zhenxun/services/ai/core/engine/append_only.py create mode 100644 zhenxun/services/ai/core/engine/context_renderer.py create mode 100644 zhenxun/services/ai/core/engine/structured_parser.py create mode 100644 zhenxun/services/ai/core/engine/token_counter.py create mode 100644 zhenxun/services/ai/core/exceptions.py create mode 100644 zhenxun/services/ai/core/messages/__init__.py create mode 100644 zhenxun/services/ai/core/messages/context_events.py create mode 100644 zhenxun/services/ai/core/messages/models.py create mode 100644 zhenxun/services/ai/core/messages/parts.py create mode 100644 zhenxun/services/ai/core/messages/requests.py create mode 100644 zhenxun/services/ai/core/messages/responses.py create mode 100644 zhenxun/services/ai/core/messages/shared.py create mode 100644 zhenxun/services/ai/core/messages/types.py create mode 100644 zhenxun/services/ai/core/models.py create mode 100644 zhenxun/services/ai/core/options.py create mode 100644 zhenxun/services/ai/core/protocols/__init__.py create mode 100644 zhenxun/services/ai/core/protocols/llm.py create mode 100644 zhenxun/services/ai/core/protocols/middleware.py create mode 100644 zhenxun/services/ai/core/protocols/tool.py create mode 100644 zhenxun/services/ai/core/stream_events.py create mode 100644 zhenxun/services/ai/core/templates.py create mode 100644 zhenxun/services/ai/flow/__init__.py create mode 100644 zhenxun/services/ai/flow/agent/__init__.py create mode 100644 zhenxun/services/ai/flow/agent/agent.py create mode 100644 zhenxun/services/ai/flow/agent/bridge.py create mode 100644 zhenxun/services/ai/flow/agent/capabilities.py create mode 100644 zhenxun/services/ai/flow/agent/engine/__init__.py create mode 100644 zhenxun/services/ai/flow/agent/engine/builders.py create mode 100644 zhenxun/services/ai/flow/agent/engine/directive.py create mode 100644 zhenxun/services/ai/flow/agent/engine/executor.py create mode 100644 zhenxun/services/ai/flow/agent/models.py create mode 100644 zhenxun/services/ai/flow/base.py create mode 100644 zhenxun/services/ai/flow/concurrency.py create mode 100644 zhenxun/services/ai/flow/team/__init__.py create mode 100644 zhenxun/services/ai/flow/team/capabilities.py create mode 100644 zhenxun/services/ai/flow/team/models.py create mode 100644 zhenxun/services/ai/flow/team/router.py create mode 100644 zhenxun/services/ai/flow/team/runner.py create mode 100644 zhenxun/services/ai/flow/team/strategy.py create mode 100644 zhenxun/services/ai/flow/team/task_tools.py create mode 100644 zhenxun/services/ai/flow/team/team.py create mode 100644 zhenxun/services/ai/flow/workflow/__init__.py create mode 100644 zhenxun/services/ai/flow/workflow/auto.py create mode 100644 zhenxun/services/ai/flow/workflow/base.py create mode 100644 zhenxun/services/ai/flow/workflow/decorators.py create mode 100644 zhenxun/services/ai/flow/workflow/engine.py create mode 100644 zhenxun/services/ai/flow/workflow/nodes.py create mode 100644 zhenxun/services/ai/flow/workflow/types.py create mode 100644 zhenxun/services/ai/guardrails.py create mode 100644 zhenxun/services/ai/llm/__init__.py rename zhenxun/services/{ => ai}/llm/adapters/__init__.py (51%) create mode 100644 zhenxun/services/ai/llm/adapters/base.py create mode 100644 zhenxun/services/ai/llm/adapters/deepseek.py create mode 100644 zhenxun/services/ai/llm/adapters/doubao.py rename zhenxun/services/{ => ai}/llm/adapters/factory.py (56%) create mode 100644 zhenxun/services/ai/llm/adapters/gemini.py create mode 100644 zhenxun/services/ai/llm/adapters/glm.py rename zhenxun/services/{llm/adapters/components => ai/llm/adapters/handlers}/__init__.py (100%) create mode 100644 zhenxun/services/ai/llm/adapters/handlers/base.py create mode 100644 zhenxun/services/ai/llm/adapters/handlers/gemini_handlers.py create mode 100644 zhenxun/services/ai/llm/adapters/handlers/mimo_handlers.py create mode 100644 zhenxun/services/ai/llm/adapters/handlers/openai_handlers.py create mode 100644 zhenxun/services/ai/llm/adapters/jina.py create mode 100644 zhenxun/services/ai/llm/adapters/mimo.py create mode 100644 zhenxun/services/ai/llm/adapters/minimax.py create mode 100644 zhenxun/services/ai/llm/adapters/openai.py create mode 100644 zhenxun/services/ai/llm/adapters/openrouter.py create mode 100644 zhenxun/services/ai/llm/api.py create mode 100644 zhenxun/services/ai/llm/builder.py create mode 100644 zhenxun/services/ai/llm/engine/__init__.py create mode 100644 zhenxun/services/ai/llm/engine/middlewares.py create mode 100644 zhenxun/services/ai/llm/engine/router.py create mode 100644 zhenxun/services/ai/llm/engine/schema_transformer.py create mode 100644 zhenxun/services/ai/llm/engine/service.py create mode 100644 zhenxun/services/ai/llm/manager.py create mode 100644 zhenxun/services/ai/llm/system/__init__.py create mode 100644 zhenxun/services/ai/llm/system/cache.py create mode 100644 zhenxun/services/ai/llm/system/capabilities.py create mode 100644 zhenxun/services/ai/llm/system/models.py create mode 100644 zhenxun/services/ai/llm/system/network.py create mode 100644 zhenxun/services/ai/message_builder.py create mode 100644 zhenxun/services/ai/run/__init__.py create mode 100644 zhenxun/services/ai/run/blackboard.py create mode 100644 zhenxun/services/ai/run/capabilities.py create mode 100644 zhenxun/services/ai/run/context.py create mode 100644 zhenxun/services/ai/run/di.py create mode 100644 zhenxun/services/ai/run/hitl.py create mode 100644 zhenxun/services/ai/run/hooks.py create mode 100644 zhenxun/services/ai/run/models.py create mode 100644 zhenxun/services/ai/run/session.py create mode 100644 zhenxun/services/ai/run/subscribers.py create mode 100644 zhenxun/services/ai/run/ui.py create mode 100644 zhenxun/services/ai/sandbox/__init__.py create mode 100644 zhenxun/services/ai/sandbox/addons/__init__.py create mode 100644 zhenxun/services/ai/sandbox/addons/base.py create mode 100644 zhenxun/services/ai/sandbox/addons/mcp_proxy.py create mode 100644 zhenxun/services/ai/sandbox/drivers/__init__.py create mode 100644 zhenxun/services/ai/sandbox/drivers/base.py create mode 100644 zhenxun/services/ai/sandbox/drivers/docker.py create mode 100644 zhenxun/services/ai/sandbox/environments.py create mode 100644 zhenxun/services/ai/sandbox/manager.py create mode 100644 zhenxun/services/ai/sandbox/models.py create mode 100644 zhenxun/services/ai/sandbox/protocols.py create mode 100644 zhenxun/services/ai/sandbox/registry.py create mode 100644 zhenxun/services/ai/sandbox/runtimes.py create mode 100644 zhenxun/services/ai/sandbox/storage.py create mode 100644 zhenxun/services/ai/tools/__init__.py create mode 100644 zhenxun/services/ai/tools/bridges/__init__.py create mode 100644 zhenxun/services/ai/tools/bridges/delegate.py create mode 100644 zhenxun/services/ai/tools/bridges/handoff.py create mode 100644 zhenxun/services/ai/tools/bridges/matcher_bridge.py create mode 100644 zhenxun/services/ai/tools/core/__init__.py create mode 100644 zhenxun/services/ai/tools/core/capabilities.py create mode 100644 zhenxun/services/ai/tools/core/decorators.py create mode 100644 zhenxun/services/ai/tools/core/schema.py create mode 100644 zhenxun/services/ai/tools/core/tool.py create mode 100644 zhenxun/services/ai/tools/core/toolkit.py create mode 100644 zhenxun/services/ai/tools/engine/__init__.py create mode 100644 zhenxun/services/ai/tools/engine/executor.py create mode 100644 zhenxun/services/ai/tools/engine/registry.py create mode 100644 zhenxun/services/ai/tools/models.py create mode 100644 zhenxun/services/ai/tools/providers/__init__.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/__init__.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/blackboard.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/hitl.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/memory.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/native.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/rest_api.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/sandbox.py create mode 100644 zhenxun/services/ai/tools/providers/builtin/slots.py create mode 100644 zhenxun/services/ai/tools/providers/mcp/__init__.py create mode 100644 zhenxun/services/ai/tools/providers/mcp/provider.py create mode 100644 zhenxun/services/ai/tools/providers/mcp/toolkit.py create mode 100644 zhenxun/services/ai/tools/providers/skills/__init__.py create mode 100644 zhenxun/services/ai/tools/providers/skills/capabilities.py create mode 100644 zhenxun/services/ai/tools/providers/skills/manager.py create mode 100644 zhenxun/services/ai/tools/providers/skills/models.py create mode 100644 zhenxun/services/ai/tools/providers/skills/toolkit.py create mode 100644 zhenxun/services/ai/utils/__init__.py create mode 100644 zhenxun/services/ai/utils/runtime.py create mode 100644 zhenxun/services/ai/utils/scope.py create mode 100644 zhenxun/services/ai/utils/utils.py delete mode 100644 zhenxun/services/llm/adapters/base.py delete mode 100644 zhenxun/services/llm/adapters/components/gemini_components.py delete mode 100644 zhenxun/services/llm/adapters/components/interfaces.py delete mode 100644 zhenxun/services/llm/adapters/components/openai_components.py delete mode 100644 zhenxun/services/llm/adapters/gemini.py delete mode 100644 zhenxun/services/llm/adapters/openai.py delete mode 100644 zhenxun/services/llm/api.py delete mode 100644 zhenxun/services/llm/config/__init__.py delete mode 100644 zhenxun/services/llm/config/generation.py delete mode 100644 zhenxun/services/llm/config/providers.py delete mode 100644 zhenxun/services/llm/core.py delete mode 100644 zhenxun/services/llm/manager.py delete mode 100644 zhenxun/services/llm/memory.py delete mode 100644 zhenxun/services/llm/service.py delete mode 100644 zhenxun/services/llm/session.py delete mode 100644 zhenxun/services/llm/tools.py delete mode 100644 zhenxun/services/llm/types/__init__.py delete mode 100644 zhenxun/services/llm/types/capabilities.py delete mode 100644 zhenxun/services/llm/types/exceptions.py delete mode 100644 zhenxun/services/llm/types/models.py delete mode 100644 zhenxun/services/llm/types/protocols.py delete mode 100644 zhenxun/services/llm/utils.py create mode 100644 zhenxun/utils/lifespan.py diff --git a/.gitignore b/.gitignore index c408e941..6006696b 100644 --- a/.gitignore +++ b/.gitignore @@ -144,7 +144,7 @@ data/ log/ backup/ .idea/ -resources/ +/resources .vscode/launch.json -./.env.dev +./.env.dev \ No newline at end of file diff --git a/envs/pydantic-v1/pyproject.toml b/envs/pydantic-v1/pyproject.toml index 3f38ebeb..32323ddd 100644 --- a/envs/pydantic-v1/pyproject.toml +++ b/envs/pydantic-v1/pyproject.toml @@ -41,6 +41,8 @@ dependencies = [ "pydantic>=1.0.0,<2.0.0", "json-repair>=0.54.0,<0.55.0", "alibabacloud-devops20210625>=5.0.2,<6.0.0", + "jieba>=0.42.1", + "aiodocker>=0.24.0", ] [project.optional-dependencies] @@ -138,6 +140,7 @@ executionEnvironments = [ typeCheckingMode = "standard" reportShadowedImports = false +reportMissingImports = "none" disableBytesTypePromotions = true [tool.pytest.ini_options] diff --git a/envs/pydantic-v1/uv.lock b/envs/pydantic-v1/uv.lock index 7c77a621..51c2841b 100644 --- a/envs/pydantic-v1/uv.lock +++ b/envs/pydantic-v1/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -20,6 +20,18 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "aiodocker" +version = "0.27.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/19/f07d8532d7489ed8629847d004d0bbd286287286a76828be43aca7e6b106/aiodocker-0.27.0.tar.gz", hash = "sha256:74586f4929aee4563ee7db50996a1a39ac35e06c80701daad31f94aadb6551c7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/54/cd3aa680c9653b8708e164d610948e7a9aa50bba619705ac6169f61b0837/aiodocker-0.27.0-py3-none-any.whl", hash = "sha256:c55647bdcaf546dd4fc9b52794a9510575e126888dff478a9185f580a40dc45e" }, +] + [[package]] name = "aiofiles" version = "23.2.1" @@ -1378,6 +1390,12 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/65/6c/9d72435c72adfa6e4ed1824b6df7fffbeaaf15c653881e9b041a318ba572/iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f" }, ] +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2" } + [[package]] name = "jinja2" version = "3.1.6" @@ -4181,6 +4199,7 @@ version = "0.2.4" source = { virtual = "." } dependencies = [ { name = "aiocache", extra = ["redis"] }, + { name = "aiodocker" }, { name = "aiofiles" }, { name = "alibabacloud-devops20210625" }, { name = "beautifulsoup4" }, @@ -4189,6 +4208,7 @@ dependencies = [ { name = "dateparser" }, { name = "feedparser" }, { name = "imagehash" }, + { name = "jieba" }, { name = "json-repair" }, { name = "lxml" }, { name = "multidict" }, @@ -4240,6 +4260,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiocache", extras = ["redis"], specifier = ">=0.12.3,<0.13.0" }, + { name = "aiodocker", specifier = ">=0.24.0" }, { name = "aiofiles", specifier = ">=23.2.1,<24.0.0" }, { name = "alibabacloud-devops20210625", specifier = ">=5.0.2,<6.0.0" }, { name = "asyncpg", marker = "extra == 'postgresql'", specifier = ">=0.20.0" }, @@ -4249,6 +4270,7 @@ requires-dist = [ { name = "dateparser", specifier = ">=1.2.0,<2.0.0" }, { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "imagehash", specifier = ">=4.3.1,<5.0.0" }, + { name = "jieba", specifier = ">=0.42.1" }, { name = "json-repair", specifier = ">=0.54.0,<0.55.0" }, { name = "lxml", specifier = ">=5.1.0,<6.0.0" }, { name = "multidict", specifier = ">=6.0.0,!=6.3.2" }, diff --git a/envs/pydantic-v2/pyproject.toml b/envs/pydantic-v2/pyproject.toml index 5d1c0201..a4119467 100644 --- a/envs/pydantic-v2/pyproject.toml +++ b/envs/pydantic-v2/pyproject.toml @@ -41,6 +41,9 @@ dependencies = [ "pydantic>=2.0.0,<3.0.0", "json-repair>=0.54.0,<0.55.0", "alibabacloud-devops20210625>=5.0.2,<6.0.0", + "mcp>=1.8.0", + "jieba>=0.42.1", + "aiodocker>=0.24.0", ] [project.optional-dependencies] @@ -138,6 +141,7 @@ executionEnvironments = [ typeCheckingMode = "standard" reportShadowedImports = false +reportMissingImports = "none" disableBytesTypePromotions = true [tool.pytest.ini_options] diff --git a/envs/pydantic-v2/uv.lock b/envs/pydantic-v2/uv.lock index dc747702..9d75e30e 100644 --- a/envs/pydantic-v2/uv.lock +++ b/envs/pydantic-v2/uv.lock @@ -1,8 +1,11 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -20,6 +23,18 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "aiodocker" +version = "0.27.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/19/f07d8532d7489ed8629847d004d0bbd286287286a76828be43aca7e6b106/aiodocker-0.27.0.tar.gz", hash = "sha256:74586f4929aee4563ee7db50996a1a39ac35e06c80701daad31f94aadb6551c7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/54/cd3aa680c9653b8708e164d610948e7a9aa50bba619705ac6169f61b0837/aiodocker-0.27.0-py3-none-any.whl", hash = "sha256:c55647bdcaf546dd4fc9b52794a9510575e126888dff478a9185f580a40dc45e" }, +] + [[package]] name = "aiofiles" version = "23.2.1" @@ -1322,6 +1337,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -1388,6 +1412,12 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/65/6c/9d72435c72adfa6e4ed1824b6df7fffbeaaf15c653881e9b041a318ba572/iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f" }, ] +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2" } + [[package]] name = "jinja2" version = "3.1.6" @@ -1409,6 +1439,34 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/e9/08/abe317237add63c3e62f18a981bccf92112b431835b43d844aedaf61f4a0/json_repair-0.54.3-py3-none-any.whl", hash = "sha256:4cdc132ee27d4780576f71bf27a113877046224a808bfc17392e079cb344fb81" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -1627,6 +1685,31 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -2134,7 +2217,10 @@ name = "numpy" version = "2.4.4" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0" } wheels = [ @@ -2648,6 +2734,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de" }, +] + [[package]] name = "pyee" version = "13.0.1" @@ -2678,6 +2778,23 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ec/cd/bd196b2cf014afb1009de8b0f05ecd54011d881944e62763f3c1b1e8ef37/pygtrie-2.5.0-py3-none-any.whl", hash = "sha256:8795cda8105493d5ae159a5bef313ff13156c5d4d72feddefacaad59f8c8ce16" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.21.2" @@ -2928,7 +3045,10 @@ name = "pywavelets" version = "1.9.0" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.4.4", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, @@ -2985,6 +3105,31 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3061,6 +3206,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + [[package]] name = "regex" version = "2026.4.4" @@ -3231,6 +3391,274 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -3402,7 +3830,10 @@ name = "scipy" version = "1.17.1" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.4.4", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, @@ -3495,6 +3926,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -4298,6 +4742,7 @@ version = "0.2.4" source = { virtual = "." } dependencies = [ { name = "aiocache", extra = ["redis"] }, + { name = "aiodocker" }, { name = "aiofiles" }, { name = "alibabacloud-devops20210625" }, { name = "beautifulsoup4" }, @@ -4306,8 +4751,10 @@ dependencies = [ { name = "dateparser" }, { name = "feedparser" }, { name = "imagehash" }, + { name = "jieba" }, { name = "json-repair" }, { name = "lxml" }, + { name = "mcp" }, { name = "multidict" }, { name = "nb-cli" }, { name = "nonebot-adapter-onebot" }, @@ -4357,6 +4804,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiocache", extras = ["redis"], specifier = ">=0.12.3,<0.13.0" }, + { name = "aiodocker", specifier = ">=0.24.0" }, { name = "aiofiles", specifier = ">=23.2.1,<24.0.0" }, { name = "alibabacloud-devops20210625", specifier = ">=5.0.2,<6.0.0" }, { name = "asyncpg", marker = "extra == 'postgresql'", specifier = ">=0.20.0" }, @@ -4366,8 +4814,10 @@ requires-dist = [ { name = "dateparser", specifier = ">=1.2.0,<2.0.0" }, { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "imagehash", specifier = ">=4.3.1,<5.0.0" }, + { name = "jieba", specifier = ">=0.42.1" }, { name = "json-repair", specifier = ">=0.54.0,<0.55.0" }, { name = "lxml", specifier = ">=5.1.0,<6.0.0" }, + { name = "mcp", specifier = ">=1.8.0" }, { name = "multidict", specifier = ">=6.0.0,!=6.3.2" }, { name = "nb-cli", specifier = ">=1.3.0" }, { name = "nonebot-adapter-onebot", specifier = ">=2.3.1" }, diff --git a/pyproject.toml b/pyproject.toml index e508cfe4..a8eee325 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ dependencies = [ "uvloop>=0.21.0; sys_platform != 'win32'", "pytest-timeout>=2.4.0", "aiomysql>=0.3.2", + "mcp>=1.8.0", + "jieba>=0.42.1", + "aiodocker>=0.24.0", ] [project.scripts] @@ -144,6 +147,7 @@ executionEnvironments = [ typeCheckingMode = "standard" reportShadowedImports = false +reportMissingImports = "none" disableBytesTypePromotions = true [tool.pytest.ini_options] diff --git a/requirements.txt b/requirements.txt index 3251cf37..14f4dcec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,6 @@ alibabacloud-devops20210625>=5.0.2,<6.0.0 json_repair>=0.54.0,<0.55.0 redis>=5 asyncpg>=0.20.0 +mcp>=1.8.0 +jieba>=0.42.1 +aiodocker>=0.24.0 diff --git a/uv.lock b/uv.lock index dd9d37d1..7a2d9ed3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,11 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -20,6 +23,18 @@ redis = [ { name = "redis" }, ] +[[package]] +name = "aiodocker" +version = "0.27.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/19/f07d8532d7489ed8629847d004d0bbd286287286a76828be43aca7e6b106/aiodocker-0.27.0.tar.gz", hash = "sha256:74586f4929aee4563ee7db50996a1a39ac35e06c80701daad31f94aadb6551c7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/54/cd3aa680c9653b8708e164d610948e7a9aa50bba619705ac6169f61b0837/aiodocker-0.27.0-py3-none-any.whl", hash = "sha256:c55647bdcaf546dd4fc9b52794a9510575e126888dff478a9185f580a40dc45e" }, +] + [[package]] name = "aiofiles" version = "24.1.0" @@ -1327,6 +1342,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -1392,6 +1416,12 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/65/6c/9d72435c72adfa6e4ed1824b6df7fffbeaaf15c653881e9b041a318ba572/iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f" }, ] +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2" } + [[package]] name = "jinja2" version = "3.1.6" @@ -1413,6 +1443,34 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/e9/08/abe317237add63c3e62f18a981bccf92112b431835b43d844aedaf61f4a0/json_repair-0.54.3-py3-none-any.whl", hash = "sha256:4cdc132ee27d4780576f71bf27a113877046224a808bfc17392e079cb344fb81" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -1631,6 +1689,31 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -2567,6 +2650,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de" }, +] + [[package]] name = "pyee" version = "13.0.1" @@ -2597,6 +2694,23 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ec/cd/bd196b2cf014afb1009de8b0f05ecd54011d881944e62763f3c1b1e8ef37/pygtrie-2.5.0-py3-none-any.whl", hash = "sha256:8795cda8105493d5ae159a5bef313ff13156c5d4d72feddefacaad59f8c8ce16" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.21.2" @@ -2868,7 +2982,10 @@ name = "pywavelets" version = "1.9.0" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11'" }, @@ -2925,6 +3042,31 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -3001,6 +3143,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + [[package]] name = "regex" version = "2026.4.4" @@ -3171,6 +3328,274 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -3281,7 +3706,10 @@ name = "scipy" version = "1.17.1" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", marker = "python_full_version >= '3.11'" }, @@ -3374,6 +3802,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -4177,6 +4618,7 @@ version = "0.2.4" source = { editable = "." } dependencies = [ { name = "aiocache", extra = ["redis"] }, + { name = "aiodocker" }, { name = "aiofiles" }, { name = "aiomysql" }, { name = "alibabacloud-devops20210625" }, @@ -4187,8 +4629,10 @@ dependencies = [ { name = "dateparser" }, { name = "feedparser" }, { name = "imagehash" }, + { name = "jieba" }, { name = "json-repair" }, { name = "lxml" }, + { name = "mcp" }, { name = "multidict" }, { name = "nb-cli" }, { name = "nonebot-adapter-onebot" }, @@ -4233,6 +4677,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiocache", extras = ["redis"], specifier = ">=0.12.3" }, + { name = "aiodocker", specifier = ">=0.24.0" }, { name = "aiofiles", specifier = ">=23.2.1" }, { name = "aiomysql", specifier = ">=0.3.2" }, { name = "alibabacloud-devops20210625", specifier = ">=5.0.2,<6.0.0" }, @@ -4243,8 +4688,10 @@ requires-dist = [ { name = "dateparser", specifier = ">=1.2.0,<2.0.0" }, { name = "feedparser", specifier = ">=6.0.11,<7.0.0" }, { name = "imagehash", specifier = ">=4.3.1,<5.0.0" }, + { name = "jieba", specifier = ">=0.42.1" }, { name = "json-repair", specifier = ">=0.54.0,<0.55.0" }, { name = "lxml", specifier = ">=5.1.0,<6.0.0" }, + { name = "mcp", specifier = ">=1.8.0" }, { name = "multidict", specifier = ">=6.0.0,!=6.3.2" }, { name = "nb-cli", specifier = ">=1.3.0" }, { name = "nonebot-adapter-onebot", specifier = ">=2.3.1" }, diff --git a/zhenxun/builtin_plugins/help/data_source.py b/zhenxun/builtin_plugins/help/data_source.py index 5515040b..b8f8ff38 100644 --- a/zhenxun/builtin_plugins/help/data_source.py +++ b/zhenxun/builtin_plugins/help/data_source.py @@ -9,12 +9,9 @@ from zhenxun.models.group_console import GroupConsole from zhenxun.models.level_user import LevelUser from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.statistics import Statistics -from zhenxun.services import ( - LLMException, - LLMMessage, - avatar_service, - generate, -) +from zhenxun.services import avatar_service +from zhenxun.services.ai.core.exceptions import LLMException +from zhenxun.services.ai.llm.api import chat from zhenxun.services.db_context import with_db_timeout from zhenxun.services.log import logger from zhenxun.services.message_load import is_db_unhealthy @@ -364,12 +361,9 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes: f"{system_prompt}\n\n=== 功能列表和说明 ===\n{knowledge_base}" ) - messages = [ - LLMMessage.system(full_instruction), - LLMMessage.user(question), - ] - response = await generate( - messages=messages, + response = await chat( + message=question, + instruction=full_instruction, model=Config.get_config("help", "DEFAULT_LLM_MODEL"), ) diff --git a/zhenxun/builtin_plugins/llm_manager/__init__.py b/zhenxun/builtin_plugins/llm_manager/__init__.py index d48893fc..4967fa63 100644 --- a/zhenxun/builtin_plugins/llm_manager/__init__.py +++ b/zhenxun/builtin_plugins/llm_manager/__init__.py @@ -1,5 +1,7 @@ from collections import defaultdict +from arclet.alconna import MultiVar +from nonebot.adapters import Event from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata from nonebot_plugin_alconna import ( @@ -13,6 +15,7 @@ from nonebot_plugin_alconna import ( on_alconna, store_true, ) +from nonebot_plugin_waiter import prompt from zhenxun.configs.utils import PluginExtraData from zhenxun.services.log import logger @@ -35,20 +38,20 @@ __plugin_meta__ = PluginMetadata( llm info - 查看指定模型的详细信息和能力。 - llm default [Provider/ModelName] - - 查看或设置全局默认模型。 - - 不带参数: 查看当前默认模型。 - - 带参数: 设置新的默认模型。 - - 例子: llm default Gemini/gemini-2.0-flash - llm test - 测试指定模型的连通性和API Key有效性。 llm keys - 查看指定提供商的所有API Key状态。 - llm reset-key [--key ] - - 重置提供商的所有或指定API Key的失败状态。 + llm mcp [action] [targets...] + - 管理 MCP (Model Context Protocol) 服务。 + - 不带参数: 查看当前配置的 MCP 服务列表及序号。 + - 添加/add : 动态添加或修改 MCP 配置 (需包含 mcpServers)。 + - 开启/关闭 : 批量切换目标 MCP 的状态。也可以使用 on/off。 + - 删除/del : 删除指定 MCP 服务 (需要确认)。 + - 重载/reload: 重新读取 mcp.json 配置文件。 + - 例子: llm mcp 开启 1 3 bingcn """, extra=PluginExtraData( author="HibiKier", @@ -67,18 +70,19 @@ llm_cmd = on_alconna( help_text="查看模型列表", ), Subcommand("info", Args["model_name", str], help_text="查看模型详情"), - Subcommand("default", Args["model_name?", str], help_text="查看或设置默认模型"), Subcommand( "test", Args["model_name", str], alias=["ping"], help_text="测试模型连通性" ), Subcommand("keys", Args["provider_name", str], help_text="查看API密钥状态"), Subcommand( - "reset-key", - Args["provider_name", str], - Option("--key", Args["api_key", str], help_text="指定要重置的API Key"), - help_text="重置API Key状态", + "mcp", + Option("添加", Args["json_strs", MultiVar(str)], alias=["add"]), + Option("开启", Args["targets", MultiVar(str)], alias=["on"]), + Option("关闭", Args["targets", MultiVar(str)], alias=["off"]), + Option("删除", Args["targets", MultiVar(str)], alias=["del"]), + Option("重载", alias=["reload"]), + help_text="管理 MCP 服务", ), - Option("--all", action=store_true, help_text="显示所有条目"), ), permission=SUPERUSER, priority=5, @@ -135,23 +139,6 @@ async def handle_info(arp: Arparma, model_name: Match[str]): await llm_cmd.finish(MessageUtils.build_message(image_bytes)) -@llm_cmd.assign("default") -async def handle_default(arp: Arparma, model_name: Match[str]): - """处理 'llm default' 命令""" - if model_name.available: - logger.info( - f"设置默认模型为: {model_name.result}", - command="LLM Manage", - session=arp.header_result, - ) - _success, message = await DataSource.set_default_model(model_name.result) - await llm_cmd.finish(message) - else: - logger.info("查看默认模型", command="LLM Manage", session=arp.header_result) - current_default = await DataSource.get_default_model() - await llm_cmd.finish(f"当前全局默认模型为: {current_default or '未设置'}") - - @llm_cmd.assign("test") async def handle_test(arp: Arparma, model_name: Match[str]): """处理 'llm test' 命令""" @@ -186,16 +173,102 @@ async def handle_keys(arp: Arparma, provider_name: Match[str]): await llm_cmd.finish(MessageUtils.build_message(image)) -@llm_cmd.assign("reset-key") -async def handle_reset_key( - arp: Arparma, provider_name: Match[str], api_key: Match[str] -): - """处理 'llm reset-key' 命令""" - key_to_reset = api_key.result if api_key.available else None - log_msg = f"重置 {provider_name.result} 的 " + ( - "指定API Key" if key_to_reset else "所有API Keys" - ) - logger.info(log_msg, command="LLM Manage", session=arp.header_result) +@llm_cmd.assign("mcp") +async def handle_mcp(arp: Arparma, event: Event): + """处理 'llm mcp' 命令""" + is_enable = None + targets = () - _success, message = await DataSource.reset_key(provider_name.result, key_to_reset) - await llm_cmd.finish(message) + if arp.exist("mcp.重载"): + await DataSource.reload_mcp_config() + await llm_cmd.finish("✅ MCP 配置已成功重载并应用!") + + if arp.exist("mcp.添加"): + raw_text = event.get_plaintext() + import re + + match = re.search(r"\{.*\}", raw_text, re.DOTALL) + if not match: + await llm_cmd.finish("❌ 无法从输入中提取 JSON,请确保包含完整的 {} 括号。") + + json_str = match.group(0) + _success, msg = await DataSource.add_mcp_servers_from_json(json_str) + await llm_cmd.finish(msg) + + if arp.exist("mcp.删除"): + targets = arp.query("mcp.删除.targets", ()) + if isinstance(targets, str): + targets = (targets,) + + if not targets: + await llm_cmd.finish( + "请指定需要删除的 MCP ID 或名称,例如:llm mcp del 1 3" + ) + + valid_names, invalid_targets = await DataSource.resolve_mcp_targets(targets) + if not valid_names: + await llm_cmd.finish( + f"⚠️ 未找到任何有效的 MCP 服务。\n无效目标: {', '.join(invalid_targets)}" + ) + + confirm_msg = ( + f"⚠️ 即将永久删除以下 {len(valid_names)} 个 MCP 服务:\n" + f"{', '.join(valid_names)}\n\n" + "确认删除请在 30 秒内回复「Y」或「是」,取消请回复其他内容。" + ) + resp = await prompt(confirm_msg, timeout=30) + if resp is None: + await llm_cmd.finish("⏳ 等待超时,已自动取消删除操作。") + + user_input = resp.extract_plain_text().strip().lower() + if user_input not in {"y", "yes", "是", "1", "确认", "ok"}: + await llm_cmd.finish("🛑 已取消删除操作。") + + await DataSource.delete_mcp_servers(valid_names) + await llm_cmd.finish(f"🗑️ 已成功删除 MCP 服务: {', '.join(valid_names)}") + + if arp.exist("mcp.开启"): + is_enable = True + targets = arp.query("mcp.开启.targets", ()) + elif arp.exist("mcp.关闭"): + is_enable = False + targets = arp.query("mcp.关闭.targets", ()) + + if is_enable is None: + logger.info("获取 MCP 列表", command="LLM Manage", session=arp.header_result) + mcp_list = await DataSource.get_mcp_list() + image = await Presenters.format_mcp_list_as_image(mcp_list) + await llm_cmd.finish(MessageUtils.build_message(image)) + + if not targets: + await llm_cmd.finish( + "请指定需要操作的 MCP ID 或名称,例如:llm mcp 开启 1 3 bingcn" + ) + + if isinstance(targets, str): + targets = (targets,) + + logger.info( + f"批量{'开启' if is_enable else '关闭'} MCP: {targets}", + command="LLM Manage", + session=arp.header_result, + ) + + success_names, invalid_targets = await DataSource.toggle_mcp_servers( + targets, is_enable + ) + + msg_parts = [] + if success_names: + status_txt = "开启" if is_enable else "关闭" + msg_parts.append( + f"✅ 已成功{status_txt} {len(success_names)} 个" + f"MCP 服务:\n{', '.join(success_names)}" + ) + if invalid_targets: + msg_parts.append(f"⚠️ 以下 ID 或名称无效被忽略:\n{', '.join(invalid_targets)}") + + if not msg_parts: + msg_parts.append("没有任何配置被修改。") + + await llm_cmd.finish("\n\n".join(msg_parts)) diff --git a/zhenxun/builtin_plugins/llm_manager/data_source.py b/zhenxun/builtin_plugins/llm_manager/data_source.py index d0d72634..3ab1a9ee 100644 --- a/zhenxun/builtin_plugins/llm_manager/data_source.py +++ b/zhenxun/builtin_plugins/llm_manager/data_source.py @@ -1,18 +1,15 @@ +import json import time from typing import Any -from zhenxun.services.llm import ( - LLMException, - get_global_default_model_name, +from zhenxun.configs.path_config import DATA_PATH +from zhenxun.services.ai.core.exceptions import LLMException +from zhenxun.services.ai.llm.api import chat +from zhenxun.services.ai.llm.manager import ( get_model_instance, list_available_models, - set_global_default_model_name, ) -from zhenxun.services.llm.core import KeyStatus -from zhenxun.services.llm.manager import ( - reset_key_status, -) -from zhenxun.services.llm.types import LLMMessage +from zhenxun.services.ai.tools.providers.mcp.provider import mcp_provider class DataSource: @@ -39,27 +36,12 @@ class DataSource: except LLMException: return None - @staticmethod - async def get_default_model() -> str | None: - """获取全局默认模型""" - return get_global_default_model_name() - - @staticmethod - async def set_default_model(model_name_str: str) -> tuple[bool, str]: - """设置全局默认模型""" - success = set_global_default_model_name(model_name_str) - if success: - return True, f"✅ 成功将默认模型设置为: {model_name_str}" - else: - return False, f"❌ 设置失败,模型 '{model_name_str}' 不存在或无效。" - @staticmethod async def test_model_connectivity(model_name_str: str) -> tuple[bool, str]: """测试模型连通性""" start_time = time.monotonic() try: - async with await get_model_instance(model_name_str) as model: - await model.generate_response([LLMMessage.user("你好")]) + await chat("你好", model=model_name_str) end_time = time.monotonic() latency = (end_time - start_time) * 1000 return ( @@ -70,7 +52,7 @@ class DataSource: return ( False, f"❌ 模型 '{model_name_str}' 连接测试失败:\n" - f"{e.user_friendly_message}\n错误码: {e.code.name}", + f"{e.user_friendly_message}\n错误类型: {e.__class__.__name__}", ) except Exception as e: return False, f"❌ 测试时发生未知错误: {e!s}" @@ -78,7 +60,7 @@ class DataSource: @staticmethod async def get_key_status(provider_name: str) -> list[dict[str, Any]] | None: """获取并排序指定提供商的API Key状态""" - from zhenxun.services.llm.manager import get_key_usage_stats + from zhenxun.services.ai.llm.manager import get_key_usage_stats all_stats = await get_key_usage_stats() provider_stats = all_stats.get(provider_name) @@ -93,11 +75,30 @@ class DataSource: ] def sort_key(item: dict[str, Any]): - status_priority = item.get("status_enum", KeyStatus.UNUSED).value + status_map = { + "DISABLED": 0, + "ERROR": 1, + "COOLDOWN": 2, + "WARNING": 3, + "HEALTHY": 4, + "UNUSED": 5, + } + status_str = item.get("status", "HEALTHY") + if ( + item.get("successes", 0) == 0 + and item.get("failures", 0) == 0 + and status_str == "HEALTHY" + ): + status_str = "UNUSED" + status_priority = status_map.get(status_str, 5) + total = item.get("successes", 0) + item.get("failures", 0) + success_rate = ( + (item.get("successes", 0) / total * 100) if total > 0 else 100.0 + ) return ( status_priority, - 100 - item.get("success_rate", 100.0), - -item.get("total_calls", 0), + 100 - success_rate, + -total, ) sorted_stats_list = sorted(stats_list, key=sort_key) @@ -105,17 +106,159 @@ class DataSource: return sorted_stats_list @staticmethod - async def reset_key(provider_name: str, api_key: str | None) -> tuple[bool, str]: - """重置API Key状态""" - success = await reset_key_status(provider_name, api_key) - if success: - if api_key: - if len(api_key) > 8: - target = f"API Key '{api_key[:4]}...{api_key[-4:]}'" - else: - target = f"API Key '{api_key}'" + async def get_mcp_list() -> list[dict[str, Any]]: + """获取排序后的 MCP 列表""" + await mcp_provider.initialize() + if not mcp_provider._config: + return [] + + mcp_servers = mcp_provider._config.mcpServers + sorted_names = sorted(mcp_servers.keys()) + + result = [] + for idx, name in enumerate(sorted_names): + conf = mcp_servers[name] + target = "" + if conf.transport in ("stdio", "sandbox_proxy") and conf.command: + target = f"{conf.command} {' '.join(conf.args)}" + elif conf.transport in ("sse", "streamable-http") and conf.url: + target = conf.url + + result.append( + { + "id": idx + 1, + "name": name, + "enabled": conf.enabled, + "transport": conf.transport, + "target": target, + } + ) + return result + + @staticmethod + async def resolve_mcp_targets( + targets: tuple[Any, ...], + ) -> tuple[list[str], list[str]]: + """将输入的 ID 或名称解析为实际的 MCP 服务名称""" + await mcp_provider.initialize() + if not mcp_provider._config: + return [], list(map(str, targets)) + + mcp_servers = mcp_provider._config.mcpServers + sorted_names = sorted(mcp_servers.keys()) + + valid_names = [] + invalid_targets = [] + + for tgt in targets: + tgt_str = str(tgt) + target_name = None + + if tgt_str.isdigit(): + idx = int(tgt_str) - 1 + if 0 <= idx < len(sorted_names): + target_name = sorted_names[idx] else: - target = "所有API Keys" - return True, f"✅ 成功重置提供商 '{provider_name}' 的 {target} 的状态。" - else: - return False, "❌ 重置失败,请检查提供商名称或API Key是否正确。" + if tgt_str in mcp_servers: + target_name = tgt_str + + if target_name: + valid_names.append(target_name) + else: + invalid_targets.append(tgt_str) + + return list(dict.fromkeys(valid_names)), list(dict.fromkeys(invalid_targets)) + + @staticmethod + async def toggle_mcp_servers( + targets: tuple[Any, ...], is_enable: bool + ) -> tuple[list[str], list[str]]: + """批量切换 MCP 状态""" + valid_names, invalid_targets = await DataSource.resolve_mcp_targets(targets) + if not mcp_provider._config: + return [], invalid_targets + + mcp_servers = mcp_provider._config.mcpServers + success_names = [] + + for target_name in valid_names: + conf = mcp_servers[target_name] + if conf.enabled != is_enable: + conf.enabled = is_enable + if not is_enable: + if tk := mcp_provider._toolkits.pop(target_name, None): + await tk.close() + else: + if target_name not in mcp_provider._toolkits: + mcp_provider._setup_toolkit(target_name, conf) + success_names.append(target_name) + + if success_names: + mcp_provider._discovered_tools = None + mcp_provider._save_config() + + return success_names, invalid_targets + + @staticmethod + async def reload_mcp_config() -> None: + """完全重新加载 MCP 配置""" + await mcp_provider.shutdown() + mcp_provider._config = None + mcp_provider._discovered_tools = None + await mcp_provider.initialize() + + @staticmethod + async def delete_mcp_servers(names: list[str]) -> None: + """删除指定的 MCP 服务""" + for name in names: + await mcp_provider.unregister_server(name) + + @staticmethod + async def add_mcp_servers_from_json(json_str: str) -> tuple[bool, str]: + """将 JSON 字符串解析并合并到 mcp.json""" + mcp_path = DATA_PATH / "ai" / "mcp.json" + + try: + json_str = json_str.strip() + if json_str.startswith("```"): + lines = json_str.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + json_str = "\n".join(lines).strip() + + new_config = json.loads(json_str) + if not isinstance(new_config, dict) or "mcpServers" not in new_config: + return False, "❌ JSON 格式不正确,必须包含顶层键 'mcpServers'。" + + new_servers = new_config["mcpServers"] + if not isinstance(new_servers, dict) or not new_servers: + return False, "❌ 'mcpServers' 不能为空且必须为 JSON 对象(dict)。" + + if mcp_path.exists(): + with mcp_path.open("r", encoding="utf-8") as f: + current_config = json.load(f) + else: + current_config = {"mcpServers": {}} + + if "mcpServers" not in current_config: + current_config["mcpServers"] = {} + + added_names = [] + for name, conf in new_servers.items(): + current_config["mcpServers"][name] = conf + added_names.append(name) + + mcp_path.parent.mkdir(parents=True, exist_ok=True) + with mcp_path.open("w", encoding="utf-8") as f: + json.dump(current_config, f, ensure_ascii=False, indent=2) + + await DataSource.reload_mcp_config() + + return True, f"✅ 成功添加/更新 MCP 服务: {', '.join(added_names)}" + + except json.JSONDecodeError as e: + return False, f"❌ JSON 解析失败: {e}" + except Exception as e: + return False, f"❌ 添加 MCP 服务时发生未知错误: {e}" diff --git a/zhenxun/builtin_plugins/llm_manager/presenters.py b/zhenxun/builtin_plugins/llm_manager/presenters.py index b5fc2ce9..6b9ced12 100644 --- a/zhenxun/builtin_plugins/llm_manager/presenters.py +++ b/zhenxun/builtin_plugins/llm_manager/presenters.py @@ -1,9 +1,9 @@ -from typing import Any +import time +from typing import Any, Literal from zhenxun import ui from zhenxun.services import renderer_service -from zhenxun.services.llm.core import KeyStatus -from zhenxun.services.llm.types import ModelModality +from zhenxun.services.ai.core.models import ModelModality from zhenxun.ui.models import StatusBadgeCell, TextCell @@ -72,7 +72,7 @@ class Presenters: cap_list = [] if ModelModality.IMAGE in caps.input_modalities: - cap_list.append("视觉") + cap_list.append("图片") if ModelModality.VIDEO in caps.input_modalities: cap_list.append("视频") if ModelModality.AUDIO in caps.input_modalities: @@ -93,11 +93,16 @@ class Presenters: md.head("模型详情", 2) temp_value = model.temperature or provider.temperature or "未设置" - token_value = model.max_tokens or provider.max_tokens or "未设置" + input_tokens = caps.max_input_tokens + context_window = ( + f"{int(input_tokens / 1000)}K" + if input_tokens >= 1000 + else str(input_tokens) + ) md.text(f"- **名称**: {model.model_name}") md.text(f"- **默认温度**: {temp_value}") - md.text(f"- **最大Token**: {token_value}") + md.text(f"- **上下文窗口**: {context_window}") md.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}") return await renderer_service.render(md.with_style("light")) @@ -112,33 +117,41 @@ class Presenters: data_list = [] for key_info in sorted_stats: - status_enum: KeyStatus = key_info["status_enum"] + status_str = key_info.get("status", "HEALTHY") + successes = key_info.get("successes", 0) + failures = key_info.get("failures", 0) + total_calls = successes + failures - if status_enum == KeyStatus.COOLDOWN: - cooldown_seconds = int(key_info["cooldown_seconds_left"]) + if total_calls == 0 and status_str == "HEALTHY": + status_str = "UNUSED" + + if status_str == "COOLDOWN": + cooldown_seconds = max( + 0, int(key_info.get("cooldown_until", 0) - time.time()) + ) formatted_time = _format_seconds(cooldown_seconds) status_cell = StatusBadgeCell( text=f"冷却中({formatted_time})", status_type="info" ) else: - status_map = { - KeyStatus.DISABLED: ("永久禁用", "error"), - KeyStatus.ERROR: ("错误", "error"), - KeyStatus.WARNING: ("告警", "warning"), - KeyStatus.HEALTHY: ("健康", "ok"), - KeyStatus.UNUSED: ("未使用", "info"), + status_map: dict[ + str, + tuple[str, Literal["ok", "error", "warning", "info", "success"]], + ] = { + "DISABLED": ("永久禁用", "error"), + "ERROR": ("错误", "error"), + "WARNING": ("告警", "warning"), + "HEALTHY": ("健康", "ok"), + "UNUSED": ("未使用", "info"), } - text, status_type = status_map.get(status_enum, ("未知", "info")) - status_cell = StatusBadgeCell(text=text, status_type=status_type) # type: ignore + text, status_type = status_map.get(status_str, ("未知", "info")) + status_cell = StatusBadgeCell(text=text, status_type=status_type) - total_calls = key_info["total_calls"] total_calls_text = ( - f"{key_info['success_count']}/{total_calls}" - if total_calls > 0 - else "0/0" + f"{successes}/{total_calls}" if total_calls > 0 else "0/0" ) - success_rate = key_info["success_rate"] + success_rate = (successes / total_calls * 100) if total_calls > 0 else 100.0 success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A" rate_color = None if total_calls > 0: @@ -148,13 +161,18 @@ class Presenters: rate_color = "#E6A23C" success_rate_cell = TextCell(content=success_rate_text, color=rate_color) - avg_latency = key_info["avg_latency"] - avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A" + avg_latency_text = "N/A" last_error = key_info.get("last_error") or "-" if len(last_error) > 25: last_error = last_error[:22] + "..." + suggested_action = "-" + if status_str == "DISABLED": + suggested_action = "检查配额或换Key" + elif status_str == "COOLDOWN": + suggested_action = "等待恢复" + data_list.append( [ TextCell(content=key_info["key_id"]), @@ -163,7 +181,7 @@ class Presenters: success_rate_cell, TextCell(content=avg_latency_text), TextCell(content=last_error), - TextCell(content=key_info["suggested_action"]), + TextCell(content=suggested_action), ] ) @@ -181,3 +199,38 @@ class Presenters: ) table.add_rows(data_list) return await renderer_service.render(table, use_cache=False) + + @staticmethod + async def format_mcp_list_as_image(mcp_list: list[dict[str, Any]]) -> bytes: + """将MCP列表格式化为表格图片""" + title = "MCP 服务管理列表" + if not mcp_list: + table = ui.table(title=title, tip="当前未配置任何 MCP 服务。").set_headers( + ["ID", "MCP名称", "协议", "状态", "目标"] + ) + return await renderer_service.render(table) + + column_name = ["ID", "MCP名称", "协议", "状态", "目标"] + rows_data = [] + for mcp in mcp_list: + is_enable = mcp["enabled"] + status_type = "success" if is_enable else "info" + status_text = "开启" if is_enable else "关闭" + rows_data.append( + [ + TextCell(content=str(mcp["id"])), + TextCell(content=mcp["name"]), + TextCell(content=mcp["transport"]), + StatusBadgeCell(text=status_text, status_type=status_type), + TextCell(content=mcp["target"]), + ] + ) + + table = ui.table( + title=title, + tip="使用 `llm mcp 开启/关闭 ` 来修改状态,支持批量操作", + ) + table.set_headers(column_name) + table.set_column_alignments(["center", "left", "left", "center", "left"]) + table.add_rows(rows_data) + return await renderer_service.render(table, use_cache=False) diff --git a/zhenxun/builtin_plugins/superuser/reload_setting.py b/zhenxun/builtin_plugins/superuser/reload_setting.py index 6d37d903..1b9c7a14 100644 --- a/zhenxun/builtin_plugins/superuser/reload_setting.py +++ b/zhenxun/builtin_plugins/superuser/reload_setting.py @@ -9,8 +9,8 @@ from nonebot_plugin_session import EventSession from zhenxun.configs.config import Config from zhenxun.configs.utils import PluginExtraData, RegisterConfig -from zhenxun.services.llm.config.providers import get_llm_config -from zhenxun.services.llm.manager import clear_model_cache +from zhenxun.services.ai.config import get_llm_config +from zhenxun.services.ai.llm.manager import clear_all_cache from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.manager.priority_manager import PriorityLifecycle @@ -108,7 +108,7 @@ async def _reload_plugin_limit_config() -> None: async def _reload_runtime_config() -> None: Config.reload() get_llm_config.cache_clear() - clear_model_cache() + clear_all_cache() await _reload_plugin_limit_config() with contextlib.suppress(Exception): _reschedule_auto_reload_job() diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index ec0ff65d..1f4c2052 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -18,31 +18,10 @@ require("nonebot_plugin_htmlrender") require("nonebot_plugin_uninfo") require("nonebot_plugin_waiter") +from .ai import chat from .avatar_service import avatar_service from .db_context import Model, disconnect, with_db_timeout from .group_settings_service import group_settings_service -from .llm import ( - AI, - AIConfig, - CommonOverrides, - LLMContentPart, - LLMException, - LLMGenerationConfig, - LLMMessage, - chat, - clear_model_cache, - code, - create_multimodal_message, - embed, - generate, - generate_structured, - get_cache_stats, - get_model_instance, - list_available_models, - list_embedding_models, - search, - set_global_default_model_name, -) from .log import logger from .plugin_init import PluginInit, PluginInitManager from .renderer import renderer_service @@ -54,14 +33,7 @@ from .scheduler import ( ) __all__ = [ - "AI", - "AIConfig", - "CommonOverrides", "ExecutionPolicy", - "LLMContentPart", - "LLMException", - "LLMGenerationConfig", - "LLMMessage", "Model", "PluginInit", "PluginInitManager", @@ -69,22 +41,10 @@ __all__ = [ "Trigger", "avatar_service", "chat", - "clear_model_cache", - "code", - "create_multimodal_message", "disconnect", - "embed", - "generate", - "generate_structured", - "get_cache_stats", - "get_model_instance", "group_settings_service", - "list_available_models", - "list_embedding_models", "logger", "renderer_service", "scheduler_manager", - "search", - "set_global_default_model_name", "with_db_timeout", ] diff --git a/zhenxun/services/ai/__init__.py b/zhenxun/services/ai/__init__.py new file mode 100644 index 00000000..bafbe519 --- /dev/null +++ b/zhenxun/services/ai/__init__.py @@ -0,0 +1,19 @@ +from .core.messages import LLMMessage +from .flow import Agent, Team, Workflow +from .llm import IntentBuilder, chat, generate_structured +from .run import Inject, RunContext +from .tools import Rules, tool + +__all__ = [ + "Agent", + "Inject", + "IntentBuilder", + "LLMMessage", + "Rules", + "RunContext", + "Team", + "Workflow", + "chat", + "generate_structured", + "tool", +] diff --git a/zhenxun/services/ai/capabilities/__init__.py b/zhenxun/services/ai/capabilities/__init__.py new file mode 100644 index 00000000..a8af4713 --- /dev/null +++ b/zhenxun/services/ai/capabilities/__init__.py @@ -0,0 +1,19 @@ +from .base import ( + AbstractCapability, + WrapModelRequestHandler, + WrapRunHandler, + WrapToolExecuteHandler, + WrapToolValidateHandler, +) +from .wrappers import CombinedCapability, DynamicCapability, WrapperCapability + +__all__ = [ + "AbstractCapability", + "CombinedCapability", + "DynamicCapability", + "WrapModelRequestHandler", + "WrapRunHandler", + "WrapToolExecuteHandler", + "WrapToolValidateHandler", + "WrapperCapability", +] diff --git a/zhenxun/services/ai/capabilities/base.py b/zhenxun/services/ai/capabilities/base.py new file mode 100644 index 00000000..f6f93d49 --- /dev/null +++ b/zhenxun/services/ai/capabilities/base.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union + +from zhenxun.services.ai.core.messages import ChatRequest, ChatResponse +from zhenxun.services.ai.core.options import GenerationConfig + +if TYPE_CHECKING: + from zhenxun.services.ai.core.models import LLMContext + from zhenxun.services.ai.run import AgentRunResult, RunContext + +WrapRunHandler = Callable[[], Awaitable["AgentRunResult[Any]"]] +"""整个 Agent 运行过程包裹的处理函数类型""" + +WrapModelRequestHandler = Callable[ + ["LLMContext[ChatRequest, ChatResponse]"], Awaitable[ChatResponse] +] +"""单次大模型 API 请求包裹的处理函数类型""" + +WrapToolValidateHandler = Callable[[str | dict[str, Any]], Awaitable[dict[str, Any]]] +"""工具参数校验过程包裹的处理函数类型""" + +WrapToolExecuteHandler = Callable[[dict[str, Any]], Awaitable[Any]] +"""单一工具执行过程包裹的处理函数类型""" + + +CapabilityPosition = Literal["outermost", "innermost"] +"""Capability 在洋葱模型中的固定执行位置(最外层或最内层)""" + +CapabilityRef = Union[type["AbstractCapability"], "AbstractCapability"] +"""对 Capability 的引用,可以是 Capability 实例或类类型""" + + +@dataclass +class CapabilityOrdering: + """定义拦截器 (Capability) 的拓扑排序约束。 + 采用洋葱模型语义:排在列表前面的拦截器在最外层执行。 + """ + + position: CapabilityPosition | None = None + """固定位置:outermost (最外层) 或 innermost (最内层)""" + wraps: Sequence[CapabilityRef] = () + """当前拦截器必须包裹(即在...之前执行)目标拦截器""" + wrapped_by: Sequence[CapabilityRef] = () + """当前拦截器必须被包裹(即在...之后执行)目标拦截器""" + requires: Sequence[type["AbstractCapability"]] = () + """当前拦截器依赖的其他拦截器类型,若缺失则报错""" + + +class AbstractCapability: + """ + Agent 能力组件基类协议。 + 所有业务逻辑拦截(限流、权限、动态 Prompt)请在此实现。 + 底层网络重试、并发控制等请勿在此处理。 + """ + + @classmethod + def get_serialization_name(cls) -> str | None: + """用于 YAML/JSON 反序列化的注册标识符""" + return cls.__name__ + + @classmethod + def from_spec(cls, **kwargs) -> "AbstractCapability": + """从 Spec 的 kwargs 中实例化对象""" + return cls(**kwargs) + + def __init_subclass__(cls, **kwargs): + """自动将继承此类的所有拦截器注册到中心表""" + super().__init_subclass__(**kwargs) + CapabilityRegistry.register(cls) + + def get_ordering(self) -> CapabilityOrdering | None: + """获取该拦截器的拓扑排序约束。子类可重写此方法以锁定执行顺序。""" + return None + + async def for_run(self, context: RunContext) -> "AbstractCapability": + """获取专用于单次运行的实例。 + 默认返回自身(无状态)。 + 若需要记录单次运行的上下文状态,请返回深/浅拷贝(如 return copy.copy(self))。 + """ + return self + + async def get_generation_config( + self, context: RunContext + ) -> GenerationConfig | None: + """运行开始前触发。允许动态下发大模型配置(覆盖或合并 Agent 的默认配置)。""" + return None + + async def get_system_prompts(self, context: RunContext) -> list[str]: + return [] + + async def get_tools(self, context: RunContext) -> list[Any]: + return [] + + async def prepare_tools( + self, context: RunContext, tool_defs: list[Any] + ) -> list[Any]: + """运行开始前/装配工具时触发。允许动态增删改当前将发往大模型的工具列表。 + 默认实现:无操作,直接返回传入的工具列表。""" + return tool_defs + + async def wrap_run( + self, context: RunContext, handler: WrapRunHandler + ) -> "AgentRunResult[Any]": + """包裹整个 Agent 运行过程 (洋葱模型)。""" + return await handler() + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + """包裹单次大模型 API 请求 (洋葱模型)。""" + return await handler(llm_context) + + async def wrap_tool_validate( + self, + context: RunContext, + tool_name: str, + args: str | dict[str, Any], + handler: WrapToolValidateHandler, + ) -> dict[str, Any]: + """包裹工具的参数校验过程 (洋葱模型)。""" + return await handler(args) + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> Any: + """包裹单一工具的执行 (洋葱模型)。""" + return await handler(arguments) + + +class CapabilityRegistry: + """Capability 序列化注册表""" + + _registry: ClassVar[dict[str, type[AbstractCapability]]] = {} + + @classmethod + def register(cls, cap_cls: type[AbstractCapability]): + name = cap_cls.get_serialization_name() + if name: + cls._registry[name] = cap_cls + + @classmethod + def get(cls, name: str) -> type[AbstractCapability] | None: + return cls._registry.get(name) diff --git a/zhenxun/services/ai/capabilities/builtin.py b/zhenxun/services/ai/capabilities/builtin.py new file mode 100644 index 00000000..b00ce96a --- /dev/null +++ b/zhenxun/services/ai/capabilities/builtin.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import json +from typing import Any + +from zhenxun.models.user_console import UserConsole +from zhenxun.services.ai.capabilities import ( + AbstractCapability, + WrapModelRequestHandler, + WrapToolExecuteHandler, +) +from zhenxun.services.ai.core.exceptions import ( + GuardrailViolationError, + LLMException, + ModelRetry, + ResponseParseException, + SchemaParseError, + ToolFatalError, + UpstreamServerException, +) +from zhenxun.services.ai.core.messages import ( + ChatRequest, + ChatResponse, + ToolCallPart, +) +from zhenxun.services.ai.core.models import LLMContext +from zhenxun.services.ai.run.context import RunContext +from zhenxun.services.ai.utils import PermissionUtils +from zhenxun.services.log import logger +from zhenxun.utils.enum import GoldHandle +from zhenxun.utils.exception import InsufficientGold + + +def _get_tool_meta(tool: Any, key: str, default: Any = None) -> Any: + """辅助方法:安全地提取工具元数据中指定的键值""" + if not tool: + return default + settings = getattr(tool, "settings", None) + meta = (settings.metadata if settings else None) or getattr(tool, "metadata", {}) + return meta.get(key, default) + + +class StuckDetectionCapability(AbstractCapability): + """死循环检测:使用前置请求拦截防止 LLM 陷入无限重试""" + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + import hashlib + + max_repeated_errors = 3 + action_hashes = [] + messages = list(llm_context.request.messages) + idx = len(messages) - 1 + + while idx >= 0: + msg = messages[idx] + if msg.role == "tool": + batch_tool_contents = [] + while idx >= 0 and messages[idx].role == "tool": + for tr in messages[idx].tool_returns: + batch_tool_contents.append(f"{tr.tool_name}:{tr.output}") + idx -= 1 + + if ( + idx >= 0 + and messages[idx].role == "assistant" + and messages[idx].tool_calls + ): + assistant_msg = messages[idx] + batch_tool_calls = [] + for tc in assistant_msg.tool_calls: + if isinstance(tc, ToolCallPart): + args_str = ( + tc.args + if isinstance(tc.args, str) + else json.dumps(tc.args, ensure_ascii=False) + ) + batch_tool_calls.append(f"{tc.tool_name}:{args_str}") + + batch_tool_calls.sort() + batch_tool_contents.sort() + + state_str = ( + "|".join(batch_tool_calls) + + "||" + + "|".join(batch_tool_contents) + ) + state_hash = hashlib.md5(state_str.encode("utf-8")).hexdigest() + action_hashes.append(state_hash) + idx -= 1 + else: + break + elif msg.role == "assistant": + idx -= 1 + else: + break + + if len(action_hashes) >= max_repeated_errors: + recent_hashes = action_hashes[:max_repeated_errors] + if len(set(recent_hashes)) == 1: + logger.warning( + "[StuckDetection] 拦截到死循环:连续 " + f"{max_repeated_errors} 次产生完全相同的状态哈希碰撞。" + ) + raise ToolFatalError( + "Agent 触发终极防呆机制:连续 " + f"{max_repeated_errors} 次产生完全相同的" + "无效工具调用状态,已物理阻断以节省 Token。" + ) + + return await handler(llm_context) + + +class GlobalCycleLimitCapability(AbstractCapability): + """全局防死循环检测中间件:跨 Agent 追踪大模型调用总次数""" + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + global_cycles = ( + context.session.shared_state.get("__global_cycle_count__", 0) + 1 + ) + context.session.shared_state["__global_cycle_count__"] = global_cycles + + global_max = llm_context.request.extra.get("__global_max_cycles__") + if global_max is None: + from zhenxun.services.ai.config import get_llm_config + + global_max = get_llm_config().agent_settings.global_max_cycles + + if global_max is not None and global_cycles > global_max: + from zhenxun.services.ai.core.exceptions import AbortException + + logger.error( + "🚨 触发全局防护:整个流水线执行步数已达到全局上限 " + f"({global_max}),强制熔断!" + ) + raise AbortException( + reason=f"全局大模型思考循环次数已超限 ({global_max}次)", + display="🚨 系统保护触发:任务过于复杂或陷入多智能体死循环," + "已被强行中断以节省资源。", + ) + + return await handler(llm_context) + + +class PermissionCapability(AbstractCapability): + """权限校验中间件:在执行前根据确定参数进行动态鉴权""" + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> dict[str, Any]: + tool = context.call.current_tool + admin_level = _get_tool_meta(tool, "admin_level", 0) + if admin_level > 0: + if not await PermissionUtils.check_admin_level(context, admin_level): + msg = ( + "系统警告:用户权限不足(需要等级 " + f"{admin_level})。" + "请温和地向用户解释权限不足,并拒绝执行。" + ) + user_id = context.get_user_id() + logger.warning( + f"🛡️ [Capability] 权限拦截: 用户 {user_id} 尝试调用 " + f"{getattr(tool, 'name', 'unknown')}" + ) + from zhenxun.services.ai.core.exceptions import ToolFatalError + + raise ToolFatalError( + msg, display_content=f"❌ 权限不足: 需要等级 {admin_level}" + ) + return await handler(arguments) + + +class BillingCapability(AbstractCapability): + """经济系统中间件:执行前扣除金币""" + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> dict[str, Any]: + tool = context.call.current_tool + cost_gold = _get_tool_meta(tool, "cost_gold", 0) + if cost_gold > 0: + user_id = context.get_user_id() + platform = context.get_platform() + if user_id: + try: + await UserConsole.reduce_gold( + user_id, + cost_gold, + GoldHandle.PLUGIN, + f"agent_tool:{getattr(tool, 'name', 'unknown')}", + platform, + ) + except InsufficientGold: + msg = ( + f"系统警告:用户金币不足(需要 {cost_gold} 金币,但余额不够)。" + "请向用户解释金币不足,提醒可通过签到赚取,并拒绝执行。" + ) + logger.warning( + f"💰 [Capability] 金币拦截: 用户 {user_id} 尝试调用 " + f"{getattr(tool, 'name', 'unknown')}" + ) + from zhenxun.services.ai.core.exceptions import ToolFatalError + + raise ToolFatalError( + msg, display_content=f"❌ 余额不足: 需要 {cost_gold} 金币" + ) + return await handler(arguments) + + +class ToolRetryAndReflectionCapability(AbstractCapability): + """ + 重试与自愈反思中间件。 + 接管原执行器中的重试计数与致命异常熔断。 + 将 Python 异常优雅地转化为大模型的反思 Prompt。 + """ + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> Any: + try: + return await handler(arguments) + except Exception as e: + from zhenxun.services.ai.core.exceptions import ( + AbortException, + ControlFlowExit, + ToolFatalError, + ToolFinishException, + ) + from zhenxun.services.ai.tools.engine.executor import ToolExecutionPolicy + from zhenxun.services.ai.tools.models import ToolResult + + if isinstance(e, ControlFlowExit): + raise e + + retries = context.run.tool_retries.get(tool_name, 0) + retries += 1 + context.run.tool_retries[tool_name] = retries + + from typing import cast + + from zhenxun.services.ai.tools.core.tool import BaseTool + + tool = cast(BaseTool, context.call.current_tool) + policy = ToolExecutionPolicy(tool) + max_retries_limit = policy.max_retries + + if isinstance(e, ToolFatalError | ToolFinishException): + display_msg = getattr(e, "display_content", f"❌ 系统致命错误: {e}") + raise AbortException(reason=str(e), display=display_msg) + + if retries > max_retries_limit: + raise AbortException( + reason=f"工具 '{tool_name}' 连续出错达 {retries} 次,超出上限。", + display=f"🚨 工具 '{tool_name}' 已达最大重试次数,执行阻断。", + ) + + return ToolResult(output=f"执行发生异常: {e}").as_error() + + +class ReflexionCapability(AbstractCapability): + """自愈反思与验证引擎 (Reflexion Engine)。 + 统一处理结构化解析失败 and 语义护栏拦截。""" + + async def wrap_tool_execute(self, context, tool_name, arguments, handler): + try: + return await handler(arguments) + except Exception as error: + from zhenxun.services.ai.core.engine.structured_parser import ( + DEFAULT_IVR_TEMPLATE, + ) + from zhenxun.services.ai.core.exceptions import ModelRetry, ToolRetryError + from zhenxun.services.ai.tools.models import ToolResult + + if isinstance(error, ToolRetryError | ModelRetry): + error_msg = getattr(error, "message", str(error)) + feedback_prompt = DEFAULT_IVR_TEMPLATE.format(error_msg=error_msg) + context.run.add_system_prompt(feedback_prompt) + return ToolResult( + output=f"执行失败:{error_msg}", + ).as_error() + raise error.with_traceback(None) from None + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + output_processor = llm_context.request.extra.get("output_processor") + guardrails = llm_context.request.extra.get("guardrails", []) + + if not output_processor and not guardrails: + return await handler(llm_context) + + max_retries = llm_context.request.extra.get("max_retries", 3) + error_template = ( + output_processor.error_template if output_processor else "{error_msg}" + ) + + ivr_messages = list(llm_context.request.messages) + last_exception: Exception | None = None + + from zhenxun.services.ai.guardrails import GuardrailPipeline + + pipeline = GuardrailPipeline(guardrails) if guardrails else None + + for attempt in range(max_retries + 1): + llm_context.request.messages = list(ivr_messages) + current_response_text: str = "" + + try: + if pipeline: + llm_context.request.messages = await pipeline.run_input_pipeline( + llm_context.request.messages, context + ) + + from typing import cast + + response = await handler(llm_context) + current_response_text = response.text + + if response.tool_calls: + return response + + if output_processor: + final_obj = await output_processor.validate_and_parse( + current_response_text, context=context + ) + else: + final_obj = current_response_text + + if pipeline: + resp_out, final_obj_out = await pipeline.run_output_pipeline( + response, final_obj, context + ) + from typing import cast + + response = cast("ChatResponse", resp_out) + final_obj = final_obj_out + current_response_text = response.text + + response.parsed_obj = final_obj + return response + + except Exception as e: + from typing import cast + + from zhenxun.services.ai.core.messages import LLMMessage + + is_model_retry = isinstance(e, ModelRetry) + is_llm_error = isinstance(e, LLMException) + llm_error: LLMException | None = ( + cast(LLMException, e) if is_llm_error else None + ) + last_exception = e + + if ( + not is_model_retry + and llm_error + and not isinstance( + llm_error, ResponseParseException | UpstreamServerException + ) + ): + raise e + + if attempt < max_retries: + if is_model_retry: + error_msg = getattr(e, "message", str(e)) + raw_response = current_response_text + else: + error_msg = ( + llm_error.details.get("validation_error", str(e)) + if llm_error + else str(e) + ) + raw_response = current_response_text or ( + llm_error.details.get("raw_response", "") + if llm_error + else "" + ) + + logger.warning( + "输出校验未通过 " + f"(尝试 {attempt + 1}/{max_retries + 1})。" + f"启动反思修复闭环... 失败原因: {error_msg}" + ) + + if raw_response: + ivr_messages.append( + cast( + LLMMessage, + LLMMessage.assistant_text_response(raw_response), + ) + ) + + if isinstance(e, SchemaParseError): + feedback_prompt = ( + "### ❌ [格式解析失败]\n" + "你输出的结构化数据(JSON)格式损坏或字段不匹配," + "未能通过 Schema 校验。\n\n" + "**解析错误报告:**\n" + f"> {error_msg}\n\n" + "**修正要求:** 请仔细检查缺失的必填字段、错误的数据类型或" + "未闭合的括号,严格参考你可用的工具 Schema 定义," + "重新输出正确格式的数据。" + ) + elif isinstance(e, GuardrailViolationError): + feedback_prompt = ( + "### 🛡️ [业务护栏违规]\n" + "你输出的数据格式完全正确,但在业务逻辑层触发了合规/风控护栏。\n\n" + "**拦截原因报告:**\n" + f"> {error_msg}\n\n" + "**修正要求:** 请结合上述反馈报告," + "反思你的决策逻辑或内容生成," + "在保持数据格式正确的前提下,重新生成符合护栏规范的内容。" + ) + else: + if output_processor and error_template: + feedback_prompt = error_template.format(error_msg=error_msg) + else: + from zhenxun.services.ai.core.engine import ( + structured_parser as sp, + ) + + feedback_prompt = sp.DEFAULT_IVR_TEMPLATE.format( + error_msg=error_msg + ) + ivr_messages.append( + cast(LLMMessage, LLMMessage.user(feedback_prompt)) + ) + continue + + if llm_error and not getattr(llm_error, "recoverable", True): + raise llm_error.with_traceback(None) from None + + if last_exception: + raise last_exception.with_traceback(None) from None + raise UpstreamServerException( + "反思循环耗尽,未能生成符合所有校验规则的合法结果。", + ).with_traceback(None) from None diff --git a/zhenxun/services/ai/capabilities/wrappers.py b/zhenxun/services/ai/capabilities/wrappers.py new file mode 100644 index 00000000..7905def6 --- /dev/null +++ b/zhenxun/services/ai/capabilities/wrappers.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from collections.abc import Callable +import graphlib +from typing import TYPE_CHECKING, Any + +from zhenxun.services.ai.core.messages import ChatRequest, ChatResponse +from zhenxun.services.ai.core.options import GenerationConfig + +from .base import ( + AbstractCapability, + CapabilityRef, + WrapModelRequestHandler, + WrapRunHandler, + WrapToolExecuteHandler, + WrapToolValidateHandler, +) + +if TYPE_CHECKING: + from zhenxun.services.ai.core.models import LLMContext + from zhenxun.services.ai.run import AgentRunResult, RunContext + + +def sort_capabilities(caps: list["AbstractCapability"]) -> list["AbstractCapability"]: + """使用标准库 graphlib.TopologicalSorter 实现拦截器拓扑排序,解决执行顺序冲突""" + if len(caps) <= 1: + return caps + + ts = graphlib.TopologicalSorter() + n = len(caps) + for i in range(n): + ts.add(i) + + orderings = [c.get_ordering() for c in caps] + leaf_types = [{type(c)} for c in caps] + + def _ref_matches( + ref: CapabilityRef, types: set[type], inst: AbstractCapability + ) -> bool: + if isinstance(ref, type): + return any(issubclass(t, ref) for t in types) + return inst is ref + + all_types = set().union(*leaf_types) + for i, o in enumerate(orderings): + if o and o.requires: + for req in o.requires: + if not any(issubclass(t, req) for t in all_types): + raise ValueError( + f"Capability '{type(caps[i]).__name__}' 依赖 '{req.__name__}' " + f"但未在管线中找到该组件。" + ) + + outermost = {i for i, o in enumerate(orderings) if o and o.position == "outermost"} + innermost = {i for i, o in enumerate(orderings) if o and o.position == "innermost"} + + for oi in outermost: + for j in range(n): + if j != oi and j not in outermost: + ts.add(j, oi) + + for ii in innermost: + for j in range(n): + if j != ii and j not in innermost: + ts.add(ii, j) + + for i, o in enumerate(orderings): + if not o: + continue + for ref in o.wraps: + for j in range(n): + if i != j and _ref_matches(ref, leaf_types[j], caps[j]): + ts.add(j, i) + for ref in o.wrapped_by: + for j in range(n): + if i != j and _ref_matches(ref, leaf_types[j], caps[j]): + ts.add(i, j) + + try: + order = list(ts.static_order()) + except graphlib.CycleError: + raise ValueError( + "Capability 拓扑排序失败,存在循环依赖约束。" + "请检查 wraps 或 wrapped_by 的配置。" + ) + + return [caps[i] for i in order] + + +class CombinedCapability(AbstractCapability): + """ + 组合能力容器。 + 将多个 Capability 按顺序融合成一个复合的洋葱模型, + 处理生命周期的正序/倒序和链式调用。 + """ + + def __init__(self, capabilities: list[AbstractCapability]): + flat = [] + for c in capabilities: + if isinstance(c, CombinedCapability): + flat.extend(c.capabilities) + else: + flat.append(c) + + deduped = [] + seen = set() + for c in flat: + if id(c) not in seen: + seen.add(id(c)) + deduped.append(c) + self.capabilities = sort_capabilities(deduped) + + async def for_run(self, context: RunContext) -> "AbstractCapability": + new_caps = [] + changed = False + for cap in self.capabilities: + new_cap = await cap.for_run(context) + new_caps.append(new_cap) + if new_cap is not cap: + changed = True + + if changed: + return CombinedCapability(new_caps) + return self + + async def get_generation_config( + self, context: RunContext + ) -> GenerationConfig | None: + final_config = None + for cap in self.capabilities: + cap_config = await cap.get_generation_config(context) + if cap_config: + if final_config is None: + final_config = cap_config + else: + final_config = final_config.merge_with(cap_config) + return final_config + + async def get_system_prompts(self, context: RunContext) -> list[str]: + prompts = [] + for cap in self.capabilities: + prompts.extend(await cap.get_system_prompts(context)) + return prompts + + async def get_tools(self, context: RunContext) -> list[Any]: + tools = [] + for cap in self.capabilities: + tools.extend(await cap.get_tools(context)) + return tools + + async def prepare_tools( + self, context: RunContext, tool_defs: list[Any] + ) -> list[Any]: + current_defs = list(tool_defs) + for cap in self.capabilities: + res = await cap.prepare_tools(context, current_defs) + if res is not None: + current_defs = res + return current_defs + + async def wrap_run( + self, context: RunContext, handler: WrapRunHandler + ) -> "AgentRunResult[Any]": + chain = handler + for cap in reversed(self.capabilities): + chain = _make_wrap_link(cap, "wrap_run", context, {}, chain, None) + return await chain() + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + chain = handler + for cap in reversed(self.capabilities): + chain = _make_wrap_link( + cap, "wrap_model_request", context, {}, chain, "llm_context" + ) + return await chain(llm_context) + + async def wrap_tool_validate( + self, + context: RunContext, + tool_name: str, + args: str | dict[str, Any], + handler: WrapToolValidateHandler, + ) -> dict[str, Any]: + chain = handler + for cap in reversed(self.capabilities): + chain = _make_wrap_link( + cap, + "wrap_tool_validate", + context, + {"tool_name": tool_name}, + chain, + "args", + ) + return await chain(args) + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> Any: + chain = handler + for cap in reversed(self.capabilities): + chain = _make_wrap_link( + cap, + "wrap_tool_execute", + context, + {"tool_name": tool_name}, + chain, + "arguments", + ) + return await chain(arguments) + + +def _make_wrap_link( + cap: AbstractCapability, + hook_name: str, + ctx: RunContext, + static_kwargs: dict[str, Any], + inner_handler: Callable[..., Any], + handler_arg: str | None, +) -> Callable[..., Any]: + """构建洋葱模型中间件链的单一闭包节点。""" + frozen_kwargs = dict(static_kwargs) + + if handler_arg: + + async def wrapper(value: Any) -> Any: + kw = dict(frozen_kwargs) + kw[handler_arg] = value + hook_method = getattr(cap, hook_name) + return await hook_method(ctx, handler=inner_handler, **kw) + + return wrapper + + async def wrapper_no_arg() -> Any: + hook_method = getattr(cap, hook_name) + return await hook_method(ctx, handler=inner_handler, **frozen_kwargs) + + return wrapper_no_arg + + +class DynamicCapability(AbstractCapability): + """动态能力注入:允许在运行时基于上下文生成真正的 Capability""" + + def __init__(self, capability_func: Callable): + self.capability_func = capability_func + + @classmethod + def get_serialization_name(cls) -> str | None: + return None + + async def for_run(self, context: RunContext) -> "AbstractCapability": + from nonebot.utils import is_coroutine_callable + + if is_coroutine_callable(self.capability_func): + cap = await self.capability_func(context) + else: + cap = self.capability_func(context) + if cap is None: + return self + return await cap.for_run(context) + + +class WrapperCapability(AbstractCapability): + """ + 代理包装能力基类 (Decorator Pattern)。 + 默认将所有生命周期钩子透明透传给内部包裹的 (wrapped) 实例。 + """ + + def __init__(self, wrapped: AbstractCapability): + self.wrapped = wrapped + + @classmethod + def get_serialization_name(cls) -> str | None: + return None + + async def for_run(self, context: RunContext) -> "AbstractCapability": + new_wrapped = await self.wrapped.for_run(context) + if new_wrapped is self.wrapped: + return self + import copy + + new_self = copy.copy(self) + new_self.wrapped = new_wrapped + return new_self + + async def get_generation_config( + self, context: RunContext + ) -> GenerationConfig | None: + return await self.wrapped.get_generation_config(context) + + async def get_system_prompts(self, context: RunContext) -> list[str]: + return await self.wrapped.get_system_prompts(context) + + async def get_tools(self, context: RunContext) -> list[Any]: + return await self.wrapped.get_tools(context) + + async def prepare_tools( + self, context: RunContext, tool_defs: list[Any] + ) -> list[Any]: + return await self.wrapped.prepare_tools(context, tool_defs) + + async def wrap_run( + self, context: RunContext, handler: WrapRunHandler + ) -> "AgentRunResult[Any]": + return await self.wrapped.wrap_run(context, handler) + + async def wrap_model_request( + self, + context: RunContext, + llm_context: LLMContext[ChatRequest, ChatResponse], + handler: WrapModelRequestHandler, + ) -> ChatResponse: + return await self.wrapped.wrap_model_request(context, llm_context, handler) + + async def wrap_tool_validate( + self, + context: RunContext, + tool_name: str, + args: str | dict[str, Any], + handler: WrapToolValidateHandler, + ) -> dict[str, Any]: + return await self.wrapped.wrap_tool_validate(context, tool_name, args, handler) + + async def wrap_tool_execute( + self, + context: RunContext, + tool_name: str, + arguments: dict[str, Any], + handler: WrapToolExecuteHandler, + ) -> Any: + return await self.wrapped.wrap_tool_execute( + context, tool_name, arguments, handler + ) diff --git a/zhenxun/services/ai/config/__init__.py b/zhenxun/services/ai/config/__init__.py new file mode 100644 index 00000000..6fa9af83 --- /dev/null +++ b/zhenxun/services/ai/config/__init__.py @@ -0,0 +1,19 @@ +from .manager import ( + get_ai_config, + get_gemini_safety_threshold, + get_llm_config, + register_llm_configs, +) +from .models import DebugLogOptions, DefaultModelsConfig, LLMConfig, ProviderConfig + +register_llm_configs() +__all__ = [ + "DebugLogOptions", + "DefaultModelsConfig", + "LLMConfig", + "ProviderConfig", + "get_ai_config", + "get_gemini_safety_threshold", + "get_llm_config", + "register_llm_configs", +] diff --git a/zhenxun/services/ai/config/manager.py b/zhenxun/services/ai/config/manager.py new file mode 100644 index 00000000..e82f1fe5 --- /dev/null +++ b/zhenxun/services/ai/config/manager.py @@ -0,0 +1,274 @@ +from functools import lru_cache +from typing import Any + +from zhenxun.configs.config import Config +from zhenxun.configs.utils import parse_as +from zhenxun.utils.pydantic_compat import model_dump + +from .models import DebugLogOptions, LLMConfig, ProviderConfig + + +def get_ai_config(): + """获取 AI 配置组""" + return Config.get("AI") + + +def get_default_providers() -> list[dict[str, Any]]: + """获取默认提供商配置列表。""" + return [ + { + "name": "DeepSeek", + "api_key": "YOUR_API_KEY", + "api_base": "https://api.deepseek.com", + "api_type": "deepseek", + "models": [ + { + "model_name": "deepseek-v4-pro", + }, + { + "model_name": "deepseek-v4-flash", + }, + ], + }, + { + "name": "Doubao", + "api_key": "YOUR_ARK_API_KEY", + "api_base": "https://ark.cn-beijing.volces.com/api", + "api_type": "doubao", + "models": [ + {"model_name": "doubao-seed-1-6-250615"}, + {"model_name": "doubao-seed-1-6-flash-250615"}, + ], + }, + { + "name": "siliconflow", + "api_key": "YOUR_ARK_API_KEY", + "api_base": "https://api.siliconflow.cn", + "api_type": "openai", + "models": [ + {"model_name": "deepseek-ai/DeepSeek-V4-Flash"}, + {"model_name": "BAAI/bge-m3"}, + {"model_name": "BAAI/bge-reranker-v2-m3"}, + ], + }, + { + "name": "GLM", + "api_key": "YOUR_API_KEY", + "api_base": "https://open.bigmodel.cn", + "api_type": "glm", + "models": [ + {"model_name": "glm-4.6v-flash"}, + {"model_name": "glm-5v-turbo"}, + ], + }, + { + "name": "Gemini", + "api_key": [ + "AIzaSy*****************************", + "AIzaSy*****************************", + ], + "api_base": "https://generativelanguage.googleapis.com", + "api_type": "gemini", + "models": [ + {"model_name": "gemini-3.5-flash"}, + {"model_name": "gemini-3.1-flash-lite"}, + {"model_name": "gemini-2.5-flash-image"}, + {"model_name": "gemini-embedding-2"}, + {"model_name": "gemini-3.1-flash-tts-preview"}, + ], + }, + { + "name": "OpenRouter", + "api_key": "YOUR_OPENROUTER_API_KEY", + "api_base": "https://openrouter.ai/api", + "api_type": "openrouter", + "models": [ + {"model_name": "google/gemini-3.1-flash-lite"}, + {"model_name": "x-ai/grok-4"}, + ], + }, + { + "name": "MiniMax", + "api_key": "YOUR_API_KEY", + "api_base": "https://api.minimaxi.com", + "api_type": "minimax", + "models": [ + {"model_name": "MiniMax-M3"}, + {"model_name": "MiniMax-M2.7"}, + {"model_name": "MiniMax-M2.7-highspeed"}, + ], + }, + { + "name": "MiMo", + "api_key": "YOUR_MIMO_API_KEY", + "api_base": "https://api.xiaomimimo.com", + "api_type": "mimo", + "models": [ + {"model_name": "mimo-v2.5-pro"}, + {"model_name": "mimo-v2.5"}, + {"model_name": "mimo-v2.5-tts"}, + ], + }, + ] + + +def register_llm_configs(): + """注册 LLM 服务的配置项""" + + llm_config = LLMConfig() + + Config.add_plugin_config( + "AI", + "default_models", + model_dump(llm_config.default_models), + help="不同任务类型的全局默认模型配置字典", + type=dict, + ) + Config.add_plugin_config( + "AI", + "client_settings", + model_dump(llm_config.client_settings), + help=( + "LLM客户端高级设置。\n" + "包含: timeout(超时秒数), max_retries(重试次数), " + "retry_delay(重试延迟), structured_retries(结构化生成重试)" + ), + type=dict, + ) + Config.add_plugin_config( + "AI", + "debug_log", + model_dump(llm_config.debug_log), + help=( + "LLM日志详情开关。示例: {'show_tools': True, 'show_schema': False, " + "'show_safety': False}" + ), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "context_settings", + model_dump(llm_config.context_settings), + help=( + "智能上下文管理与压缩配置。\n" + "包含:\n" + " - llm_summary: 大模型总结策略配置\n" + " - enable: 是否开启大模型对话总结以压缩上下文\n" + " - trigger_threshold: 触发压缩的 Token 阈值。<=1.0为比例,>1.0为绝对 Token 数\n" # noqa: E501 + " - max_history_turns: 触发压缩的最大历史对话轮数\n" + " - summarization_model: 指定用于总结的大模型名称\n" + " - summarization_prompt: 指导大模型总结的系统提示词\n" + " - keep_recent_turns: 总结外强制原样保留的最近对话轮数\n" + " - vision_window_size: 多模态滑动窗口大小。0表示无限制,>0表示仅保留最近N轮包含多模态真实数据的消息,超过则自动降级为占位符\n" # noqa: E501 + " - tool_pruning: 工具结果过载修剪策略配置\n" + " - enable: 是否开启长工具输出结果的自动修剪\n" + " - trigger_threshold: 触发修剪的工具纯 Token 阈值。<=1.0为比例,>1.0为绝对 Token 数\n" # noqa: E501 + " - max_history_turns: 触发修剪的最大工具消息轮数。设为 0 表示不限制轮数\n" # noqa: E501 + " - keep_recent_turns: 修剪时强制原样保留的最新的工具消息轮数" + ), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "MODEL_GROUPS", + llm_config.model_groups, + help=( + "虚拟模型路由组配置 (Virtual Router Groups)。\n" + "键为组名,值为模型名称或其它组名的列表。\n" + "使用 chat(model='cheap_models') 时系统将自动按列表顺序轮询和故障转移。" + ), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "agent_settings", + model_dump(llm_config.agent_settings), + help=( + "Agent 执行引擎默认设置。\n" + "包含: max_cycles(最大工具循环数), enable_parallel_calls(允许并行), " + "reflexion_retries(反思重试次数), " + "enable_fallback_summary(达到最大循环时兜底总结), " + "enable_hitl(是否允许智能体主动向用户求助), " + "mcp_cleanup_timeout(MCP 闲置回收时间)" + ), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "sandbox", + model_dump(llm_config.sandbox), + help=( + "沙箱底层环境基础设施配置。\n" + "包含: enable_sandbox(全局开关), sandbox_type(驱动类型), docker_image" + "(使用的镜像), " + "cleanup_timeout(空闲清理超时秒数), enable_vfs_helper(开启VFS防逃逸探针)。" + ), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "provider_settings", + model_dump(llm_config.provider_settings), + help=("厂商专属高级设置。\n包含各厂商全局的特有策略开关"), + type=dict, + ) + + Config.add_plugin_config( + "AI", + "PROVIDERS", + get_default_providers(), + help=( + "配置多个 AI 服务提供商及其模型信息。\n" + "注意:可以在特定模型配置下添加 'api_type' 以覆盖提供商的全局设置。\n" + "支持的 api_type 包括:\n" + "- 'openai': 标准 OpenAI 格式 (DeepSeek, SiliconFlow等)\n" + "- 'gemini': Google Gemini API\n" + "- 'glm': 智谱 AI (GLM)\n" + "- 'doubao': 字节跳动火山引擎 (Doubao)\n" + "- 'jina': Jina AI (专精于多模态嵌入与重排)\n" + "- 'openrouter': OpenRouter 聚合平台\n" + "- 'openai_responses': 支持新版 responses 格式的 OpenAI 兼容接口\n" + "- 'smart': 智能路由模式 (主要用于第三方中转场景,自动根据模型名" + "分发请求到 openai 或 gemini)" + ), + default_value=[], + type=list[ProviderConfig], + ) + + +@lru_cache(maxsize=1) +def get_llm_config() -> LLMConfig: + """获取 LLM 配置实例""" + ai_config = get_ai_config() + + raw_debug = ai_config.get("debug_log", False) + if isinstance(raw_debug, bool): + debug_log_val = DebugLogOptions( + show_tools=raw_debug, show_schema=raw_debug, show_safety=raw_debug + ) + else: + debug_log_val = raw_debug + + config_data = { + "default_models": ai_config.get("default_models", {}), + "client_settings": ai_config.get("client_settings", {}), + "debug_log": debug_log_val, + "PROVIDERS": ai_config.get("PROVIDERS", []), + "context_settings": ai_config.get("context_settings", {}), + "model_groups": ai_config.get("MODEL_GROUPS", {}), + "agent_settings": ai_config.get("agent_settings", {}), + "sandbox": ai_config.get("sandbox", {}), + "provider_settings": ai_config.get("provider_settings", {}), + } + + return parse_as(LLMConfig, config_data) + + +def get_gemini_safety_threshold() -> str: + """获取 Gemini 安全过滤阈值配置。""" + return get_llm_config().provider_settings.gemini.safety_threshold diff --git a/zhenxun/services/ai/config/models.py b/zhenxun/services/ai/config/models.py new file mode 100644 index 00000000..cd890b1d --- /dev/null +++ b/zhenxun/services/ai/config/models.py @@ -0,0 +1,213 @@ +from pydantic import BaseModel, Field + +from zhenxun.services.ai.core.models import ModelDetail + + +class DebugLogOptions(BaseModel): + """调试日志细粒度控制选项""" + + show_tools: bool = True + """是否在日志中显示工具定义 (JSON Schema)""" + show_schema: bool = True + """是否在日志中显示结构化输出 Schema (response_format)""" + show_safety: bool = True + """是否在日志中显示安全设置 (safetySettings)""" + + def __bool__(self) -> bool: + return self.show_tools or self.show_schema or self.show_safety + + +class ClientSettings(BaseModel): + """LLM 客户端底层网络与重试设置""" + + timeout: int = 300 + """API 请求超时时间 (秒)""" + max_retries: int = 3 + """请求失败时的最大重试次数""" + retry_delay: int = 2 + """请求重试的基础延迟时间 (秒)""" + structured_retries: int = 2 + """结构化生成校验失败时的最大重试次数 (IVR)""" + + +class LLMSummaryConfig(BaseModel): + """LLM 自然语言总结压缩策略配置""" + + enable: bool = True + """是否开启大模型对话总结以压缩上下文""" + trigger_threshold: float = 0.8 + """触发压缩的 Token 阈值。<=1.0 为比例,>1.0 为绝对 Token 数""" + max_history_turns: int = 0 + """触发压缩的最大历史对话轮数。设为 0 表示不限制轮数(仅受 Token 阈值控制)。""" + summarization_model: str | None = "DeepSeek/deepseek-v4-flash" + """指定用于执行总结任务的大模型名称,为空则使用全局默认""" + summarization_prompt: str = ( + "请以客观、精炼的语言概括以下对话内容。重点保留:" + "1. 核心讨论话题及重要决定;" + "2. 用户的个性特征、核心偏好、提及的生活背景或特殊设定;" + "3. 双方互动的温度与情感基调。无需保留寒暄等客套话。" + ) + """指导大模型进行总结的系统提示词""" + keep_recent_turns: int = 3 + """在总结之外,强制原样保留的最近对话轮数""" + + +class ToolPruningConfig(BaseModel): + """工具结果修剪策略配置""" + + enable: bool = False + """是否开启长工具输出结果的自动修剪""" + trigger_threshold: float = 0.6 + """触发修剪的工具纯 Token 阈值。<=1.0 为比例,>1.0 为绝对 Token 数""" + max_history_turns: int = 15 + """触发修剪的最大工具消息轮数。设为 0 表示不限制轮数。""" + keep_recent_turns: int = 3 + """修剪时强制原样保留的最新的工具消息轮数,确保当下反思不受影响""" + + +class ContextManagementSettings(BaseModel): + """智能上下文管理与压缩算法设置""" + + llm_summary: LLMSummaryConfig = Field(default_factory=LLMSummaryConfig) + """大模型自然语言总结策略""" + + vision_window_size: int = Field(default=3) + """多模态滑动窗口大小。0表示无限制,>0表示仅保留最近N轮包含多模态真实数据的消息,超龄则自动降级为占位符""" + + tool_pruning: ToolPruningConfig = Field(default_factory=ToolPruningConfig) + """工具结果过载修剪策略""" + + +class GeminiProviderSettings(BaseModel): + """Gemini 厂商专属高级配置""" + + safety_threshold: str = Field(default="BLOCK_NONE") + """Gemini 安全过滤阈值 (BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, + BLOCK_ONLY_HIGH, BLOCK_NONE)""" + + allow_mixed_tools: bool = Field(default=False) + """是否允许同时混合使用本地自定义工具和厂商云端内置工具""" + + +class ProviderSettingsGroup(BaseModel): + """按厂商划分的高级专属设置组""" + + gemini: GeminiProviderSettings = Field(default_factory=GeminiProviderSettings) + """Gemini 相关专属配置""" + + +class ProviderConfig(BaseModel): + """LLM 服务提供商 (接口方) 配置模型""" + + name: str + """提供商唯一标识名称""" + api_key: str | list[str] + """API 密钥或密钥列表 (支持轮询)""" + api_base: str | None = None + """API 基础 URL 路径""" + api_type: str = "openai" + """API 协议类型 (openai/gemini/zhipu/etc.)""" + openai_compat: bool = False + """是否强制使用 OpenAI 兼容模式""" + temperature: float | None = 0.7 + """该提供商下模型的默认温度""" + generation_max_tokens: int | None = None + """该提供商下模型的默认最大输出限制""" + models: list[ModelDetail] + """该提供商提供的具体模型列表""" + timeout: int = 180 + """针对该提供商的特定超时时间""" + + +class DefaultModelsConfig(BaseModel): + """按任务分类的默认模型配置""" + + chat: str | None = Field(default="Gemini/gemini-3.5-flash") + embedding: str | None = Field(default="Gemini/gemini-embedding-2") + tts: str | None = Field(default="Gemini/gemini-3.1-flash-tts-preview") + image: str | None = Field(default="Gemini/gemini-2.5-flash-image") + rerank: str | None = Field(default="siliconflow/BAAI/bge-reranker-v2-m3") + + +class AgentEngineSettings(BaseModel): + """全局默认的 Agent 推理引擎配置""" + + max_cycles: int = 10 + """工具调用最大循环次数""" + global_max_cycles: int = 30 + """整个会话生命周期内(跨嵌套智能体)的大模型绝对循环次数上限,用于防死循环""" + enable_parallel_calls: bool = True + """允许并行工具调用""" + reflexion_retries: int = 1 + """反思重试次数""" + enable_fallback_summary: bool = True + """达到最大循环次数时,是否触发大模型兜底总结(而不是直接报错)""" + enable_hitl: bool = False + """是否允许智能体主动挂起任务,向用户求助 (Human-in-the-Loop)""" + mcp_cleanup_timeout: int = 900 + """MCP 服务自动清理的闲置超时时间(秒)。0表示关闭自动清理机制(永久驻留)""" + + +class SandboxSettings(BaseModel): + """沙箱底层基础设施环境配置""" + + enable_sandbox: bool = Field(default=False) + """全局沙箱功能硬开关。关闭后将彻底不加载沙箱底层驱动(如 Docker), + 极大提升冷启动速度。""" + sandbox_type: str = Field(default="docker") + """沙箱底层驱动类型: docker 等""" + docker_image: str = Field(default="zhenxun-sandbox:latest") + """Docker 沙箱使用的镜像名称 (自定义 Jupyter 增强版)""" + cleanup_timeout: int = Field(default=1800) + """沙箱自动清理的闲置超时时间(秒)。0表示关闭,不自动清理""" + enable_vfs_helper: bool = Field(default=True) + """是否开启 VFS 路径逃逸防范探针,默认开启。遇到兼容性问题时可关闭""" + + +class LLMConfig(BaseModel): + """AI 模块全局持久化配置总模型""" + + default_models: DefaultModelsConfig = Field(default_factory=DefaultModelsConfig) + """全局按任务分类的默认模型路由表""" + client_settings: ClientSettings = Field(default_factory=ClientSettings) + """客户端通用连接配置""" + providers: list[ProviderConfig] = Field(default_factory=list) + """已配置的提供商列表""" + debug_log: DebugLogOptions = Field(default_factory=DebugLogOptions) + """日志调试开关配置""" + context_settings: ContextManagementSettings = Field( + default_factory=ContextManagementSettings + ) + """上下文管理相关配置""" + model_groups: dict[str, list[str]] = Field( + default_factory=lambda: { + "cheap_models": [ + "Gemini/gemini-3.5-flash", + "Doubao/doubao-seed-1-6-250615", + ], + } + ) + """虚拟模型路由组配置 (Virtual Router Groups)""" + agent_settings: AgentEngineSettings = Field(default_factory=AgentEngineSettings) + """Agent 执行引擎层核心默认参数配置""" + sandbox: SandboxSettings = Field(default_factory=SandboxSettings) + """沙箱基础设施环境相关配置""" + provider_settings: ProviderSettingsGroup = Field( + default_factory=ProviderSettingsGroup + ) + """按厂商划分的专属高级全局开关与策略""" + + def validate_model_name(self, provider_model_name: str) -> bool: + """验证模型名称在当前配置中是否存在""" + if "/" not in provider_model_name: + return provider_model_name.strip() in self.model_groups + if not provider_model_name or "/" not in provider_model_name: + return False + parts = provider_model_name.split("/", 1) + p_name, m_name = parts[0], parts[1] + for p in self.providers: + if p.name == p_name: + for m in p.models: + if m.model_name == m_name: + return True + return False diff --git a/zhenxun/services/ai/context/__init__.py b/zhenxun/services/ai/context/__init__.py new file mode 100644 index 00000000..6c951fa7 --- /dev/null +++ b/zhenxun/services/ai/context/__init__.py @@ -0,0 +1,20 @@ +""" +Zhenxun AI - 上下文、记忆与知识管理子系统门面 (Context, Memory & Knowledge Facade) +""" + +from .knowledge import FileSystemKnowledge, VectorKnowledge +from .memory import ( + AgentSessionFacade, + MemoryBuilder, + memory_manager, +) +from .rag import RAGBuilder + +__all__ = [ + "AgentSessionFacade", + "FileSystemKnowledge", + "MemoryBuilder", + "RAGBuilder", + "VectorKnowledge", + "memory_manager", +] diff --git a/zhenxun/services/ai/context/knowledge/__init__.py b/zhenxun/services/ai/context/knowledge/__init__.py new file mode 100644 index 00000000..49c473dd --- /dev/null +++ b/zhenxun/services/ai/context/knowledge/__init__.py @@ -0,0 +1,7 @@ +from .filesystem import FileSystemKnowledge +from .vector import VectorKnowledge + +__all__ = [ + "FileSystemKnowledge", + "VectorKnowledge", +] diff --git a/zhenxun/services/ai/context/knowledge/base.py b/zhenxun/services/ai/context/knowledge/base.py new file mode 100644 index 00000000..af8c3e28 --- /dev/null +++ b/zhenxun/services/ai/context/knowledge/base.py @@ -0,0 +1,10 @@ +from zhenxun.services.ai.tools.core.toolkit import BaseToolkit + + +class BaseKnowledge(BaseToolkit): + """ + 知识库基类。 + 继承自 BaseToolkit,允许知识库向 Agent 提供自定义的检索工具(如搜索、读取)。 + """ + + pass diff --git a/zhenxun/services/ai/context/knowledge/filesystem.py b/zhenxun/services/ai/context/knowledge/filesystem.py new file mode 100644 index 00000000..c6fa791b --- /dev/null +++ b/zhenxun/services/ai/context/knowledge/filesystem.py @@ -0,0 +1,131 @@ +import os +from pathlib import Path +import re +from typing import Any + +from zhenxun.services.ai.tools.core.decorators import tool +from zhenxun.services.ai.tools.models import ToolResult +from zhenxun.services.log import logger + +from .base import BaseKnowledge + + +class FileSystemKnowledge(BaseKnowledge): + """ + 纯文本文件系统知识库。 + 零依赖,无需向量数据库。通过大模型原生工具 (grep, list, read) 让其自主翻阅本地文件。 + """ + + default_instructions = ( + "## 本地文件知识库\n" + "你拥有访问本地专业文档的权限。在回答问题前,请遵循以下流程:\n" + "1. **搜索**:优先使用 `search_knowledge_files` 通过关键词查找相关内容。\n" + "2. **概览**:如果需要了解文件结构,使用 `list_knowledge_files`。\n" + "3. **阅读**:找到目标后,使用 `read_knowledge_file` 获取完整上下文。\n" + "**核心原则**:严禁凭空捏造事实,必须基于文件内容提取信息。" + ) + + def __init__( + self, + base_dir: str | Path, + allowed_extensions: tuple[str, ...] | None = None, + **kwargs: Any, + ): + """ + 初始化文本文件系统知识库。 + + 参数: + base_dir: 知识库对应的本地根目录路径。 + allowed_extensions: 允许读取和检索的文件后缀元组, + 默认支持 txt, md, json, csv, yaml, log。 + **kwargs: 透传给父类 BaseKnowledge 的额外参数。 + """ + super().__init__(**kwargs) + self.base_dir = Path(base_dir).resolve() + self.allowed_extensions = allowed_extensions or ( + ".txt", + ".md", + ".json", + ".csv", + ".yaml", + ".log", + ) + if not self.base_dir.exists(): + logger.warning(f"[FileSystemKnowledge] 警告:目录不存在 {self.base_dir}") + self.base_dir.mkdir(parents=True, exist_ok=True) + + def _is_safe_path(self, target_path: Path) -> bool: + """安全检查:防止跨目录访问""" + try: + return target_path.resolve().is_relative_to(self.base_dir) + except Exception: + return False + + @tool( + name="search_knowledge_files", + description="在知识库中搜索包含指定关键词的文件内容和上下文。", + ) + async def search_knowledge_files(self, keyword: str) -> ToolResult: + """扫描所有文本文件,返回包含关键词的行及上下文。""" + results = [] + try: + pattern = re.compile(re.escape(keyword), re.IGNORECASE) + except Exception: + return ToolResult(output=f"无效的搜索关键词: {keyword}").as_error() + + for root, _, files in os.walk(self.base_dir): + for file in files: + if not file.endswith(self.allowed_extensions): + continue + + file_path = Path(root) / file + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + lines = content.splitlines() + + matches = [] + for i, line in enumerate(lines): + if pattern.search(line): + start = max(0, i - 1) + end = min(len(lines), i + 2) + context = "\n".join(lines[start:end]) + matches.append(context) + + if matches: + rel_path = file_path.relative_to(self.base_dir).as_posix() + match_text = "\n---\n".join(matches[:5]) + results.append(f"📁 文件: {rel_path}\n{match_text}") + except Exception: + continue + + if not results: + return ToolResult(output=f"未找到包含 '{keyword}' 的内容。尝试更换关键词。") + + final_output = "\n\n======\n\n".join(results[:10]) + return ToolResult(output=final_output) + + @tool(name="list_knowledge_files", description="列出知识库中所有可用的文档路径。") + async def list_knowledge_files(self) -> ToolResult: + files = [] + for root, _, filenames in os.walk(self.base_dir): + for filename in filenames: + file_path = Path(root) / filename + rel_path = file_path.relative_to(self.base_dir).as_posix() + files.append(rel_path) + + if not files: + return ToolResult(output="知识库当前为空。") + return ToolResult(output="可用文件列表:\n" + "\n".join(files)) + + @tool( + name="read_knowledge_file", description="读取知识库中指定文件的完整文本内容。" + ) + async def read_knowledge_file(self, file_path: str) -> ToolResult: + target = (self.base_dir / file_path).resolve() + if not self._is_safe_path(target): + return ToolResult(output="❌ 安全拦截:越权访问").as_error() + if not target.is_file(): + return ToolResult(output=f"❌ 文件不存在: {file_path}").as_error() + + content = target.read_text(encoding="utf-8", errors="ignore") + return ToolResult(output=content) diff --git a/zhenxun/services/ai/context/knowledge/readers.py b/zhenxun/services/ai/context/knowledge/readers.py new file mode 100644 index 00000000..403fd728 --- /dev/null +++ b/zhenxun/services/ai/context/knowledge/readers.py @@ -0,0 +1,74 @@ +import asyncio +import csv +from pathlib import Path + +from zhenxun.services.ai.context.rag.models import BaseRecord +from zhenxun.services.log import logger + + +class BaseReader: + """读取器基类""" + + async def read(self, file_path: Path) -> BaseRecord | None: + raise NotImplementedError + + +class TextReader(BaseReader): + """处理 .txt, .md, .json 等纯文本""" + + async def read(self, file_path: Path) -> BaseRecord | None: + try: + import aiofiles + + async with aiofiles.open(file_path, encoding="utf-8", errors="ignore") as f: + content = await f.read() + + enriched_content = f"文档名称:{file_path.stem}\n文档内容:\n{content}" + return BaseRecord( + content=enriched_content, + metadata={ + "name": file_path.name, + "extension": file_path.suffix.lower(), + }, + ) + except Exception as e: + logger.error(f"[TextReader] 读取文件失败 {file_path}: {e}") + return None + + +class CSVReader(BaseReader): + """ + 处理 .csv 报表 + 将其标准化为逗号分隔的文本块,便于后续的 RowChunking 处理。 + """ + + async def read(self, file_path: Path) -> BaseRecord | None: + try: + + def _read_csv(): + with open(file_path, encoding="utf-8", errors="ignore") as f: + reader = csv.reader(f) + return [ + ",".join( + [ + str(cell).replace("\n", " ").replace("\r", "") + for cell in row + ] + ) + for row in reader + ] + + lines = await asyncio.to_thread(_read_csv) + content = "\n".join(lines) + + enriched_content = f"数据表名称:{file_path.stem}\n数据内容:\n{content}" + return BaseRecord( + content=enriched_content, + metadata={ + "name": file_path.name, + "extension": file_path.suffix.lower(), + }, + ) + except Exception as e: + logger.error(f"[CSVReader] 读取 CSV 失败 {file_path}: {e}") + return None diff --git a/zhenxun/services/ai/context/knowledge/vector.py b/zhenxun/services/ai/context/knowledge/vector.py new file mode 100644 index 00000000..429f9c62 --- /dev/null +++ b/zhenxun/services/ai/context/knowledge/vector.py @@ -0,0 +1,319 @@ +from pathlib import Path +from typing import Any, Literal + +import anyio +from nonebot.adapters import Bot, Event +from pydantic import BaseModel, Field + +from zhenxun.services.ai.context.knowledge.base import BaseKnowledge +from zhenxun.services.ai.context.knowledge.readers import ( + BaseReader, + CSVReader, + TextReader, +) +from zhenxun.services.ai.context.rag.engine import ScopedRAGClient +from zhenxun.services.ai.context.rag.models import BaseRecord +from zhenxun.services.ai.core.messages import LLMMessage +from zhenxun.services.ai.llm.api import generate_structured +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.tools.core.decorators import tool +from zhenxun.services.ai.tools.models import ToolkitConfig, ToolResult +from zhenxun.services.log import logger + + +class QueryAnalysis(BaseModel): + """大模型结构化提取查询意图""" + + keywords: list[str] = Field( + description="提取出1~3个极其简短的搜索短语或名词,严格去除所有客套话、修饰词和标点。如果用户意图跨度较大,可以拆分为多个短语。" + ) + + +class VectorKnowledge(BaseKnowledge): + """ + 原生语义向量知识库。 + 将长文档切分、向量化并存入关系型/向量数据库, + 向大模型提供语义检索 (Semantic Search) 工具。 + """ + + default_instructions = ( + "## 语义知识库\n" + "你拥有访问外部语义向量知识库的权限。请遵循以下规则:\n" + "1. **优先检索**:在回答专业或背景问题时,务必使用 `search_knowledge` 工具。\n" + "2. **语义搜索**:你可以直接输入完整的问题或描述作为检索词," + "系统会自动进行语义匹配。\n" + "3. **精确过滤**:如果你需要查阅特定范围,可以在 filters 参数中" + "传入 JSON 字典进行精确匹配(如 {'source': 'local_file'})。\n" + "4. **基于事实**:必须仅根据检索到的内容回答,严禁编造信息。" + ) + + default_auto_inject_template = ( + "### 📚 [本地知识库自动检索结果]\n" + "基于用户的最新提问,系统后台已自动为你检索了以下参考资料。" + "请你务必优先结合以下资料回答用户的问题,严禁编造:\n\n" + "{knowledge_text}" + ) + + _global_storage: Any = None + + def __init__( + self, + rag_client: ScopedRAGClient | None = None, + injection_mode: Literal["tool", "auto", "smart"] = "tool", + query_rewrite_model: str | None = None, + auto_inject_template: str | None = None, + query_rewrite_prompt: str | None = None, + query_rewrite_instruction: str | None = None, + search_limit: int = 8, + inject_limit: int = 12, + **kwargs: Any, + ): + """ + 初始化向量语义知识库工具箱。 + + 参数: + rag_client: RAG 基础设施客户端实例,默认 None。 + injection_mode: 知识库的介入模式。 + - "tool": 纯工具模式 (默认)。大模型需自主思考并显式调用 `search_knowledge` 工具获取信息。 + - "auto": 自动注入模式。向大模型隐藏检索工具,直接使用用户的原始输入去数据库粗筛并静默注入。 + - "smart": 智能查询模式。向大模型隐藏检索工具,先利用 LLM 对用户的提问进行改写,再查库注入,准确率最高。 + query_rewrite_model: 在 "smart" 模式下,指定用于重写查询词的大模型名称(为空则跟随当前主模型)。 + auto_inject_template: 自动/智能注入模式下向大模型提示词注入的模板字符串,默认 None。 + query_rewrite_prompt: 智能模式下对查询词进行改写时的提示词,默认 None。 + query_rewrite_instruction: 智能模式下进行查询词改写的大模型 System 提示词说明,默认 None。 + search_limit: 单次库检索的返回记录数限制,默认 8。 + inject_limit: 最终合并去重后注入给大模型的上下文片段数上限,默认 12。 + **kwargs: 传递给父类的其他关键字参数。 + """ # noqa: E501 + self.injection_mode = injection_mode + self.query_rewrite_model = query_rewrite_model + self.auto_inject_template = ( + auto_inject_template or self.default_auto_inject_template + ) + self.query_rewrite_prompt = ( + query_rewrite_prompt + or "用户原始提问:{query}\n\n请提取核心搜索词用于专业知识库向量检索。" + ) + self.query_rewrite_instruction = ( + query_rewrite_instruction or "你是一个资深的数据检索架构师。" + ) + self.search_limit = search_limit + self.inject_limit = inject_limit + + if injection_mode in ("auto", "smart"): + config = kwargs.get("config") + if not config: + config = ToolkitConfig() + kwargs["config"] = config + if config.exclude is None: + config.exclude = [] + config.exclude.append("search_knowledge") + + super().__init__(**kwargs) + + if rag_client is None: + from zhenxun.services.ai.context.rag.backends import DictStorageBackend + from zhenxun.services.ai.context.rag.builder import RAGBuilder + + rag_client = RAGBuilder(DictStorageBackend()).build() + + self.rag_client = rag_client + self.readers: dict[str, BaseReader] = {} + + txt_reader = TextReader() + for ext in [".txt", ".md", ".json", ".log", ".yaml", ".yml", ".ini"]: + self.readers[ext] = txt_reader + self.readers[".csv"] = CSVReader() + + @classmethod + def from_event( + cls, + event: Event | None = None, + bot: Bot | None = None, + isolation: Literal["group", "user"] = "group", + **kwargs, + ) -> "VectorKnowledge": + """ + 根据 NoneBot 的 Event 自动推导并创建一个物理隔离的向量知识库实例。 + """ + from zhenxun.services.ai.context.memory.types import Isolation + from zhenxun.services.ai.context.rag.backends import DictStorageBackend + from zhenxun.services.ai.context.rag.builder import RAGBuilder + from zhenxun.services.ai.run.context import NoneBotDeps + from zhenxun.services.ai.utils import ContextUtils + + if not bot or not event: + deps = NoneBotDeps.get_current() + bot = bot or (deps.bot if deps else None) + event = event or (deps.event if deps else None) + + if not bot or not event: + raise ValueError( + "无法隐式获取当前对话的 Bot 或 Event 上下文," + "如果您在定时任务或后台线程中使用,请显式传入 bot 和 event 参数。" + ) + + scope_builder = ( + Isolation.GROUP_SHARED() + if isolation == "group" + else Isolation.USER_GLOBAL() + ) + session_meta = ContextUtils.generate_session_meta( + bot=bot, event=event, scope_builder=scope_builder, namespace="auto_kb" + ) + + if cls._global_storage is None: + cls._global_storage = DictStorageBackend() + + client = ( + RAGBuilder(cls._global_storage) + .with_scope(session_meta.accessible_scopes) + .build() + ) + return cls(rag_client=client, **kwargs) + + def register_reader( + self, ext: str | list[str], reader: BaseReader + ) -> "VectorKnowledge": + """挂载自定义后缀文件解析器 (如 PDF, Docx),支持链式调用""" + exts = [ext] if isinstance(ext, str) else ext + for e in exts: + e = e.lower() + if not e.startswith("."): + e = f".{e}" + self.readers[e] = reader + return self + + def get_instructions(self) -> str | None: + """ + 如果是自动/智能注入模式,对大模型完全隐藏检索提示词,防止其误调用。 + """ + if self.injection_mode != "tool": + return None + return super().get_instructions() + + async def before_llm_request( + self, context: RunContext, messages: list[Any] + ) -> None: + """ + 生命周期钩子:在向底层 LLM 发起请求前触发。 + 负责执行 "auto" 或 "smart" 模式下的前置主动检索与上下文注入。 + """ + if self.injection_mode == "tool": + return + + user_input = context.run.user_input + if not user_input: + return + + queries_to_search = [user_input] + + if self.injection_mode == "smart": + try: + model_to_use = self.query_rewrite_model or context.run.current_model + prompt = self.query_rewrite_prompt.format(query=user_input) + res = await generate_structured( + message=prompt, + response_model=QueryAnalysis, + model=model_to_use, + instruction=self.query_rewrite_instruction, + ) + if res.keywords: + logger.info( + f"✨ [Smart Knowledge] 搜索词改写成功: '{user_input}' -> " + f"{res.keywords}" + ) + queries_to_search = res.keywords + except Exception as e: + logger.warning(f"[Smart Knowledge] Query 改写失败,降级使用原词: {e}") + + all_results = [] + seen_ids = set() + for q in queries_to_search: + results = await self.rag_client.search(query=q, limit=self.search_limit) + for res in results: + if res.record.id not in seen_ids: + seen_ids.add(res.record.id) + all_results.append(res) + + if not all_results: + return + + all_results.sort(key=lambda x: x.score, reverse=True) + all_results = all_results[: self.inject_limit] + + formatted_results = [] + for result in all_results: + doc_name = result.record.metadata.get("name", "未命名文档") + formatted_results.append( + f"📄 来源: {doc_name}\n内容片段:\n{result.record.content}" + ) + + knowledge_text = "\n\n======\n\n".join(formatted_results) + system_prompt = self.auto_inject_template.format(knowledge_text=knowledge_text) + + messages.insert(0, LLMMessage.system(system_prompt)) + + async def add_document(self, document: BaseRecord) -> int: + """ + 通过注入的 Ingestion Pipeline 处理并入库文档 + 返回成功入库的 Chunk 数量。 + """ + return await self.rag_client.ingest([document]) + + async def add_file(self, file_path: str | Path) -> int: + """ + 读取并注入单个文件。 + """ + aio_path = anyio.Path(file_path) + std_path = Path(file_path) + if not await aio_path.is_file(): + logger.error(f"[VectorKnowledge] 文件不存在: {std_path}") + return 0 + + ext = std_path.suffix.lower() + reader = self.readers.get(ext) + if not reader: + logger.warning(f"当前知识库未配置支持解析文件后缀: {ext}") + return 0 + + doc = await reader.read(std_path) + if not doc: + return 0 + + return await self.rag_client.ingest([doc]) + + async def add_directory(self, dir_path: str | Path) -> int: + """扫描目录并注入所有支持的文件""" + total_chunks = 0 + aio_path = anyio.Path(dir_path) + async for p in aio_path.rglob("*"): + if await p.is_file(): + total_chunks += await self.add_file(Path(p)) + return total_chunks + + @tool( + name="search_knowledge", + description=( + "在语义知识库中搜索最相关的内容片段。可以通过 filters 字典进行额外过滤。" + ), + ) + async def search_knowledge( + self, query: str, filters: dict[str, Any] | None = None, limit: int = 5 + ) -> ToolResult: + results = await self.rag_client.search( + query=query, limit=limit, metadata_filters=filters + ) + + if not results: + return ToolResult(output=f"知识库中未找到与 '{query}' 紧密相关的内容。") + + formatted_results = [] + for result in results: + doc_name = result.record.metadata.get("name", "未命名文档") + formatted_results.append( + f"📄 来源: {doc_name}\n" f"片段内容:\n{result.record.content}" + ) + + final_text = "\n\n======\n\n".join(formatted_results) + return ToolResult(output=final_text) diff --git a/zhenxun/services/ai/context/memory/__init__.py b/zhenxun/services/ai/context/memory/__init__.py new file mode 100644 index 00000000..28419e54 --- /dev/null +++ b/zhenxun/services/ai/context/memory/__init__.py @@ -0,0 +1,23 @@ +from .builder import MemoryBuilder +from .compression import MemoryPolicy +from .facades import AgentSessionFacade +from .manager import memory_manager +from .models import ( + BaseMemoryIngestionMiddleware, + MemoryConfig, +) +from .types import ( + Isolation, + SessionMetadata, +) + +__all__ = [ + "AgentSessionFacade", + "BaseMemoryIngestionMiddleware", + "Isolation", + "MemoryBuilder", + "MemoryConfig", + "MemoryPolicy", + "SessionMetadata", + "memory_manager", +] diff --git a/zhenxun/services/ai/context/memory/builder.py b/zhenxun/services/ai/context/memory/builder.py new file mode 100644 index 00000000..52be7af5 --- /dev/null +++ b/zhenxun/services/ai/context/memory/builder.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any +from typing_extensions import Self + +from pydantic import BaseModel + +if TYPE_CHECKING: + from zhenxun.services.ai.context.memory.models import MemorySlot + from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseChatContext, + BaseMemoryIngestionMiddleware, + BaseSlotContext, + ) + from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend + from zhenxun.services.ai.context.rag.engine import ScopedRAGClient + +from zhenxun.services.ai.context.memory.compression import MemoryPolicy +from zhenxun.services.ai.context.memory.models import ( + ContextCompressionConfig, + IngestionConfig, + LongTermConfig, + MemoryConfig, + ShortTermConfig, + SlotMemoryConfig, +) +from zhenxun.services.ai.context.memory.types import ( + AutoRecallPolicy, +) +from zhenxun.services.ai.utils.scope import ScopeBuilder + + +class MemoryBuilder: + """ + 记忆配置的链式构建器 (Fluent Builder)。 + """ + + def __init__(self): + """ + 初始化 MemoryBuilder 实例。 + + 创建一个默认关闭短期和长期记忆,并包含默认上下文压缩配置的构建器。 + """ + self._config = MemoryConfig( + short_term=ShortTermConfig(enable=False), + slots=SlotMemoryConfig(enable=False), + long_term=LongTermConfig(enable=False), + compression=ContextCompressionConfig(), + ingestion=IngestionConfig(), + ) + + @classmethod + def auto(cls) -> "MemoryBuilder": + """ + 创建一个开箱即用的默认记忆配置构建器。 + + 默认开启隔离的短期记忆,并使用 LLM 对话摘要进行上下文压缩。 + """ + return cls().with_short_term(enable=True).with_llm_summary() + + @classmethod + def resolve( + cls, memory: bool | MemoryConfig | "MemoryBuilder" | None + ) -> MemoryConfig: + if isinstance(memory, MemoryConfig): + return memory + if isinstance(memory, cls): + return memory.build() + if isinstance(memory, bool): + return MemoryConfig( + short_term=ShortTermConfig(enable=memory), + long_term=LongTermConfig(enable=memory), + ) + + return MemoryConfig(short_term=ShortTermConfig(enable=False)) + + def with_base_isolation(self, isolation: ScopeBuilder) -> Self: + """设置顶层基准隔离级别,短期/中期/长期记忆将默认继承此级别""" + self._config.base_isolation = isolation + self._config.short_term.isolation = isolation + return self + + def with_short_term( + self, + enable: bool = True, + isolation: ScopeBuilder | None = None, + backend: "str | BaseChatContext | None" = None, + ) -> Self: + """ + 配置短期对话历史记忆。 + + 参数: + enable: 是否开启短期记忆。 + isolation: 记忆隔离级别 (ScopeBuilder),决定会话历史记录的区分范围。 + backend: 短期记忆存储后端实例,如果为 None 则使用全局默认后端。 + """ + self._config.short_term.enable = enable + if isolation is not None: + self._config.base_isolation = isolation + self._config.short_term.isolation = isolation + if backend is not None: + self._config.short_term.backend = backend + return self + + def with_slots( + self, + enable: bool = True, + scopes: dict[str, ScopeBuilder] | None = None, + default_slots: list["MemorySlot"] | None = None, + backend: "str | BaseSlotContext | None" = None, + instructions: str | None = None, + ) -> Self: + """ + 配置核心槽位记忆 (Memory Slots)。 + + 参数: + enable: 是否启用槽位记忆。 + scopes: 语义化作用域映射字典。如果只有一个键值对,则大模型不可见该参数。 + default_slots: 首次初始化时自动写入的默认槽位列表。 + backend: 槽位记忆存储后端,如果为 None 则使用全局默认后端。 + instructions: 覆写内置槽位管理工具箱的默认系统提示词规则。 + """ + self._config.slots.enable = enable + if scopes is not None: + self._config.slots.scopes = scopes + if default_slots is not None: + self._config.slots.default_slots = default_slots + if backend is not None: + self._config.slots.backend = backend + if instructions is not None: + self._config.slots.instructions = instructions + return self + + def with_long_term( + self, + enable: bool = True, + scopes: dict[str, ScopeBuilder] | None = None, + engine: "ScopedRAGClient | None" = None, + backend: "str | StorageBackend | None" = None, + embedder: "Embedder | str | None" = None, + agentic: bool = True, + auto_recall: AutoRecallPolicy = False, + instructions: str | None = None, + ) -> Self: + """ + 配置长期向量记忆与 RAG 设定。 + + 参数: + enable: 是否启用长期记忆。 + scopes: 语义化作用域映射字典。如果只有一个键值对,则大模型不可见该参数。 + engine: 高级 RAG 检索引擎实例 (推荐)。若提供,将接管记忆的底层检索、混合与重排。 + backend: 长期记忆存储后端。 + embedder: 用于向量化的文本嵌入模型实例。 + agentic: 是否开启主动智能体记忆管理 (增删改查工具自动注入)。 + auto_recall: 长期记忆的自动召回策略,支持 bool 或 Callable 函数。 + instructions: 覆写内置长期记忆工具箱的默认系统提示词规则。 + """ # noqa: E501 + self._config.long_term.enable = enable + self._config.long_term.engine = engine + if scopes is not None: + self._config.long_term.scopes = scopes + self._config.long_term.backend = backend + self._config.long_term.embedder = embedder + self._config.long_term.agentic = agentic + self._config.long_term.auto_recall = auto_recall + if instructions is not None: + self._config.long_term.instructions = instructions + return self + + def with_multimodal_window(self, window_size: int = 5) -> Self: + """ + 配置多模态历史视窗大小。 + + 超出此窗口的图片/视频等富媒体消息会自动转换为纯文本占位符,以节省 Token 预算。 + + 参数: + window_size: 允许保留多模态信息的最新的对话轮数。 + """ + self._config.compression.vision_window = window_size + return self + + def with_llm_summary( + self, + trigger_tokens: int = 4000, + max_turns: int = 0, + keep_recent_turns: int = 0, + summarization_model: str | None = None, + summarization_prompt: str = "请概括以下对话内容,保留关键的约束条件、用户偏好、已完成的任务状态和未解决的问题。", # noqa: E501 + ) -> Self: + """ + 配置使用大模型自然语言总结作为上下文压缩策略。 + + 参数: + trigger_tokens: 触发压缩的 Token 门槛。 + max_turns: 压缩策略作用的最大历史对话轮数上限。 + keep_recent_turns: 在大模型总结之外,强制保留的最近原始对话轮数。 + summarization_model: 负责生成总结的大模型名称。 + summarization_prompt: 生成总结时所使用的系统提示词。 + """ + self._config.compression.policy = MemoryPolicy.llm_summarize( + trigger_tokens=trigger_tokens, + max_turns=max_turns, + keep_recent_turns=keep_recent_turns, + summarization_model=summarization_model, + summarization_prompt=summarization_prompt, + ) + return self + + def with_structured_summary( + self, + trigger_tokens: int = 4000, + max_turns: int = 0, + keep_recent_turns: int = 0, + summarization_model: str | None = None, + response_model: type[BaseModel] | None = None, + prompt_template: str | None = None, + format_callback: Callable[[Any], str] | None = None, + ) -> Self: + """ + 配置使用自定义结构化 JSON 提取作为上下文压缩策略。 + + 参数: + trigger_tokens: 触发压缩的 Token 门槛。 + max_turns: 压缩策略作用的最大历史对话轮数上限。 + keep_recent_turns: 强制保留的最近原始对话轮数。 + summarization_model: 负责生成结构化总结的大模型名称。 + response_model: (可选) 自定义的 Pydantic 数据模型,用于指导提取的结构。 + prompt_template: (可选) 提取提示词模板, + 支持 {prev_summary} 和 {dialogue} 变量。 + format_callback: (可选) 将提取出的 Pydantic 实例格式化为字符串的回调函数。 + """ + self._config.compression.policy = MemoryPolicy.structured_summarize( + trigger_tokens=trigger_tokens, + max_turns=max_turns, + keep_recent_turns=keep_recent_turns, + summarization_model=summarization_model, + response_model=response_model, + prompt_template=prompt_template, + format_callback=format_callback, + ) + return self + + def unlimited(self) -> Self: + """ + 配置为不进行任何截断和压缩的策略。 + 适用于短程会话或者具备超长上下文窗口的底层语言模型。 + """ + self._config.compression.policy = MemoryPolicy.unlimited() + return self + + def with_ingestion_middlewares( + self, *middlewares: "BaseMemoryIngestionMiddleware" + ) -> Self: + """ + 配置记忆入库管线中间件。 + 用于在消息正式落盘前进行实体消解、隐私脱敏、自动打标签等操作。 + """ + self._config.ingestion.middlewares.extend(middlewares) + return self + + def build(self) -> MemoryConfig: + """ + 生成最终构建好的 MemoryConfig 配置对象。 + """ + if not self._config.slots.scopes: + self._config.slots.scopes = {"私有": self._config.base_isolation} + if not self._config.long_term.scopes: + self._config.long_term.scopes = {"私有": self._config.base_isolation} + return self._config diff --git a/zhenxun/services/ai/context/memory/capabilities.py b/zhenxun/services/ai/context/memory/capabilities.py new file mode 100644 index 00000000..6808dbd6 --- /dev/null +++ b/zhenxun/services/ai/context/memory/capabilities.py @@ -0,0 +1,52 @@ +from typing import Any + +from zhenxun.services.ai.capabilities.base import AbstractCapability +from zhenxun.services.ai.context.memory.models import MemoryConfig +from zhenxun.services.ai.run.context import RunContext +from zhenxun.services.ai.tools.providers.builtin.memory import MemoryManagementToolkit + + +class AgenticMemoryCapability(AbstractCapability): + """ + 智能体主动记忆管理能力 (Agentic Memory Management)。 + 当 `MemoryConfig.long_term.enable == True` 且 `agentic == True` 时隐式挂载, + 在运行时动态组装并向大模型提供 `MemoryManagementToolkit` 工具箱。 + """ + + def __init__(self, memory_config: MemoryConfig, namespace: str): + self.memory_config = memory_config + self.namespace = namespace + + async def get_tools(self, context: RunContext) -> list[Any]: + kwargs = self.memory_config.long_term.toolkit_kwargs.copy() + kwargs["memory_config"] = self.memory_config + kwargs["namespace"] = self.namespace + if self.memory_config.long_term.instructions is not None: + kwargs["instructions"] = self.memory_config.long_term.instructions + + toolkit = MemoryManagementToolkit(**kwargs) + return [toolkit] + + +class SlotMemoryCapability(AbstractCapability): + """ + 槽位记忆能力组件。 + 当 `MemoryConfig.slots.enable == True` 时隐式挂载, + 在运行时动态组装并向大模型提供 `MemorySlotToolkit` 工具箱。 + """ + + def __init__(self, memory_config: MemoryConfig, namespace: str): + self.memory_config = memory_config + self.namespace = namespace + + async def get_tools(self, context: RunContext) -> list[Any]: + from zhenxun.services.ai.tools.providers.builtin.slots import MemorySlotToolkit + + kwargs = self.memory_config.slots.toolkit_kwargs.copy() + kwargs["memory_config"] = self.memory_config + kwargs["namespace"] = self.namespace + if self.memory_config.slots.instructions is not None: + kwargs["instructions"] = self.memory_config.slots.instructions + + toolkit = MemorySlotToolkit(**kwargs) + return [toolkit] diff --git a/zhenxun/services/ai/context/memory/compression.py b/zhenxun/services/ai/context/memory/compression.py new file mode 100644 index 00000000..9aec2f8c --- /dev/null +++ b/zhenxun/services/ai/context/memory/compression.py @@ -0,0 +1,688 @@ +from abc import abstractmethod +from collections.abc import Callable +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseMemoryReducer, +) +from zhenxun.services.ai.core.engine.token_counter import token_counter +from zhenxun.services.ai.core.messages import ( + AudioPart, + FilePart, + ImagePart, + LLMMessage, + SystemMessage, + TextPart, + VideoPart, +) +from zhenxun.services.ai.llm.manager import get_default_model +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_copy + + +class MultimodalPlaceholderReducer(BaseMemoryReducer): + """视觉媒体降级:将超过一定轮数的老图片/视频替换为 <图片> 占位符文本""" + + def __init__(self, window_size: int = 5): + """ + 初始化多模态占位符修剪器。 + + 参数: + window_size: 多模态视窗大小。在保留最近的指定数量的多模态消息对后, + 超出的旧消息中多模态内容(如图片、视频等)将被替换为占位文本。 + """ + self.window_size = window_size + + @staticmethod + def apply_multimodal_placeholder(message: LLMMessage) -> LLMMessage: + sanitized_message = model_copy(message, deep=False) + new_content_parts = [] + + for part in sanitized_message.content: + if isinstance(part, ImagePart): + new_content_parts.append(TextPart(text="<图片>")) + elif isinstance(part, AudioPart): + new_content_parts.append(TextPart(text="<音频>")) + elif isinstance(part, VideoPart): + new_content_parts.append(TextPart(text="<视频>")) + elif isinstance(part, FilePart): + new_content_parts.append(TextPart(text="<文件>")) + elif isinstance(part, TextPart) and "[多模态内容:" in part.text: + new_content_parts.append(TextPart(text="<图片>")) + else: + new_content_parts.append(part) + + merged_parts = [] + for part in new_content_parts: + if ( + isinstance(part, TextPart) + and merged_parts + and isinstance(merged_parts[-1], TextPart) + ): + new_text = (merged_parts[-1].text or "") + " " + (part.text or "") + merged_parts[-1] = TextPart(text=new_text.strip()) + else: + merged_parts.append(part) + + sanitized_message.content = merged_parts + sanitized_message.token_cost = None + return sanitized_message + + async def reduce(self, messages, current_tokens, model_name, base_overhead=0): + if self.window_size <= 0: + return messages, False, current_tokens + + processed_messages = [] + user_multimodal_count = 0 + changed = False + + for msg in reversed(messages): + has_multimodal = False + if isinstance(msg.content, list): + has_multimodal = any( + isinstance(p, ImagePart | AudioPart | VideoPart | FilePart) + or (isinstance(p, TextPart) and "[多模态内容:" in p.text) + for p in msg.content + ) + + if has_multimodal: + if msg.role == "user": + user_multimodal_count += 1 + if user_multimodal_count > self.window_size: + processed_messages.append(self.apply_multimodal_placeholder(msg)) + changed = True + else: + processed_messages.append(msg) + else: + processed_messages.append(msg) + + if not changed: + return messages, False, current_tokens + + processed_messages.reverse() + new_tokens = token_counter.count_context( + processed_messages, model_name, base_overhead + ) + return processed_messages, True, new_tokens + + +class MessageDropper(BaseMemoryReducer): + """消息丢弃器:在 Token 超过阈值时丢弃最早的非置顶消息对。""" + + def __init__(self, trigger_tokens: int = 4000): + """ + 初始化消息丢弃器。 + + 参数: + trigger_tokens: 触发丢弃策略的 Token 阈值上限。 + 当当前对话 Token 总数超过此值时,将触发硬截断。 + """ + self.trigger_tokens = trigger_tokens + + async def reduce(self, messages, current_tokens, model_name, base_overhead=0): + if current_tokens <= self.trigger_tokens: + return messages, False, current_tokens + + logger.info( + "✂️ [MemoryCompression] 触发硬截断丢弃策略 | 原因: " + f"当前 Token 预估 ({current_tokens}) 仍超过硬性上限 ({self.trigger_tokens})," # noqa: E501 + "开始丢弃最旧的历史对话..." + ) + new_messages = list(messages) + changed = False + + while current_tokens > self.trigger_tokens: + user_indices = [ + i + for i, m in enumerate(new_messages) + if m.role == "user" + and not (m.metadata and m.metadata.get("pinned", False)) + ] + + if len(user_indices) < 2: + break + + start_idx = user_indices[0] + end_idx = user_indices[1] + + del new_messages[start_idx:end_idx] + changed = True + + current_tokens = token_counter.count_context( + new_messages, model_name, base_overhead + ) + return new_messages, changed, current_tokens + + +class ToolPrunerReducer(BaseMemoryReducer): + """工具结果修剪器:纯粹计算工具输出的 Token 和轮数,超标时剔除老旧工具返回结果""" + + def __init__( + self, + keep_recent_turns: int = 3, + trigger_tokens: int = 4000, + max_turns: int = 0, + ): + """ + 初始化工具结果修剪器。 + + 参数: + keep_recent_turns: 保留最近的工具调用轮数(不进行内容截断的轮数)。 + trigger_tokens: 触发工具修剪策略的工具总 Token 阈值上限。 + max_turns: 触发工具修剪的最大工具调用轮数上限。若为 0,则不限制轮数。 + """ + self.keep_recent_turns = keep_recent_turns + self.trigger_tokens = trigger_tokens + self.max_turns = max_turns + + async def reduce(self, messages, current_tokens, model_name, base_overhead=0): + tool_msgs = [m for m in messages if m.role == "tool"] + tool_turns = len(tool_msgs) + + if tool_turns == 0: + return messages, False, current_tokens + + tool_tokens = sum(token_counter.count_message(m, model_name) for m in tool_msgs) + + is_token_exceeded = tool_tokens > self.trigger_tokens + is_turn_exceeded = self.max_turns > 0 and tool_turns > self.max_turns + + if not (is_token_exceeded or is_turn_exceeded): + return messages, False, current_tokens + + reasons = [] + if is_token_exceeded: + reasons.append(f"工具Token超标 ({tool_tokens} > {self.trigger_tokens})") + if is_turn_exceeded: + reasons.append(f"工具调用轮数超限 ({tool_turns} > {self.max_turns})") + + logger.info( + f"✂️ [MemoryCompression] 触发工具结果修剪策略 | 原因: {' 且 '.join(reasons)}" + ) + + from zhenxun.services.ai.core.messages import ToolReturnPart + + new_messages = [] + tools_kept = 0 + changed = False + + for msg in reversed(messages): + if msg.role != "tool": + new_messages.append(msg) + continue + + if tools_kept < self.keep_recent_turns: + tools_kept += 1 + new_messages.append(msg) + continue + + new_content = [] + part_changed = False + for p in msg.content: + if isinstance(p, ToolReturnPart): + old_len = len(str(p.output)) + new_p = model_copy( + p, + update={ + "output": f"[数据过载自动截断 - 原长度: {old_len} 字符]" + }, + ) + new_content.append(new_p) + part_changed = True + changed = True + else: + new_content.append(p) + + if part_changed: + new_msg = model_copy( + msg, update={"content": new_content, "token_cost": None} + ) + new_messages.append(new_msg) + else: + new_messages.append(msg) + + if not changed: + return messages, False, current_tokens + + new_messages.reverse() + new_total = token_counter.count_context(new_messages, model_name, base_overhead) + return new_messages, True, new_total + + +class AbstractSummarizerReducer(BaseMemoryReducer): + """抽象总结压缩器:提取阈值判断与上下文分流的公共逻辑""" + + def __init__( + self, + strategy_name: str, + keep_recent_turns: int = 0, + trigger_tokens: int = 4000, + max_turns: int | None = None, + summarization_model: str | None = None, + ): + """ + 初始化抽象总结压缩基类。 + + 参数: + strategy_name: 压缩策略的名称,用于日志输出和追踪。 + keep_recent_turns: 压缩时需要保留的最新的对话轮数(不参与总结的轮数)。 + trigger_tokens: 触发总结策略的 Token 阈值上限。 + max_turns: 触发总结策略的最大对话轮数上限。 + summarization_model: 用于执行总结压缩大模型请求的模型名称,若为 None 则使用默认模型。 + """ # noqa: E501 + self.strategy_name = strategy_name + self.keep_recent_turns = keep_recent_turns + self.trigger_tokens = trigger_tokens + self.max_turns = max_turns + self.summarization_model = summarization_model + + @abstractmethod + async def _execute_summarization( + self, to_summarize: list[LLMMessage], prev_summary: str + ) -> LLMMessage | None: + """由子类实现具体的 LLM 调用逻辑,返回新的总结消息""" + pass + + async def reduce(self, messages, current_tokens, model_name, base_overhead=0): + user_turns = sum( + 1 + for m in messages + if m.role == "user" + and not (m.metadata and m.metadata.get("is_summary", False)) + ) + is_token_exceeded = current_tokens > self.trigger_tokens + is_turn_exceeded = ( + self.max_turns is not None + and self.max_turns > 0 + and user_turns > self.max_turns + ) + + if not (is_token_exceeded or is_turn_exceeded): + return messages, False, current_tokens + + reasons = [] + if is_token_exceeded: + reasons.append(f"Token 预估超限 ({current_tokens} > {self.trigger_tokens})") + if is_turn_exceeded: + reasons.append(f"有效对话轮次超限 ({user_turns} > {self.max_turns})") + logger.info( + f"🔄 [MemoryCompression] 触发{self.strategy_name}策略 | 原因: " + f"{' 且 '.join(reasons)}" + ) + + pinned_msgs, working_msgs, prev_summary = [], [], "" + for msg in messages: + is_pinned = isinstance(msg, SystemMessage) or ( + msg.metadata and msg.metadata.get("pinned", False) + ) + if msg.metadata and msg.metadata.get("is_summary", False): + prev_summary = msg.extract_text + elif is_pinned: + pinned_msgs.append(msg) + else: + working_msgs.append(msg) + + user_indices = [i for i, m in enumerate(working_msgs) if m.role == "user"] + + if len(user_indices) <= self.keep_recent_turns: + return messages, False, current_tokens + + split_idx = ( + user_indices[-self.keep_recent_turns] + if self.keep_recent_turns > 0 + else len(working_msgs) + ) + to_summarize = working_msgs[:split_idx] + to_keep = working_msgs[split_idx:] + + new_summary_msg = await self._execute_summarization(to_summarize, prev_summary) + if not new_summary_msg: + return messages, False, current_tokens + + new_messages = [*pinned_msgs, new_summary_msg, *to_keep] + return ( + new_messages, + True, + token_counter.count_context(new_messages, model_name, base_overhead), + ) + + +class LLMSummarizerReducer(AbstractSummarizerReducer): + """大模型总结压缩器:将较早的历史对话记录通过 LLM 压缩合并为一段文本摘要。""" + + def __init__( + self, + keep_recent_turns: int = 0, + trigger_tokens: int = 4000, + max_turns: int | None = None, + summarization_model: str | None = None, + summarization_prompt: str = ( + "请概括以下对话内容,保留关键的约束条件、用户偏好、" + "已完成的任务状态和未解决的问题。" + ), + ): + """ + 初始化大模型总结压缩器。 + + 参数: + keep_recent_turns: 压缩时需要保留的最新的对话轮数。 + trigger_tokens: 触发总结策略的 Token 阈值上限。 + max_turns: 触发总结策略的最大对话轮数上限。 + summarization_model: 用于执行总结压缩的大模型名称。 + summarization_prompt: 发送给大模型的总结引导 Prompt 提示词。 + """ + super().__init__( + strategy_name="历史对话合并总结", + keep_recent_turns=keep_recent_turns, + trigger_tokens=trigger_tokens, + max_turns=max_turns, + summarization_model=summarization_model, + ) + self.summarization_prompt = summarization_prompt + + async def _execute_summarization( + self, to_summarize: list[LLMMessage], prev_summary: str + ) -> LLMMessage | None: + prompt_text = f"### 📋 [对话摘要任务]\n{self.summarization_prompt}\n\n" + if prev_summary: + prompt_text += "#### önceki_summary (参考先前的快照):\n" + prompt_text += f"> {prev_summary}\n\n" + prompt_text += "#### 待处理的历史消息流:\n" + for m in to_summarize: + c_str = m.extract_text[:1500] + speaker = m.source_name if m.source_name else m.role.capitalize() + prompt_text += f"[{speaker}]: {c_str}\n" + prompt_text += "\n" + + from zhenxun.services.ai.llm.api import chat + + try: + model_to_use = self.summarization_model or get_default_model("chat") + response = await chat( + prompt_text, + model=model_to_use, + instruction="你是后台记忆整理引擎。请客观、简明输出当前对话全局摘要。", + ) + new_summary_msg = LLMMessage.assistant_text_response( + f"【历史对话摘要记忆】\n{response.text}" + ) + new_summary_msg.metadata = {"is_summary": True, "pinned": True} + return new_summary_msg + except Exception as e: + logger.error( + f"[{self.__class__.__name__}] 压缩总结调用失败,已跳过本次压缩: {e}" + ) + return None + + +_T_Summary = TypeVar("_T_Summary", bound=BaseModel) + + +class StructuredSummaryReducer(AbstractSummarizerReducer, Generic[_T_Summary]): + """结构化总结压缩器:基于 JSON Schema 格式化抽取长上下文状态信息并合并""" + + def __init__( + self, + response_model: type[_T_Summary], + prompt_template: str, + format_callback: Callable[[_T_Summary], str], + keep_recent_turns: int = 0, + trigger_tokens: int = 4000, + max_turns: int | None = None, + summarization_model: str | None = None, + instruction: str = ( + "请提取并合并先前的状态和最新的对话内容,保持精简,不要编造事实" + ), + ): + """ + 初始化结构化总结压缩器。 + + 参数: + response_model: 接收结构化输出的 Pydantic 模型类,需继承自 BaseModel。 + prompt_template: 用于抽取合并状态的 Prompt 模板,包含 {prev_summary} 和 {dialogue} 占位符。 + format_callback: 格式化回调函数,用于将结构化 Pydantic 响应对象转换为便于大模型阅读的字符串。 + keep_recent_turns: 压缩时需要保留的最新的对话轮数。 + trigger_tokens: 触发总结策略的 Token 阈值上限。 + max_turns: 触发总结策略的最大对话轮数上限。 + summarization_model: 用于执行总结压缩的大模型名称。 + instruction: 指导大模型生成结构化数据时的系统指令说明。 + """ # noqa: E501 + super().__init__( + strategy_name="结构化状态抽取压缩", + keep_recent_turns=keep_recent_turns, + trigger_tokens=trigger_tokens, + max_turns=max_turns, + summarization_model=summarization_model, + ) + self.response_model = response_model + self.prompt_template = prompt_template + self.format_callback = format_callback + self.instruction = instruction + + async def _execute_summarization( + self, to_summarize: list[LLMMessage], prev_summary: str + ) -> LLMMessage | None: + dialogue_text = "" + for m in to_summarize: + c_str = m.extract_text[:1500] + speaker = m.source_name if m.source_name else m.role.capitalize() + dialogue_text += f"[{speaker}]: {c_str}\n" + + prompt_text = self.prompt_template.format( + prev_summary=prev_summary, dialogue=dialogue_text + ) + + from zhenxun.services.ai.llm.api import generate_structured + + try: + model_to_use = self.summarization_model or get_default_model("chat") + summary_obj = await generate_structured( + prompt_text, + response_model=self.response_model, + model=model_to_use, + instruction=self.instruction, + ) + + summary_text = self.format_callback(summary_obj) + + new_summary_msg = LLMMessage.assistant_text_response( + f"【历史状态摘要记忆】\n{summary_text}" + ) + new_summary_msg.metadata = {"is_summary": True, "pinned": True} + return new_summary_msg + except Exception as e: + logger.error( + f"[{self.__class__.__name__}] 结构化总结失败,已跳过本次压缩: {e}" + ) + return None + + +class CondenserPipeline: + """上下文压缩流水线:按顺序依次执行各阶段的记忆压缩减项。""" + + def __init__(self, reducers: list[BaseMemoryReducer]): + """ + 初始化上下文压缩流水线。 + + 参数: + reducers: 压缩减项器列表,将按顺序对记忆进行多阶段修剪和压缩。 + """ + self.reducers = reducers + + @classmethod + def create_from_configs( + cls, memory_config: Any, model_name: str + ) -> "CondenserPipeline": + """基于全局和局部配置组装压缩管线工厂方法""" + from zhenxun.services.ai.config import get_llm_config + from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities + + config = get_llm_config().context_settings + pipeline_reducers = [] + caps = get_model_capabilities(model_name) + + vw = config.vision_window_size + if memory_config and memory_config.compression.vision_window is not None: + vw = memory_config.compression.vision_window + if vw > 0: + pipeline_reducers.append(MultimodalPlaceholderReducer(window_size=vw)) + + tp = config.tool_pruning + if tp.enable: + tp_limit = ( + int(caps.max_input_tokens * tp.trigger_threshold) + if tp.trigger_threshold <= 1.0 + else int(tp.trigger_threshold) + ) + pipeline_reducers.append( + ToolPrunerReducer( + keep_recent_turns=tp.keep_recent_turns, + trigger_tokens=tp_limit, + max_turns=tp.max_history_turns, + ) + ) + + policy = memory_config.compression.policy if memory_config else None + if policy is not None: + pipeline_reducers.extend(policy) + else: + threshold = config.llm_summary.trigger_threshold + if memory_config and memory_config.compression.threshold is not None: + threshold = memory_config.compression.threshold + + limit = ( + int(caps.max_input_tokens * threshold) + if threshold <= 1.0 + else int(threshold) + ) + + max_turns = config.llm_summary.max_history_turns + if ( + memory_config + and memory_config.compression.max_history_turns is not None + ): + max_turns = memory_config.compression.max_history_turns + + if config.llm_summary.enable: + pipeline_reducers.extend( + MemoryPolicy.llm_summarize( + trigger_tokens=limit, + max_turns=max_turns, + keep_recent_turns=config.llm_summary.keep_recent_turns, + summarization_model=config.llm_summary.summarization_model, + summarization_prompt=config.llm_summary.summarization_prompt, + ) + ) + else: + pipeline_reducers.extend(MemoryPolicy.unlimited()) + + return cls(pipeline_reducers) + + async def run( + self, messages, model_name, base_overhead=0 + ) -> tuple[list[LLMMessage], bool]: + current_tokens = token_counter.count_context( + messages, model_name, base_overhead + ) + + current_messages = messages + any_changed = False + for reducer in self.reducers: + current_messages, changed, current_tokens = await reducer.reduce( + current_messages, + current_tokens, + model_name, + base_overhead, + ) + if changed: + any_changed = True + return current_messages, any_changed + + +class MemoryPolicy: + """ + 记忆策略工厂 (Strategy Factory Facade)。 + 为开发者提供开箱即用的上下文压缩管线组装方案。 + """ + + @staticmethod + def unlimited() -> list[BaseMemoryReducer]: + """无限制模式。不进行任何形式的截断和总结,适用于短对话或纯 Agent 内部流转。""" + return [] + + @staticmethod + def llm_summarize( + trigger_tokens: int = 4000, + max_turns: int | None = None, + keep_recent_turns: int = 0, + summarization_model: str | None = None, + summarization_prompt: str = ( + "请概括以下对话内容,保留关键的约束条件、用户偏好、" + "已完成的任务状态和未解决的问题。" + ), + ) -> list[BaseMemoryReducer]: + """LLM 总结压缩模式。Token 达标后,自动将历史对话合并为一段 Summary。""" + return [ + LLMSummarizerReducer( + keep_recent_turns=keep_recent_turns, + trigger_tokens=trigger_tokens, + max_turns=max_turns, + summarization_model=summarization_model, + summarization_prompt=summarization_prompt, + ), + MessageDropper(trigger_tokens=trigger_tokens), + ] + + @staticmethod + def structured_summarize( + trigger_tokens: int = 4000, + max_turns: int | None = None, + keep_recent_turns: int = 0, + summarization_model: str | None = None, + response_model: type[BaseModel] | None = None, + prompt_template: str | None = None, + format_callback: Callable[[Any], str] | None = None, + ) -> list[BaseMemoryReducer]: + """结构化总结压缩模式。使用 JSON Schema 强制大模型提取核心状态。""" + + class DefaultStateSummary(BaseModel): + user_context: str = Field( + description="用户的核心意图、诉求、人设或长期记忆规则。" + ) + completed_tasks: str = Field(description="已完成的操作或已经确认的情节。") + pending_tasks: str = Field(description="正在进行中的任务或尚未解答的问题。") + current_state: str = Field( + description="当前状态,如重要变量、玩家血量、关键物品坐标等。" + ) + + def default_format(obj: DefaultStateSummary) -> str: + return ( + f"👤 用户上下文: {obj.user_context}\n" + f"✅ 已完成/确认: {obj.completed_tasks}\n" + f"⏳ 待处理/疑问: {obj.pending_tasks}\n" + f"📌 当前状态: {obj.current_state}" + ) + + default_prompt = ( + "你是一个专门用于长上下文状态压缩的引擎。请阅读以下先前的总结和旧对话," + "提取核心状态信息,并合并它们。\n\n" + "<之前的状态摘要>\n{prev_summary}\n\n\n" + "<需要合并的旧对话记录>\n" + "{dialogue}" + "\n" + ) + + return [ + StructuredSummaryReducer( + response_model=response_model or DefaultStateSummary, + prompt_template=prompt_template or default_prompt, + format_callback=format_callback or default_format, + keep_recent_turns=keep_recent_turns, + trigger_tokens=trigger_tokens, + max_turns=max_turns, + summarization_model=summarization_model, + ), + MessageDropper(trigger_tokens=trigger_tokens), + ] diff --git a/zhenxun/services/ai/context/memory/engine.py b/zhenxun/services/ai/context/memory/engine.py new file mode 100644 index 00000000..42047f95 --- /dev/null +++ b/zhenxun/services/ai/context/memory/engine.py @@ -0,0 +1,260 @@ +from collections.abc import Sequence +from typing import Any, cast + +from zhenxun.services.ai.context.memory.compression import ( + CondenserPipeline, +) +from zhenxun.services.ai.context.memory.manager import memory_manager +from zhenxun.services.ai.context.memory.models import MemoryConfig +from zhenxun.services.ai.context.memory.types import SessionMetadata +from zhenxun.services.ai.core.engine.context_renderer import ContextConverter +from zhenxun.services.ai.core.messages import AgentMessage +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_copy + + +class MemoryReader: + """ + 记忆读取器 (Memory Reader)。 + 负责从数据库中提取短期上下文历史,召回长期的背景知识,并执行自动压缩。 + """ + + def __init__( + self, session_meta: SessionMetadata, memory_config: MemoryConfig | None + ): + """ + 初始化记忆读取器。 + + 参数: + session_meta: 会话元数据,包含 Namespace 与作用域映射等上下文信息。 + memory_config: 记忆系统的配置对象,控制长期、短期及槽位记忆的启用与逻辑。 + """ + self.session_meta = session_meta + self.memory_config = memory_config + + async def get_long_term_context(self, user_input: str) -> str: + """ + 基于用户输入召回长期记忆(RAG),返回格式化后的背景提示词。 + """ + if ( + not self.memory_config + or not self.memory_config.long_term.enable + or not user_input + ): + return "" + + policy = self.memory_config.long_term.auto_recall + should_recall = False + + if isinstance(policy, bool): + should_recall = policy + elif callable(policy): + import inspect + + try: + res = policy(user_input, self.session_meta) + if inspect.isawaitable(res): + should_recall = await res + else: + should_recall = bool(res) + except Exception as e: + logger.error(f"[MemoryReader] 自定义 auto_recall 函数执行失败: {e}") + should_recall = False + + if not should_recall: + return "" + + ltm_scope = memory_manager.get_long_term_memory( + self.memory_config, + namespace=self.session_meta.selector.namespace or "global", + ) + if not ltm_scope: + return "" + + matches = await ltm_scope.recall(session=self.session_meta, query=user_input) + if matches: + logger.debug(f"🧠 [MemoryReader] 长期记忆召回详情 (Query: '{user_input}'):") + for i, m in enumerate(matches): + logger.debug( + f" [{i + 1}] 得分: {m.score:.4f} | 内容: {m.record.content}" + ) + + threshold = self.memory_config.long_term.recall_threshold + valid_matches = [m for m in matches if m.score >= threshold] + if not valid_matches: + logger.debug("🧠 [MemoryReader] 召回的记忆均未达到相关性阈值,已丢弃。") + return "" + + fact_str = "\n".join(f"- {m.record.content}" for m in valid_matches) + logger.debug( + f"🧠 [MemoryReader]" + f"成功截取并注入 {len(valid_matches)} 条高价值长期记忆。" + ) + return f"[系统补充:有关用户的长期记忆设定]\n{fact_str}" + return "" + + async def get_slots_context(self) -> str: + """ + 读取并组装核心槽位记忆 (Memory Slots),返回 XML 格式字符串供大模型使用。 + """ + if not self.memory_config or not self.memory_config.slots.enable: + return "" + slot_ctx = memory_manager.get_slot_context( + self.memory_config, + namespace=self.session_meta.selector.namespace or "global", + ) + if not slot_ctx: + return "" + + if self.memory_config.slots.default_slots: + for default_slot in self.memory_config.slots.default_slots: + existing = await slot_ctx.get_slot( + self.session_meta, default_slot.label + ) + if not existing: + await slot_ctx.set_slot(self.session_meta, default_slot) + + slots = await slot_ctx.list_pinned_slots(self.session_meta) + if not slots: + return "" + + show_scope = False + if ( + self.memory_config + and self.memory_config.slots.scopes + and len(self.memory_config.slots.scopes) > 1 + ): + show_scope = True + + xml_parts = [""] + for slot in slots: + if show_scope: + semantic_name = self.session_meta.scope_name_mapping.get( + slot.scope, "未知" + ) + xml_parts.append( + f' \n' + f" {slot.content}\n" + " " + ) + else: + xml_parts.append( + f' \n {slot.content}\n ' + ) + xml_parts.append("") + return "\n".join(xml_parts) + + async def get_short_term_context( + self, + model_name: str, + override_history: Sequence[AgentMessage] | None = None, + ) -> list[AgentMessage]: + """ + 拉取短期对话历史,并执行 Token 压缩。 + """ + current_history: list[AgentMessage] = [] + if override_history is not None: + current_history = list(override_history) + + chat_context = memory_manager.get_chat_context( + self.memory_config, + namespace=self.session_meta.selector.namespace or "global", + ) + + if self.memory_config and self.memory_config.short_term.enable and chat_context: + if override_history is not None: + flattened_override = ContextConverter.flatten_to_llm_messages( + override_history + ) + await chat_context.set_messages(self.session_meta, flattened_override) + else: + current_history = cast( + list[AgentMessage], + await chat_context.get_messages(self.session_meta), + ) + + pipeline = CondenserPipeline.create_from_configs( + self.memory_config, model_name + ) + if pipeline.reducers: + flattened_to_reduce = ContextConverter.flatten_to_llm_messages( + current_history + ) + new_history, changed = await pipeline.run( + flattened_to_reduce, model_name=model_name, base_overhead=0 + ) + if changed: + await chat_context.set_messages(self.session_meta, new_history) + logger.info( + "💾 [MemoryReader] 压缩截断完毕,已同步覆写数据库。" + f"压缩后条数: {len(new_history)}" + ) + current_history = cast(list[AgentMessage], new_history) + + return current_history + + +class MemoryWriter: + """ + 记忆写入器 (Memory Writer)。 + 负责将对话增量安全地写入数据库。 + """ + + def __init__( + self, + session_meta: SessionMetadata, + memory_config: MemoryConfig | None, + context: Any = None, + ): + """ + 初始化记忆写入器。 + + 参数: + session_meta: 会话元数据,包含 Namespace 与作用域映射等上下文信息。 + memory_config: 记忆系统的配置对象,控制记忆存入的逻辑。 + context: 运行时上下文环境,作为可选参数传入,供中间件使用,默认 None。 + """ + self.session_meta = session_meta + self.memory_config = memory_config + self.context = context + + async def save_new_messages( + self, + new_messages: Sequence[AgentMessage], + ): + """将新产生的对话增量保存到数据库""" + if not new_messages: + return + + messages_to_save = new_messages + + if self.memory_config and self.memory_config.ingestion.middlewares: + messages_to_save = [model_copy(m, deep=True) for m in new_messages] + + for middleware in self.memory_config.ingestion.middlewares: + try: + messages_to_save = await middleware.process( + messages_to_save, self.context + ) + except Exception as e: + logger.error( + f"[MemoryIngestion] 中间件 {middleware.__class__.__name__} " + f"执行失败: {e}", + e=e, + ) + + if not messages_to_save: + return + + chat_ctx = memory_manager.get_chat_context( + self.memory_config, + namespace=self.session_meta.selector.namespace or "global", + ) + + flattened_msgs = ContextConverter.flatten_to_llm_messages( + messages_to_save, self.context + ) + + if chat_ctx and self.memory_config and self.memory_config.short_term.enable: + if flattened_msgs: + await chat_ctx.add_messages(self.session_meta, flattened_msgs) diff --git a/zhenxun/services/ai/context/memory/facades.py b/zhenxun/services/ai/context/memory/facades.py new file mode 100644 index 00000000..539d1c45 --- /dev/null +++ b/zhenxun/services/ai/context/memory/facades.py @@ -0,0 +1,128 @@ +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal + +from zhenxun.services.ai.context.memory.types import ( + MemorySlot, + SessionMetadata, +) +from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage + +if TYPE_CHECKING: + from zhenxun.services.ai.context.memory.manager import GlobalMemoryManager + from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseChatContext, + BaseSlotContext, + ) + + +class ChatHistoryFacade: + """短期对话历史门面""" + + def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata): + self.manager = manager + self.session_meta = session_meta + + @property + def _backend(self) -> "BaseChatContext | None": + return self.manager.get_chat_context( + None, self.session_meta.namespace or "global" + ) + + async def get(self, limit: int | None = None) -> list[LLMMessage]: + """获取当前会话的历史消息""" + if not self._backend: + return [] + msgs = await self._backend.get_messages(self.session_meta) + return msgs[-limit:] if limit else msgs + + async def add(self, messages: Sequence[AgentMessage] | AgentMessage) -> None: + """向当前会话追加一条或多条历史消息""" + if not self._backend: + return + from zhenxun.services.ai.core.engine.context_renderer import ContextConverter + + msgs = messages if isinstance(messages, Sequence) else [messages] + flattened = ContextConverter.flatten_to_llm_messages(msgs) + if flattened: + await self._backend.add_messages(self.session_meta, flattened) + + async def clear(self) -> None: + """清空当前会话的短期对话历史""" + if not self._backend: + return + await self._backend.clear(self.session_meta) + + +class SlotFacade: + """中期记忆槽门面""" + + def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata): + self.manager = manager + self.session_meta = session_meta + + @property + def _backend(self) -> "BaseSlotContext | None": + """获取底层槽位存储后端""" + return self.manager.get_slot_context( + None, self.session_meta.namespace or "global" + ) + + async def get(self, label: str) -> str | None: + """获取指定标识的槽位记忆内容""" + if not self._backend: + return None + slot = await self._backend.get_slot(self.session_meta, label) + return slot.content if slot else None + + async def set( + self, + label: str, + content: str, + scope: Literal["session", "global"] = "session", + size_limit: int = 2000, + pinned: bool = True, + ) -> None: + """设置或更新指定的槽位记忆""" + if not self._backend: + return + slot = MemorySlot( + label=label, + content=content, + scope=scope, + size_limit=size_limit, + pinned=pinned, + ) + await self._backend.set_slot(self.session_meta, slot) + + async def delete( + self, label: str, scope: Literal["session", "global"] = "session" + ) -> None: + """删除指定的槽位记忆""" + if not self._backend: + return + await self._backend.delete_slot(self.session_meta, label, scope) + + async def list_all(self) -> dict[str, str]: + """获取当前会话所有被置顶的槽位记忆""" + if not self._backend: + return {} + slots = await self._backend.list_pinned_slots(self.session_meta) + return {s.label: s.content for s in slots} + + +class AgentSessionFacade: + """ + 提供给第三方开发者的会话记忆访问聚合门面 (Facade)。 + """ + + def __init__(self, manager: "GlobalMemoryManager", session_meta: SessionMetadata): + self.manager = manager + self.session_meta = session_meta + self.history = ChatHistoryFacade(manager, session_meta) + self.slots = SlotFacade(manager, session_meta) + + async def clear_all(self) -> None: + """一键清空当前会话下的短期对话历史与记忆槽""" + cleaner = self.manager.cleaner().session(self.session_meta.session_id) + await cleaner.clear_short_term() + await cleaner.clear_slots() diff --git a/zhenxun/services/ai/context/memory/manager.py b/zhenxun/services/ai/context/memory/manager.py new file mode 100644 index 00000000..64f28ff9 --- /dev/null +++ b/zhenxun/services/ai/context/memory/manager.py @@ -0,0 +1,211 @@ +from collections.abc import Callable +from typing import Any, cast + +from zhenxun.services.ai.context.memory.models import MemoryConfig +from zhenxun.services.ai.context.memory.storage.backends import ( + InMemoryChatContext, + MemoryScope, +) +from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseChatContext, + BaseSlotContext, +) +from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend +from zhenxun.services.ai.utils.scope import BaseScopeBuilder +from zhenxun.utils.utils import infer_plugin_namespace + + +class MemoryCleaner(BaseScopeBuilder["MemoryCleaner"]): + """ + 声明式记忆清理构建器 (Query Builder)。 + 为第三方开发者提供极端友好的链式 API,彻底屏蔽底层前缀逻辑。 + """ + + def __init__(self, manager: "GlobalMemoryManager"): + super().__init__() + self.manager = manager + self._config: Any = None + + def config(self, cfg: Any): + """指定私有记忆配置(自动识别未全局注册 of 第三方私有数据库实例)""" + self._config = cfg.build() if hasattr(cfg, "build") else cfg + return self + + async def clear_short_term(self): + """一键清理目标范围下的短期对话历史记忆""" + if self._config and self._config.short_term.backend: + await self._config.short_term.backend.clear_by_query(self._selector) + else: + for backend in self.manager._chat_backends.values(): + await backend.clear_by_query(self._selector) + + async def clear_slots(self): + """一键清理目标范围下的中期记忆槽 (Memory Slots)""" + if self._config and self._config.slots.backend: + await self._config.slots.backend.clear_by_query(self._selector) + else: + for backend in self.manager._slot_backends.values(): + await backend.clear_by_query(self._selector) + + async def clear_long_term(self): + """一键清理目标范围下的长期向量记忆 (RAG Vector Database)""" + if self._config and self._config.long_term.backend: + from zhenxun.services.ai.context.rag.backends import StorageBackend + + storage = cast(StorageBackend, self._config.long_term.backend) + await storage.clear_by_query(self._selector) + else: + for factory in self.manager._storage_factories.values(): + storage = factory() + if hasattr(storage, "clear_by_query"): + await storage.clear_by_query(self._selector) + else: + await storage.delete(scope_prefix=self._selector.scope_prefix) + + async def clear_all(self): + """一键清理指定范围下的所有生命周期记忆(对话、槽位、RAG)""" + from zhenxun.services.log import logger + + await self.clear_short_term() + await self.clear_slots() + await self.clear_long_term() + logger.info( + f"🧹 [MemoryCleaner] 成功清理作用域 '{self._selector.scope_prefix}'" + "下的所有记忆痕迹!" + ) + + +class GlobalMemoryManager: + """ + 全局记忆大管家 (IoC 容器)。 + 使用现代化依赖注入机制管理短/长期记忆引擎的默认实例。 + """ + + def __init__(self): + self._chat_backends: dict[str, BaseChatContext] = { + "global": InMemoryChatContext() + } + self._slot_backends: dict[str, BaseSlotContext] = {} + + from zhenxun.services.ai.context.rag.backends import DictStorageBackend + + self._storage_factories: dict[str, Callable[[], StorageBackend]] = { + "global": lambda: DictStorageBackend() + } + + def register_chat_backend( + self, backend: BaseChatContext, scope: str | None = None + ) -> None: + """注册特定命名空间的短期记忆存储后端。""" + ns = scope if scope is not None else infer_plugin_namespace() + self._chat_backends[ns] = backend + + def register_slot_backend( + self, backend: BaseSlotContext, scope: str | None = None + ) -> None: + """注册特定命名空间的中期记忆槽存储后端。""" + ns = scope if scope is not None else infer_plugin_namespace() + self._slot_backends[ns] = backend + + def register_storage_factory( + self, factory: Callable[[], StorageBackend], scope: str | None = None + ) -> None: + """注册特定命名空间的长期记忆向量存储工厂。""" + ns = scope if scope is not None else infer_plugin_namespace() + self._storage_factories[ns] = factory + + def cleaner(self) -> MemoryCleaner: + """获取声明式记忆清理构建器,供第三方开发者极速清理指定记忆""" + return MemoryCleaner(self) + + def get_embedder(self, embedder_val: "Embedder | str | None") -> Embedder | None: + """获取向量化引擎实例。如果传入的是字符串,则视为 API 模型名称。""" + if not embedder_val: + return None + + if isinstance(embedder_val, str): + from zhenxun.services.ai.context.rag.backends.embedders import ( + DefaultEmbedder, + ) + + return DefaultEmbedder(model_name=embedder_val) + + return embedder_val + + def get_chat_context( + self, config: MemoryConfig | None, namespace: str = "global" + ) -> BaseChatContext | None: + """根据配置分配对应的短期对话历史实例""" + if not config or not config.short_term.enable: + return None + + backend_cfg = config.short_term.backend + if backend_cfg is not None: + return cast(BaseChatContext, backend_cfg) + + return self._chat_backends.get(namespace) or self._chat_backends["global"] + + def get_slot_context( + self, config: MemoryConfig | None, namespace: str = "global" + ) -> BaseSlotContext | None: + """根据配置分配对应的槽位记忆实例""" + if not config or not config.slots.enable: + return None + + backend_cfg = config.slots.backend + if backend_cfg is not None: + return cast(BaseSlotContext, backend_cfg) + + return self._slot_backends.get(namespace) or self._slot_backends["global"] + + def get_long_term_memory( + self, config: MemoryConfig | None, namespace: str = "global" + ) -> MemoryScope | None: + """根据声明式配置动态组装长期向量记忆实例""" + if not config or not config.long_term.enable: + return None + + if config.long_term.engine is not None: + return MemoryScope( + rag_client=config.long_term.engine, + ) + + storage_instance = None + backend_cfg = config.long_term.backend + if backend_cfg is not None: + storage_instance = cast(StorageBackend, backend_cfg) + else: + factory = ( + self._storage_factories.get(namespace) + or self._storage_factories["global"] + ) + storage_instance = factory() + + embedder = self.get_embedder(config.long_term.embedder) + + from zhenxun.services.ai.context.rag.builder import RAGBuilder + + builder = RAGBuilder(storage_instance).with_scope("/") + if embedder: + builder.with_embedder(embedder) + + from zhenxun.services.ai.context.memory.models import MemoryScoringConfig + + scoring_cfg = MemoryScoringConfig() + + builder.enable_lifecycle_scoring( + half_life_days=scoring_cfg.recency_half_life_days, + decay_weight=scoring_cfg.recency_weight, + semantic_weight=scoring_cfg.semantic_weight, + importance_weight=scoring_cfg.importance_weight, + reinforcement_weight=scoring_cfg.reinforcement_weight, + ) + + client = builder.build() + + return MemoryScope( + rag_client=client, + ) + + +memory_manager = GlobalMemoryManager() diff --git a/zhenxun/services/ai/context/memory/models.py b/zhenxun/services/ai/context/memory/models.py new file mode 100644 index 00000000..e2f8ccb2 --- /dev/null +++ b/zhenxun/services/ai/context/memory/models.py @@ -0,0 +1,173 @@ +""" +记忆域类型定义 +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseChatContext, + BaseMemoryIngestionMiddleware, + BaseMemoryReducer, + BaseSlotContext, +) +from zhenxun.services.ai.context.memory.types import ( + AutoRecallPolicy, + Isolation, + MemorySlot, + SessionMetadata, +) +from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend +from zhenxun.services.ai.context.rag.engine import ScopedRAGClient +from zhenxun.services.ai.utils.scope import ScopeBuilder + + +class SlotMemoryConfig(BaseModel): + """槽位记忆 (Memory Slots) 配置""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + enable: bool = Field(default=False) + """是否启用中期记忆槽""" + scopes: dict[str, ScopeBuilder] | None = Field(default=None) + """语义化作用域映射字典,供大模型作为 Literal 选择。如果只有一个,则自动隐藏参数""" + default_slots: list[MemorySlot] = Field(default_factory=list) + """首次初始化时自动写入的默认槽位列表""" + backend: str | BaseSlotContext | None = Field(default=None) + """ + 指定底层槽位记忆数据库注册名称,或直接传入 BaseSlotContext 实例。 + 为空则使用全局默认 + """ + instructions: str | None = Field(default=None) + """覆写内置槽位管理工具箱的系统提示词""" + toolkit_kwargs: dict[str, Any] = Field(default_factory=dict) + """透传给底层 MemorySlotToolkit 的高级参数 (如 prefix, exclude, shared_options)""" + + +class MemoryScoringConfig(BaseModel): + """长期记忆的复合打分与检索配置""" + + recency_weight: float = Field(default=0.3) + """时间衰减权重""" + semantic_weight: float = Field(default=0.5) + """语义相似度权重""" + importance_weight: float = Field(default=0.2) + """重要性权重""" + recency_half_life_days: int = Field(default=30) + """时间衰减的半衰期(天)""" + + reinforcement_weight: float = Field(default=0.2) + """访问强化的加权权重 (被检索越多得分越高)""" + + +class ShortTermConfig(BaseModel): + """短期对话记忆配置""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + enable: bool = Field(default=True) + """是否启用短期对话记忆上下文""" + backend: str | BaseChatContext | None = Field(default=None) + """ + 指定底层短期记忆数据库注册名称,或直接传入 BaseChatContext 实例。 + 为空则使用全局默认 + """ + isolation: ScopeBuilder = Field(default_factory=Isolation.AGENT_USER) + """单一的记忆隔离级别 (ScopeBuilder),决定短期记忆存储边界""" + + +class LongTermConfig(BaseModel): + """长期向量记忆配置""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + enable: bool = Field(default=False) + """是否启用长期记忆(开启后自动赋予 Agent 存取记忆的工具,并附加 RAG 召回能力)""" + engine: ScopedRAGClient | None = Field(default=None) + """ + [推荐] 指定底层的高级 RAG 检索引擎实例。若传入此项,将覆盖默认的 backend + 和 embedder 配置。 + """ + backend: str | StorageBackend | None = Field(default=None) + """ + 指定底层长期向量数据库 (Storage) 注册名称,或直接传入 StorageBackend 实例。 + 为空则使用全局默认 + """ + scopes: dict[str, ScopeBuilder] | None = Field(default=None) + """语义化作用域映射字典,决定长期记忆存储边界。如果只有一个,则自动隐藏参数""" + embedder: str | Embedder | None = Field(default=None) + """ + 指定底层向量化引擎 (Embedder) 实例,若为字符串则视为 API 模型名称。 + 为空则使用全局默认 + """ + + agentic: bool = Field(default=True) + """是否赋予大模型主动管理记忆的能力 (Agentic Memory)""" + auto_recall: AutoRecallPolicy = Field(default=False) + """长期记忆的自动召回策略,默认 False (从不自动召回),由大模型自主 + 决定调用搜索工具""" + recall_threshold: float = Field(default=0.5) + """长期记忆召回的最低余弦相似度要求""" + instructions: str | None = Field(default=None) + """覆写内置长期记忆管理工具箱的系统提示词""" + toolkit_kwargs: dict[str, Any] = Field(default_factory=dict) + """透传给底层 MemoryManagementToolkit 的高级参数""" + + +class ContextCompressionConfig(BaseModel): + """上下文压缩与管理配置""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + threshold: float | None = Field(default=None) + """(局部重写) 触发记忆压缩的 Token 阈值""" + max_history_turns: int | None = Field(default=None) + """(局部重写) 触发记忆压缩的对话轮数上限。设为 0 表示不限制轮数。""" + vision_window: int | None = Field(default=None) + """多模态滑动窗口大小。0表示关闭该功能,>0表示仅保留最近N轮包含多模态数据的消息,None表示跟随全局配置。""" + policy: list[BaseMemoryReducer] | None = Field(default=None) + """核心记忆压缩策略管线 (List[BaseMemoryReducer])。为 None 时将应用全局默认策略。""" + + +class IngestionConfig(BaseModel): + """记忆入库管线配置""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + middlewares: list[BaseMemoryIngestionMiddleware] = Field(default_factory=list) + """入库中间件列表(按顺序依次执行清洗过滤)""" + + +class MemoryConfig(BaseModel): + """统一的记忆配置项声明""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + base_isolation: ScopeBuilder = Field(default_factory=Isolation.AGENT_USER) + """顶层基准隔离级别,短期/中期/长期记忆将默认继承此级别""" + short_term: ShortTermConfig = Field(default_factory=ShortTermConfig) + """短期对话记忆配置""" + slots: SlotMemoryConfig = Field(default_factory=SlotMemoryConfig) + """槽位记忆配置""" + long_term: LongTermConfig = Field(default_factory=LongTermConfig) + """长期向量记忆配置""" + compression: ContextCompressionConfig = Field( + default_factory=ContextCompressionConfig + ) + """上下文压缩与管理配置""" + ingestion: IngestionConfig = Field(default_factory=IngestionConfig) + """记忆入库前的清洗与过滤管线配置""" + + +__all__ = [ + "AutoRecallPolicy", + "BaseMemoryIngestionMiddleware", + "ContextCompressionConfig", + "IngestionConfig", + "Isolation", + "LongTermConfig", + "MemoryConfig", + "MemoryScoringConfig", + "SessionMetadata", + "ShortTermConfig", +] diff --git a/zhenxun/services/ai/context/memory/storage/__init__.py b/zhenxun/services/ai/context/memory/storage/__init__.py new file mode 100644 index 00000000..c4597146 --- /dev/null +++ b/zhenxun/services/ai/context/memory/storage/__init__.py @@ -0,0 +1,21 @@ +from .backends import ( + AbstractMemoryRecord, + AbstractSlotRecord, + InMemoryChatContext, + MemoryScope, + TortoiseChatContext, + TortoiseSlotContext, + get_orm_chat_context, + get_orm_slot_context, +) + +__all__ = [ + "AbstractMemoryRecord", + "AbstractSlotRecord", + "InMemoryChatContext", + "MemoryScope", + "TortoiseChatContext", + "TortoiseSlotContext", + "get_orm_chat_context", + "get_orm_slot_context", +] diff --git a/zhenxun/services/ai/context/memory/storage/backends.py b/zhenxun/services/ai/context/memory/storage/backends.py new file mode 100644 index 00000000..8211c5b5 --- /dev/null +++ b/zhenxun/services/ai/context/memory/storage/backends.py @@ -0,0 +1,465 @@ +import asyncio +import base64 +from collections.abc import Callable +import datetime +import time +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from zhenxun.services.ai.context.rag.engine import ScopedRAGClient + +from nonebot.utils import is_coroutine_callable +from tortoise import fields +from tortoise.timezone import now + +from zhenxun.services.ai.context.memory.storage.interfaces import ( + BaseChatContext, + BaseSlotContext, +) +from zhenxun.services.ai.context.memory.types import ( + MemorySlot, + SessionMetadata, +) +from zhenxun.services.ai.context.rag.models import BaseRecord, SearchResult +from zhenxun.services.ai.core.messages import ( + AssistantMessage, + LLMContentPart, + LLMMessage, + SystemMessage, + ToolMessage, + UserMessage, +) +from zhenxun.services.ai.utils.scope import ScopeSelector +from zhenxun.services.db_context import Model +from zhenxun.utils.pydantic_compat import TypeAdapter, model_dump + + +class DBMessageSerializer: + """将 LLMMessage 与数据库 JSON 格式进行序列化/反序列化的帮助类""" + + @staticmethod + def deserialize_content(content_raw: Any) -> list[LLMContentPart]: + from zhenxun.services.ai.core.messages import TextPart + + content_parts: list[LLMContentPart] = [] + if isinstance(content_raw, list): + adapter = TypeAdapter(LLMContentPart) + for p in content_raw: + if isinstance(p, dict): + for k in list(p.keys()): + if k.startswith("_is_b64_"): + orig_k = k[8:] + if orig_k in p and isinstance(p[orig_k], str): + p[orig_k] = base64.b64decode(p[orig_k]) + p.pop(k, None) + content_parts.append(adapter.validate_python(p)) + elif isinstance(content_raw, str): + content_parts.append(TextPart(text=content_raw)) + return content_parts + + @staticmethod + def serialize_content(content_payload: Any) -> list[dict[str, Any]]: + from pathlib import Path + + if isinstance(content_payload, str): + return [{"type": "text", "text": content_payload}] + elif isinstance(content_payload, list): + processed_content = [] + for p in content_payload: + p_dump = ( + model_dump(p, exclude_none=True) + if hasattr(p, "model_dump") + else (p.copy() if isinstance(p, dict) else p) + ) + if isinstance(p_dump, dict): + for k, v in list(p_dump.items()): + if isinstance(v, bytes): + p_dump[k] = base64.b64encode(v).decode("utf-8") + p_dump[f"_is_b64_{k}"] = True + elif isinstance(v, Path): + p_dump[k] = str(v) + processed_content.append(p_dump) + return ( + processed_content + if processed_content + else [ + {"type": "text", "text": "[仅包含思维链或工具调度,无实质文本输出]"} + ] + ) + return [] + + +class MemoryScope: + """长期记忆的作用域视图与 RAG 管线。""" + + def __init__( + self, + rag_client: "ScopedRAGClient", + ): + self.rag_client = rag_client + self._background_tasks: set[Any] = set() + + async def remember( + self, + session: SessionMetadata, + content: str, + importance: float = 0.5, + metadata: dict[str, Any] | None = None, + ) -> None: + """通过 RAG Ingestion Pipeline 完成记忆落盘""" + meta = metadata.copy() if metadata else {} + meta.update( + { + "scope": session.scope_prefix, + "importance": importance, + "created_at": time.time(), + } + ) + record = BaseRecord(content=content, metadata=meta) + + await self.rag_client.ingest([record]) + + async def recall( + self, + session: SessionMetadata, + query: str, + limit: int = 10, + metadata_filter: dict[str, Any] | None = None, + ) -> list[SearchResult]: + """委托至 Retriever 检索与重排,并触发读时惰性强化""" + matches = await self.rag_client.search( + query=query, + limit=limit, + scopes=session.accessible_scopes, + metadata_filters=metadata_filter, + ) + if matches: + task = asyncio.create_task( + self._reinforce_memories([m.record for m in matches]) + ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return matches + + async def update( + self, + session: SessionMetadata, + record_id: str, + new_content: str, + importance: float = 0.5, + metadata: dict[str, Any] | None = None, + ) -> bool: + """原子更新:通过先删后插,确保底层向量(Embedding)能根据新文本被正确刷新""" + deleted_count = await self.forget(session, record_ids=[record_id]) + if deleted_count > 0: + await self.remember( + session=session, + content=new_content, + importance=importance, + metadata=metadata, + ) + return True + return False + + async def forget( + self, session: SessionMetadata, record_ids: list[str] | None = None + ) -> int: + return await self.rag_client.delete( + record_ids=record_ids, + ) + + async def _reinforce_memories(self, records: list[BaseRecord]): + import time + + now = time.time() + for r in records: + r.metadata["access_count"] = r.metadata.get("access_count", 0) + 1 + r.metadata["last_accessed_at"] = now + await self.rag_client.storage.update(r) + + +class InMemoryChatContext(BaseChatContext): + def __init__(self): + self._messages: dict[str, list[LLMMessage]] = {} + + async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]: + return list(self._messages.get(session.session_id, [])) + + async def search( + self, query: str, session: SessionMetadata, limit: int = 10 + ) -> list[LLMMessage]: + results = [] + for msg in self._messages.get(session.session_id, []): + if query in msg.extract_text: + results.append(msg) + if len(results) >= limit: + break + return results + + async def add_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + if session.session_id not in self._messages: + self._messages[session.session_id] = [] + self._messages[session.session_id].extend(messages) + + async def set_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + self._messages[session.session_id] = list(messages) + + async def clear(self, session: SessionMetadata) -> None: + self._messages.pop(session.session_id, None) + + async def clear_by_query(self, query: ScopeSelector) -> None: + """内存级:前缀匹配清理所有符合要求的短期会话""" + scope_prefix = query.scope_prefix + keys_to_delete = [ + sid for sid in self._messages.keys() if sid.startswith(scope_prefix) + ] + for sid in keys_to_delete: + self._messages.pop(sid, None) + + +class AbstractMemoryRecord(Model): + """Tortoise ORM 短期记忆持久化基类 (Mixin)。""" + + id = fields.UUIDField(pk=True, description="主键") + session_id = fields.CharField(max_length=255, index=True) + role = fields.CharField(max_length=32) + content = fields.JSONField() + api_context = fields.JSONField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + metadata = fields.JSONField(null=True) + + class Meta: # type: ignore + abstract = True + + +class TortoiseChatContext(BaseChatContext): + def __init__( + self, + model_class: type[AbstractMemoryRecord], + custom_save_hook: Callable[ + [AbstractMemoryRecord, LLMMessage, SessionMetadata], Any + ] + | None = None, + ): + self.model_class = model_class + self.custom_save_hook = custom_save_hook + + def _row_to_message(self, row: AbstractMemoryRecord) -> LLMMessage: + content_parts = DBMessageSerializer.deserialize_content(row.content) + metadata: dict[str, Any] | None = ( + row.metadata if isinstance(row.metadata, dict) else None + ) + kwargs = { + "content": content_parts, + "metadata": metadata, + "created_at": row.created_at.timestamp() if row.created_at else time.time(), + } + role = row.role + if role == "system": + return cast(LLMMessage, SystemMessage(**kwargs)) + elif role == "user": + return cast(LLMMessage, UserMessage(**kwargs)) + elif role == "assistant": + return cast(LLMMessage, AssistantMessage(**kwargs)) + elif role == "tool": + return cast(LLMMessage, ToolMessage(**kwargs)) + return cast(LLMMessage, LLMMessage(role=role, **kwargs)) + + async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]: + rows = ( + await self.model_class.filter(session_id=session.session_id) + .order_by("created_at") + .all() + ) + return [self._row_to_message(row) for row in rows] + + async def search( + self, query: str, session: SessionMetadata, limit: int = 10 + ) -> list[LLMMessage]: + rows = ( + await self.model_class.filter( + session_id=session.session_id, content__icontains=query + ) + .order_by("-created_at") + .limit(limit) + .all() + ) + return [self._row_to_message(row) for row in reversed(rows)] + + async def add_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + if not messages: + return + + base_time = now() + + last_msg = ( + await self.model_class.filter(session_id=session.session_id) + .order_by("-created_at") + .first() + ) + if last_msg and last_msg.created_at and last_msg.created_at >= base_time: + base_time = last_msg.created_at + datetime.timedelta(milliseconds=10) + + orm_objects = [] + for i, msg in enumerate(messages): + content_payload = DBMessageSerializer.serialize_content(msg.content) + + msg_time = base_time + datetime.timedelta(milliseconds=i * 10) + orm_obj = self.model_class( + session_id=session.session_id, + role=msg.role, + content=content_payload, + api_context=None, + metadata=msg.metadata, + created_at=msg_time, + ) + if self.custom_save_hook: + if is_coroutine_callable(self.custom_save_hook): + await self.custom_save_hook(orm_obj, msg, session) + else: + self.custom_save_hook(orm_obj, msg, session) + orm_objects.append(orm_obj) + if orm_objects: + await self.model_class.bulk_create(orm_objects) + + async def set_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + await self.clear(session) + await self.add_messages(session, messages) + + async def clear(self, session: SessionMetadata) -> None: + await self.model_class.filter(session_id=session.session_id).delete() + + async def clear_by_query(self, query: ScopeSelector) -> None: + """ORM 级:利用数据库 startswith 原生语法批量级联删除短期记忆""" + scope_prefix = query.scope_prefix + await self.model_class.filter(session_id__startswith=scope_prefix).delete() + + +def get_orm_chat_context( + model_class: type[AbstractMemoryRecord], + custom_save_hook: Callable[[AbstractMemoryRecord, LLMMessage, SessionMetadata], Any] + | None = None, +) -> TortoiseChatContext: + """ + [工厂方法] 供第三方开发者调用, + 将 Tortoise ORM 表直接包装为对话历史记录系统。 + """ + return TortoiseChatContext( + model_class=model_class, custom_save_hook=custom_save_hook + ) + + +class AbstractSlotRecord(Model): + """Tortoise ORM 记忆槽持久化基类 (Mixin)。""" + + id = fields.CharField( + pk=True, max_length=128, description="复合主键: session_id + label" + ) + session_id = fields.CharField(max_length=255, index=True) + label = fields.CharField(max_length=64, index=True) + content = fields.TextField() + size_limit = fields.IntField(default=2000) + pinned = fields.BooleanField(default=True) + scope = fields.CharField(max_length=255) + description = fields.CharField(max_length=255, default="") + created_at = fields.FloatField() + updated_at = fields.FloatField() + + class Meta: # type: ignore + abstract = True + + +class TortoiseSlotContext(BaseSlotContext): + def __init__(self, model_class: type[AbstractSlotRecord]): + self.model_class = model_class + + def _row_to_slot(self, row: AbstractSlotRecord) -> MemorySlot: + return MemorySlot( + label=row.label, + content=row.content, + size_limit=row.size_limit, + pinned=row.pinned, + scope=row.scope, + description=row.description, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None: + rows = await self.model_class.filter( + session_id__in=session.accessible_scopes, label=label + ).all() + + row_map = {r.session_id: r for r in rows} + for scope in reversed(session.accessible_scopes): + if scope in row_map: + return self._row_to_slot(row_map[scope]) + return None + + async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None: + composite_id = f"{slot.scope}_{slot.label}" + + await self.model_class.update_or_create( + id=composite_id, + defaults={ + "session_id": slot.scope, + "label": slot.label, + "content": slot.content, + "size_limit": slot.size_limit, + "pinned": slot.pinned, + "scope": slot.scope, + "description": slot.description, + "created_at": slot.created_at, + "updated_at": slot.updated_at, + }, + ) + + async def delete_slot( + self, session: SessionMetadata, label: str, scope: str + ) -> None: + composite_id = f"{scope}_{label}" + await self.model_class.filter(id=composite_id).delete() + + async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]: + rows = await self.model_class.filter( + session_id__in=session.accessible_scopes, pinned=True + ).all() + + merged = {} + for scope in session.accessible_scopes: + for row in rows: + if row.session_id == scope: + merged[row.label] = self._row_to_slot(row) + + return [s for s in merged.values() if s.content.strip()] + + async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]: + rows = await self.model_class.filter( + session_id__in=session.accessible_scopes + ).all() + + merged = {} + for scope in session.accessible_scopes: + for row in rows: + if row.session_id == scope: + merged[row.label] = self._row_to_slot(row) + return list(merged.values()) + + async def clear_by_query(self, query: ScopeSelector) -> None: + scope_prefix = query.scope_prefix + await self.model_class.filter(session_id__startswith=scope_prefix).delete() + + +def get_orm_slot_context(model_class: type[AbstractSlotRecord]) -> TortoiseSlotContext: + """ + [工厂方法] 供第三方开发者调用,将 Tortoise ORM 表直接包装为记忆槽存储系统。 + """ + return TortoiseSlotContext(model_class=model_class) diff --git a/zhenxun/services/ai/context/memory/storage/interfaces.py b/zhenxun/services/ai/context/memory/storage/interfaces.py new file mode 100644 index 00000000..978269f3 --- /dev/null +++ b/zhenxun/services/ai/context/memory/storage/interfaces.py @@ -0,0 +1,110 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence + +from zhenxun.services.ai.context.memory.types import ( + MemorySlot, + SessionMetadata, +) +from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage +from zhenxun.services.ai.run.context import RunContext +from zhenxun.services.ai.utils.scope import ScopeSelector + + +class BaseChatContext(ABC): + """短期对话历史记忆接口""" + + @abstractmethod + async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]: + """获取当前会话的所有历史消息。""" + ... + + @abstractmethod + async def search( + self, query: str, session: SessionMetadata, limit: int = 10 + ) -> list[LLMMessage]: + """根据查询词搜索当前会话的历史消息。""" + ... + + @abstractmethod + async def add_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + """向当前会话追加消息。""" + ... + + @abstractmethod + async def set_messages( + self, session: SessionMetadata, messages: list[LLMMessage] + ) -> None: + """重置并设置当前会话的消息列表。""" + ... + + @abstractmethod + async def clear(self, session: SessionMetadata) -> None: + """清空当前会话的历史消息。""" + ... + + @abstractmethod + async def clear_by_query(self, query: ScopeSelector) -> None: + """根据条件领域查询对象清理对话历史。""" + ... + + +class BaseSlotContext(ABC): + """中期记忆槽持久化接口""" + + @abstractmethod + async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None: + """获取指定会话下特定标签的记忆槽。""" + ... + + @abstractmethod + async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None: + """设置或更新指定会话下的记忆槽。""" + ... + + @abstractmethod + async def delete_slot( + self, session: SessionMetadata, label: str, scope: str + ) -> None: + """删除指定会话下特定标签和作用域的记忆槽。""" + ... + + @abstractmethod + async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]: + """列出当前会话所有固定的记忆槽。""" + ... + + @abstractmethod + async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]: + """列出当前会话的所有记忆槽(包括未置顶的)。""" + ... + + @abstractmethod + async def clear_by_query(self, query: ScopeSelector) -> None: + """根据条件领域查询对象清理记忆槽。""" + ... + + +class BaseMemoryReducer(ABC): + """记忆压缩器基类""" + + @abstractmethod + async def reduce( + self, + messages: list[LLMMessage], + current_tokens: int, + model_name: str, + base_overhead: int = 0, + ) -> tuple[list[LLMMessage], bool, int]: + """对消息列表进行压缩处理。""" + ... + + +class BaseMemoryIngestionMiddleware(ABC): + """记忆入库中间件基类,在写入数据库前拦截并修改/清洗消息""" + + @abstractmethod + async def process( + self, messages: Sequence[AgentMessage], context: RunContext + ) -> list[AgentMessage]: ... diff --git a/zhenxun/services/ai/context/memory/types.py b/zhenxun/services/ai/context/memory/types.py new file mode 100644 index 00000000..860ed86e --- /dev/null +++ b/zhenxun/services/ai/context/memory/types.py @@ -0,0 +1,103 @@ +from collections.abc import Awaitable, Callable +import time + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.utils.scope import ScopeBuilder, ScopeSelector + +AutoRecallPolicy = bool | Callable[[str, "SessionMetadata"], Awaitable[bool] | bool] +"""长期记忆的自动召回策略""" + + +class MemorySlot(BaseModel): + """可编辑的持久化记忆槽 (Mid-Term Memory)""" + + label: str = Field(...) + """槽位唯一标签标识 (如 persona, preferences)""" + content: str = Field(default="") + """槽位存储的具体文本内容""" + size_limit: int = Field(default=2000) + """槽位内容的最大字符数限制""" + pinned: bool = Field(default=True) + """是否固定注入到大模型的每次系统提示词中""" + scope: str = Field(...) + """作用域:表示隔离的路径前缀 (scope_prefix)""" + description: str = Field(default="") + """该记忆槽的用途说明,便于大模型理解""" + created_at: float = Field(default_factory=time.time) + """创建时间戳""" + updated_at: float = Field(default_factory=time.time) + """最近更新时间戳""" + + +class Isolation: + """预设策略工厂,提供友好的隔离级别声明式 API""" + + @staticmethod + def _base() -> ScopeBuilder: + """获取底座通用隔离级别(包含Bot、平台、命名空间、智能体)。""" + return ScopeBuilder().bot().platform().namespace().agent() + + @classmethod + def GROUP_SHARED(cls) -> ScopeBuilder: + """群组共享隔离:同群内共享金库与记忆。""" + return cls._base().group() + + @classmethod + def USER_GLOBAL(cls) -> ScopeBuilder: + """用户全局隔离:跨群、跨插件共享用户记忆。""" + return cls._base().user() + + @classmethod + def GROUP_USER(cls) -> ScopeBuilder: + """群组用户隔离:单群内单用户独立隔离。""" + return cls._base().group().user() + + @classmethod + def AGENT_USER(cls) -> ScopeBuilder: + """智能体用户隔离:单智能体单用户物理隔离。""" + return cls.GROUP_USER() + + +class SessionMetadata(BaseModel): + """结构化会话元数据""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + session_id: str = Field(...) + """核心会话标识符。""" + selector: ScopeSelector = Field(default_factory=ScopeSelector) + """统一的作用域与实体资源选择器。""" + isolation_level: ScopeBuilder | None = Field(default=None) + """生成此会话时的隔离级别。""" + scope_prefix: str = Field(default="/") + """基于隔离级别生成的路径作用域,用于长期记忆 (RAG) 的向量检索前缀过滤。""" + accessible_scopes: list[str] = Field(default_factory=lambda: ["/"]) + """ + 当前会话有权访问的作用域列表,用于 Slice 联合检索。 + """ + scope_name_mapping: dict[str, str] = Field(default_factory=dict) + """物理路径到语义化名称的逆向映射字典,供大模型友好阅读""" + + @property + def platform(self) -> str | None: + return self.selector.platform + + @property + def group_id(self) -> str | None: + return self.selector.group_id + + @property + def user_id(self) -> str | None: + return self.selector.user_id + + @property + def namespace(self) -> str | None: + return self.selector.namespace + + @property + def agent_name(self) -> str | None: + return self.selector.agent_name + + def __str__(self) -> str: + return self.session_id diff --git a/zhenxun/services/ai/context/rag/__init__.py b/zhenxun/services/ai/context/rag/__init__.py new file mode 100644 index 00000000..c6f37a4c --- /dev/null +++ b/zhenxun/services/ai/context/rag/__init__.py @@ -0,0 +1,26 @@ +""" +Zhenxun AI - RAG (检索增强生成) 基础设施层 +""" + +from .backends.embedders import Embedder +from .backends.storages import ( + AbstractVectorRecord, + StorageBackend, + TortoiseStorageBackend, +) +from .builder import RAGBuilder +from .configs import RAGConfig +from .engine import ScopedRAGClient +from .models import BaseRecord, SearchResult + +__all__ = [ + "AbstractVectorRecord", + "BaseRecord", + "Embedder", + "RAGBuilder", + "RAGConfig", + "ScopedRAGClient", + "SearchResult", + "StorageBackend", + "TortoiseStorageBackend", +] diff --git a/zhenxun/services/ai/context/rag/backends/__init__.py b/zhenxun/services/ai/context/rag/backends/__init__.py new file mode 100644 index 00000000..8ff2cc16 --- /dev/null +++ b/zhenxun/services/ai/context/rag/backends/__init__.py @@ -0,0 +1,20 @@ +from .embedders import DefaultEmbedder, Embedder +from .storages import ( + AbstractVectorRecord, + DictStorageBackend, + LanceDBStorageBackend, + QdrantStorageBackend, + StorageBackend, + TortoiseStorageBackend, +) + +__all__ = [ + "AbstractVectorRecord", + "DefaultEmbedder", + "DictStorageBackend", + "Embedder", + "LanceDBStorageBackend", + "QdrantStorageBackend", + "StorageBackend", + "TortoiseStorageBackend", +] diff --git a/zhenxun/services/ai/context/rag/backends/embedders.py b/zhenxun/services/ai/context/rag/backends/embedders.py new file mode 100644 index 00000000..246c3628 --- /dev/null +++ b/zhenxun/services/ai/context/rag/backends/embedders.py @@ -0,0 +1,176 @@ +from abc import ABC, abstractmethod +import asyncio +import threading +from typing import Any, Literal, Protocol, runtime_checkable + +from zhenxun.services.ai.core.messages import EmbedBatch +from zhenxun.services.ai.llm.api import embed as api_embed +from zhenxun.services.ai.message_builder import MessageBuilder +from zhenxun.services.log import logger + +EmbedTaskType = Literal[ + "general", "query", "document", "similarity", "classification", "clustering" +] + + +@runtime_checkable +class Embedder(Protocol): + """ + 向量化引擎协议 (Callable Protocol)。 + 任何实现了异步 __call__ 的对象或闭包函数均可作为 Embedder。 + """ + + async def __call__( + self, input_batch: Any, task: EmbedTaskType = "general", **kwargs + ) -> list[list[float]]: + """ + 将文本、多模态或预构建的 EmbedBatch 转换为向量列表。 + """ + ... + + +class DefaultEmbedder(Embedder): + """系统默认的向量化引擎,调用大模型底座 API""" + + def __init__(self, model_name: str | None = None, config: Any = None): + self.model_name = model_name + self.config = config + + async def __call__( + self, input_batch: Any, task: EmbedTaskType = "general", **kwargs + ) -> list[list[float]]: + if not input_batch: + return [] + try: + res = await api_embed( + input_batch, model=self.model_name, task=task, config=self.config + ) + return res.embeddings + except Exception as e: + logger.error(f"DefaultEmbedder 向量化失败: {e}", e=e) + return [] + + +class BaseLocalEmbedder(Embedder, ABC): + """本地向量化引擎基类,统一处理多模态降级与同步推理由协程包裹逻辑。""" + + def __init__(self, model_name: str): + self.model_name = model_name + self._model_lock = threading.Lock() + + @abstractmethod + def _encode_texts(self, texts: list[str]) -> list[list[float]]: + """子类只需实现此同步的批量文本向量化方法即可。""" + pass + + async def __call__( + self, input_batch: Any, task: EmbedTaskType = "general", **kwargs + ) -> list[list[float]]: + if not input_batch: + return [] + + if isinstance(input_batch, EmbedBatch): + batch = input_batch + else: + batch = await MessageBuilder.normalize_to_embed_batch(input_batch) + + texts = batch.to_text_only(f"本地模型 {self.model_name}") + + if not texts: + return [] + + def _sync_embed(): + return self._encode_texts(texts) + + return await asyncio.to_thread(_sync_embed) + + +class FastEmbedder(BaseLocalEmbedder): + """ + 基于 FastEmbed 的轻量级本地向量化引擎。 + 零 PyTorch 依赖,CPU 推理极快。 + """ + + def __init__(self, model_name: str | None = None): + super().__init__(model_name or "BAAI/bge-small-zh-v1.5") + self.model = None + + import importlib.util + + if importlib.util.find_spec("fastembed") is None: + raise ImportError( + "⚠️ 使用 FastEmbed 需要额外依赖,请在终端执行: pip install fastembed" + ) + + def _ensure_model_loaded(self): + """线程安全的懒加载机制""" + if self.model is None: + with self._model_lock: + if self.model is None: + try: + from fastembed import TextEmbedding + except ImportError: + raise ImportError( + "⚠️ 使用 FastEmbed 需要额外依赖," + "请在终端执行: pip install fastembed" + ) + logger.info( + f"正在后台加载 FastEmbed 本地模型: {self.model_name} ... " + "(首次加载可能需要极长时间下载)" + ) + self.model = TextEmbedding(model_name=self.model_name) + logger.info(f"FastEmbed 模型 {self.model_name} 加载完毕!") + + def _encode_texts(self, texts: list[str]) -> list[list[float]]: + self._ensure_model_loaded() + assert self.model is not None + return [vec.tolist() for vec in self.model.embed(texts)] + + +class SentenceTransformerEmbedder(BaseLocalEmbedder): + """ + 基于 Sentence-Transformers 的本地向量化引擎。 + 支持 GPU 加速,适合重度用户。 + """ + + def __init__(self, model_name: str | None = None): + super().__init__(model_name or "BAAI/bge-small-zh-v1.5") + self.model = None + + import importlib.util + + if importlib.util.find_spec("sentence_transformers") is None: + raise ImportError( + "⚠️ 使用 SentenceTransformers 需要额外依赖," + "请在终端执行: pip install sentence-transformers" + ) + + def _ensure_model_loaded(self): + """线程安全的懒加载机制""" + if self.model is None: + with self._model_lock: + if self.model is None: + try: + from sentence_transformers import ( + SentenceTransformer, + ) + except ImportError: + raise ImportError( + "⚠️ 使用 SentenceTransformers 需要额外依赖," + "请在终端执行: pip install sentence-transformers" + ) + logger.info( + "正在后台加载 SentenceTransformer " + f"本地模型: {self.model_name} ... " + "(首次加载可能需要极长时间下载)" + ) + self.model = SentenceTransformer(self.model_name) + logger.info( + f"SentenceTransformer 模型 {self.model_name} 加载完毕!" + ) + + def _encode_texts(self, texts: list[str]) -> list[list[float]]: + self._ensure_model_loaded() + assert self.model is not None + embeddings = self.model.encode(texts) + return embeddings.tolist() diff --git a/zhenxun/services/ai/context/rag/backends/storages.py b/zhenxun/services/ai/context/rag/backends/storages.py new file mode 100644 index 00000000..af3a4c43 --- /dev/null +++ b/zhenxun/services/ai/context/rag/backends/storages.py @@ -0,0 +1,628 @@ +import os +from typing import ClassVar, Protocol, runtime_checkable +import uuid + +import numpy as np +from tortoise import fields + +from zhenxun.services.ai.context.rag.models import ( + BaseRecord, + QueryRequest, + SearchResult, +) +from zhenxun.services.ai.context.rag.retrieval import FilterEvaluator +from zhenxun.services.ai.utils.scope import ScopeSelector +from zhenxun.services.db_context import Model + + +@runtime_checkable +class StorageBackend(Protocol): + """纯粹的向量存储后端协议""" + + async def save(self, records: list[BaseRecord]) -> None: + """保存或更新数据块""" + ... + + async def search( + self, query: QueryRequest, scopes: list[str] | None = None + ) -> list[SearchResult]: + """按向量和前缀检索数据块""" + ... + + async def update(self, record: BaseRecord) -> None: + """更新已有数据块""" + ... + + async def delete( + self, record_ids: list[str] | None = None, scope_prefix: str | None = None + ) -> int: + """删除数据块""" + ... + + async def clear_by_query(self, query: ScopeSelector) -> int: + """根据统一领域查询对象清理数据块(在各实现中回退到 delete)""" + ... + + async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]: + """获取作用域下所有记录(用于容量控制)""" + ... + + +def normalize_vector(vec: list[float] | np.ndarray) -> np.ndarray: + """将一维向量转化为 float32 数组并进行 L2 归一化""" + v = np.array(vec, dtype=np.float32) + norm = np.linalg.norm(v) + if norm == 0: + return v + return v / norm + + +def normalize_matrix(mat: np.ndarray) -> np.ndarray: + """将二维矩阵的每一行进行 L2 归一化""" + norms = np.linalg.norm(mat, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return mat / norms + + +class DictStorageBackend(StorageBackend): + """基于内存字典的轻量级纯净 RAG 存储实现""" + + _shared_records: ClassVar[dict[str, BaseRecord]] = {} + _shared_vectors: ClassVar[dict[str, np.ndarray]] = {} + + def __init__(self): + self._records = self._shared_records + self._vectors = self._shared_vectors + + async def save(self, records: list[BaseRecord]) -> None: + for r in records: + self._records[r.id] = r + if r.embedding: + self._vectors[r.id] = normalize_vector(r.embedding) + else: + self._vectors.pop(r.id, None) + + async def search( + self, query: QueryRequest, scopes: list[str] | None = None + ) -> list[SearchResult]: + candidate_ids = [] + for record in self._records.values(): + if scopes is not None: + if record.metadata.get("scope", "/") not in scopes: + continue + if not FilterEvaluator.evaluate(record.metadata, query.metadata_filters): + continue + if not query.embedding and query.text and query.text not in record.content: + continue + candidate_ids.append(record.id) + + if not candidate_ids: + return [] + + results = [] + if query.search_type == "sparse": + import jieba + + tokens = set(jieba.lcut_for_search(query.text.lower())) + for r_id in candidate_ids: + record = self._records[r_id] + content = record.content.lower() + matched_count = sum(1 for t in tokens if t in content) + if matched_count > 0: + score = matched_count / len(tokens) + results.append(SearchResult(record=record, score=score)) + elif query.search_type == "dense" and query.embedding: + q_vec = normalize_vector(query.embedding) + valid_ids = [r_id for r_id in candidate_ids if r_id in self._vectors] + + if valid_ids: + try: + mat = np.array([self._vectors[r_id] for r_id in valid_ids]) + scores = mat @ q_vec + for r_id, score in zip(valid_ids, scores): + results.append( + SearchResult(record=self._records[r_id], score=float(score)) + ) + except ValueError as e: + from zhenxun.services.log import logger + + logger.warning( + "⚠️ DictStorage 中缓存的向量维度与当前查询维度不匹配," + f"跳过向量检索。原因: {e}" + ) + + missing_ids = [r_id for r_id in candidate_ids if r_id not in self._vectors] + for r_id in missing_ids: + results.append(SearchResult(record=self._records[r_id], score=0.1)) + else: + for r_id in candidate_ids: + results.append(SearchResult(record=self._records[r_id], score=0.1)) + + results.sort(key=lambda x: x.score, reverse=True) + return results[: query.limit] + + async def update(self, record: BaseRecord) -> None: + if record.id in self._records: + self._records[record.id] = record + if record.embedding: + self._vectors[record.id] = normalize_vector(record.embedding) + else: + self._vectors.pop(record.id, None) + + async def delete( + self, record_ids: list[str] | None = None, scope_prefix: str | None = None + ) -> int: + to_delete = [] + for r_id, r in self._records.items(): + if scope_prefix is not None: + if not r.metadata.get("scope", "/").startswith(scope_prefix): + continue + if record_ids and r_id not in record_ids: + continue + to_delete.append(r_id) + for r_id in to_delete: + del self._records[r_id] + self._vectors.pop(r_id, None) + return len(to_delete) + + async def clear_by_query(self, query: ScopeSelector) -> int: + return await self.delete(scope_prefix=query.scope_prefix) + + async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]: + res = [] + for r in self._records.values(): + if scope_prefix is not None: + if r.metadata.get("scope", "/") != scope_prefix: + continue + res.append(r) + return res + + +class AbstractVectorRecord(Model): + id = fields.CharField(pk=True, max_length=64) + scope = fields.CharField(max_length=255, index=True) + content = fields.TextField() + embedding = fields.JSONField(null=True) + meta_data = fields.JSONField(null=True) + + class Meta: # type: ignore + abstract = True + + +class TortoiseStorageBackend(StorageBackend): + def __init__(self, model_class: type[AbstractVectorRecord]): + self.model_class = model_class + + def _to_base_record(self, row: AbstractVectorRecord) -> BaseRecord: + return BaseRecord( + id=row.id, + content=row.content, + embedding=row.embedding if isinstance(row.embedding, list) else None, + metadata=row.meta_data if isinstance(row.meta_data, dict) else {}, + ) + + async def save(self, records: list[BaseRecord]) -> None: + for r in records: + await self.model_class.update_or_create( + id=r.id, + defaults={ + "content": r.content, + "scope": r.metadata.get("scope", "/"), + "embedding": r.embedding, + "meta_data": r.metadata, + }, + ) + + async def search( + self, query: QueryRequest, scopes: list[str] | None = None + ) -> list[SearchResult]: + query_orm = self.model_class.all() + if scopes is not None: + query_orm = query_orm.filter(scope__in=scopes) + + if query.search_type == "sparse" and query.text: + import jieba + from tortoise.expressions import Q + + tokens = [ + t for t in jieba.lcut_for_search(query.text) if len(t.strip()) > 1 + ] or [query.text] + q_expr = Q() + for token in tokens: + q_expr |= Q(content__icontains=token) + query_orm = query_orm.filter(q_expr) + elif query.search_type == "dense" and not query.embedding and query.text: + query_orm = query_orm.filter(content__icontains=query.text) + + rows = await query_orm + + valid_rows = [] + for row in rows: + row_meta = row.meta_data if isinstance(row.meta_data, dict) else {} + if not FilterEvaluator.evaluate(row_meta, query.metadata_filters): + continue + valid_rows.append(row) + + if not valid_rows: + return [] + + results = [] + if query.search_type == "sparse": + import jieba + + tokens = set(jieba.lcut_for_search(query.text.lower())) + for row in valid_rows: + content = row.content.lower() + matched_count = sum(1 for t in tokens if t in content) + score = matched_count / len(tokens) if tokens else 0.1 + results.append( + SearchResult(record=self._to_base_record(row), score=score) + ) + elif query.search_type == "dense" and query.embedding: + q_vec = normalize_vector(query.embedding) + vec_rows = [] + missing_rows = [] + + for row in valid_rows: + if isinstance(row.embedding, list): + vec_rows.append(row) + else: + missing_rows.append(row) + + if vec_rows: + try: + raw_mat = np.array( + [r.embedding for r in vec_rows], dtype=np.float32 + ) + norm_mat = normalize_matrix(raw_mat) + scores = norm_mat @ q_vec + + for row, score in zip(vec_rows, scores): + results.append( + SearchResult( + record=self._to_base_record(row), score=float(score) + ) + ) + except ValueError as e: + from zhenxun.services.log import logger + + logger.warning( + "⚠️ 数据库中缓存的向量维度与当前模型查询维度不匹配," + f"已安全跳过向量检索(降级为稀疏匹配)。原因: {e}" + ) + + for row in missing_rows: + results.append( + SearchResult(record=self._to_base_record(row), score=0.1) + ) + else: + for row in valid_rows: + results.append( + SearchResult(record=self._to_base_record(row), score=0.1) + ) + + results.sort(key=lambda x: x.score, reverse=True) + return results[: query.limit] + + async def update(self, record: BaseRecord) -> None: + await self.model_class.filter(id=record.id).update( + content=record.content, + scope=record.metadata.get("scope", "/"), + embedding=record.embedding, + meta_data=record.metadata, + ) + + async def delete( + self, record_ids: list[str] | None = None, scope_prefix: str | None = None + ) -> int: + query = self.model_class.all() + if scope_prefix is not None: + query = query.filter(scope__startswith=scope_prefix) + if record_ids is not None: + if not record_ids: + return 0 + query = query.filter(id__in=record_ids) + + return await query.delete() + + async def clear_by_query(self, query: ScopeSelector) -> int: + return await self.delete(scope_prefix=query.scope_prefix) + + async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]: + query = self.model_class.all() + if scope_prefix is not None: + query = query.filter(scope=scope_prefix) + rows = await query + return [self._to_base_record(row) for row in rows] + + +class QdrantStorageBackend(StorageBackend): + """Qdrant 向量数据库可选存储后端""" + + def __init__( + self, + location: str = ":memory:", + collection_name: str = "zhenxun_rag", + **kwargs, + ): + try: + from qdrant_client import AsyncQdrantClient + except ImportError: + raise ImportError( + "缺少 Qdrant 依赖!请执行 `pip install qdrant-client` 安装" + ) + + self.client = AsyncQdrantClient(location=location, **kwargs) + self.collection_name = collection_name + self._initialized = False + + async def _ensure_collection(self, dim: int): + if self._initialized: + return + from qdrant_client.models import Distance, VectorParams + + if not await self.client.collection_exists(self.collection_name): + await self.client.create_collection( + collection_name=self.collection_name, + vectors_config=VectorParams(size=dim, distance=Distance.COSINE), + ) + self._initialized = True + + async def save(self, records: list[BaseRecord]) -> None: + if not records: + return + dim = len(records[0].embedding) if records[0].embedding else 1536 + await self._ensure_collection(dim) + + from qdrant_client.models import PointStruct + + points = [] + for r in records: + points.append( + PointStruct( + id=r.id + if len(r.id) == 36 + else str(uuid.uuid5(uuid.NAMESPACE_DNS, r.id)), + vector=r.embedding or [], + payload={"content": r.content, "metadata": r.metadata}, + ) + ) + await self.client.upsert(collection_name=self.collection_name, points=points) + + async def search( + self, query: QueryRequest, scopes: list[str] | None = None + ) -> list[SearchResult]: + if query.search_type == "dense" and not query.embedding: + return [] + if query.embedding: + await self._ensure_collection(len(query.embedding)) + + from qdrant_client.models import FieldCondition, Filter, MatchText, MatchValue + + must_conditions = [] + + if scopes is not None: + try: + from qdrant_client.models import MatchAny + + must_conditions.append( + FieldCondition(key="metadata.scope", match=MatchAny(any=scopes)) + ) + except ImportError: + scope_conditions = [ + FieldCondition(key="metadata.scope", match=MatchValue(value=s)) + for s in scopes + ] + must_conditions.append(Filter(should=scope_conditions)) + + if query.metadata_filters: + for k, v in query.metadata_filters.items(): + must_conditions.append( + FieldCondition(key=f"metadata.{k}", match=MatchValue(value=v)) + ) + + if query.search_type == "sparse": + must_conditions.append( + FieldCondition(key="content", match=MatchText(text=query.text)) + ) + + query_filter = Filter(must=must_conditions) if must_conditions else None + + if query.search_type == "sparse": + results = await self.client.scroll( + collection_name=self.collection_name, + scroll_filter=query_filter, + limit=query.limit, + with_payload=True, + ) + return [ + SearchResult( + record=BaseRecord( + id=str(r.id), + content=(r.payload or {}).get("content", ""), + metadata=(r.payload or {}).get("metadata", {}), + ), + score=1.0, + ) + for r in results[0] + ] + + results = await self.client.search( # type: ignore + collection_name=self.collection_name, + query_vector=query.embedding, + limit=query.limit, + query_filter=query_filter, + ) + + return [ + SearchResult( + record=BaseRecord( + id=str(r.id), + content=r.payload.get("content", ""), + metadata=r.payload.get("metadata", {}), + ), + score=r.score, + ) + for r in results + ] + + async def update(self, record: BaseRecord) -> None: + await self.save([record]) + + async def delete( + self, record_ids: list[str] | None = None, scope_prefix: str | None = None + ) -> int: + if not await self.client.collection_exists(self.collection_name): + return 0 + from qdrant_client.models import FieldCondition, Filter, MatchText + + query_filter = None + if scope_prefix is not None: + query_filter = Filter( + must=[ + FieldCondition( + key="metadata.scope", match=MatchText(text=scope_prefix) + ) + ] + ) + if query_filter: + await self.client.delete( + collection_name=self.collection_name, points_selector=query_filter + ) + return 1 + + async def clear_by_query(self, query: ScopeSelector) -> int: + return await self.delete(scope_prefix=query.scope_prefix) + + async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]: + if not await self.client.collection_exists(self.collection_name): + return [] + from qdrant_client.models import FieldCondition, Filter, MatchText + + q_filter = None + if scope_prefix and scope_prefix != "/": + q_filter = Filter( + must=[ + FieldCondition( + key="metadata.scope", match=MatchText(text=scope_prefix) + ) + ] + ) + res = await self.client.scroll( + collection_name=self.collection_name, + scroll_filter=q_filter, + limit=10000, + with_payload=True, + ) + return [ + BaseRecord( + id=str(r.id), + content=(r.payload or {}).get("content", ""), + metadata=(r.payload or {}).get("metadata", {}), + ) + for r in res[0] + ] + + +class LanceDBStorageBackend(StorageBackend): + """LanceDB 向量数据库可选存储后端""" + + def __init__( + self, uri: str = "./data/lancedb", table_name: str = "zhenxun_rag", **kwargs + ): + try: + import lancedb + except ImportError: + raise ImportError("缺少 LanceDB 依赖!请执行 `pip install lancedb` 安装") + + os.makedirs( + os.path.dirname(uri) if os.path.dirname(uri) else ".", exist_ok=True + ) + self.db = lancedb.connect(uri) + self.table_name = table_name + + async def save(self, records: list[BaseRecord]) -> None: + if not records: + return + data = [] + dim = len(records[0].embedding) if records[0].embedding else 0 + + for r in records: + data.append( + { + "id": r.id, + "vector": r.embedding or [0.0] * dim, + "content": r.content, + "metadata": str(r.metadata), + } + ) + + if self.table_name not in self.db.table_names(): + self.db.create_table(self.table_name, data=data) + else: + self.db.open_table(self.table_name).add(data) + + async def search( + self, query: QueryRequest, scopes: list[str] | None = None + ) -> list[SearchResult]: + if self.table_name not in self.db.table_names(): + return [] + if query.search_type == "dense" and not query.embedding: + return [] + + tbl = self.db.open_table(self.table_name) + if query.search_type == "sparse": + try: + results = ( + tbl.search(query.text, query_type="fts") + .limit(query.limit) + .to_list() + ) + except Exception as e: + from zhenxun.services.log import logger + + logger.warning(f"LanceDB FTS 检索失败(可能是由于尚未创建FTS索引): {e}") + return [] + else: + results = tbl.search(query.embedding).limit(query.limit).to_list() + + import ast + + return [ + SearchResult( + record=BaseRecord( + id=r["id"], + content=r["content"], + metadata=ast.literal_eval(r["metadata"]) if "metadata" in r else {}, + ), + score=1.0 - r.get("_distance", 0.0), + ) + for r in results + ] + + async def update(self, record: BaseRecord) -> None: + pass + + async def delete( + self, record_ids: list[str] | None = None, scope_prefix: str | None = None + ) -> int: + return 0 + + async def clear_by_query(self, query: ScopeSelector) -> int: + return await self.delete(scope_prefix=query.scope_prefix) + + async def get_all(self, scope_prefix: str | None = None) -> list[BaseRecord]: + if self.table_name not in self.db.table_names(): + return [] + tbl = self.db.open_table(self.table_name) + df = tbl.to_pandas() + import ast + + res = [] + for _, row in df.iterrows(): + meta = ast.literal_eval(row["metadata"]) if "metadata" in row else {} + if scope_prefix is not None: + if meta.get("scope", "/") != scope_prefix: + continue + res.append(BaseRecord(id=row["id"], content=row["content"], metadata=meta)) + return res diff --git a/zhenxun/services/ai/context/rag/builder.py b/zhenxun/services/ai/context/rag/builder.py new file mode 100644 index 00000000..883a66b0 --- /dev/null +++ b/zhenxun/services/ai/context/rag/builder.py @@ -0,0 +1,272 @@ +from typing import Any + +from zhenxun.services.ai.context.rag.backends import StorageBackend +from zhenxun.services.ai.context.rag.configs import RAGConfig +from zhenxun.services.ai.context.rag.engine import ScopedRAGClient +from zhenxun.services.ai.context.rag.ingestion import ( + ChunkingStrategy, + DedupNode, + DocumentChunking, + DynamicChunkingNode, + EmbeddingNode, + IndexPipeline, + StorageCommitNode, +) +from zhenxun.services.ai.context.rag.retrieval import ( + BaseRetriever, + DatabaseSparseRetriever, + HybridRetriever, + LifecyclePostProcessor, + PipelineRetriever, + PostProcessor, + PreProcessor, + RerankRetriever, + VectorDBRetriever, +) +from zhenxun.services.log import logger + + +class RAGBuilder: + """ + RAG 管线组装构建器 (Fluent Builder Pattern)。 + 使用内部状态驱动模式 (The Memory Pattern),内部维护私有的 RAGConfig 实例。 + """ + + def __init__( + self, storage: StorageBackend | None = None, config: RAGConfig | None = None + ): + self._config = config or RAGConfig() + if storage is not None: + self._config.storage = storage + + def with_embedder(self, embedder: Any) -> "RAGBuilder": + """ + 设置向量化引擎。 + + 参数: + embedder: 实现了向量化协议的引擎实例 (如 DefaultEmbedder, FastEmbedder)。 + """ + self._config.embedder = embedder + return self + + def with_retriever(self, retriever: BaseRetriever) -> "RAGBuilder": + """ + 替换底层的向量库查表算法,注入自定义召回器。 + + 参数: + retriever: 实现了 BaseRetriever 协议的自定义召回器实例。 + """ + self._config.custom_retriever = retriever + return self + + def with_scope(self, scopes: str | list[str]) -> "RAGBuilder": + """ + 设置数据隔离作用域。 + + 参数: + scopes: 支持单作用域前缀(字符串)或联合检索多作用域(列表)。 + 如 "/" 或 ["/group_1", "/user_2"]。 + """ + self._config.scopes = scopes + return self + + def with_chunking(self, strategy: ChunkingStrategy) -> "RAGBuilder": + """ + 设置文档切块策略。 + + 参数: + strategy: 切块策略实例 (如 DocumentChunking, RecursiveCharacterChunking)。 + """ + self._config.chunking.strategy = strategy + return self + + def enable_dedup(self, threshold: float = 0.98) -> "RAGBuilder": + """ + 开启批处理入库去重。 + 在文本分块入库前,通过对比向量相似度拦截高度重复的内容。 + + 参数: + threshold: 去重相似度阈值,大于该值的块将被判定为重复并丢弃。 + """ + self._config.dedup.enable = True + self._config.dedup.threshold = threshold + return self + + def disable_dedup(self) -> "RAGBuilder": + """ + 关闭批处理入库去重。 + """ + self._config.dedup.enable = False + return self + + def enable_rerank(self, model_name: str, top_n: int = 5) -> "RAGBuilder": + """ + 开启大模型交叉注意力重排。 + 对初筛召回的结果使用专用的 Rerank 模型进行二次排序,极大提升召回准确率。 + + 参数: + model_name: 用于重排序的模型名称 (如 'BAAI/bge-reranker-v2-m3')。 + top_n: 重排后最终保留并返回的文档数量。 + """ + self._config.rerank.enable = True + self._config.rerank.model_name = model_name + self._config.rerank.top_n = top_n + return self + + def enable_hybrid_search( + self, dense_weight: float = 0.7, sparse_weight: float = 0.3 + ) -> "RAGBuilder": + """ + 开启双轨混合检索 (Hybrid Search) 及 RRF 融合。 + 并发调用向量检索 (Dense) 和关键词检索 (Sparse),结合两者优势。 + + 参数: + dense_weight: 稠密向量检索的分数计算权重。 + sparse_weight: 稀疏关键词(BM25等)检索的分数计算权重。 + """ + self._config.hybrid.enable = True + self._config.hybrid.dense_weight = dense_weight + self._config.hybrid.sparse_weight = sparse_weight + return self + + def enable_lifecycle_scoring( + self, + half_life_days: int = 30, + decay_weight: float = 0.3, + semantic_weight: float = 0.7, + importance_weight: float = 0.0, + reinforcement_weight: float = 0.2, + ) -> "RAGBuilder": + """ + 开启生命周期打分后处理。 + 综合考虑记忆的时间新鲜度、基础语义相似度、重要性以及访问频次,模拟人类记忆遗忘曲线。 + + 参数: + half_life_days: 时间衰减的半衰期(天)。经过这么多天后,时间得分衰减一半。 + decay_weight: 时间衰减得分所占权重。 + semantic_weight: 基础语义相似度所占权重。 + importance_weight: 客观重要性所占权重。 + reinforcement_weight: 访问强化(被回想次数越多越容易想起)所占权重。 + """ + self._config.lifecycle.enable = True + self._config.lifecycle.half_life_days = half_life_days + self._config.lifecycle.decay_weight = decay_weight + self._config.lifecycle.semantic_weight = semantic_weight + self._config.lifecycle.importance_weight = importance_weight + self._config.lifecycle.reinforcement_weight = reinforcement_weight + return self + + def add_pre_processor(self, processor: PreProcessor) -> "RAGBuilder": + """ + 挂载自定义查询前处理器。 + + 参数: + processor: 实现了 PreProcessor 协议的处理器实例。 + """ + self._config.pre_processors.append(processor) + return self + + def add_post_processor(self, processor: PostProcessor) -> "RAGBuilder": + """ + 挂载自定义检索后处理器。 + + 参数: + processor: 实现了 PostProcessor 协议的处理器实例。 + """ + self._config.post_processors.append(processor) + return self + + @classmethod + def resolve(cls, config: RAGConfig | dict | Any | None) -> RAGConfig: + """ + 解析多种格式的配置,统一转换为 RAGConfig 对象。 + + 参数: + config: RAGConfig 实例、字典或其他类型配置。 + """ + if isinstance(config, RAGConfig): + return config + if isinstance(config, dict): + return RAGConfig(**config) + return RAGConfig() + + def build(self) -> ScopedRAGClient: + """ + 完成所有积木组装,输出最终的 RAG 客户端实体。 + """ + cfg = self._config + storage = cfg.storage + if not storage: + raise ValueError("RAGBuilder 必须配置 storage 才能 build。") + + embedder = cfg.embedder + if not embedder: + from zhenxun.services.ai.context.rag.backends import DefaultEmbedder + from zhenxun.services.ai.llm.manager import get_default_model + + embedder = DefaultEmbedder(model_name=get_default_model("embedding")) + logger.debug("RAGBuilder: 未指定 Embedder,已使用系统默认 Embedder。") + + chunking_strategy = cfg.chunking.strategy or DocumentChunking() + + scopes = cfg.scopes + nodes = [ + DynamicChunkingNode(chunking_strategy), + EmbeddingNode(embedder), + ] + if cfg.dedup.enable: + nodes.append(DedupNode(cfg.dedup.threshold)) + + nodes.append(StorageCommitNode(storage)) + pipeline = IndexPipeline(nodes) + + base_retriever: BaseRetriever = cfg.custom_retriever or VectorDBRetriever( + storage, + embedder, + scopes[0] if isinstance(scopes, list) else scopes, + ) + + if cfg.hybrid.enable: + database_sparse_retriever = DatabaseSparseRetriever( + storage, scopes[0] if isinstance(scopes, list) else scopes + ) + base_retriever = HybridRetriever( + dense_retriever=base_retriever, + sparse_retriever=database_sparse_retriever, + dense_weight=cfg.hybrid.dense_weight, + sparse_weight=cfg.hybrid.sparse_weight, + ) + + if cfg.rerank.enable and cfg.rerank.model_name: + base_retriever = RerankRetriever( + base_retriever, cfg.rerank.model_name, cfg.rerank.top_n + ) + + pre_processors = list(cfg.pre_processors) + + post_processors = list(cfg.post_processors) + if cfg.lifecycle.enable: + post_processors.append( + LifecyclePostProcessor( + half_life_days=cfg.lifecycle.half_life_days, + decay_weight=cfg.lifecycle.decay_weight, + semantic_weight=cfg.lifecycle.semantic_weight, + importance_weight=cfg.lifecycle.importance_weight, + reinforcement_weight=cfg.lifecycle.reinforcement_weight, + ) + ) + + if pre_processors or post_processors: + base_retriever = PipelineRetriever( + base_retriever, + pre_processors=pre_processors, + post_processors=post_processors, + ) + + return ScopedRAGClient( + storage=storage, + retriever=base_retriever, + pipeline=pipeline, + scopes=scopes, + config=cfg, + ) diff --git a/zhenxun/services/ai/context/rag/configs.py b/zhenxun/services/ai/context/rag/configs.py new file mode 100644 index 00000000..d2938a61 --- /dev/null +++ b/zhenxun/services/ai/context/rag/configs.py @@ -0,0 +1,86 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.context.rag.backends import Embedder, StorageBackend +from zhenxun.services.ai.context.rag.ingestion import ChunkingStrategy +from zhenxun.services.ai.context.rag.retrieval import ( + BaseRetriever, + PostProcessor, + PreProcessor, +) + +StorageConfigType = dict[str, Any] + + +class ChunkingConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + strategy: ChunkingStrategy | None = None + """文档切块策略""" + + +class DedupConfig(BaseModel): + enable: bool = True + """是否开启入库去重""" + threshold: float = 0.98 + """去重相似度阈值""" + + +class RerankConfig(BaseModel): + enable: bool = False + """是否开启重排""" + model_name: str | None = None + """重排使用的模型名称""" + top_n: int = 5 + """重排后保留的文档数量""" + + +class HybridSearchConfig(BaseModel): + enable: bool = False + """是否开启双轨混合检索及 RRF 融合""" + dense_weight: float = 0.7 + """向量检索权重""" + sparse_weight: float = 0.3 + """BM25 检索权重""" + + +class LifecycleConfig(BaseModel): + enable: bool = False + """是否开启生命周期打分(时间衰减+访问强化)后处理""" + half_life_days: int = 30 + """半衰期天数""" + decay_weight: float = 0.3 + """时间衰减权重""" + semantic_weight: float = 0.7 + """语义相似度权重""" + importance_weight: float = 0.0 + """重要性打分权重""" + reinforcement_weight: float = 0.2 + """访问强化得分权重""" + + +class RAGConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + storage: StorageBackend | None = None + """存储后端""" + embedder: Embedder | None = None + """向量化引擎""" + custom_retriever: BaseRetriever | None = None + """自定义召回器""" + scopes: str | list[str] = "/" + """数据隔离作用域""" + chunking: ChunkingConfig = Field(default_factory=ChunkingConfig) + """文档切块配置""" + dedup: DedupConfig = Field(default_factory=DedupConfig) + """去重配置""" + + rerank: RerankConfig = Field(default_factory=RerankConfig) + """重排配置""" + hybrid: HybridSearchConfig = Field(default_factory=HybridSearchConfig) + """混合检索与 RRF 融合配置""" + lifecycle: LifecycleConfig = Field(default_factory=LifecycleConfig) + """生命周期打分配置""" + pre_processors: list[PreProcessor] = Field(default_factory=list) + """自定义查询前处理器列表""" + post_processors: list[PostProcessor] = Field(default_factory=list) + """自定义检索后处理器列表""" diff --git a/zhenxun/services/ai/context/rag/engine.py b/zhenxun/services/ai/context/rag/engine.py new file mode 100644 index 00000000..b6f870a0 --- /dev/null +++ b/zhenxun/services/ai/context/rag/engine.py @@ -0,0 +1,96 @@ +from typing import Any + +from zhenxun.services.ai.context.rag.backends import ( + StorageBackend, +) +from zhenxun.services.ai.context.rag.configs import RAGConfig +from zhenxun.services.ai.context.rag.ingestion import ( + IndexPipeline, +) +from zhenxun.services.ai.context.rag.models import ( + BaseRecord, + SearchResult, +) +from zhenxun.services.ai.context.rag.retrieval import ( + BaseRetriever, +) +from zhenxun.services.ai.utils.scope import normalize_scope_path + + +class ScopedRAGClient: + """ + RAG 基础设施门面。 + 封装了存储后端、检索器和写入管线,将所有操作透明地限定在指定的作用域前缀下。 + """ + + def __init__( + self, + storage: StorageBackend, + retriever: BaseRetriever, + pipeline: IndexPipeline, + scopes: str | list[str] = "/", + config: RAGConfig | None = None, + ): + """ + 初始化 ScopedRAGClient 实例。 + + 参数: + storage: 底层向量/文档存储后端。 + retriever: 数据召回检索器。 + pipeline: 数据入库和索引分块处理管线。 + scopes: 数据隔离作用域,支持单作用域前缀或多作用域前缀列表。 + config: 全局的 RAG 配置项。 + """ + self.storage = storage + self.retriever = retriever + self.pipeline = pipeline + self.config = config or RAGConfig() + self._background_tasks = set() + if isinstance(scopes, str): + self.scopes = [normalize_scope_path(scopes)] + else: + self.scopes = [normalize_scope_path(s) for s in scopes] + + self.scope_prefix = self.scopes[0] if self.scopes else "/" + + async def ingest(self, records: list[BaseRecord]) -> int: + """通过 RAG Ingestion Pipeline 处理并入库数据""" + for r in records: + if "scope" not in r.metadata: + r.metadata["scope"] = self.scope_prefix + + res = await self.pipeline.run(records) + return len(res) + + async def search( + self, + query: Any, + limit: int = 10, + scopes: str | list[str] | None = None, + **kwargs: Any, + ) -> list[SearchResult]: + """ + 多作用域联合切片视图检索 (Union Search)。 + 并发向多个独立的作用域发起检索,并对结果进行合并、去重和重排。 + """ + target_scopes = self.scopes + if scopes is not None: + target_scopes = ( + [normalize_scope_path(scopes)] + if isinstance(scopes, str) + else [normalize_scope_path(s) for s in scopes] + ) + + if not target_scopes: + return [] + + kwargs["scopes"] = target_scopes + return await self.retriever.retrieve(query, limit=limit, **kwargs) + + async def update(self, record: BaseRecord) -> None: + record.metadata["scope"] = self.scope_prefix + await self.storage.update(record) + + async def delete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int: + kwargs["scope_prefix"] = self.scope_prefix + return await self.storage.delete(record_ids=record_ids, **kwargs) diff --git a/zhenxun/services/ai/context/rag/ingestion.py b/zhenxun/services/ai/context/rag/ingestion.py new file mode 100644 index 00000000..f0e36a1a --- /dev/null +++ b/zhenxun/services/ai/context/rag/ingestion.py @@ -0,0 +1,521 @@ +from abc import ABC, abstractmethod +import asyncio +import re + +from zhenxun.services.ai.context.rag.models import BaseRecord +from zhenxun.services.ai.context.rag.utils import cosine_similarity +from zhenxun.services.log import logger + + +class ChunkingStrategy(ABC): + @abstractmethod + def chunk(self, record: BaseRecord) -> list[BaseRecord]: + raise NotImplementedError + + def clean_text(self, text: str) -> str: + cleaned_text = re.sub(r"\n+", "\n", text) + cleaned_text = re.sub(r"[ \t]+", " ", cleaned_text) + return cleaned_text.strip() + + def _create_chunk_record( + self, original_record: BaseRecord, chunk_number: int, content: str + ) -> BaseRecord: + meta_data = original_record.metadata.copy() + meta_data["chunk_index"] = chunk_number + meta_data["chunk_size"] = len(content) + meta_data["parent_id"] = original_record.id + return BaseRecord( + id=f"{original_record.id}_{chunk_number}", + content=content, + metadata=meta_data, + ) + + +class DocumentChunking(ChunkingStrategy): + """段落语义分块策略 (按双换行切分)""" + + def __init__(self, chunk_size: int = 1000): + """ + 初始化段落语义分块策略。 + + 参数: + chunk_size: 单个分块的最大字符长度限制,默认 1000。 + """ + self.chunk_size = chunk_size + + def chunk(self, record: BaseRecord) -> list[BaseRecord]: + if len(record.content) <= self.chunk_size: + return [ + self._create_chunk_record(record, 0, self.clean_text(record.content)) + ] + + raw_paragraphs = record.content.split("\n\n") + paragraphs = [self.clean_text(para) for para in raw_paragraphs if para.strip()] + + chunks: list[BaseRecord] = [] + current_chunk_texts = [] + current_length = 0 + chunk_index = 0 + + for para in paragraphs: + para_len = len(para) + if current_length + para_len > self.chunk_size and current_chunk_texts: + chunk_content = "\n\n".join(current_chunk_texts) + chunks.append( + self._create_chunk_record(record, chunk_index, chunk_content) + ) + chunk_index += 1 + current_chunk_texts = [] + current_length = 0 + + current_chunk_texts.append(para) + current_length += para_len + 2 + + if current_chunk_texts: + chunk_content = "\n\n".join(current_chunk_texts) + chunks.append(self._create_chunk_record(record, chunk_index, chunk_content)) + + return chunks + + +class RecursiveCharacterChunking(ChunkingStrategy): + """递归字符分块策略""" + + def __init__( + self, + chunk_size: int = 1000, + overlap: int = 100, + separators: list[str] | None = None, + ): + """ + 初始化递归字符分块策略。 + + 参数: + chunk_size: 单个分块的最大字符长度限制,默认 1000。 + overlap: 相邻分块之间的重叠字符长度,默认 100。 + separators: 用于切分文本的候选分隔符列表,按优先级从高到低尝试, + 默认包含段落、句子和常见标点。 + """ + if overlap >= chunk_size: + raise ValueError(f"重叠长度 ({overlap}) 必须小于分块大小 ({chunk_size})") + self.chunk_size = chunk_size + self.overlap = overlap + self.separators = separators or [ + "\n\n", + "\n", + "。", + "!", + "?", + ";", + ",", + " ", + "", + ] + + def _split_text(self, text: str, separators: list[str]) -> list[str]: + """核心递归切分逻辑""" + final_chunks = [] + separator = separators[-1] + new_separators = [] + + for i, _s in enumerate(separators): + if _s == "": + separator = _s + break + if _s in text: + separator = _s + new_separators = separators[i + 1 :] + break + + if separator: + splits = [s for s in text.split(separator) if s] + else: + splits = list(text) + + good_splits = [] + for s in splits: + if len(s) < self.chunk_size: + good_splits.append(s) + else: + if good_splits: + merged_chunks = self._merge_splits(good_splits, separator) + final_chunks.extend(merged_chunks) + good_splits = [] + if new_separators: + final_chunks.extend(self._split_text(s, new_separators)) + else: + for i in range(0, len(s), self.chunk_size): + final_chunks.append(s[i : i + self.chunk_size]) + + if good_splits: + merged_chunks = self._merge_splits(good_splits, separator) + final_chunks.extend(merged_chunks) + + return final_chunks + + def _merge_splits(self, splits: list[str], separator: str) -> list[str]: + """将零散的切片合并为符合 chunk_size 的块,并处理 Overlap""" + chunks = [] + current_chunk = [] + current_length = 0 + + for split in splits: + split_len = len(split) + sep_len = len(separator) if current_chunk else 0 + + if current_length + sep_len + split_len > self.chunk_size and current_chunk: + chunk_str = separator.join(current_chunk) + chunks.append(chunk_str) + + while current_length > self.overlap or ( + current_length + sep_len + split_len > self.chunk_size + and len(current_chunk) > 0 + ): + popped = current_chunk.pop(0) + current_length -= len(popped) + ( + len(separator) if current_chunk else 0 + ) + sep_len = len(separator) if current_chunk else 0 + + current_chunk.append(split) + current_length += sep_len + split_len + + if current_chunk: + chunk_str = separator.join(current_chunk) + chunks.append(chunk_str) + + return chunks + + def chunk(self, record: BaseRecord) -> list[BaseRecord]: + content = record.content.strip() + + if len(content) <= self.chunk_size: + return [self._create_chunk_record(record, 0, content)] + + text_chunks = self._split_text(content, self.separators) + + chunks: list[BaseRecord] = [] + for i, text_chunk in enumerate(text_chunks): + clean_chunk = text_chunk.strip() + if clean_chunk: + chunks.append(self._create_chunk_record(record, i, clean_chunk)) + + return chunks + + +class RowChunking(ChunkingStrategy): + """ + 行数据分块策略 (专为 CSV/表格设计) + 核心特性:自动识别表头,并将其附加到每一个被切分的 Chunk 首部,防止上下文丢失。 + """ + + def __init__(self, rows_per_chunk: int = 50): + """ + 初始化表格行数据分块策略。 + + 参数: + rows_per_chunk: 每个分块包含的数据行数(不含表头),默认 50。 + """ + self.rows_per_chunk = rows_per_chunk + + def chunk(self, record: BaseRecord) -> list[BaseRecord]: + lines = record.content.splitlines() + lines = [line for line in lines if line.strip()] + + if not lines: + return [] + + header = lines[0] + data_lines = lines[1:] + + if not data_lines: + return [self._create_chunk_record(record, 0, header)] + + chunks: list[BaseRecord] = [] + chunk_index = 0 + + for i in range(0, len(data_lines), self.rows_per_chunk): + chunk_lines = [header, *data_lines[i : i + self.rows_per_chunk]] + chunk_content = "\n".join(chunk_lines) + chunks.append(self._create_chunk_record(record, chunk_index, chunk_content)) + chunk_index += 1 + + return chunks + + +class DeduplicationProcessor: + """ + 入库批处理去重器 (Intra-batch Deduplication)。 + 在 Chunk 存入数据库前,通过对比向量相似度,拦截高度重复的内容(如群聊复读机内容)。 + """ + + def __init__(self, threshold: float = 0.98): + """ + 初始化入库批处理去重处理器。 + + 参数: + threshold: 余弦相似度重复阈值,超过该阈值的块将被判定为重复并过滤, + 默认 0.98。 + """ + self.threshold = threshold + + async def process(self, records: list[BaseRecord]) -> list[BaseRecord]: + if not records or len(records) <= 1: + return records + + kept_records: list[BaseRecord] = [] + dropped_count = 0 + + for record in records: + if not record.embedding: + kept_records.append(record) + continue + + is_duplicate = False + for kept in kept_records: + if not kept.embedding: + continue + sim = cosine_similarity(record.embedding, kept.embedding) + if sim >= self.threshold: + is_duplicate = True + dropped_count += 1 + break + + if not is_duplicate: + kept_records.append(record) + + if dropped_count > 0: + logger.debug( + f"🧹 [入库管线] 触发批处理去重,已拦截 {dropped_count} " + f"个高度重复的 Chunk (阈值: {self.threshold})" + ) + + return kept_records + + +class BaseBatchNode(ABC): + """批处理节点基类:一次性接收并处理全部记录""" + + @abstractmethod + async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: ... + + +class BaseMapNode(ABC): + """单映射节点基类:接收单条记录,引擎负责并发调度,返回None代表丢弃该数据""" + + @abstractmethod + async def process_one( + self, record: BaseRecord + ) -> BaseRecord | list[BaseRecord] | None: ... + + +class DynamicChunkingNode(BaseBatchNode): + """智能路由切块节点。根据记录的扩展名动态选择切块策略。""" + + def __init__( + self, + default_strategy: ChunkingStrategy, + custom_strategies: dict[str, ChunkingStrategy] | None = None, + ): + """ + 初始化智能路由切块节点。 + + 参数: + default_strategy: 默认的切块策略。 + custom_strategies: 针对特定文件后缀的自定义切块策略映射表, + 默认 CSV 文件使用 RowChunking。 + """ + self.default_strategy = default_strategy + self.strategies = custom_strategies or {".csv": RowChunking(rows_per_chunk=30)} + + async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: + chunks = [] + for record in records: + ext = record.metadata.get("extension", "") + strategy = self.strategies.get(ext, self.default_strategy) + chunks.extend(strategy.chunk(record)) + return chunks + + +class BaseEmbeddingBatchNode(BaseBatchNode): + """批量向量化抽象基类:提取文本、分批请求 API 并将结果写回的公共逻辑""" + + def __init__(self, embedder, batch_size: int = 80): + """ + 初始化批量向量化抽象基类。 + + 参数: + embedder: 向量嵌入模型/函数,用于将文本生成向量。 + batch_size: 向量化请求的单批次大小限制,默认 80。 + """ + self.embedder = embedder + self.batch_size = batch_size + + @abstractmethod + def _filter_target_records(self, records: list[BaseRecord]) -> list[BaseRecord]: + """由子类实现:筛选出本次需要进行向量化的目标记录""" + pass + + async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: + if not self.embedder or not records: + return records + + target_records = self._filter_target_records(records) + if not target_records: + return records + + texts = [r.content for r in target_records] + try: + vecs = [] + for i in range(0, len(texts), self.batch_size): + batch_texts = texts[i : i + self.batch_size] + batch_vecs = await self.embedder(batch_texts, task="document") + vecs.extend(batch_vecs) + + for i, r in enumerate(target_records): + if vecs and i < len(vecs) and vecs[i]: + r.embedding = vecs[i] + except Exception as e: + logger.error(f"[{self.__class__.__name__}] 批量向量化失败: {e}") + + return records + + +class EmbeddingNode(BaseEmbeddingBatchNode): + """并发向量化初次构建节点""" + + def _filter_target_records(self, records: list[BaseRecord]) -> list[BaseRecord]: + return [r for r in records if r.content.strip()] + + +class DedupNode(BaseBatchNode): + """批次内查重节点""" + + def __init__(self, threshold: float): + """ + 初始化批次内查重节点。 + + 参数: + threshold: 余弦相似度重复阈值,超过该阈值的块将被判定为重复并过滤。 + """ + self.processor = DeduplicationProcessor(threshold=threshold) + + async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: + return await self.processor.process(records) + + +class StorageCommitNode(BaseBatchNode): + """持久化事务提交节点 (Reduce)。统一收集意图并执行并发数据库 I/O。""" + + def __init__(self, storage): + """ + 初始化持久化事务提交节点。 + + 参数: + storage: 存储后端,负责将记录存入或删除。 + """ + self.storage = storage + + async def process_batch(self, records: list[BaseRecord]) -> list[BaseRecord]: + if not records: + return records + + to_delete = set() + to_update = [] + to_insert = [] + + for record in records: + if record.action == "delete": + to_delete.add(record.id) + elif record.action == "update": + to_update.append(record) + elif record.action == "insert": + to_insert.append(record) + + if to_delete: + await self.storage.delete(record_ids=list(to_delete)) + + if to_update: + await asyncio.gather(*[self.storage.update(r) for r in to_update]) + + if to_insert: + await self.storage.save(to_insert) + + logger.debug( + "💾 RAG 事务提交完成:插入 " + f"{len(to_insert)} 条, 更新 {len(to_update)} 条, " + f"删除 {len(to_delete)} 条。" + ) + return to_insert + to_update + + +class IndexPipeline: + """统一入库流水线 (Map-Reduce 范式并发调度引擎)""" + + def __init__( + self, + nodes: list[BaseBatchNode | BaseMapNode] | None = None, + max_workers: int = 5, + ): + """ + 初始化统一入库流水线。 + + 参数: + nodes: 管道节点列表,按顺序执行数据处理,默认 None。 + max_workers: 最大并发工作协程数,用于 Map 节点的并发调度,默认 5。 + """ + self.nodes = nodes or [] + self.max_workers = max_workers + + def add_node(self, node: BaseBatchNode | BaseMapNode): + self.nodes.append(node) + + async def run(self, records: list[BaseRecord]) -> list[BaseRecord]: + if not records: + return [] + + current_records = records + for node in self.nodes: + if not current_records: + break + + if isinstance(node, BaseBatchNode): + current_records = await node.process_batch(current_records) + elif isinstance(node, BaseMapNode): + sem = asyncio.Semaphore(self.max_workers) + map_node: BaseMapNode = node + + async def _process_with_sem(r: BaseRecord): + async with sem: + return await map_node.process_one(r) + + tasks = [_process_with_sem(r) for r in current_records] + results = await asyncio.gather(*tasks) + + next_records = [] + for r in results: + if isinstance(r, list): + next_records.extend(r) + elif r is not None: + next_records.append(r) + current_records = next_records + else: + raise ValueError(f"未知的管道节点类型: {type(node)}") + + return current_records + + +__all__ = [ + "BaseBatchNode", + "BaseMapNode", + "ChunkingStrategy", + "DedupNode", + "DeduplicationProcessor", + "DocumentChunking", + "DynamicChunkingNode", + "EmbeddingNode", + "IndexPipeline", + "RecursiveCharacterChunking", + "RowChunking", + "StorageCommitNode", +] diff --git a/zhenxun/services/ai/context/rag/models.py b/zhenxun/services/ai/context/rag/models.py new file mode 100644 index 00000000..674a3ed8 --- /dev/null +++ b/zhenxun/services/ai/context/rag/models.py @@ -0,0 +1,47 @@ +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class BaseRecord(BaseModel): + """RAG 基础记录载体,没有任何业务属性""" + + id: str = Field(default_factory=lambda: __import__("uuid").uuid4().hex) + """记录的唯一标识符""" + content: str = Field(...) + """数据块的文本内容""" + embedding: list[float] | None = Field(default=None) + """数据块对应的向量嵌入""" + metadata: dict[str, Any] = Field(default_factory=dict) + """数据块的元数据字典""" + action: Literal["insert", "update", "delete", "ignore"] = Field(default="insert") + """数据块在索引管线中的操作意图""" + + +class SearchResult(BaseModel): + """搜索结果""" + + record: BaseRecord + """检索到的基础记录""" + score: float + """检索相似度得分""" + + +class QueryRequest(BaseModel): + """通用检索请求""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + text: str = Field(default="") + """原始查询文本""" + embedding: list[float] | None = Field(default=None) + """用于向量检索的数组""" + search_type: Literal["dense", "sparse", "hybrid"] = Field(default="dense") + """检索类型标识:稠密向量、稀疏关键词或混合""" + metadata_filters: dict[str, Any] | None = Field(default=None) + """元数据精确匹配字典""" + limit: int = Field(default=10) + """返回的最大条数""" + + +StorageConfigType = dict[str, Any] diff --git a/zhenxun/services/ai/context/rag/retrieval.py b/zhenxun/services/ai/context/rag/retrieval.py new file mode 100644 index 00000000..b306ca15 --- /dev/null +++ b/zhenxun/services/ai/context/rag/retrieval.py @@ -0,0 +1,439 @@ +from abc import abstractmethod +import asyncio +import time +from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable + +from zhenxun.services.ai.context.rag.models import QueryRequest, SearchResult +from zhenxun.services.log import logger + +if TYPE_CHECKING: + from zhenxun.services.ai.context.rag.backends.storages import StorageBackend + + +def normalize_query_text(query: Any) -> str: + """辅助函数:提取各种输入形式(如字符串、平台Message对象)的纯文本用于检索""" + if isinstance(query, str): + return query + if hasattr(query, "extract_plain_text"): + return query.extract_plain_text() + return str(query) if query is not None else "" + + +@runtime_checkable +class BaseRetriever(Protocol): + """ + 检索器核心协议。 + 任何实现了 retrieve 方法的对象均可作为检索器 + (不仅限于向量检索,也可包含 BM25、SQL 搜索等)。 + """ + + @abstractmethod + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: ... + + +@runtime_checkable +class PostProcessor(Protocol): + """后处理器协议(如重排、时间衰减打分等)。""" + + @abstractmethod + async def process( + self, results: list[SearchResult], query: str + ) -> list[SearchResult]: ... + + +@runtime_checkable +class PreProcessor(Protocol): + """预处理器协议(如 LLM Query 改写、意图提取等)。""" + + @abstractmethod + async def process(self, query: str) -> list[str]: + """接收原始查询,返回一个或多个处理/改写后的查询词""" + ... + + +class FilterEvaluator: + """纯 Python 内存求值器,用于为轻量级 Storage 提供字典精确匹配过滤""" + + @classmethod + def evaluate( + cls, metadata: dict[str, Any], filter_dict: dict[str, Any] | None + ) -> bool: + if filter_dict is None: + return True + return all(metadata.get(k) == v for k, v in filter_dict.items()) + + +class VectorDBRetriever(BaseRetriever): + """基于向量数据库的标准检索器""" + + def __init__( + self, + storage: "StorageBackend", + embedder: Any, + scope_prefix: str | None = None, + score_threshold: float = 0.4, + ): + """ + 初始化向量数据库检索器。 + + 参数: + storage: 存储后端,用于执行向量相似度搜索。 + embedder: 向量嵌入模型/函数,用于将文本转换为向量。 + scope_prefix: 作用域前缀,用于限制检索范围,默认 None。 + score_threshold: 分数阈值,过滤掉相似度低于该值的检索结果,默认 0.4。 + """ + self.storage = storage + self.embedder = embedder + self.scope_prefix = scope_prefix + self.score_threshold = score_threshold + + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: + text_query = normalize_query_text(query) + + vecs = await self.embedder(query, task="query") + query_vec = vecs[0] if vecs else None + + if not text_query.strip() and not query_vec: + return [] + + req = QueryRequest( + text=text_query, + embedding=query_vec, + limit=limit * 2, + search_type="dense", + metadata_filters=kwargs.get("metadata_filters"), + ) + effective_scopes = kwargs.get( + "scopes", [self.scope_prefix] if self.scope_prefix else None + ) + results = await self.storage.search(req, scopes=effective_scopes) + return [r for r in results if r.score >= self.score_threshold][:limit] + + +class DatabaseSparseRetriever(BaseRetriever): + """纯数据库下沉的稀疏检索器 (Keyword/FTS)""" + + def __init__( + self, + storage: "StorageBackend", + scope_prefix: str | None = None, + score_threshold: float = 0.0, + ): + """ + 初始化数据库稀疏检索器。 + + 参数: + storage: 存储后端,用于执行全文检索/关键词检索。 + scope_prefix: 作用域前缀,用于限制检索范围,默认 None。 + score_threshold: 分数阈值,过滤掉相关度低于该值的检索结果,默认 0.0。 + """ + self.storage = storage + self.scope_prefix = scope_prefix + self.score_threshold = score_threshold + + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: + text_query = normalize_query_text(query) + if not text_query.strip(): + return [] + + req = QueryRequest( + text=text_query, + limit=limit * 2, + search_type="sparse", + metadata_filters=kwargs.get("metadata_filters"), + ) + effective_scopes = kwargs.get( + "scopes", [self.scope_prefix] if self.scope_prefix else None + ) + results = await self.storage.search(req, scopes=effective_scopes) + return [r for r in results if r.score > self.score_threshold][:limit] + + +class RerankRetriever(BaseRetriever): + """带大模型交叉注意力重排的高阶检索器 (Decorator Pattern)""" + + def __init__( + self, + base_retriever: BaseRetriever, + model_name: str | None = None, + top_n: int = 5, + oversample_factor: int = 2, + min_oversample: int = 20, + ): + """ + 初始化重排检索器。 + + 参数: + base_retriever: 基础检索器,用于初筛。 + model_name: 重排模型的名称,默认 None。 + top_n: 重排后保留的前 N 个文档数,默认 5。 + oversample_factor: 过采样系数,决定初筛检索的文档数量倍数,默认 2。 + min_oversample: 最小过采样文档数,默认 20。 + """ + self.base_retriever = base_retriever + self.model_name = model_name + self.top_n = top_n + self.oversample_factor = oversample_factor + self.min_oversample = min_oversample + + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: + oversample_limit = max(limit * self.oversample_factor, self.min_oversample) + initial_results = await self.base_retriever.retrieve( + query, limit=oversample_limit, **kwargs + ) + + if not initial_results: + return [] + + text_query = normalize_query_text(query) + + docs: list[str | dict[str, str]] = [ + res.record.content for res in initial_results + ] + + from zhenxun.services.ai.llm.api import rerank + + try: + reranked = await rerank( + query=text_query, + documents=docs, + top_n=min(limit, self.top_n), + model=self.model_name, + ) + except Exception as e: + logger.warning(f"Rerank 重排请求失败,将降级返回初筛结果: {e}") + return initial_results[:limit] + + final_results = [] + for rr in reranked: + original_res = initial_results[rr.index] + original_res.score = rr.relevance_score + final_results.append(original_res) + + return final_results + + +class PipelineRetriever(BaseRetriever): + """支持挂载多个后处理器的流水线检索器""" + + def __init__( + self, + base_retriever: BaseRetriever, + post_processors: list[PostProcessor] | None = None, + pre_processors: list[PreProcessor] | None = None, + ): + """ + 初始化流水线检索器。 + + 参数: + base_retriever: 基础检索器,执行最初的检索过程。 + post_processors: 后处理器列表,用于对检索到的结果进行重排、过滤等后处理,默认 None。 + pre_processors: 预处理器列表,用于对查询词进行改写、扩展等预处理,默认 None。 + """ # noqa: E501 + self.base_retriever = base_retriever + self.post_processors = post_processors or [] + self.pre_processors = pre_processors or [] + + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: + text_query = normalize_query_text(query) + + queries_to_search = [query] + + if text_query.strip(): + processed_texts = [text_query] + for pp in self.pre_processors: + new_texts = [] + for t in processed_texts: + new_texts.extend(await pp.process(t)) + processed_texts = new_texts + + if len(processed_texts) > 1 or ( + len(processed_texts) == 1 and processed_texts[0] != text_query + ): + queries_to_search.extend(processed_texts) + + all_results = [] + seen_ids = set() + + for q in queries_to_search: + res = await self.base_retriever.retrieve(q, limit=limit * 2, **kwargs) + for r in res: + if r.record.id not in seen_ids: + seen_ids.add(r.record.id) + all_results.append(r) + + results = sorted(all_results, key=lambda x: x.score, reverse=True) + + for pp in self.post_processors: + results = await pp.process(results, query) + + return results[:limit] + + +class LifecyclePostProcessor(PostProcessor): + """生命周期后处理器(融合时间衰减与惰性访问强化)""" + + def __init__( + self, + half_life_days: int = 30, + decay_weight: float = 0.3, + semantic_weight: float = 0.7, + importance_weight: float = 0.0, + reinforcement_weight: float = 0.2, + ): + """ + 初始化生命周期后处理器。 + + 参数: + half_life_days: 记忆衰减半衰期天数,控制信息随时间的降权速度,默认 30。 + decay_weight: 时间衰减得分的权重,默认 0.3。 + semantic_weight: 语义相关度得分的权重,默认 0.7。 + importance_weight: 信息重要性得分的权重,默认 0.0。 + reinforcement_weight: 惰性访问强化(如访问次数得分)的权重,默认 0.2。 + """ + self.half_life_days = half_life_days + self.decay_weight = decay_weight + self.semantic_weight = semantic_weight + self.importance_weight = importance_weight + self.reinforcement_weight = reinforcement_weight + + async def process( + self, results: list[SearchResult], query: str + ) -> list[SearchResult]: + now = time.time() + import math + + for res in results: + created_at = res.record.metadata.get("created_at", now) + importance = res.record.metadata.get("importance", 0.5) + access_count = res.record.metadata.get("access_count", 0) + last_accessed_at = res.record.metadata.get("last_accessed_at", created_at) + age_days = max(0.0, (now - last_accessed_at) / 86400.0) + decay = 0.5 ** (age_days / self.half_life_days) + access_score = min(1.0, math.log1p(access_count) / 5.0) + res.score = ( + (self.semantic_weight * res.score) + + (self.decay_weight * decay) + + (self.importance_weight * importance) + + (self.reinforcement_weight * access_score) + ) + + results.sort(key=lambda x: x.score, reverse=True) + return results + + +class HybridRetriever(BaseRetriever): + """ + 双轨混合检索器 (Hybrid Search Engine)。 + 并发调用 Dense (VectorDB) 和 Sparse (BM25),并使用倒数秩融合 (RRF) 算法合并结果。 + """ + + def __init__( + self, + dense_retriever: BaseRetriever, + sparse_retriever: BaseRetriever, + dense_weight: float = 0.7, + sparse_weight: float = 0.3, + rrf_k: int = 60, + oversample_factor: int = 2, + min_oversample: int = 20, + ): + """ + 初始化双轨混合检索器。 + + 参数: + dense_retriever: 稠密向量检索器,用于语义召回。 + sparse_retriever: 稀疏文本检索器,用于关键词召回(如 BM25)。 + dense_weight: 稠密向量检索的加权权重,默认 0.7。 + sparse_weight: 稀疏文本检索的加权权重,默认 0.3。 + rrf_k: 倒数秩融合(RRF)算法中的常数参数,默认 60。 + """ + self.dense_retriever = dense_retriever + self.sparse_retriever = sparse_retriever + self.dense_weight = dense_weight + self.sparse_weight = sparse_weight + self.rrf_k = rrf_k + self.oversample_factor = oversample_factor + self.min_oversample = min_oversample + + async def retrieve( + self, query: Any, limit: int = 10, **kwargs: Any + ) -> list[SearchResult]: + oversample_limit = max(limit * self.oversample_factor, self.min_oversample) + + results = await asyncio.gather( + self.dense_retriever.retrieve(query, limit=oversample_limit, **kwargs), + self.sparse_retriever.retrieve(query, limit=oversample_limit, **kwargs), + return_exceptions=True, + ) + + for res in results: + if isinstance(res, ImportError): + raise res + + dense_res = ( + cast(list[SearchResult], results[0]) + if not isinstance(results[0], BaseException) + else [] + ) + sparse_res = ( + cast(list[SearchResult], results[1]) + if not isinstance(results[1], BaseException) + else [] + ) + + if isinstance(results[0], BaseException): + logger.error(f"[HybridSearch] 向量检索异常: {results[0]}") + if isinstance(results[1], BaseException): + logger.error(f"[HybridSearch] BM25 检索异常: {results[1]}") + + rrf_scores: dict[str, float] = {} + merged_records = {} + + for rank, res in enumerate(dense_res): + record_id = res.record.id + merged_records[record_id] = res.record + rrf_score = 1.0 / (self.rrf_k + rank + 1) + rrf_scores[record_id] = rrf_scores.get(record_id, 0.0) + ( + self.dense_weight * rrf_score + ) + + for rank, res in enumerate(sparse_res): + record_id = res.record.id + merged_records[record_id] = res.record + rrf_score = 1.0 / (self.rrf_k + rank + 1) + rrf_scores[record_id] = rrf_scores.get(record_id, 0.0) + ( + self.sparse_weight * rrf_score + ) + + max_possible_score = (self.dense_weight * (1.0 / (self.rrf_k + 1))) + ( + self.sparse_weight * (1.0 / (self.rrf_k + 1)) + ) + + final_results = [] + for record_id, score in sorted( + rrf_scores.items(), key=lambda x: x[1], reverse=True + ): + normalized_score = ( + score / max_possible_score if max_possible_score > 0 else 0.0 + ) + final_results.append( + SearchResult(record=merged_records[record_id], score=normalized_score) + ) + + logger.debug( + f"⚖️ [HybridSearch] 融合完成: " + f"Dense({len(dense_res)}) + Sparse({len(sparse_res)}) " + f"-> Merged({len(final_results)}), 截取 Top {limit}" + ) + return final_results[:limit] diff --git a/zhenxun/services/ai/context/rag/utils.py b/zhenxun/services/ai/context/rag/utils.py new file mode 100644 index 00000000..02c5ea33 --- /dev/null +++ b/zhenxun/services/ai/context/rag/utils.py @@ -0,0 +1,12 @@ +import numpy as np + + +def cosine_similarity(vec1: list[float], vec2: list[float]) -> float: + """使用 numpy 计算两组向量的余弦相似度""" + if not vec1 or not vec2 or len(vec1) != len(vec2): + return 0.0 + v1, v2 = np.array(vec1), np.array(vec2) + norm1, norm2 = np.linalg.norm(v1), np.linalg.norm(v2) + if norm1 == 0 or norm2 == 0: + return 0.0 + return float(np.dot(v1, v2) / (norm1 * norm2)) diff --git a/zhenxun/services/ai/core/__init__.py b/zhenxun/services/ai/core/__init__.py new file mode 100644 index 00000000..cae8ea30 --- /dev/null +++ b/zhenxun/services/ai/core/__init__.py @@ -0,0 +1,25 @@ +from .exceptions import LLMException +from .messages import ( + AgentEvent, + AgentMessage, + HandoffEvent, + LLMMessage, + TaskLifecycleEvent, +) +from .options import ( + GenerationConfig, +) +from .templates import ( + PromptTemplate, +) + +__all__ = [ + "AgentEvent", + "AgentMessage", + "GenerationConfig", + "HandoffEvent", + "LLMException", + "LLMMessage", + "PromptTemplate", + "TaskLifecycleEvent", +] diff --git a/zhenxun/services/ai/core/engine/append_only.py b/zhenxun/services/ai/core/engine/append_only.py new file mode 100644 index 00000000..1a68cd86 --- /dev/null +++ b/zhenxun/services/ai/core/engine/append_only.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass +import hashlib +import json +from typing import Any + +from zhenxun.utils.pydantic_compat import model_dump + + +@dataclass +class StablePrefixSnapshot: + """系统提示词与工具的稳定前缀快照""" + + system_prompt: list[str] + tools: list[Any] + fingerprint: str + + +class StablePrefix: + """ + 一个冻结 of 系统前缀(系统提示词 + 工具)。 + 通过比对特征指纹,在内容未改变时避免重新构建。 + """ + + def __init__(self): + self._snapshot: StablePrefixSnapshot | None = None + self._version = 0 + + @property + def fingerprint(self) -> str: + return self._snapshot.fingerprint if self._snapshot else "" + + @property + def version(self) -> int: + return self._version + + @property + def built(self) -> bool: + return self._snapshot is not None + + def build(self, system_prompt: list[str], tools: list[Any]) -> bool: + """ + 构建或重新构建前缀。 + 返回 True 表示内容发生实质变化(缓存可能失效),False 表示使用旧快照。 + """ + snapshot = self._take_snapshot(system_prompt, tools) + if self._snapshot and self._snapshot.fingerprint == snapshot.fingerprint: + return False + self._snapshot = snapshot + self._version += 1 + return True + + def invalidate(self): + self._snapshot = None + + def to_context(self) -> tuple[list[str], list[Any]]: + if not self._snapshot: + raise RuntimeError("StablePrefix.to_context() called before build()") + return self._snapshot.system_prompt, self._snapshot.tools + + def _take_snapshot( + self, system_prompt: list[str], tools: list[Any] + ) -> StablePrefixSnapshot: + parsed_tools = [] + for t in tools: + if hasattr(t, "name"): + parsed_tools.append((t.name, getattr(t, "description", ""))) + elif isinstance(t, dict): + parsed_tools.append((t.get("name", ""), t.get("description", ""))) + else: + parsed_tools.append(str(t)) + + payload = {"s": system_prompt, "t": parsed_tools} + json_str = json.dumps(payload, default=str, sort_keys=True) + fingerprint = hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8] + return StablePrefixSnapshot( + system_prompt=list(system_prompt), + tools=list(tools), + fingerprint=fingerprint, + ) + + +class AppendOnlyLog: + """追加写入模式 of 对话日志管理器""" + + def __init__(self): + self._entries: list[Any] = [] + + @property + def length(self) -> int: + return len(self._entries) + + def append(self, message: Any): + self._entries.append(message) + + def extend(self, messages: list[Any]): + self._entries.extend(messages) + + def clear(self): + self._entries.clear() + + def to_messages(self) -> list[Any]: + """返回浅拷贝 of 消息列表,防止外部意外修改""" + return list(self._entries) + + +class AppendOnlyContextManager: + """ + 为大模型 Prefix Cache 深度定制 of 上下文管理器。 + 将上下文拆分为绝对稳定 of Prefix (系统提示/工具) 和只增不减 of Log (对话历史)。 + """ + + def __init__(self): + self.prefix = StablePrefix() + self.log = AppendOnlyLog() + self._last_sync_count = 0 + self._synced_digest = 0 + + def build( + self, system_prompt: list[str], tools: list[Any] + ) -> tuple[list[str], list[Any], list[Any]]: + """装配并获取当前 of 完整上下文元组:(系统提示词, 历史消息, 工具)""" + self.prefix.build(system_prompt, tools) + sys_p, ts = self.prefix.to_context() + return sys_p, self.log.to_messages(), ts + + def sync_messages(self, normalized_messages: list[Any]): + """ + 同步消息游标。 + 通过滚动摘要算法(Rolling Digest)自动检测历史消息是否被就地篡改或截断。 + 如果是,则自动重置基线;否则执行极速追加写入。 + """ + if 0 < self._last_sync_count <= len(normalized_messages): + synced_part = normalized_messages[: self._last_sync_count] + if self._compute_digest(synced_part) != self._synced_digest: + self.log.clear() + self._last_sync_count = 0 + + if len(normalized_messages) < self._last_sync_count: + self.log.clear() + self._last_sync_count = 0 + + new_msgs = normalized_messages[self._last_sync_count :] + for msg in new_msgs: + self.log.append(msg) + + self._last_sync_count = len(normalized_messages) + self._synced_digest = self._compute_digest(normalized_messages) + + def invalidate(self): + """使前缀快照失效(通常在模型发生变更时调用)""" + self.prefix.invalidate() + + def reset_sync_cursor(self): + """强制重置对话历史游标和日志""" + self.log.clear() + self._last_sync_count = 0 + self._synced_digest = 0 + + def _compute_digest(self, messages: list[Any]) -> int: + """核心:计算消息列表 of 指纹,用于识别内容篡改。包含 role 与 content。""" + from zhenxun.services.ai.core.engine.context_renderer import ContextConverter + + payloads = [] + flattened = ContextConverter.flatten_to_llm_messages(messages) + for msg in flattened: + try: + d = model_dump(msg, include={"role", "content"}) + payloads.append(d) + except Exception: + payloads.append(str(msg)) + + def _default(obj): + if isinstance(obj, bytes): + return "" + return str(obj) + + json_str = json.dumps(payloads, default=_default, sort_keys=True) + return int(hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8], 16) diff --git a/zhenxun/services/ai/core/engine/context_renderer.py b/zhenxun/services/ai/core/engine/context_renderer.py new file mode 100644 index 00000000..aa17e2ec --- /dev/null +++ b/zhenxun/services/ai/core/engine/context_renderer.py @@ -0,0 +1,52 @@ +from collections.abc import Sequence +from typing import Any + +from zhenxun.services.ai.core.messages import AgentEvent, AgentMessage, LLMMessage +from zhenxun.services.log import logger + + +class ContextConverter: + """ + 上下文边界降维转换器。 + 负责将内存中混合了 AgentEvent 与 LLMMessage 的业务事件流, + 安全拍平为底层大模型可读的原生 API 载体。 + """ + + @staticmethod + def flatten_to_llm_messages( + messages: Sequence[AgentMessage], context: Any | None = None + ) -> list[LLMMessage]: + flattened: list[LLMMessage] = [] + + for msg in messages: + if isinstance(msg, LLMMessage): + flattened.append(msg) + elif isinstance(msg, AgentEvent): + try: + res = msg.to_llm_message(context) + if res is None: + continue + + if isinstance(res, str): + flattened.append(LLMMessage.system(res)) + elif isinstance(res, LLMMessage): + flattened.append(res) + elif isinstance(res, list): + flattened.extend(res) + else: + logger.warning( + f"事件 {msg.__class__.__name__} 的 to_llm_message " + f"返回了不支持的类型: {type(res)}" + ) + except Exception as e: + logger.error( + f"业务事件 [{msg.__class__.__name__}] " + f"在降维渲染为大模型 Prompt 时发生崩溃: {e}\n" + f"防呆拦截:请检查该事件 to_llm_message 方法的实现。" + ) + else: + logger.warning( + f"ContextConverter 遇到未知类型的消息,已跳过: {type(msg)}" + ) + + return flattened diff --git a/zhenxun/services/ai/core/engine/structured_parser.py b/zhenxun/services/ai/core/engine/structured_parser.py new file mode 100644 index 00000000..d33434f0 --- /dev/null +++ b/zhenxun/services/ai/core/engine/structured_parser.py @@ -0,0 +1,214 @@ +import types +from typing import Any, Generic, Union, cast, get_origin + +import json_repair +from nonebot.compat import type_validate_json +from pydantic import BaseModel, Field, ValidationError, create_model + +from zhenxun.services.ai.core.exceptions import ( + ControlFlowExit, + ModelRetry, + SchemaParseError, +) +from zhenxun.services.ai.core.models import ToolDefinition +from zhenxun.services.ai.run.models import OutputDataT +from zhenxun.services.ai.tools.core.tool import BaseTool +from zhenxun.services.ai.tools.models import StructuredSubmissionResult, ToolResult +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_json_schema, model_validate + +DEFAULT_IVR_TEMPLATE = ( + "### ❌ [输出内容或格式验证失败]\n" + "你的上一次输出未能通过系统的校验与规则检查。请立即启动修正流程:\n\n" + "**错误反馈报告:**\n" + "> {error_msg}\n\n" + "**修正要求:** 请结合反馈报告," + "仔细反思你的输出内容或格式,\n" + "并重新生成正确的数据以满足所有的规则与规范。" +) + + +class BaseOutputProcessor(Generic[OutputDataT]): + """ + 统一的结构化输出处理器。 + 负责管理 Schema 生成、Prompt 约束注入以及最终的 + JSON 反序列化和业务校验。 + """ + + def __init__( + self, + response_model: type[Any] | None = None, + error_template: str | None = None, + raw_schema: dict[str, Any] | None = None, + ): + """ + 初始化结构化输出处理器。 + + 参数: + response_model: 期望的输出目标 Pydantic 模型类或 Union 类型,默认 None。 + error_template: 当 JSON 解析或模型验证失败时,反馈给大模型的 IVR 纠错提示词模板,默认 None。 + raw_schema: 显式传入的原始 JSON Schema 字典, + 如果不为 None 则跳过根据 response_model 生成,默认 None。 + """ # noqa: E501 + self.original_model = response_model + self.error_template = error_template or DEFAULT_IVR_TEMPLATE + self.raw_schema = raw_schema + self.target_model = None + self.is_union_wrapped = False + + if response_model is not None: + self.target_model, self.is_union_wrapped = self._create_union_wrapper( + response_model + ) + + @staticmethod + def _create_union_wrapper(union_type: Any) -> tuple[type[BaseModel], bool]: + """[私有方法] 如果是 Union 类型, + 动态构建带 kind 区分字段的模型""" + origin = get_origin(union_type) + union_types = [Union] + if hasattr(types, "UnionType"): + union_types.append(types.UnionType) + + if origin not in union_types: + return union_type, False + + UnionWrapper = create_model( + "UnionResponseWrapper", + result=( + union_type, + Field(..., description="根据你的决策,输出对应的结构化数据"), + ), + ) + return UnionWrapper, True + + def get_json_schema(self) -> dict[str, Any]: + """提取目标模型的 JSON Schema""" + if self.raw_schema is not None: + return self.raw_schema + if self.target_model is None: + raise ValueError("未提供 response_model 或 raw_schema") + try: + return model_json_schema(self.target_model) + except AttributeError: + return self.target_model.schema() + + def _parse_and_validate(self, text: str) -> Any: + """[私有方法] 执行带有容错修复的 JSON 解析与模型验证""" + if self.raw_schema is not None: + import json + + try: + return json.loads(text) + except Exception: + try: + return json_repair.loads(text, skip_json_loads=True) + except Exception as repair_error: + raise SchemaParseError(f"JSON格式损坏: {repair_error}") + if self.target_model is None: + raise SchemaParseError("未提供 response_model 或 raw_schema") + try: + return type_validate_json(self.target_model, text) + except (ValidationError, ValueError) as e: + try: + logger.warning(f"标准JSON解析失败,尝试使用json_repair修复: {e}") + repaired_obj = json_repair.loads(text, skip_json_loads=True) + return model_validate(self.target_model, repaired_obj) + except Exception as repair_error: + logger.error( + f"LLM结构化输出校验最终失败: {repair_error}", + e=repair_error, + ) + raise SchemaParseError( + f"JSON格式损坏或字段不匹配,未能通过Schema验证: {repair_error}" + ) + except Exception as e: + logger.error(f"解析LLM结构化输出时发生未知错误: {e}", e=e) + raise SchemaParseError(f"解析LLM的JSON输出时失败: {e}") + + async def validate_and_parse(self, text: str, context: Any = None) -> OutputDataT: + """执行 JSON 解析与回调验证""" + try: + parsed_obj = self._parse_and_validate(text) + + current_obj = parsed_obj + + if getattr(self, "is_union_wrapped", False): + current_obj = getattr(current_obj, "result") + + final_obj = cast(OutputDataT, current_obj) + + return final_obj + except Exception as e: + raise e + + +class SubmitFinalResultExecutable(BaseTool): + """ + 动态生成的提交最终结果工具。 + 用于将大模型的结构化输出拦截并终止 AgentExecutor 的循环。 + """ + + def __init__( + self, + output_processor: BaseOutputProcessor, + guardrails: list[Any] | None = None, + ): + """ + 初始化提交最终结果的动态执行工具。 + + 参数: + output_processor: 绑定的结构化输出处理器,用于验证提交的最终结果。 + guardrails: 用于在结果输出前进行安全合规拦截的护栏中间件列表,默认 None。 + """ + super().__init__( + name="submit_final_result", + description=( + "当你完成所有必要的调查 and 思考后," + "必须且只能调用此工具来提交最终的结构化结果。" + "提交后任务将立刻结束。" + ), + ) + self.output_processor = output_processor + self.guardrails = guardrails or [] + + async def get_definition(self, context: Any | None = None) -> ToolDefinition | None: + if getattr(self, "_dynamic_def", None) is not None: + return self._dynamic_def + schema = self.output_processor.get_json_schema() + return ToolDefinition( + name=self.name, + description=self.description, + parameters=schema, + ) + + async def execute(self, context: Any | None = None, **kwargs) -> ToolResult: + parse_target = kwargs + if isinstance(kwargs, dict): + if "kwargs" in kwargs and len(kwargs) == 1: + parse_target = kwargs["kwargs"] + elif "result" in kwargs and len(kwargs) == 1: + parse_target = kwargs["result"] + + try: + json_str = __import__("json").dumps(parse_target, ensure_ascii=False) + final_obj = await self.output_processor.validate_and_parse( + json_str, context=context + ) + from zhenxun.services.ai.guardrails import GuardrailPipeline + + pipeline = GuardrailPipeline(self.guardrails) + json_str, final_obj = await pipeline.run_output_pipeline( + json_str, final_obj, context + ) + + return StructuredSubmissionResult( + output="结构化数据已成功提交", parsed_obj=final_obj + ) + except ControlFlowExit as e: + raise e + except ModelRetry as e: + raise e + except Exception as e: + error_msg = f"系统捕获到解析异常:\n{e}" + raise SchemaParseError(error_msg) diff --git a/zhenxun/services/ai/core/engine/token_counter.py b/zhenxun/services/ai/core/engine/token_counter.py new file mode 100644 index 00000000..f8845e50 --- /dev/null +++ b/zhenxun/services/ai/core/engine/token_counter.py @@ -0,0 +1,191 @@ +""" +LLM Token 动态预估与上下文管理模块 +""" + +from collections.abc import Sequence +import math +import re +from typing import Any + +from zhenxun.services.ai.core.messages import ( + AgentEvent, + AgentMessage, + AudioPart, + FilePart, + ImagePart, + LLMMessage, + TextPart, + ThoughtPart, + ToolCallPart, + ToolMessage, + ToolReturnPart, + UsageInfo, + VideoPart, +) + + +class TokenCounter: + """ + Token 计数器 + 基于确定性规则,摆脱外部库依赖,提供绝对稳定的 Token 消耗预估基线。 + """ + + @staticmethod + def _count_text(text: str) -> int: + """基于字符类型近似计算纯文本的 Token 消耗量。""" + if not text: + return 0 + cjk_chars = len(re.findall(r"[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]", text)) + ascii_chars = len(text) - cjk_chars + return math.ceil(cjk_chars * 1.2 + ascii_chars * 0.3) + + @staticmethod + def _count_image(resolution_hint: str | None, model_name: str) -> int: + """根据分辨率策略和模型厂商计算单张图片的 Token 消耗量。""" + if "gemini" in model_name.lower(): + res = (resolution_hint or "").upper() + if "ULTRA_HIGH" in res: + return 6192 + if "HIGH" in res: + return 3096 + if "LOW" in res: + return 258 + return 1032 + return 765 + + @classmethod + def count_tools_schema(cls, obj: dict | list | str | Any) -> int: + """递归计算 JSON Schema 结构在被大模型作为工具时的 Token 开销。""" + if isinstance(obj, dict): + cost = len(obj.keys()) * 12 + for k, v in obj.items(): + if k == "description" and isinstance(v, str): + cost += int(len(v) * 0.3) + else: + cost += cls.count_tools_schema(v) + return cost + elif isinstance(obj, list): + return sum(cls.count_tools_schema(item) for item in obj) + return 0 + + @classmethod + def count_message(cls, msg: LLMMessage, model_name: str) -> int: + """累加计算单条包含多模态片段和工具调用的消息 Token 总数。""" + if msg.token_cost is not None: + return msg.token_cost + + total_tokens = 4 + + if isinstance(msg, ToolMessage): + total_tokens += 40 + + if isinstance(msg.content, str): + total_tokens += cls._count_text(msg.content) + elif isinstance(msg.content, list): + for part in msg.content: + if isinstance(part, TextPart) and part.text: + total_tokens += cls._count_text(part.text) + elif isinstance(part, ImagePart): + total_tokens += cls._count_image( + getattr(part, "media_resolution", None), model_name + ) + elif isinstance(part, VideoPart | AudioPart | FilePart): + total_tokens += 1032 + elif isinstance(part, ThoughtPart) and part.thought_text: + total_tokens += cls._count_text(part.thought_text) + elif isinstance(part, ToolCallPart) and part.args: + total_tokens += cls._count_text(str(part.args)) + elif isinstance(part, ToolReturnPart) and part.output: + total_tokens += cls._count_text(str(part.output)) + + msg.token_cost = total_tokens + return total_tokens + + @classmethod + def count_context( + cls, messages: Sequence[AgentMessage], model_name: str, base_overhead: int = 0 + ) -> int: + """计算整个对话历史上下文的 Token 总和。""" + if not messages: + return base_overhead + + total = base_overhead + for msg in messages: + if isinstance(msg, AgentEvent): + try: + res = msg.to_llm_message(None) + if res is None: + continue + if isinstance(res, str): + total += cls.count_message(LLMMessage.system(res), model_name) + elif isinstance(res, list): + total += sum(cls.count_message(m, model_name) for m in res) + elif isinstance(res, LLMMessage): + total += cls.count_message(res, model_name) + except Exception: + pass + else: + total += cls.count_message(msg, model_name) + return total + + +token_counter = TokenCounter + + +def parse_usage_info(usage_info: dict | None) -> UsageInfo: + """ + 全协议统一遥测解析器 (Universal Telemetry Parser) + 兼容 OpenAI Standard、OpenAI Responses (v1/responses) 以及 Gemini (usageMetadata)。 + """ + if not usage_info or not isinstance(usage_info, dict): + return UsageInfo() + + prompt = 0 + completion = 0 + total = 0 + cache_hit = 0 + cache_miss = 0 + reasoning = 0 + + if "promptTokenCount" in usage_info or "candidatesTokenCount" in usage_info: + prompt = usage_info.get("promptTokenCount", 0) + completion = usage_info.get("candidatesTokenCount", 0) + total = usage_info.get("totalTokenCount", 0) + reasoning = usage_info.get("thoughtsTokenCount", 0) + cache_hit = usage_info.get("cachedContentTokenCount", 0) + + elif "input_tokens" in usage_info or "output_tokens" in usage_info: + prompt = usage_info.get("input_tokens", 0) + completion = usage_info.get("output_tokens", 0) + total = usage_info.get("total_tokens", 0) + cache_hit = (usage_info.get("input_tokens_details") or {}).get( + "cached_tokens", 0 + ) + reasoning = (usage_info.get("output_tokens_details") or {}).get( + "reasoning_tokens", 0 + ) + + else: + prompt = usage_info.get("prompt_tokens", 0) + completion = usage_info.get("completion_tokens", 0) + total = usage_info.get("total_tokens", 0) + + cache_hit = usage_info.get("prompt_cache_hit_tokens") or ( + usage_info.get("prompt_tokens_details") or {} + ).get("cached_tokens", 0) + cache_miss = usage_info.get("prompt_cache_miss_tokens", 0) + reasoning = (usage_info.get("completion_tokens_details") or {}).get( + "reasoning_tokens", 0 + ) + + if cache_miss == 0 and prompt > 0: + cache_miss = max(0, prompt - cache_hit) + + return UsageInfo( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=total, + prompt_cache_hit_tokens=cache_hit, + prompt_cache_miss_tokens=cache_miss, + reasoning_tokens=reasoning, + ) diff --git a/zhenxun/services/ai/core/exceptions.py b/zhenxun/services/ai/core/exceptions.py new file mode 100644 index 00000000..fdc326eb --- /dev/null +++ b/zhenxun/services/ai/core/exceptions.py @@ -0,0 +1,349 @@ +""" +自定义异常与错误码定义 +""" + +from typing import Any + + +class ModelRetry(Exception): + """用于通知大模型修正并重试的异常""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class SchemaParseError(ModelRetry): + """格式解析异常。当大模型返回的 JSON 损坏或不符合 Schema 时抛出。""" + + def __init__(self, message: str): + super().__init__(message) + + +class GuardrailViolationError(ModelRetry): + """护栏违规异常。当大模型返回的数据格式正确,但违反业务规则时抛出。""" + + def __init__(self, message: str): + super().__init__(message) + + +class ControlFlowExit(BaseException): + """控制流退出基类,继承自BaseException以避免被常规Exception捕获,用于静默中断。""" + + pass + + +class ToolFatalError(ControlFlowExit): + """ + 致命工具异常(不可恢复)。 + 当工具执行遇到权限不足、严重系统故障等大模型无法通过重试解决的问题时抛出。 + 这会直接熔断 Agent 推理流,并将 display_content 抛给用户。 + """ + + def __init__(self, message: str, display_content: str | None = None): + self.message = message + self.display_content = display_content or f"❌ 工具遇到致命错误: {message}" + super().__init__(self.message) + + +class GuardrailFatalException(ControlFlowExit): + """护栏致命拦截异常 (触发 ABORT/REJECT 时抛出)""" + + def __init__(self, guard_name: str, reason: str, display: str | None = None): + self.guard_name = guard_name + self.reason = reason + self.display = display or f"🛡️ 安全拦截: {reason}" + super().__init__(f"Guardrail '{guard_name}' aborted execution: {reason}") + + +class ToolRetryError(Exception): + """ + 可恢复工具异常。 + 当参数解析错误、业务逻辑校验失败、网络超时等问题发生时抛出。 + 会被 ToolExecutor 捕获并转化为引导大模型自我反思 (Reflexion) 的 ToolResult。 + """ + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + + +class ToolFinishException(ToolFatalError): + """ + 工具执行中止异常。 + 当工具开发者希望立刻停止大模型的思考循环,并直接将错误/提示信息返回给用户时抛出。 + 此异常不会被大模型进行"影子自愈(Reflexion)",而是直接熔断 Agent 执行流。 + """ + + def __init__(self, message: str, display_content: str | None = None): + super().__init__(message, display_content) + + +class AbortException(ControlFlowExit): + """异常中止当前 Agent 思考流。""" + + def __init__(self, reason: str, display: Any = None): + self.reason = reason + self.display = display + super().__init__(f"Aborted: {reason}") + + +class InterventionHandledException(ControlFlowExit): + """ + 干预成功处理异常。 + 当用户的消息被成功作为 STEER 或 FOLLOW_UP 注入到后台运行中的 Agent 队列时抛出, + 用于中断当前的新请求生命周期,避免重复启动。 + """ + + def __init__(self, message: str, display_content: str | None = None): + self.message = message + self.display_content = display_content + super().__init__(self.message) + + +class ConcurrencyRejectException(ControlFlowExit): + """并发拒绝异常。当 Agent 设置为 REJECT 且正在忙碌时抛出。""" + + def __init__(self, message: str, display: Any = None): + self.message = message + self.display = display or "⏳ 智能体正在处理您的上一个请求,请稍后再试~" + super().__init__(message) + + +class ConcurrencyInterruptException(ControlFlowExit): + """并发打断异常。当 Agent 设置为 INTERRUPT 且被新请求打断时抛出。""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class NeedsInputException(Exception): + """ + 当工具配置了 interactive=True 且缺少必要参数(或参数验证失败)时抛出此异常, + 用于交由外部中间件捕获并进行 HITL (Human-in-the-loop) 参数补充。 + """ + + def __init__( + self, missing_field: str, missing_description: str, original_kwargs: dict + ): + self.missing_field = missing_field + self.missing_description = missing_description + self.original_kwargs = original_kwargs + super().__init__( + f"Need input for parameter: {missing_field} - {missing_description}" + ) + + +class NeedsAuthException(Exception): + """ + 当工具在执行过程中发现授权失效或凭证过期时主动抛出。 + 用于交由外部中间件捕获并重新发起授权 (HITL) 流程。 + """ + + def __init__(self, provider: str, message: str): + self.provider = provider + self.message = message + super().__init__(f"Needs auth for: {provider} - {message}") + + +class SandboxPathEscapeError(Exception): + """当沙箱内的路径解析结果试图逃逸出允许的工作区根目录时抛出""" + + def __init__(self, path: str, resolved_path: str | None = None, reason: str = ""): + self.path = path + self.resolved_path = resolved_path + self.reason = reason + msg = f"沙箱路径逃逸拦截: {path}" + if resolved_path: + msg += f" (解析至 {resolved_path})" + if reason: + msg += f" - {reason}" + super().__init__(msg) + + +class WorkspaceIOError(Exception): + """沙箱文件系统读写操作失败""" + + def __init__(self, path: str, message: str, cause: Exception | None = None): + self.path = path + self.cause = cause + super().__init__(f"沙箱 IO 异常 [{path}]: {message}") + + +class SandboxFatalError(ToolFatalError): + """沙箱底层容器发生致命崩溃(如 OOM, 被宿主机强杀等)""" + + def __init__(self, message: str, display_content: str | None = None): + display = display_content or f"❌ 沙箱不可用: {message}" + super().__init__(message, display_content=display) + + +class LLMException(Exception): + """LLM 服务相关的基础异常类 (多态基类)""" + + def __init__( + self, + message: str, + details: dict[str, Any] | None = None, + cause: Exception | None = None, + ): + self.message = message + self.details = details or {} + self.cause = cause + super().__init__(message) + + @property + def is_retryable(self) -> bool: + """是否允许在当前节点进行退避重试(如偶发网络抖动)""" + return False + + @property + def should_failover(self) -> bool: + """是否允许触发节点故障转移(切换到下一个备用模型)""" + return False + + @property + def should_rotate_key(self) -> bool: + """是否应该标记当前 Key 失效并轮换 API Key""" + return False + + @property + def user_friendly_message(self) -> str: + """返回适合向用户展示的错误消息""" + return "AI服务暂时不可用,请稍后再试。" + + def __str__(self) -> str: + if self.details: + safe_details = {k: v for k, v in self.details.items() if k != "api_key"} + if safe_details: + return f"{self.message} (详情: {safe_details})" + return self.message + + +class InvalidRequestException(LLMException): + @property + def user_friendly_message(self) -> str: + return "请求参数错误或API类型不支持,请检查输入内容。" + + +class ContextLengthExceededException(LLMException): + @property + def user_friendly_message(self) -> str: + return "输入内容过长,请缩短后重试。" + + +class ContentFilteredException(LLMException): + @property + def user_friendly_message(self) -> str: + return "内容被安全过滤,请修改后重试。" + + +class ConfigurationException(LLMException): + @property + def user_friendly_message(self) -> str: + return "AI模型配置错误或未找到,请联系管理员检查配置。" + + +class AuthenticationException(LLMException): + @property + def should_rotate_key(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "API密钥无效或权限不足,请联系管理员更新配置。" + + +class QuotaExceededException(LLMException): + @property + def should_rotate_key(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "API使用配额已用尽,请稍后再试或联系管理员。" + + +class LocationNotSupportedException(LLMException): + @property + def should_failover(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return ( + "当前网络环境不支持此 AI 模型。\n" + "建议: 请尝试更换代理节点至支持的地区或切换备用模型。" + ) + + +class RateLimitException(LLMException): + @property + def is_retryable(self) -> bool: + return True + + @property + def should_rotate_key(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "请求过于频繁,已被AI服务限流,请稍后再试。" + + +class UpstreamServerException(LLMException): + @property + def is_retryable(self) -> bool: + return True + + @property + def should_failover(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "AI服务响应异常或端点宕机,请稍后再试。" + + +class NetworkTimeoutException(LLMException): + @property + def is_retryable(self) -> bool: + return True + + @property + def should_failover(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "AI服务请求超时,请稍后再试。" + + +class ResponseParseException(LLMException): + @property + def is_retryable(self) -> bool: + return True + + @property + def user_friendly_message(self) -> str: + return "AI服务响应解析失败,请稍后再试。" + + +def get_user_friendly_error_message(error: Exception) -> str: + """将任何异常转换为用户友好的错误消息""" + if isinstance(error, LLMException): + return error.user_friendly_message + + error_str = str(error).lower() + + if "timeout" in error_str or "timed out" in error_str: + return "网络请求超时,请检查服务器网络或代理连接。" + if "connect" in error_str and ("refused" in error_str or "error" in error_str): + return "无法连接到 AI 服务商,请检查网络连接或代理设置。" + if "proxy" in error_str: + return "代理连接失败,请检查代理服务器是否正常运行。" + if "ssl" in error_str or "certificate" in error_str: + return "SSL 证书验证失败,请检查网络环境。" + + return f"服务暂时不可用 ({type(error).__name__}),请稍后再试。" diff --git a/zhenxun/services/ai/core/messages/__init__.py b/zhenxun/services/ai/core/messages/__init__.py new file mode 100644 index 00000000..4fc4a55a --- /dev/null +++ b/zhenxun/services/ai/core/messages/__init__.py @@ -0,0 +1,131 @@ +""" +消息与响应域类型定义 - 统一导出门面 +""" + +from nonebot.compat import PYDANTIC_V2 + +from .context_events import ( + AgentEvent, + HandoffEvent, + TaskLifecycleEvent, +) +from .models import ( + AssistantMessage, + LLMMessage, + SystemMessage, + ToolMessage, + UserMessage, +) +from .parts import ( + AudioPart, + BaseContentPart, + EmbedBatch, + EmbedPayload, + FilePart, + ImagePart, + LLMContentPart, + TextDeltaPart, + TextPart, + ThoughtDeltaPart, + ThoughtPart, + ToolCallDeltaPart, + ToolCallPart, + ToolReturnPart, + VideoPart, +) +from .requests import ( + BaseRequest, + ChatRequest, + EmbeddingRequest, + ImageRequest, + RerankRequest, + SpeechRequest, +) +from .responses import ( + AudioResponse, + ChatResponse, + EmbeddingResponse, + ImageResponse, + RerankResponse, +) +from .shared import ( + LLMCodeExecution, + LLMGroundingAttribution, + LLMGroundingMetadata, + RerankDocument, + RerankResult, + UsageInfo, +) +from .types import ( + AgentMessage, + AnyLLMMessage, + AssistantContentUnion, + ContentT, + PromptInput, + RoleT, + SystemContentUnion, + ToolContentUnion, + UserContentUnion, +) + +if PYDANTIC_V2: + ChatResponse.model_rebuild() + LLMMessage.model_rebuild() + SystemMessage.model_rebuild() + UserMessage.model_rebuild() + AssistantMessage.model_rebuild() + ToolMessage.model_rebuild() + RerankResponse.model_rebuild() + + +__all__ = [ + "AgentEvent", + "AgentMessage", + "AnyLLMMessage", + "AssistantContentUnion", + "AssistantMessage", + "AudioPart", + "AudioResponse", + "BaseContentPart", + "BaseRequest", + "ChatRequest", + "ChatResponse", + "ContentT", + "EmbedBatch", + "EmbedPayload", + "EmbeddingRequest", + "EmbeddingResponse", + "FilePart", + "HandoffEvent", + "ImagePart", + "ImageRequest", + "ImageResponse", + "LLMCodeExecution", + "LLMContentPart", + "LLMGroundingAttribution", + "LLMGroundingMetadata", + "LLMMessage", + "PromptInput", + "RerankDocument", + "RerankRequest", + "RerankResponse", + "RerankResult", + "RoleT", + "SpeechRequest", + "SystemContentUnion", + "SystemMessage", + "TaskLifecycleEvent", + "TextDeltaPart", + "TextPart", + "ThoughtDeltaPart", + "ThoughtPart", + "ToolCallDeltaPart", + "ToolCallPart", + "ToolContentUnion", + "ToolMessage", + "ToolReturnPart", + "UsageInfo", + "UserContentUnion", + "UserMessage", + "VideoPart", +] diff --git a/zhenxun/services/ai/core/messages/context_events.py b/zhenxun/services/ai/core/messages/context_events.py new file mode 100644 index 00000000..345ee8ec --- /dev/null +++ b/zhenxun/services/ai/core/messages/context_events.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from .models import LLMMessage + + +class AgentEvent(BaseModel): + """ + 业务事件抽象基类/协议。 + 支持作为一种特殊的消息,直接被混入到大模型的上下文(记忆)中。 + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore + + def to_llm_message( + self, context: Any | None = None + ) -> LLMMessage | list[LLMMessage] | str | None: + """ + 将业务事件渲染为大模型能看懂的 API 原生消息。 + 子类必须重写此方法。 + 返回 None 代表此事件对大模型不可见(例如:纯后台打点或审计日志)。 + 如果返回 str,系统将默认包装为 SystemMessage 发送给大模型。 + """ + return None + + +class TaskLifecycleEvent(AgentEvent): + """内置业务事件:任务状态打点追踪""" + + task_name: str + """任务名称""" + action: Literal["start", "complete", "fail"] + """任务状态动作,支持 "start"(开始)、"complete"(完成)、"fail"(失败)""" + error_msg: str | None = None + """任务执行失败时的具体错误描述,可选""" + + def to_llm_message(self, context: Any | None = None) -> str | None: + if self.action == "start": + return f"[任务生命周期] 开始执行任务:{self.task_name}" + elif self.action == "complete": + return f"[任务生命周期] 任务已完美达成:{self.task_name}" + elif self.action == "fail": + return ( + f"[任务生命周期] 任务执行失败:{self.task_name},原因:{self.error_msg}" + ) + return None + + +class HandoffEvent(AgentEvent): + """内置业务事件:控制权移交记录""" + + target: str + """目标接收节点或 Agent 的名称""" + reason: str + """移交控制权的具体原因说明""" + context_data: Any = None + """移交时附带的上下文数据,默认为 None""" + + def to_llm_message(self, context: Any | None = None) -> str | None: + return ( + f"[控制权移交] 任务及会话控制权已被系统转移至节点 " + f"'{self.target}'。移交原因:{self.reason}" + ) + + +__all__ = [ + "AgentEvent", + "HandoffEvent", + "TaskLifecycleEvent", +] diff --git a/zhenxun/services/ai/core/messages/models.py b/zhenxun/services/ai/core/messages/models.py new file mode 100644 index 00000000..f6ff2446 --- /dev/null +++ b/zhenxun/services/ai/core/messages/models.py @@ -0,0 +1,366 @@ +""" +标准消息实体 - 依赖 parts.py +""" + +from __future__ import annotations + +import base64 +from collections.abc import Sequence +import time +from typing import Annotated, Any, Generic, Literal, cast +from typing_extensions import Self, TypeVar + +from pydantic import BaseModel, Field + +from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validator + +from .parts import ( + BaseContentPart, + FilePart, + ImagePart, + LLMContentPart, + TextPart, + ToolCallPart, + ToolReturnPart, +) + +RoleT = TypeVar("RoleT", default=str, covariant=True) +"""泛型:消息参与者角色类型变量""" + +ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True) +"""泛型:多模态片段数组的元素内容类型变量""" + + +class LLMMessage(BaseModel, Generic[RoleT, ContentT]): + """ + LLM 消息基类与门面工厂。 + 提供统一的元数据访问、魔法加法重载以及极简实例化方法。 + """ + + role: RoleT + """消息参与者角色 (如 user, assistant, system, tool)""" + content: list[ContentT] = Field(default_factory=list) + """容纳实际数据的多模态片段数组""" + created_at: float = Field(default_factory=time.time) + """消息最初被构建的 Unix 时间戳""" + metadata: dict[str, Any] | None = Field(default=None) + """自由存取字典,供系统内穿透传递额外状态数据""" + + @property + def tool_calls(self) -> list[ToolCallPart]: + """获取当前消息中包含的所有工具调用请求片段""" + return [p for p in self.content if isinstance(p, ToolCallPart)] + + @property + def tool_returns(self) -> list[ToolReturnPart]: + """获取当前消息中包含的所有工具执行结果片段""" + return [p for p in self.content if isinstance(p, ToolReturnPart)] + + @model_validator(mode="before") + @classmethod + def _normalize_content(cls, data: Any) -> Any: + """核心拦截:外部传入 str 时自动转为 Part,保持内部类型绝对纯净""" + if isinstance(data, dict): + content = data.get("content") + if isinstance(content, str): + data["content"] = ( + [{"type": "text", "text": content}] if content.strip() else [] + ) + elif content is None: + data["content"] = [] + elif isinstance(content, list): + new_content = [] + for item in content: + if isinstance(item, str): + new_content.append({"type": "text", "text": item}) + else: + new_content.append(item) + data["content"] = new_content + elif not isinstance(content, list): + import json + + try: + text_val = json.dumps(content, ensure_ascii=False) + except Exception: + text_val = str(content) + data["content"] = [{"type": "text", "text": text_val}] + return data + + def __add__(self, other: str | LLMContentPart | "LLMMessage") -> Self: + """极简语法糖:支持通过加号拼接文本或多模态片段。 + 示例: msg = LLMMessage.user("查看图片:") + ImagePart(url="...") + """ + new_msg = cast(Self, model_copy(self, deep=True)) + if isinstance(other, str): + new_msg.content.append(cast(Any, TextPart(text=other))) + elif isinstance(other, BaseContentPart): + new_msg.content.append(cast(Any, other)) + elif isinstance(other, LLMMessage): + new_msg.content.extend(cast(Any, other.content)) + return new_msg + + @property + def extract_text(self) -> str: + """便捷属性:提取当前消息中所有的纯文本""" + return "".join(p.text for p in self.content if isinstance(p, TextPart)) + + @property + def source(self) -> str | None: + """获取消息的来源标识 (存取于 metadata 中)""" + return self.metadata.get("source") if self.metadata else None + + @source.setter + def source(self, value: str | None): + """设置消息的来源标识""" + if self.metadata is None: + self.metadata = {} + self.metadata["source"] = value + + @property + def source_name(self) -> str | None: + """获取消息来源的具体名称 (例如真实用户的昵称)""" + return self.metadata.get("source_name") if self.metadata else None + + @source_name.setter + def source_name(self, value: str | None): + """设置消息来源的具体名称""" + if self.metadata is None: + self.metadata = {} + self.metadata["source_name"] = value + + @property + def scope(self) -> str | None: + """获取该消息所绑定的作用域或会话ID""" + return self.metadata.get("scope") if self.metadata else None + + @scope.setter + def scope(self, value: str | None): + """设置该消息的作用域""" + if self.metadata is None: + self.metadata = {} + self.metadata["scope"] = value + + @property + def thought_signature(self) -> str | None: + """获取连续对话中用于保持思考一致性的加密签名 (Gemini 专属)""" + return self.metadata.get("thought_signature") if self.metadata else None + + @thought_signature.setter + def thought_signature(self, value: str | None): + """设置思考加密签名""" + if self.metadata is None: + self.metadata = {} + self.metadata["thought_signature"] = value + + @property + def token_cost(self) -> int | None: + """获取该消息自身消耗的预估或真实 Token 数""" + return self.metadata.get("token_cost") if self.metadata else None + + @token_cost.setter + def token_cost(self, value: int | None): + """设置 Token 消耗数""" + if self.metadata is None: + self.metadata = {} + self.metadata["token_cost"] = value + + @classmethod + def user( + cls, + content: str | Sequence[Any], + source: str | None = None, + source_name: str | None = None, + scope: str | None = None, + ) -> "UserMessage": + """ + 工厂方法:创建一条 User (用户) 角色的消息。 + + 参数: + content: 消息内容,支持纯字符串或多模态片段数组。 + source: 可选,消息来源标识。 + source_name: 可选,消息来源的可读名称。 + scope: 可选,消息关联的会话作用域。 + """ + if isinstance(content, str): + content = [TextPart(text=content)] if content.strip() else [] + elif not isinstance(content, list): + content = list(content) + msg = UserMessage(content=cast(Any, content)) + msg.source = source + msg.source_name = source_name + msg.scope = scope + return msg + + @classmethod + def assistant_tool_calls( + cls, + tool_calls: list[ToolCallPart], + content: str | Sequence[Any] = "", + scope: str | None = None, + ) -> "AssistantMessage": + """ + 工厂方法:创建一条包含工具调用请求的 Assistant (助手) 角色消息。 + + 参数: + tool_calls: 大模型发出的工具调用片段列表。 + content: 伴随工具调用的其他文本或思维过程。 + scope: 可选,消息关联的会话作用域。 + """ + if isinstance(content, str): + _content: list[Any] = [TextPart(text=content)] if content else [] + else: + _content = list(content) + _content.extend(tool_calls) + msg = AssistantMessage(content=cast(Any, _content)) + msg.scope = scope + return msg + + @classmethod + def assistant_text_response( + cls, + content: str | Sequence[Any], + scope: str | None = None, + ) -> "AssistantMessage": + """ + 工厂方法:创建一条仅包含普通文本/思维过程的 Assistant (助手) 角色消息。 + + 参数: + content: 大模型生成的文本回复内容。 + scope: 可选,消息关联的会话作用域。 + """ + if isinstance(content, str): + content = [TextPart(text=content)] if content and content.strip() else [] + elif not isinstance(content, list): + content = list(content) + msg = AssistantMessage(content=cast(Any, content)) + msg.scope = scope + return msg + + def add_text(self, text: str) -> Self: + """链式添加文本内容""" + self.content.append(cast(Any, TextPart(text=text))) + return self + + def add_image_url(self, url: str) -> Self: + """链式添加网络图片""" + self.content.append(cast(Any, ImagePart(url=url))) + return self + + def add_image_base64(self, b64_data: str, mime_type: str = "image/png") -> Self: + """链式添加 Base64 图片""" + self.content.append( + cast(Any, ImagePart(raw=base64.b64decode(b64_data), mime_type=mime_type)) + ) + return self + + def add_file_url(self, url: str, mime_type: str | None = None) -> Self: + """链式添加文件链接""" + self.content.append(cast(Any, FilePart(url=url, mime_type=mime_type))) + return self + + @classmethod + def tool_response( + cls, + tool_call_id: str, + function_name: str, + result: Any, + scope: str | None = None, + ) -> "ToolMessage": + """ + 工厂方法:创建一条 Tool (工具) 角色消息,用于承载工具执行完毕后的返回结果。 + + 参数: + tool_call_id: 对应的大模型发出调用请求时的 ID。 + function_name: 执行的工具名称。 + result: 工具执行的结果负载 (会被自动 JSON 序列化)。 + scope: 可选,消息关联的会话作用域。 + """ + _content = [ + ToolReturnPart( + tool_call_id=tool_call_id, tool_name=function_name, output=result + ) + ] + msg = ToolMessage(content=cast(Any, _content)) + msg.scope = scope + return msg + + @classmethod + def system( + cls, + content: str | Sequence[Any], + scope: str | None = None, + ) -> "SystemMessage": + """ + 工厂方法:创建一条 System (系统) 角色的设定消息。 + + 参数: + content: 系统的 Prompt 指令。 + scope: 可选,消息关联的会话作用域。 + """ + if isinstance(content, str): + content = [TextPart(text=content)] if content.strip() else [] + elif not isinstance(content, list): + content = list(content) + msg = SystemMessage(content=cast(Any, content)) + msg.scope = scope + return msg + + def to_storage_dict(self) -> dict[str, Any]: + """数据库瘦身存储,仅保留核心字段""" + return model_dump( + self, + exclude_none=True, + include={ + "role", + "content", + "metadata", + }, + ) + + +class SystemMessage(LLMMessage[Literal["system"], TextPart]): + """系统消息:通常用于在对话开头向模型提供系统级指令 (System Prompt)、 + 角色设定或背景上下文。""" + + role: Literal["system"] = "system" + content: list[Annotated[TextPart, Field(discriminator="type")]] = Field( + default_factory=list + ) + + +class UserMessage(LLMMessage[Literal["user"], LLMContentPart]): + """用户消息:代表来自最终用户或外部触发源的输入,支持包含文本、图片、文件等多模态数据。""" + + role: Literal["user"] = "user" + content: list[LLMContentPart] = Field(default_factory=list) + + +class AssistantMessage(LLMMessage[Literal["assistant"], LLMContentPart]): + """助手消息:代表大模型 (AI) 生成的回复。 + 可能包含纯文本、思维链过程或工具调用请求。""" + + role: Literal["assistant"] = "assistant" + content: list[LLMContentPart] = Field(default_factory=list) + + +class ToolMessage(LLMMessage[Literal["tool"], LLMContentPart]): + """工具消息:用于承载由用户端执行工具后,将结果返回给大模型的消息容器。""" + + role: Literal["tool"] = "tool" + content: list[LLMContentPart] = Field(default_factory=list) + + def model_post_init(self, context: Any, /) -> None: + """验证消息的有效性""" + if not self.tool_returns: + raise ValueError("工具角色的消息必须包含 ToolReturnPart") + + +__all__ = [ + "AssistantMessage", + "ContentT", + "LLMMessage", + "RoleT", + "SystemMessage", + "ToolMessage", + "UserMessage", +] diff --git a/zhenxun/services/ai/core/messages/parts.py b/zhenxun/services/ai/core/messages/parts.py new file mode 100644 index 00000000..f7030370 --- /dev/null +++ b/zhenxun/services/ai/core/messages/parts.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Annotated, Any, Literal +from typing_extensions import Self + +from pydantic import BaseModel, Field + +from zhenxun.utils.pydantic_compat import model_validator + + +class BaseContentPart(BaseModel): + """多态消息内容的底层基类""" + + metadata: dict[str, Any] | None = Field(default=None) + """该部件的内部元数据,提供如 thought_signature、解析结果等非展示用数据""" + + @classmethod + def text_part(cls, text: str) -> "TextPart": + """创建一个纯文本片段""" + return TextPart(text=text) + + @classmethod + def thought_part(cls, text: str) -> "ThoughtPart": + """创建一个思维链 (CoT) 思考片段""" + return ThoughtPart(thought_text=text) + + @classmethod + def image_url_part(cls, url: str) -> "ImagePart": + """创建一个基于外网 URL 的图片片段""" + return ImagePart(url=url) + + @classmethod + def image_base64_part(cls, data: str, mime_type: str = "image/png") -> "ImagePart": + """创建一个基于 Base64 编码数据的图片片段""" + return ImagePart(raw=base64.b64decode(data), mime_type=mime_type) + + @classmethod + def audio_url_part(cls, url: str, mime_type: str = "audio/wav") -> "AudioPart": + """创建一个基于外网 URL 的音频片段""" + return AudioPart(url=url, mime_type=mime_type) + + @classmethod + def video_url_part(cls, url: str, mime_type: str = "video/mp4") -> "VideoPart": + """创建一个基于外网 URL 的视频片段""" + return VideoPart(url=url, mime_type=mime_type) + + @classmethod + def video_base64_part(cls, data: str, mime_type: str = "video/mp4") -> "VideoPart": + """创建一个基于 Base64 编码数据的视频片段""" + return VideoPart(raw=base64.b64decode(data), mime_type=mime_type) + + @classmethod + def audio_base64_part(cls, data: str, mime_type: str = "audio/wav") -> "AudioPart": + """创建一个基于 Base64 编码数据的音频片段""" + return AudioPart(raw=base64.b64decode(data), mime_type=mime_type) + + @classmethod + def file_uri_part( + cls, + file_uri: str, + mime_type: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> "FilePart": + """创建一个基于云端 URI (如 Google Cloud Storage gs://) 的通用文件片段""" + return FilePart(url=file_uri, mime_type=mime_type, metadata=metadata or {}) + + @classmethod + def tool_call_part( + cls, id: str, tool_name: str, args: dict[str, Any] | str + ) -> "ToolCallPart": + """创建一个工具调用请求片段""" + return ToolCallPart(id=id, tool_name=tool_name, args=args) + + @classmethod + def tool_return_part(cls, call_id: str, name: str, result: Any) -> "ToolReturnPart": + """创建一个工具执行结果片段""" + return ToolReturnPart( + tool_call_id=call_id, + tool_name=name, + output=result, + ) + + async def get_raw_bytes(self) -> bytes: + """统一获取多模态原始字节数据 (自动适配 raw、本地 path 或自动下载公网 url)""" + raw_data = getattr(self, "raw", None) + if isinstance(raw_data, bytes): + return raw_data + + path_data = getattr(self, "path", None) + if path_data is not None: + from pathlib import Path + + if isinstance(path_data, Path): + return path_data.read_bytes() + + url_data = getattr(self, "url", None) + if isinstance(url_data, str): + from zhenxun.utils.http_utils import AsyncHttpx + + content = await AsyncHttpx.get_content(url_data) + if isinstance(content, bytes): + return content + return b"" + + raise ValueError( + f"{self.__class__.__name__} 未提供有效的数据源 (url, raw, path)" + ) + + async def get_base64_data(self) -> str: + """统一获取 Base64 编码的字符串数据""" + return base64.b64encode(await self.get_raw_bytes()).decode("utf-8") + + async def get_data_uri(self, default_mime: str = "application/octet-stream") -> str: + """统一获取 Data URI (data:mime;base64,...) 格式的字符串""" + mime = getattr(self, "mime_type", None) or default_mime + return f"data:{mime};base64,{await self.get_base64_data()}" + + +class ImagePart(BaseContentPart): + """图片媒体片段""" + + type: Literal["image"] = "image" + url: str | None = None + """外网图片 URL (需能直接访问)""" + raw: bytes | None = None + """图片二进制裸数据""" + path: Path | None = None + """本地文件系统中的图片路径""" + mime_type: str | None = None + """图片 MIME 类型 (如 image/jpeg)""" + media_resolution: str | None = None + """媒体处理强制分辨率策略""" + + @model_validator(mode="after") + def _validate_source(self) -> Self: + sources = [s for s in (self.url, self.raw, self.path) if s is not None] + if len(sources) != 1: + raise ValueError("ImagePart 必须且只能提供 url, raw, path 中的一个") + return self + + +class AudioPart(BaseContentPart): + """音频媒体片段""" + + type: Literal["audio"] = "audio" + url: str | None = None + """外网音频 URL""" + raw: bytes | None = None + """音频二进制裸数据""" + path: Path | None = None + """本地文件系统中的音频路径""" + mime_type: str | None = None + """音频 MIME 类型 (如 audio/mp3)""" + + @model_validator(mode="after") + def _validate_source(self) -> Self: + sources = [s for s in (self.url, self.raw, self.path) if s is not None] + if len(sources) != 1: + raise ValueError("AudioPart 必须且只能提供 url, raw, path 中的一个") + return self + + +class VideoPart(BaseContentPart): + """视频媒体片段""" + + type: Literal["video"] = "video" + url: str | None = None + """外网视频 URL""" + raw: bytes | None = None + """视频二进制裸数据""" + path: Path | None = None + """本地文件系统中的视频路径""" + mime_type: str | None = None + """视频 MIME 类型 (如 video/mp4)""" + + @model_validator(mode="after") + def _validate_source(self) -> Self: + sources = [s for s in (self.url, self.raw, self.path) if s is not None] + if len(sources) != 1: + raise ValueError("VideoPart 必须且只能提供 url, raw, path 中的一个") + return self + + +class FilePart(BaseContentPart): + """通用文件片段 (如 PDF、代码文件等)""" + + type: Literal["file"] = "file" + url: str | None = None + """外网文件 URL (或 Google Cloud gs:// URI)""" + raw: bytes | None = None + """文件二进制裸数据""" + path: Path | None = None + """本地文件系统中的文件路径""" + mime_type: str | None = None + """文件 MIME 类型 (如 application/pdf)""" + + @model_validator(mode="after") + def _validate_source(self) -> Self: + sources = [s for s in (self.url, self.raw, self.path) if s is not None] + if len(sources) != 1: + raise ValueError("FilePart 必须且只能提供 url, raw, path 中的一个") + return self + + +class TextPart(BaseContentPart): + """基础文本片段""" + + type: Literal["text"] = "text" + text: str + """具体的纯文本内容""" + + +class ThoughtPart(BaseContentPart): + """思考链 (Chain of Thought) 片段,用于容纳模型的内部思维过程""" + + type: Literal["thought"] = "thought" + thought_text: str + """思考过程的文本""" + + +class ToolCallPart(BaseContentPart): + """工具调用请求片段 (由模型发出)""" + + type: Literal["tool_call"] = "tool_call" + id: str + """工具调用唯一 ID""" + tool_name: str + """调用的函数名称""" + args: dict[str, Any] | str + """传递给工具的参数 (解析后的字典或原始 JSON 字符串)""" + + +class ToolReturnPart(BaseContentPart): + """工具调用结果片段 (发给模型)""" + + type: Literal["tool_return"] = "tool_return" + tool_call_id: str + """关联的原始调用 ID""" + tool_name: str + """执行的工具名称""" + output: Any + """工具执行结果的有效载荷""" + + +class TextDeltaPart(BaseModel): + """流式文本增量片段""" + + type: Literal["text_delta"] = "text_delta" + content_delta: str + """流式返回的文本增量字符串""" + + +class ThoughtDeltaPart(BaseModel): + """流式思考链增量片段""" + + type: Literal["thought_delta"] = "thought_delta" + content_delta: str + """流式返回的思考过程增量字符串""" + + +class ToolCallDeltaPart(BaseModel): + """流式工具调用增量片段""" + + type: Literal["tool_call_delta"] = "tool_call_delta" + tool_call_id: str | None = None + """(可选) 流式返回的工具调用唯一ID""" + tool_name_delta: str | None = None + """(可选) 流式返回的工具名称增量""" + args_delta: str | None = None + """(可选) 流式返回的 JSON 格式参数增量""" + + +LLMContentPart = Annotated[ + TextPart + | ImagePart + | AudioPart + | VideoPart + | FilePart + | ThoughtPart + | ToolCallPart + | ToolReturnPart, + Field(discriminator="type"), +] +"""大模型底层标准内容片段的 Annotated 联合类型""" + + +class EmbedPayload(BaseModel): + """单一的嵌入载体,包含一个或多个多模态片段(融合向量)""" + + parts: list[LLMContentPart] = Field(default_factory=list) + + @property + def text(self) -> str: + """快速提取纯文本(用于向下兼容不支持多模态的模型)""" + return "".join(p.text for p in self.parts if isinstance(p, TextPart)).strip() + + @property + def has_multimodal(self) -> bool: + """判断是否包含图像/音频/视频/文件等非文本模态""" + return any(not isinstance(p, TextPart) for p in self.parts) + + +class EmbedBatch(BaseModel): + """一次嵌入 API 请求的批次载体""" + + payloads: list[EmbedPayload] = Field(default_factory=list) + + def to_text_only(self, context_name: str) -> list[str]: + """降级工具:将多模态的批量向量安全剔除图片等内容,回退为纯文本数组""" + from zhenxun.services.log import logger + + texts = [] + for payload in self.payloads: + if payload.has_multimodal: + logger.warning( + f"⚠️ 模型 {context_name} " + "不支持多模态嵌入,已自动剔除富媒体内容,静默降级为纯文本进行向量化..." + ) + texts.append(payload.text if payload.text else " ") + return texts + + +__all__ = [ + "AudioPart", + "BaseContentPart", + "EmbedBatch", + "EmbedPayload", + "FilePart", + "ImagePart", + "LLMContentPart", + "TextDeltaPart", + "TextPart", + "ThoughtDeltaPart", + "ThoughtPart", + "ToolCallDeltaPart", + "ToolCallPart", + "ToolReturnPart", + "VideoPart", +] diff --git a/zhenxun/services/ai/core/messages/requests.py b/zhenxun/services/ai/core/messages/requests.py new file mode 100644 index 00000000..8e557ace --- /dev/null +++ b/zhenxun/services/ai/core/messages/requests.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.core.models import ToolChoice +from zhenxun.services.ai.core.options import ( + GenerationConfig, + LLMEmbeddingConfig, + TTSConfig, +) +from zhenxun.utils.pydantic_compat import model_dump + +from .models import LLMMessage +from .parts import EmbedBatch + + +class BaseRequest(BaseModel): + """基础请求 DTO""" + + timeout: float | None = Field(default=None) + extra: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def get_cache_hash_payload(self) -> dict[str, Any]: + """获取用于计算缓存 Hash 的安全载荷,排除所有运行时动态变量""" + request_dict = model_dump(self, exclude_none=True) + + if "config" in request_dict and isinstance(request_dict["config"], dict): + custom_kwargs = request_dict["config"].get("custom_kwargs", {}) + if "__cache_ttl__" in custom_kwargs: + custom_kwargs.pop("__cache_ttl__") + + if "extra" in request_dict: + request_dict["extra"] = { + k: v + for k, v in request_dict["extra"].items() + if not k.startswith("_") + and k not in ("run_context", "output_processor", "guardrails") + } + return request_dict + + +class ChatRequest(BaseRequest): + """对话生成请求 DTO""" + + messages: list[LLMMessage] + config: GenerationConfig | None = None + tools: list[Any] | None = None + tool_choice: str | dict[str, Any] | ToolChoice | None = None + + def get_cache_hash_payload(self) -> dict[str, Any]: + payload = super().get_cache_hash_payload() + for msg in payload.get("messages", []): + msg.pop("created_at", None) + msg.pop("token_cost", None) + msg.pop("metadata", None) + + if "tools" in payload and isinstance(payload["tools"], list): + safe_tools = [] + for t in payload["tools"]: + if isinstance(t, dict | str): + safe_tools.append(t) + else: + safe_tools.append(getattr(t, "name", type(t).__name__)) + payload["tools"] = safe_tools + return payload + + +class EmbeddingRequest(BaseRequest): + """向量嵌入请求 DTO""" + + batch: EmbedBatch + """向量嵌入的批次载体""" + config: LLMEmbeddingConfig | None = None + """向量嵌入配置""" + + +class ImageRequest(BaseRequest): + """图像生成请求 DTO""" + + prompt: str + """图像生成提示词""" + images: list[Any] | None = None + """输入参考图像列表""" + config: GenerationConfig | None = None + """图像生成配置""" + + +class SpeechRequest(BaseRequest): + """语音合成请求 DTO""" + + input_text: str + """待合成的文本内容""" + voice: str | None = None + """发音人/音色标识 (快捷覆盖参数,为空则使用模型默认音色)""" + config: TTSConfig | None = None + """语音合成配置""" + + +class RerankRequest(BaseRequest): + """文本重排请求 DTO""" + + query: str + """检索查询词""" + documents: list[str | dict[str, str]] + """待排序的候选文档列表""" + top_n: int = 3 + """返回的最相关文档数量""" + + +__all__ = [ + "BaseRequest", + "ChatRequest", + "EmbeddingRequest", + "ImageRequest", + "RerankRequest", + "SpeechRequest", +] diff --git a/zhenxun/services/ai/core/messages/responses.py b/zhenxun/services/ai/core/messages/responses.py new file mode 100644 index 00000000..193ce26a --- /dev/null +++ b/zhenxun/services/ai/core/messages/responses.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, TypeVar + +from pydantic import BaseModel, Field + +from zhenxun.utils.pydantic_compat import parse_as + +from .parts import ( + AudioPart, + ImagePart, + LLMContentPart, + TextPart, + ThoughtPart, + ToolCallPart, + ToolReturnPart, +) +from .shared import RerankResult, UsageInfo + +T = TypeVar("T", bound=BaseModel) + + +class ChatResponse(BaseModel): + """ + 文本/多模态对话响应对象,确立 SSOT (单一数据源) 架构。 + """ + + content_parts: list[LLMContentPart] = Field(default_factory=list) + """经由解析、统一封装后的标准内容部件列表""" + usage_info: dict[str, Any] | None = None + """厂商返回的原始用量遥测字段""" + raw_response: dict[str, Any] | None = None + """完整的 API 层级原生响应字典 (未经框架清洗)""" + grounding_metadata: Any | None = None + """联网搜索、位置等基底事实归因元数据""" + parsed_obj: Any | None = Field(default=None) + """在结构化输出模式下,由中间件反序列化得出的强类型 Pydantic 对象实例""" + + def get_parsed_obj(self, model_class: type[T]) -> T | None: + """ + 从结构化生成结果中获取强类型的 Pydantic 解析对象,提供完善的 IDE 类型推导支持。 + + 参数: + model_class: 期望提取的 Pydantic 模型类。 + + 返回: + 强类型的模型实例,如果不存在则返回 None + """ + if self.parsed_obj is None: + return None + if isinstance(self.parsed_obj, model_class): + return self.parsed_obj + + try: + return parse_as(model_class, self.parsed_obj) + except Exception: + return self.parsed_obj + + @property + def tool_calls(self) -> list[ToolCallPart]: + return [p for p in self.content_parts if isinstance(p, ToolCallPart)] + + @property + def text(self) -> str: + """动态视图:提取并拼接所有文本块""" + return "".join( + p.text for p in self.content_parts if isinstance(p, TextPart) + ).strip() + + @property + def thought_text(self) -> str | None: + """动态视图:提取并拼接所有思考/推理块""" + thoughts = [ + p.thought_text for p in self.content_parts if isinstance(p, ThoughtPart) + ] + return "\n".join(thoughts).strip() if thoughts else None + + @property + def thought_signature(self) -> str | None: + """动态视图:获取当前响应中的思考指纹""" + for p in reversed(self.content_parts): + if isinstance(p, ThoughtPart | ToolCallPart | ToolReturnPart): + if p.metadata and "thought_signature" in p.metadata: + return p.metadata["thought_signature"] + return None + + @property + def images(self) -> list[bytes | Path | str]: + """动态视图:提取响应中包含的所有图片数据""" + imgs = [] + for p in self.content_parts: + if isinstance(p, ImagePart): + if p.url: + imgs.append(p.url) + elif p.raw: + imgs.append(p.raw) + elif p.path: + imgs.append(p.path) + return imgs + + @property + def audios(self) -> list[bytes | Path | str]: + """动态视图:提取音频数据""" + audios = [] + for p in self.content_parts: + if isinstance(p, AudioPart): + if p.url: + audios.append(p.url) + elif p.raw: + audios.append(p.raw) + elif p.path: + audios.append(p.path) + return audios + + +class EmbeddingResponse(BaseModel): + """Embedding 向量富响应对象""" + + embeddings: list[list[float]] + """多段输入文本对应生成的高维浮点数向量数组 (二维)""" + usage: UsageInfo = Field(default_factory=UsageInfo) + """执行向量化任务产生的 Token 用量开销统计""" + model_name: str + """实际用于执行本次编码任务的模型名称""" + + @property + def vector(self) -> list[float]: + """便捷属性:当且仅当只需提取单一文本向量时,直接返回一维向量""" + return self.embeddings[0] if self.embeddings else [] + + +class AudioResponse(BaseModel): + """统一的语音合成响应对象""" + + audio_bytes: bytes + """生成的音频二进制裸数据,可直接用于发送或保存""" + audio_format: str + """实际返回的音频格式 (如 mp3, wav)""" + usage: UsageInfo = Field(default_factory=UsageInfo) + """Token 或 字符数消耗统计""" + raw_response: Any | None = None + """原生响应体,供高级调试使用""" + model_name: str + """实际执行任务的模型名称""" + + +class ImageResponse(BaseModel): + """图像生成响应对象""" + + content_parts: list[LLMContentPart] = Field(default_factory=list) + raw_response: dict[str, Any] | None = None + + @property + def images(self) -> list[bytes | Path | str]: + """动态视图:提取响应中包含的所有图片数据""" + imgs = [] + for p in self.content_parts: + if isinstance(p, ImagePart): + if p.url: + imgs.append(p.url) + elif p.raw: + imgs.append(p.raw) + elif p.path: + imgs.append(p.path) + return imgs + + @property + def text(self) -> str: + """动态视图:提取并拼接所有 TextPart 文本内容""" + return "".join( + p.text for p in self.content_parts if isinstance(p, TextPart) + ).strip() + + +class RerankResponse(BaseModel): + """文本重排响应对象""" + + results: list[RerankResult] + """重排后的文档结果列表""" + + +__all__ = [ + "AudioResponse", + "ChatResponse", + "EmbeddingResponse", + "ImageResponse", + "RerankResponse", +] diff --git a/zhenxun/services/ai/core/messages/shared.py b/zhenxun/services/ai/core/messages/shared.py new file mode 100644 index 00000000..716769be --- /dev/null +++ b/zhenxun/services/ai/core/messages/shared.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, Field + + +@dataclass +class UsageInfo: + """使用信息数据类""" + + prompt_tokens: int = 0 + """请求发送的 Token 消耗数 (含系统提示词与历史记录)""" + completion_tokens: int = 0 + """模型回复生成的 Token 消耗数""" + total_tokens: int = 0 + """本次交互总计产生的 Token 数""" + cost: float = 0.0 + """(可选) 本次交互产生的实际账单估价""" + prompt_cache_hit_tokens: int = 0 + """被上下文缓存系统命中的 Prompt Token 数 (往往价格更低)""" + prompt_cache_miss_tokens: int = 0 + """未能命中缓存、实际执行了计算的 Prompt Token 数""" + reasoning_tokens: int = 0 + """专门用于内部思考/推理链 (CoT) 消耗的 Token 数""" + + @property + def efficiency_ratio(self) -> float: + return self.completion_tokens / max(self.prompt_tokens, 1) + + def __add__(self, other: "UsageInfo") -> "UsageInfo": + """支持 UsageInfo 相加,用于汇聚子智能体的 Token 消耗""" + if not isinstance(other, UsageInfo): + return self + return UsageInfo( + prompt_tokens=self.prompt_tokens + other.prompt_tokens, + completion_tokens=self.completion_tokens + other.completion_tokens, + total_tokens=self.total_tokens + other.total_tokens, + cost=self.cost + other.cost, + prompt_cache_hit_tokens=self.prompt_cache_hit_tokens + + other.prompt_cache_hit_tokens, + prompt_cache_miss_tokens=self.prompt_cache_miss_tokens + + other.prompt_cache_miss_tokens, + reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens, + ) + + +class LLMCodeExecution(BaseModel): + """大模型代码执行(沙箱/本地)结果实体""" + + code: str + """被执行的原始代码""" + output: str | None = None + """标准输出 (stdout)""" + error: str | None = None + """标准错误 (stderr) 或框架抛出的异常""" + execution_time: float | None = None + """代码执行耗时 (秒)""" + files_generated: list[str] | None = None + """代码执行过程中生成的工件(Artifacts)文件路径或名称列表""" + + +class LLMGroundingAttribution(BaseModel): + """基础事实溯源引用对象 (Grounding Attribution)""" + + title: str | None = None + """来源网页或文档的标题""" + uri: str | None = None + """来源内容的统一资源标识符 (URL)""" + snippet: str | None = None + """从来源网页中提取的、支撑当前生成内容的文本片段""" + confidence_score: float | None = None + """该引用来源与生成内容之间相关性的置信度分数""" + + +class LLMGroundingMetadata(BaseModel): + """检索增强/搜索引擎溯源 (Grounding) 的完整元数据字典, + 用于为大模型返回的信息提供可信背书""" + + web_search_queries: list[str] | None = None + """模型在执行检索时,实际使用的底层搜索引擎 Query 查询词列表""" + grounding_attributions: list[LLMGroundingAttribution] | None = None + """溯源引用的详情列表,用于在 UI 端构建点击跳转链接或角标""" + search_suggestions: list[dict[str, Any]] | None = None + """随搜索返回的相关搜索建议 (Search Suggestions)""" + search_entry_point: str | None = None + """一段 HTML/CSS 内容,可用于在客户端渲染标准的搜索引擎入口/建议组件""" + map_widget_token: str | None = None + """用于渲染 Google Maps 交互式地点小组件 (Places widget) 的 + 上下文 Token (针对 googleMaps 工具)""" + + +class RerankDocument(BaseModel): + """重排候选文档 (支持纯文本或图文字典)""" + + text: str | None = None + """被用于重排检索的文本内容""" + image: str | None = None + """用于多模态重排的图片内容""" + + +class RerankResult(BaseModel): + """重排返回结果""" + + index: int + """此记录对应于输入时的原始文档数组中的索引位置""" + relevance_score: float + """计算出的相关性得分 (越大相关度通常越高)""" + document: RerankDocument | None = Field(default=None) + """实际被命中的文档数据""" + + +__all__ = [ + "LLMCodeExecution", + "LLMGroundingAttribution", + "LLMGroundingMetadata", + "RerankDocument", + "RerankResult", + "UsageInfo", +] diff --git a/zhenxun/services/ai/core/messages/types.py b/zhenxun/services/ai/core/messages/types.py new file mode 100644 index 00000000..5d4dfe52 --- /dev/null +++ b/zhenxun/services/ai/core/messages/types.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Annotated, Any +from typing_extensions import TypeVar + +from nonebot_plugin_alconna import UniMessage +from pydantic import Field + +from .parts import ( + AudioPart, + FilePart, + ImagePart, + LLMContentPart, + TextPart, + ThoughtPart, + ToolCallPart, + ToolReturnPart, + VideoPart, +) + +SystemContentUnion = TextPart +"""系统消息允许的内容片段联合类型""" + +UserContentUnion = TextPart | ImagePart | AudioPart | VideoPart | FilePart +"""用户消息允许的多模态内容片段联合类型""" + +AssistantContentUnion = ( + TextPart + | ThoughtPart + | ToolCallPart + | ToolReturnPart + | ImagePart + | AudioPart + | VideoPart + | FilePart +) +"""助手回复允许的内容片段联合类型""" + +ToolContentUnion = ToolReturnPart | ImagePart | AudioPart | VideoPart | FilePart +"""工具消息允许的内容片段联合类型""" + + +RoleT = TypeVar("RoleT", default=str, covariant=True) +"""泛型:消息参与者角色类型变量""" + +ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True) +"""泛型:多模态片段数组的元素内容类型变量""" + + +from .context_events import AgentEvent +from .models import ( + AssistantMessage, + LLMMessage, + SystemMessage, + ToolMessage, + UserMessage, +) + +AnyLLMMessage = Annotated[ + SystemMessage | UserMessage | AssistantMessage | ToolMessage, + Field(discriminator="role"), +] +"""LLM 消息类型的合集联合类型""" + +PromptInput = str | UniMessage | LLMMessage | list[LLMContentPart] | Any +"""支持作为 LLM 输入的提示词对象联合类型,包括纯文本、UniMessage、LLMMessage 消息实体""" + +AgentMessage = LLMMessage | AgentEvent +"""Agent 上下文业务事件载体与原生网络载体的联合类型""" + + +__all__ = [ + "AgentMessage", + "AnyLLMMessage", + "AssistantContentUnion", + "ContentT", + "LLMContentPart", + "PromptInput", + "RoleT", + "SystemContentUnion", + "ToolContentUnion", + "UserContentUnion", +] diff --git a/zhenxun/services/ai/core/models.py b/zhenxun/services/ai/core/models.py new file mode 100644 index 00000000..d61da8c6 --- /dev/null +++ b/zhenxun/services/ai/core/models.py @@ -0,0 +1,213 @@ +""" +模型自身设定域类型定义 +""" + +import asyncio +from dataclasses import dataclass +from enum import Enum +from typing import Any, Generic, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.core.options import GenerationConfig + +ModelName = str | None + +TReq = TypeVar("TReq", bound=BaseModel) +"""泛型:LLM 请求体模型约束 (必须继承自 BaseModel)""" +TRes = TypeVar("TRes", bound=BaseModel) +"""泛型:LLM 响应体模型约束 (必须继承自 BaseModel)""" + + +@dataclass +class ModelIdentity: + """模型的身份标识与基础能力数据传输对象 (DTO),剥离运行时状态""" + + provider_name: str + """模型提供商名称""" + model_name: str + """模型名称""" + api_type: str + """API 适配器类型""" + api_base: str | None + """API 基础请求地址/网关终结点""" + path_prefix: str | None + """中转路由的 URL 前缀""" + capabilities: "ModelCapabilities" + """模型的能力配置定义描述""" + generation_config: GenerationConfig | None + """模型的默认生成配置""" + + +class CancellationToken: + """全局取消令牌,用于在异步链路中传递中止信号""" + + def __init__(self): + self._cancelled = False + self._futures: list[asyncio.Future] = [] + + def cancel(self) -> None: + self._cancelled = True + for f in self._futures: + if not f.done(): + f.cancel() + + def is_cancelled(self) -> bool: + return self._cancelled + + def raise_if_cancelled(self) -> None: + if self._cancelled: + raise asyncio.CancelledError( + "任务已被主动取消 (CancellationToken triggered)" + ) + + def link_future(self, future: asyncio.Future) -> None: + if self._cancelled: + future.cancel() + else: + self._futures.append(future) + + +class LLMContext(BaseModel, Generic[TReq, TRes]): + """LLM 执行上下文,用于在中间件管道中传递请求状态""" + + request: TReq + """强类型的各模态请求对象实体。""" + + runtime_state: dict[str, Any] = Field(default_factory=dict) + """中间件运行时的临时状态存储。""" + cancellation_token: CancellationToken | None = Field(default=None) + """全局取消令牌。""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class ToolDefinition(BaseModel): + """结构化的工具定义模型""" + + name: str = Field(...) + """工具名称""" + description: str = Field(...) + """工具描述""" + parameters: dict[str, Any] = Field(default_factory=dict) + """JSON Schema 参数""" + metadata: dict[str, Any] = Field(default_factory=dict) + """元数据""" + + +class ToolChoice(BaseModel): + """工具选择配置""" + + mode: Literal["auto", "none", "any", "required"] = Field(default="auto") + """工具选择模式""" + allowed_function_names: list[str] | None = Field(default=None) + """允许调用的函数名称列表""" + + +class ModelModality(str, Enum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + VIDEO = "video" + FILE = "file" + EMBEDDING = "embedding" + + +class ReasoningMode(str, Enum): + """推理/思考模式类型""" + + NONE = "none" + BUDGET = "budget" + LEVEL = "level" + EFFORT = "effort" + + +class ModelCapabilities(BaseModel): + """定义一个模型的核心能力。""" + + input_modalities: set[ModelModality] = Field(default={ModelModality.TEXT}) + """模型支持的输入模态集合。""" + output_modalities: set[ModelModality] = Field(default={ModelModality.TEXT}) + """模型支持的输出模态集合。""" + supports_tool_calling: bool = False + """是否支持工具调用能力。""" + is_embedding_model: bool = False + """是否为嵌入模型。""" + is_rerank_model: bool = False + """是否为重排序模型。""" + reasoning_mode: ReasoningMode = ReasoningMode.NONE + """推理模式类型。""" + reasoning_visibility: Literal["visible", "hidden", "none"] = "none" + """推理过程可见性设置。""" + reasoning_effort_map: dict[str, str] = Field(default_factory=dict) + """思考强度参数(reasoning_effort)的降级映射矩阵,如 {"max": "xhigh"}。""" + max_input_tokens: int = Field(default=256000) + """最大输入 Token 数量(用于触发上下文压缩策略,未显式声明则默认为 256K)。""" + supported_native_tools: set[str] = Field(default_factory=set) + """该模型实际支持的云端原生能力/内置工具。""" + default_voice_id: str | None = None + """默认的语音合成音色 ID(TTS模型专用)。""" + + features: set[str] = Field(default_factory=set) + """用于第三方插件动态注入的自定义能力标签。""" + + def supports_task(self, task: str) -> bool: + """判断模型是否支持指定的底层任务类型""" + if task == "embedding": + return self.is_embedding_model + elif task == "rerank": + return self.is_rerank_model + elif task == "tts": + return ModelModality.AUDIO in self.output_modalities + elif task == "image": + return ModelModality.IMAGE in self.output_modalities + elif task == "chat": + return ModelModality.TEXT in self.output_modalities + return False + + def accepts_input(self, modality: ModelModality) -> bool: + """检查模型是否支持某种输入模态""" + return modality in self.input_modalities + + def accepts_output(self, modality: ModelModality) -> bool: + """检查模型是否支持某种输出模态""" + return modality in self.output_modalities + + def has_feature(self, feature: str) -> bool: + """检查模型是否具备某个扩展特性""" + return feature in self.features + + +class ModelDetail(BaseModel): + """模型详细信息""" + + model_name: str + """模型名称。""" + is_available: bool = True + """模型是否可用。""" + temperature: float | None = None + """采样温度参数。""" + generation_max_tokens: int | None = None + """单次生成最大 Token 数。""" + api_type: str | None = None + """API 类型标识。""" + endpoint: str | None = None + """模型服务端点地址。""" + task_type: str | None = Field(default=None) + """显式声明的主任务类型 (如 'image_generation')。""" + path_prefix: str | None = Field(default=None) + """中转路由前缀,例如 '/cogvideox' 或 '/minimax'。""" + + +__all__ = [ + "CancellationToken", + "LLMContext", + "ModelCapabilities", + "ModelDetail", + "ModelIdentity", + "ModelModality", + "ModelName", + "ReasoningMode", + "ToolChoice", + "ToolDefinition", +] diff --git a/zhenxun/services/ai/core/options.py b/zhenxun/services/ai/core/options.py new file mode 100644 index 00000000..b7f9ae85 --- /dev/null +++ b/zhenxun/services/ai/core/options.py @@ -0,0 +1,408 @@ +""" +AI 模块配置数据域类型定义 +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validate + +if TYPE_CHECKING: + from zhenxun.services.ai.llm.builder import IntentBuilder + +T = TypeVar("T") + + +class ResponseFormat(Enum): + """响应格式枚举""" + + TEXT = "text" + JSON = "json" + MULTIMODAL = "multimodal" + + +class StructuredOutputStrategy(str, Enum): + """结构化输出策略""" + + NATIVE = "native" + """使用原生 API (如 OpenAI json_object/json_schema, Gemini mime_type)""" + + TOOL_CALL = "tool_call" + """构造虚假工具调用来强制输出结构化数据 (适用于指令跟随弱但工具调用强的模型)""" + PROMPT = "prompt" + """仅在 Prompt 中追加 Schema 说明,依赖文本补全""" + + +class EmbeddingTaskType(str, Enum): + """ + 文本嵌入任务类型 (对应 Gemini embedding 模型的 task_type 参数) + """ + + RETRIEVAL_QUERY = "RETRIEVAL_QUERY" + """指定给定的文本是搜索/检索设置中的查询 (Query)。""" + RETRIEVAL_DOCUMENT = "RETRIEVAL_DOCUMENT" + """指定给定的文本是被搜索语料库中的文档 (Document)。""" + SEMANTIC_SIMILARITY = "SEMANTIC_SIMILARITY" + """指定文本将用于语义文本相似度 (STS) 计算。""" + CLASSIFICATION = "CLASSIFICATION" + """指定嵌入向量将用于文本分类任务。""" + CLUSTERING = "CLUSTERING" + """指定嵌入向量将用于聚类任务。""" + QUESTION_ANSWERING = "QUESTION_ANSWERING" + """指定文本将用于问答任务。""" + FACT_VERIFICATION = "FACT_VERIFICATION" + """指定文本将用于事实核查任务。""" + + +class BaseOutputDefinition(Generic[T]): + """声明式结构化输出基类""" + + type_: type[T] + + +class ToolOutput(BaseOutputDefinition[T]): + """工具输出标记:使用强制工具调用 (Tool Call) 结束任务并返回指定结构""" + + name: str | None = None + description: str | None = None + strict: bool | None = None + + def __init__( + self, + type_: type[T], + name: str | None = None, + description: str | None = None, + strict: bool | None = None, + ): + self.type_ = type_ + self.name = name + self.description = description + self.strict = strict + + +class ReasoningEffort(str, Enum): + """推理努力程度枚举""" + + NONE = "NONE" + """不开启推理思考""" + MINIMAL = "MINIMAL" + """极低推理努力,追求最快响应""" + LOW = "LOW" + """较低推理努力""" + MEDIUM = "MEDIUM" + """中等推理努力(通常是默认值)""" + HIGH = "HIGH" + """高推理努力""" + XHIGH = "XHIGH" + """极高推理努力""" + MAX = "MAX" + """最大推理努力 (如 GLM / DeepSeek)""" + + +class ImageAspectRatio(str, Enum): + """图像宽高比枚举""" + + SQUARE = "1:1" + """正方形""" + LANDSCAPE_16_9 = "16:9" + """横向宽屏 16:9""" + PORTRAIT_9_16 = "9:16" + """竖向全屏 9:16""" + LANDSCAPE_4_3 = "4:3" + """横向标准 4:3""" + PORTRAIT_3_4 = "3:4" + """竖向标准 3:4""" + LANDSCAPE_3_2 = "3:2" + """横向 3:2""" + PORTRAIT_2_3 = "2:3" + """竖向 2:3""" + + +class ImageResolution(str, Enum): + """图像分辨率/质量枚举""" + + STANDARD = "STANDARD" + """标准分辨率""" + HD = "HD" + """高清分辨率""" + + +class CommonLLMConfig(BaseModel): + """三大厂商通用基础生成参数""" + + temperature: float | None = Field(default=None, ge=0.0, le=2.0) + """采样温度。较高的值会使输出更加随机,较低的值会使其更加集中和确定。""" + max_tokens: int | None = Field(default=None, gt=0) + """聊天完成时生成的最大 Token 数。""" + top_p: float | None = Field(default=None, ge=0.0, le=1.0) + """核采样 (Nucleus sampling) 概率阈值。""" + top_k: int | None = Field(default=None, gt=0) + """仅从概率最高的前 K 个 Token 中采样 (并非所有模型支持)。""" + frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + """频率惩罚。正值根据新 Token 在文本中的现有频率对其进行惩罚, + 降低模型逐字重复同一行的可能性。""" + presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) + """存在惩罚。正值根据新 Token 到目前为止是否出现在文本中对其进行惩罚, + 增加模型谈论新主题的可能性。""" + repetition_penalty: float | None = Field(default=None, ge=0.0, le=2.0) + """重复惩罚系数 (部分非 OpenAI 兼容模型独有)。""" + stop: list[str] | str | None = Field(default=None) + """API 停止生成后续 Token 的停止词序列。""" + reasoning_effort: ReasoningEffort | str | None = Field(default=None) + """跨厂商统一的思考/推理等级意图(如 'low', 'high', 'max')。 + 具体映射由底层的 Adapter 执行。""" + + +class OutputFormatConfig(BaseModel): + """输出格式与结构化控制""" + + response_format: ResponseFormat | dict[str, Any] | None = Field(default=None) + """响应格式类型 (枚举或字典形式的 json_schema 对象)。""" + response_mime_type: str | None = Field(default=None) + """指定 MIME 类型 (如 application/json),主要用于 Gemini。""" + response_schema: dict[str, Any] | None = Field(default=None) + """JSON Schema 字典,用于强制约束模型返回的 JSON 结构。""" + response_modalities: list[str] | None = Field(default=None) + """允许的响应模态 (如 ["TEXT", "IMAGE"]),主要用于 Gemini。""" + structured_output_strategy: StructuredOutputStrategy | str | None = Field( + default=None + ) + """结构化输出所采用的内部策略。""" + + +class ToolCallConfig(BaseModel): + """工具调用统一策略配置""" + + mode: Literal["AUTO", "ANY", "NONE"] = Field(default="AUTO") + """工具调用模式 (AUTO: 自动, ANY: 强制至少调一个, NONE: 禁用)。""" + allowed_function_names: list[str] | None = Field(default=None) + """允许被调用的特定函数名称白名单。""" + include_server_side_tool_invocations: bool | None = Field(default=None) + """是否包含服务端侧的工具调用日志流转 (主要用于 Gemini)。""" + + +class BaseProviderOption(BaseModel): + """厂商配置逃生舱基类""" + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore + + +class OpenAIOptions(BaseProviderOption): + """OpenAI 专属特权参数 (适配 Responses API)""" + + store: bool | None = Field(default=None) + """是否允许服务端留存本次请求的选项记录""" + metadata: dict[str, str] | None = Field(default=None) + """附加在请求上的自定义元数据""" + + +class GeminiOptions(BaseProviderOption): + """Gemini 专属特权参数""" + + include_thoughts: bool | None = Field(default=None) + """是否在最终响应中包含模型的内部思考过程 (Thoughts)""" + safety_settings: dict[str, str] | None = Field(default=None) + """Gemini 专有的各个维度的安全过滤阈值配置""" + retrieval_config: dict[str, Any] | None = Field(default=None) + """检索定位配置,如 LBS 经纬度信息,配合 Google Maps 工具使用""" + + +class DeepSeekOptions(BaseProviderOption): + """DeepSeek 专属特权参数""" + + thinking: bool | None = Field(default=None) + """是否强制开启或关闭 R1 模型的思维链过程""" + + +class OpenAITTSOptions(BaseProviderOption): + """OpenAI TTS 专属特权参数""" + + voice_id: str | None = Field(default=None) + """专属的音色 ID 配置""" + + +class GeminiTTSOptions(BaseProviderOption): + """Gemini TTS 专属特权参数""" + + voice_id: str | None = Field(default=None) + """专属的音色 ID 配置""" + multi_speaker: bool | None = Field(default=None) + """是否开启多说话人模式""" + second_voice: str | None = Field(default=None) + """多说话人模式下的第二音色名称""" + + +class MiniMaxTTSOptions(BaseProviderOption): + """MiniMax TTS 专属特权参数 (控制极度精细)""" + + voice_id: str | None = Field(default=None) + """专属的音色 ID 配置""" + vol: float | None = Field(default=None, gt=0.0, le=10.0) + """音量,范围 (0, 10]""" + pitch: int | None = Field(default=None, ge=-12, le=12) + """语调,范围 [-12, 12]""" + emotion: ( + Literal[ + "happy", + "sad", + "angry", + "fearful", + "disgusted", + "surprised", + "calm", + "fluent", + "whisper", + ] + | None + ) = Field(default=None) + """情感控制""" + timbre_weights: list[dict[str, Any]] | None = Field(default=None) + """音色混合权重 (最多4种)""" + pronunciation_dict: dict[str, list[str]] | None = Field(default=None) + """自定义发音字典 (如: {"tone": ["处理/(chu3)(li3)"]})""" + + +class MiMoTTSOptions(BaseProviderOption): + """MiMo TTS 专属特权参数""" + + voice_id: str | None = Field(default=None) + """专属的音色 ID 配置""" + + +class MediaGenerationConfig(BaseModel): + """多模态媒体生成/优化的全局配置""" + + aspect_ratio: ImageAspectRatio | str | None = Field(default=None) + """生成的图像/视频宽高比 (如 '16:9')""" + resolution: str | None = Field(default=None) + """生成的图像/视频分辨率 (如 '1K', '4K' 或 '1024x1024')""" + quality: Literal["low", "medium", "high", "standard", "hd"] | None = Field( + default=None + ) + """渲染质量及细节丰富水平""" + + +class TTSConfig(BaseModel): + """文本转语音 (TTS) 全局配置""" + + response_format: Literal["mp3", "wav", "pcm", "flac", "opus", "aac"] = Field( + default="mp3" + ) + """输出音频格式""" + speed: float = Field(default=1.0, ge=0.25, le=4.0) + """语速 (通用映射)""" + + openai_options: OpenAITTSOptions = Field(default_factory=OpenAITTSOptions) + gemini_options: GeminiTTSOptions = Field(default_factory=GeminiTTSOptions) + minimax_options: MiniMaxTTSOptions = Field(default_factory=MiniMaxTTSOptions) + mimo_options: MiMoTTSOptions = Field(default_factory=MiMoTTSOptions) + + custom_kwargs: dict[str, Any] = Field(default_factory=dict) + """兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class GenerationConfig(BaseModel): + """ + 现代化 LLM 生成基座 (Intent-Driven Base)。 + 隔离通用参数与厂商私有参数,彻底终结"上帝类"。 + """ + + common: CommonLLMConfig = Field(default_factory=CommonLLMConfig) + output: OutputFormatConfig = Field(default_factory=OutputFormatConfig) + tools: ToolCallConfig = Field(default_factory=ToolCallConfig) + media: MediaGenerationConfig = Field(default_factory=MediaGenerationConfig) + + openai_options: OpenAIOptions = Field(default_factory=OpenAIOptions) + gemini_options: GeminiOptions = Field(default_factory=GeminiOptions) + deepseek_options: DeepSeekOptions = Field(default_factory=DeepSeekOptions) + + enable_caching: bool | None = Field(default=None) + """是否在此次生成中开启上下文缓存 (Context Caching)""" + custom_kwargs: dict[str, Any] = Field(default_factory=dict) + """兜底逃生舱,包含的键值对将直接透传至顶层请求体中""" + validation_policy: dict[str, Any] | None = Field(default=None) + """自定义验证策略字典""" + response_validator: Callable[[Any], None] | None = Field(default=None) + """针对原始返回对象的自定义回调验证器""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + def builder(cls) -> "IntentBuilder": + from zhenxun.services.ai.llm.builder import IntentBuilder + + return IntentBuilder() + + def to_dict(self) -> dict[str, Any]: + return model_dump(self, exclude_none=True) + + def merge_with(self, other: "GenerationConfig | None") -> "GenerationConfig": + """深度合并两个配置,实现配置的无损叠加""" + if not other: + return model_copy(self, deep=True) + + base_dump = model_dump(self, exclude_none=True) + other_dump = model_dump(other, exclude_none=True) + + def deep_merge(d1: dict, d2: dict) -> dict: + res = d1.copy() + for k, v in d2.items(): + if isinstance(v, dict) and k in res and isinstance(res[k], dict): + res[k] = deep_merge(res[k], v) + else: + res[k] = v + return res + + merged_dump = deep_merge(base_dump, other_dump) + return model_validate(GenerationConfig, merged_dump) + + +class LLMEmbeddingConfig(BaseModel): + """Embedding 专用配置""" + + task_type: str | None = Field(default=None) + """生成意图的任务类型,参考 EmbeddingTaskType (主要用于 Gemini 和 Jina)""" + output_dimensionality: int | None = Field(default=None) + """请求模型强制输出(或截断)的较低维度数,实现维度压缩""" + title: str | None = Field(default=None) + """提供该文档的标题以供底层优化。仅在 task_type 为 RETRIEVAL_DOCUMENT 时有效。""" + encoding_format: str | None = Field(default="float") + """向量数据在响应中的编码格式 (通常为 float 或 base64)""" + multimodal: bool | list[str] = Field(default=False) + """是否允许多模态向量化。False 表示纯文本(极速安全);True 表示全部放行; + 也可传入 ['image', 'text'] 细粒度控制。""" + custom_kwargs: dict[str, Any] = Field(default_factory=dict) + """兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +__all__ = [ + "BaseProviderOption", + "CommonLLMConfig", + "EmbeddingTaskType", + "GeminiOptions", + "GeminiTTSOptions", + "GenerationConfig", + "ImageAspectRatio", + "ImageResolution", + "LLMEmbeddingConfig", + "MiMoTTSOptions", + "MiniMaxTTSOptions", + "OpenAIOptions", + "OpenAITTSOptions", + "OutputFormatConfig", + "ReasoningEffort", + "ResponseFormat", + "StructuredOutputStrategy", + "TTSConfig", + "ToolCallConfig", + "ToolOutput", +] diff --git a/zhenxun/services/ai/core/protocols/__init__.py b/zhenxun/services/ai/core/protocols/__init__.py new file mode 100644 index 00000000..c4aceba1 --- /dev/null +++ b/zhenxun/services/ai/core/protocols/__init__.py @@ -0,0 +1,11 @@ +""" +AI 服务协议统一导出 +""" + +from .tool import ToolExecutable, ToolProvider, ToolResolvable + +__all__ = [ + "ToolExecutable", + "ToolProvider", + "ToolResolvable", +] diff --git a/zhenxun/services/ai/core/protocols/llm.py b/zhenxun/services/ai/core/protocols/llm.py new file mode 100644 index 00000000..990a8891 --- /dev/null +++ b/zhenxun/services/ai/core/protocols/llm.py @@ -0,0 +1,76 @@ +from typing import Protocol, runtime_checkable + +from zhenxun.services.ai.core.messages import ( + AudioResponse, + ChatRequest, + ChatResponse, + EmbeddingRequest, + EmbeddingResponse, + ImageRequest, + ImageResponse, + RerankRequest, + RerankResponse, + SpeechRequest, +) +from zhenxun.services.ai.core.models import CancellationToken + + +@runtime_checkable +class SupportsChat(Protocol): + """支持文本/多模态对话生成的协议""" + + async def generate_response( + self, + request: ChatRequest, + cancellation_token: CancellationToken | None = None, + ) -> ChatResponse: + """生成文本或多模态对话的回复。""" + ... + + +@runtime_checkable +class SupportsTextEmbedding(Protocol): + """支持文本/多模态向量嵌入的协议""" + + async def generate_embeddings( + self, + request: EmbeddingRequest, + ) -> EmbeddingResponse: + """生成文本或多模态向量嵌入。""" + ... + + +@runtime_checkable +class SupportsSpeechSynthesis(Protocol): + """支持文本转语音(TTS)的协议""" + + async def generate_speech( + self, + request: SpeechRequest, + ) -> AudioResponse: + """将文本转换为语音(TTS)。""" + ... + + +@runtime_checkable +class SupportsReranking(Protocol): + """支持文档交叉注意力重排的协议""" + + async def rerank( + self, + request: RerankRequest, + ) -> RerankResponse: + """对候选文档进行交叉注意力重排。""" + ... + + +@runtime_checkable +class SupportsImageGeneration(Protocol): + """支持图像生成与编辑的协议""" + + async def generate_image( + self, + request: ImageRequest, + ) -> ImageResponse: + """根据请求生成或编辑图像。""" + ... diff --git a/zhenxun/services/ai/core/protocols/middleware.py b/zhenxun/services/ai/core/protocols/middleware.py new file mode 100644 index 00000000..9fb0900b --- /dev/null +++ b/zhenxun/services/ai/core/protocols/middleware.py @@ -0,0 +1,23 @@ +""" +LLM 中间件协议定义 +""" + +from __future__ import annotations + +from typing import Protocol + +from zhenxun.services.ai.core.models import LLMContext, TReq, TRes + + +class NextCall(Protocol[TReq, TRes]): + """中间件管道中下一个节点(或最终执行函数)的调用签名""" + + async def __call__(self, context: LLMContext[TReq, TRes], /) -> TRes: ... + + +class LLMMiddleware(Protocol[TReq, TRes]): + """LLM 中间件函数的调用签名,遵循洋葱模型嵌套包裹设计""" + + async def __call__( + self, context: LLMContext[TReq, TRes], next_call: NextCall[TReq, TRes], / + ) -> TRes: ... diff --git a/zhenxun/services/ai/core/protocols/tool.py b/zhenxun/services/ai/core/protocols/tool.py new file mode 100644 index 00000000..385a819d --- /dev/null +++ b/zhenxun/services/ai/core/protocols/tool.py @@ -0,0 +1,84 @@ +""" +工具执行与管理协议定义 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from zhenxun.services.ai.core.models import ToolDefinition + +if TYPE_CHECKING: + from zhenxun.services.ai.run import RunContext + from zhenxun.services.ai.tools.models import ( + ResolvedToolPayload, + ToolResult, + ) + + +class ToolExecutable(Protocol): + """ + 一个协议,定义了所有可被LLM调用的工具必须实现的行为。 + """ + + name: str + """工具的名称标识""" + + async def get_definition( + self, context: "RunContext | None" = None + ) -> ToolDefinition | None: + """ + 异步地获取一个结构化的工具定义。如果返回 None,则该工具对大模型不可见。 + """ + ... + + async def execute( + self, context: "RunContext | None" = None, **kwargs: Any + ) -> ToolResult: + """ + 异步执行工具并返回一个结构化的结果。 + """ + ... + + +@runtime_checkable +class ToolResolvable(Protocol): + """ + 鸭子类型解析协议。任何实现了此协议的对象, + 都可以直接被 Agent 或 LLM 的 tools 参数接收。 + """ + + async def resolve( + self, context: "RunContext | None" = None + ) -> "ResolvedToolPayload": ... + + +class ToolProvider(Protocol): + """ + 一个协议,定义了"工具提供者"的行为。 + 工具提供者负责发现或实例化具体的 ToolExecutable 对象。 + """ + + async def initialize(self) -> None: + """ + 异步初始化提供者。 + """ + ... + + async def discover_tools( + self, + allowed_servers: list[str] | None = None, + excluded_servers: list[str] | None = None, + ) -> dict[str, ToolExecutable]: + """ + 异步发现此提供者提供的所有工具。 + """ + ... + + async def get_tool_executable( + self, name: str, config: dict[str, Any] + ) -> ToolExecutable | None: + """ + 如果此提供者能处理名为 'name' 的工具,则返回一个可执行实例。 + """ + ... diff --git a/zhenxun/services/ai/core/stream_events.py b/zhenxun/services/ai/core/stream_events.py new file mode 100644 index 00000000..9c208196 --- /dev/null +++ b/zhenxun/services/ai/core/stream_events.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from typing import Any, TypeVar + +from nonebot.utils import is_coroutine_callable +from pydantic import BaseModel, ConfigDict + +from zhenxun.services.log import logger + + +class AgentStreamEvent(BaseModel): + """ + Agent 局部流事件与生命周期事件的绝对统一基类 + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class LLMStartEvent(AgentStreamEvent): + """大模型网络请求开始事件""" + + model_name: str + """请求的大模型名称""" + messages: list[Any] + """发往大模型的历史消息列表""" + + +class LLMEndEvent(AgentStreamEvent): + """大模型网络请求结束事件""" + + response: Any + """大模型返回的完整响应 (ChatResponse)""" + + +class ToolCallStartEvent(AgentStreamEvent): + """工具调用开始事件""" + + tool_name: str + """调用的工具名称""" + arguments: dict[str, Any] + """工具调用参数""" + intent: str | None = None + """从大模型调用参数中剥离出的意图 (_intent)""" + + +class ToolCallEndEvent(AgentStreamEvent): + """工具调用结束事件""" + + tool_name: str + """工具名称""" + result: Any + """工具最终返回的结果""" + is_error: bool + """工具执行是否失败""" + + +class ToolStreamChunkEvent(AgentStreamEvent): + """工具或后台任务流式进度反馈事件""" + + tool_name: str + """工具名称""" + content: str + """当前流式输出的文本片段""" + metadata: dict[str, Any] | None = None + """流式的附加元数据 (如进度比例等)""" + + +class UserCustomEvent(AgentStreamEvent): + """自定义用户界面交互事件""" + + display: Any + """用于前端渲染的展示对象 (如 UniMessage, str, ImagePart)""" + log_content: str | None = None + """用于后台打印的日志摘要""" + + +class ControlFlowEvent(AgentStreamEvent): + """控制流中断与流转事件""" + + action: str + """控制流动作,如 'handoff', 'abort', 'end_run'""" + payload: Any = None + """附加的数据载荷""" + + +T_Event = TypeVar("T_Event", bound=AgentStreamEvent) +"""泛型变量:用于绑定具体的事件类型,提供完美的 IDE 类型推导""" + + +class EventBus: + """ + 局部事件总线 (Event Bus) + EventBus 支持异步迭代与发布-订阅(Pub/Sub)模式 + """ + + def __init__(self): + """ + 初始化 EventBus 实例。 + """ + self._queue = asyncio.Queue() + self._finished = False + self._subscribers: dict[type[AgentStreamEvent], list[Callable[[Any], Any]]] = ( + defaultdict(list) + ) + self._background_tasks = set() + + def subscribe( + self, event_type: type[T_Event], handler: Callable[[T_Event], Any] + ) -> None: + """ + 注册事件监听器,用于订阅特定类型的事件。 + + 参数: + event_type: 要订阅的事件类型(需为 AgentStreamEvent 的子类)。 + handler: 事件处理回调函数。当对应事件发布时被触发,参数为事件实例。 + """ + self._subscribers[event_type].append(handler) + + async def emit(self, event: AgentStreamEvent) -> None: + """ + 发布事件,触发所有匹配的订阅者,并将事件放入迭代队列中。 + + 参数: + event: 要发布的事件实例(需继承自 AgentStreamEvent)。 + """ + handlers = [] + for ev_type, cb_list in self._subscribers.items(): + if isinstance(event, ev_type): + handlers.extend(cb_list) + + for handler in handlers: + if is_coroutine_callable(handler): + + async def _run_handler(h=handler, e=event): + try: + await h(e) + except Exception as err: + logger.error(f"EventBus 订阅者执行异常: {err}") + + task = asyncio.create_task(_run_handler()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + else: + try: + handler(event) + except Exception as err: + logger.error(f"EventBus 订阅者执行异常: {err}") + + if not self._finished: + await self._queue.put(event) + + async def end(self): + """ + 结束事件总线。等待所有后台任务执行完毕,并向队列中投放结束标记以终止异步迭代。 + """ + if self._background_tasks: + tasks = list(self._background_tasks) + await asyncio.gather(*tasks, return_exceptions=True) + self._finished = True + await self._queue.put(None) + + async def __aiter__(self): + """ + 支持异步迭代,可通过 async for 循环消费事件总线中的事件。 + + 返回: + AsyncIterator: 异步事件流生成器。 + """ + while True: + event = await self._queue.get() + if event is None: + break + yield event diff --git a/zhenxun/services/ai/core/templates.py b/zhenxun/services/ai/core/templates.py new file mode 100644 index 00000000..c27222e6 --- /dev/null +++ b/zhenxun/services/ai/core/templates.py @@ -0,0 +1,103 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Generic, cast +from typing_extensions import TypeVar + +from jinja2 import Environment + +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_dump + +if TYPE_CHECKING: + from zhenxun.services.ai.run.context import RunContext + +AgentDepsT = TypeVar("AgentDepsT", default=Any) + + +class PromptTemplate(Generic[AgentDepsT]): + """ + 核心 Prompt 渲染引擎。 + 基于 Jinja2 提供强大的模板变量替换功能。 + 支持沙盒隔离的自定义过滤器(filters)、全局函数(globals), + """ + + def __init__( + self, + template_string: str, + custom_filters: dict[str, Callable] | None = None, + custom_globals: dict[str, Any] | None = None, + ): + """ + 初始化 Prompt 渲染引擎。 + + 参数: + template_string: Jinja2 模板格式的提示词原文本。 + custom_filters: 挂载到 Jinja2 渲染环境的自定义过滤器字典,默认 None。 + custom_globals: 挂载到 Jinja2 渲染环境的全局变量或辅助函数字典,默认 None。 + """ + self.template_string = template_string + + self._env = Environment(autoescape=False) + if custom_filters: + self._env.filters.update(custom_filters) + if custom_globals: + self._env.globals.update(custom_globals) + + try: + self._template = ( + self._env.from_string(template_string) if template_string else None + ) + except Exception as e: + logger.error(f"PromptTemplate 模板语法编译失败: {e}") + self._template = None + + def format_with_context(self, context: "RunContext[AgentDepsT]") -> str: + """从运行上下文中自动提取依赖和状态字典,用于模板渲染""" + + vars_dict: dict[str, Any] = { + "ctx": context, + "ctx_deps": context.deps, + "ctx_state": context.state, + "ctx_shared": context.shared_state, + } + + flat_deps = {} + if context.deps is not None: + if hasattr(context.deps, "model_dump"): + flat_deps.update(model_dump(cast(Any, context.deps), exclude_none=True)) + elif hasattr(context.deps, "__dict__"): + flat_deps.update(context.deps.__dict__) + elif isinstance(context.deps, dict): + flat_deps.update(context.deps) + + vars_dict.update(flat_deps) + + if context.state: + collisions = set(flat_deps.keys()) & set(context.state.keys()) + if collisions: + logger.warning( + f"Prompt 模板变量发生名称冲突: {collisions}。" + "`state` 已覆盖 `deps` 中的同名变量。" + "建议在模板中使用安全隔离对象获取(如 {{ ctx_state.xxx }})。" + ) + vars_dict.update(context.state) + + return self.render(**vars_dict) + + def render(self, **variables: Any) -> str: + if not self._template: + return self.template_string or "" + + try: + return self._template.render(**variables) + except Exception as e: + logger.error( + f"Jinja2 Prompt 模板渲染失败!\n" + f"错误信息: {e}\n" + f"请检查传入的变量或 Prompt 模板语法是否有误。\n" + f"模板原内容截断: {self.template_string[:150]}...", + e=e, + ) + raise ValueError(f"Prompt 模板渲染异常: {e}") from e + + def __str__(self) -> str: + return self.template_string diff --git a/zhenxun/services/ai/flow/__init__.py b/zhenxun/services/ai/flow/__init__.py new file mode 100644 index 00000000..f1f68454 --- /dev/null +++ b/zhenxun/services/ai/flow/__init__.py @@ -0,0 +1,18 @@ +""" +Zhenxun AI - Flow (核心编排引擎) + +提供大模型任务编排的三大范式: +1. Agent: 基于动态工具调用的自主推理流。 +2. Team: 多智能体协同的群体决策流。 +3. Workflow: 基于图元/状态机的静态控制流。 +""" + +from .agent.agent import Agent +from .team.team import Team +from .workflow.engine import Workflow + +__all__ = [ + "Agent", + "Team", + "Workflow", +] diff --git a/zhenxun/services/ai/flow/agent/__init__.py b/zhenxun/services/ai/flow/agent/__init__.py new file mode 100644 index 00000000..055e5701 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/__init__.py @@ -0,0 +1,11 @@ +from .agent import Agent +from .models import ( + AgentConfig, + Persona, +) + +__all__ = [ + "Agent", + "AgentConfig", + "Persona", +] diff --git a/zhenxun/services/ai/flow/agent/agent.py b/zhenxun/services/ai/flow/agent/agent.py new file mode 100644 index 00000000..08ccf574 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/agent.py @@ -0,0 +1,1069 @@ +import asyncio +from collections.abc import AsyncIterator, Callable, Sequence +import contextlib +from pathlib import Path +from typing import Any, Generic, cast + +from zhenxun.services.ai.capabilities import ( + AbstractCapability, + DynamicCapability, +) +from zhenxun.services.ai.context.knowledge.base import BaseKnowledge +from zhenxun.services.ai.context.memory.builder import MemoryBuilder +from zhenxun.services.ai.context.memory.models import MemoryConfig +from zhenxun.services.ai.core.exceptions import ( + ConcurrencyInterruptException, + ControlFlowExit, +) +from zhenxun.services.ai.core.messages import ( + LLMMessage, + PromptInput, + UsageInfo, +) +from zhenxun.services.ai.core.models import CancellationToken +from zhenxun.services.ai.core.options import ( + BaseOutputDefinition, + GenerationConfig, +) +from zhenxun.services.ai.core.protocols.tool import ToolExecutable, ToolResolvable +from zhenxun.services.ai.core.stream_events import AgentStreamEvent, EventBus +from zhenxun.services.ai.core.templates import PromptTemplate +from zhenxun.services.ai.flow.agent.engine.builders import ToolBuilder +from zhenxun.services.ai.flow.agent.models import ( + AgentConfig, + AgentRunResources, + AgentState, + Persona, +) +from zhenxun.services.ai.flow.base import BaseRunnable +from zhenxun.services.ai.guardrails import GuardrailSource +from zhenxun.services.ai.llm.builder import IntentBuilder +from zhenxun.services.ai.run import ( + AgentRunResult, + RunContext, + Task, +) +from zhenxun.services.ai.run.context import AgentDepsT +from zhenxun.services.ai.run.di import DependencyInjector +from zhenxun.services.ai.run.models import ( + AgentRunEnd, + AgentRunError, + AgentRunStart, + OutputDataT, + StreamedRunResult, +) +from zhenxun.services.ai.tools.core.tool import BaseTool +from zhenxun.services.ai.tools.core.toolkit import BaseToolkit +from zhenxun.services.ai.tools.models import Query +from zhenxun.services.ai.tools.providers.skills.models import Skill, SkillSource +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import ( + model_construct, + model_copy, + model_dump, + parse_as, +) +from zhenxun.utils.utils import infer_plugin_namespace + +from .engine.executor import BaseAgentExecutor + +ToolSource = ( + Callable | BaseTool | dict[str, Any] | str | BaseToolkit | ToolResolvable | Query +) +"""任何可以作为工具提供给大模型的实体对象(函数、基础工具类、字典定义、工具名、工具箱、声明式查询对象)""" + +CapabilitySource = Callable | AbstractCapability +"""能力/拦截器来源(函数或 AbstractCapability 实例)""" + + +class AgentBuilder(Generic[AgentDepsT, OutputDataT]): + """ + Agent 链式构建器 (Fluent Builder)。 + """ + + def __init__(self, name: str): + self._kwargs: dict[str, Any] = {"name": name} + self._config: AgentConfig | dict | None = None + self._executor: Any | None = None + self._directive_handlers: dict[str, Any] = {} + + def with_instruction( + self, instruction: str | PromptTemplate + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置静态系统指令。 + + 参数: + instruction: 静态系统指令,可为普通字符串或模板字符串。 + """ + self._kwargs["instruction"] = instruction + return self + + def with_persona( + self, role: str, goal: str, backstory: str | None = None + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置智能体人设与角色设定。 + + 参数: + role: 扮演的角色身份。 + goal: 角色的核心目标。 + backstory: 角色背景故事或性格设定。 + """ + self._kwargs["persona"] = Persona(role=role, goal=goal, backstory=backstory) + return self + + def with_model( + self, model: str | Callable[[], str] + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置默认调用的语言模型。 + + 参数: + model: 默认模型名(如 `Provider/Model`)或返回模型名的回调。 + """ + self._kwargs["model"] = model + return self + + def with_tools( + self, *tools: ToolSource | list[ToolSource] + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置可供智能体调用的工具列表。 + + 参数: + tools: 初始工具定义,支持工具对象、函数、字典定义或工具名称。 + """ + current_tools = self._kwargs.setdefault("tools", []) + for t in tools: + if isinstance(t, list): + current_tools.extend(t) + else: + current_tools.append(t) + return self + + def with_skills( + self, *skills: str | Path | Skill | SkillSource | Sequence + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置注入的领域知识技能。 + + 参数: + skills: 注入的技能,支持 ID、目录 Path、Skill 对象或 SkillSource 动态源。 + """ + current_skills = self._kwargs.setdefault("skills", []) + for s in skills: + if isinstance(s, list | tuple | set): + current_skills.extend(s) + else: + current_skills.append(cast(Any, s)) + return self + + def with_knowledge( + self, *knowledge: BaseKnowledge | list[BaseKnowledge] + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置挂载的知识库。 + + 参数: + knowledge: 挂载的知识库,支持单个或列表。底层会自动将其注册入工具链。 + """ + current_knowledge = self._kwargs.setdefault("knowledge", []) + for k in knowledge: + if isinstance(k, list): + current_knowledge.extend(k) + else: + current_knowledge.append(k) + return self + + def with_memory( + self, memory: bool | MemoryConfig | MemoryBuilder + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置对话记忆与上下文管理策略。 + + 参数: + memory: 是否开启长期记忆与上下文压缩,支持布尔值或显式配置对象。 + """ + self._kwargs["memory"] = memory + return self + + def with_generation_config( + self, config: GenerationConfig | IntentBuilder | dict + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置大模型基础生成参数。 + + 参数: + config: 默认生成配置,支持 `GenerationConfig`、`IntentBuilder` 或 dict。 + """ + self._kwargs["generation_config"] = config + return self + + def with_intervention(self, policy: Any) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """配置运行时消息干预策略。""" + if self._config is None: + self._config = AgentConfig() + elif isinstance(self._config, dict): + self._config = AgentConfig(**self._config) + self._config.intervention_policy = policy + return self + + def with_response_model( + self, response_model: BaseOutputDefinition | type[Any] + ) -> "AgentBuilder[AgentDepsT, Any]": + """ + 配置期望大模型输出的强类型结构化数据模型。 + + 参数: + response_model: 结构化输出模型,传入 Pydantic 模型类或声明式输出对象。 + """ + self._kwargs["response_model"] = response_model + return cast(AgentBuilder[AgentDepsT, Any], self) + + def with_guardrails( + self, *guardrails: GuardrailSource | list[GuardrailSource] + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置输入/输出安全合规护栏。 + + 参数: + guardrails: 护栏定义,支持可调用对象、自然语言规则字符串或护栏实例。 + """ + current_guardrails = self._kwargs.setdefault("guardrails", []) + for g in guardrails: + if isinstance(g, list): + current_guardrails.extend(g) + else: + current_guardrails.append(g) + return self + + def with_capabilities( + self, *capabilities: CapabilitySource | list[CapabilitySource] + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置智能体的高阶能力拦截器组件。 + + 参数: + capabilities: 能力组件,可传入函数或 `AbstractCapability` 实例。 + """ + current_capabilities = self._kwargs.setdefault("capabilities", []) + for c in capabilities: + if isinstance(c, list): + current_capabilities.extend(c) + else: + current_capabilities.append(c) + return self + + def with_config( + self, config: AgentConfig | dict | None = None, **kwargs + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置智能体全局通用设置。 + + 参数: + config: 统一配置,合并了全局与单次运行策略,可传入 `AgentConfig` 或 dict。 + kwargs: 零散的配置参数,将自动覆盖或组装进配置对象中。 + """ + merged_kwargs = {} + if config: + merged_kwargs.update( + config if isinstance(config, dict) else model_dump(config) + ) + merged_kwargs.update(kwargs) + + self._config = AgentConfig(**merged_kwargs) + return self + + def with_executor( + self, executor: BaseAgentExecutor + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 配置核心思考大循环的执行策略。 + + 参数: + executor: 核心思考大循环的执行策略。 + + 返回: + AgentBuilder[AgentDepsT, OutputDataT]: 构建器自身。 + """ + self._executor = executor + return self + + def with_directive_handler( + self, name: str, handler: Any + ) -> "AgentBuilder[AgentDepsT, OutputDataT]": + """ + 动态注入自定义大模型工具控制流指令。 + """ + self._directive_handlers[name] = handler + return self + + def build(self) -> "Agent[AgentDepsT, OutputDataT]": + """ + 构建并输出最终 of Agent 实例。 + """ + return Agent( + **self._kwargs, + config=self._config, + executor=self._executor, + directive_handlers=self._directive_handlers, + ) + + +class Agent( + BaseRunnable[AgentRunResult[OutputDataT]], Generic[AgentDepsT, OutputDataT] +): + """ + Agent 运行时封装。 + 负责组织模型、工具、记忆、护栏与能力插件,并驱动单轮或流式执行。 + """ + + @classmethod + def builder(cls, name: str) -> AgentBuilder[Any, str]: + """创建一个智能体链式构建器""" + return AgentBuilder(name=name) + + def __init__( + self, + name: str, + instruction: str | PromptTemplate = "", + description: str | None = None, + persona: Persona | dict | None = None, + model: str | Callable[[], str] | None = None, + tools: list[ToolSource] | None = None, + skills: Sequence[str | Path | Skill | SkillSource] | None = None, + generation_config: GenerationConfig | IntentBuilder | dict | None = None, + response_model: BaseOutputDefinition | type[OutputDataT] | None = None, + memory: bool | MemoryConfig | MemoryBuilder = False, + knowledge: BaseKnowledge | list[BaseKnowledge] | None = None, + config: AgentConfig | dict | None = None, + guardrails: list[GuardrailSource] | None = None, + capabilities: list[CapabilitySource] | None = None, + executor: BaseAgentExecutor | None = None, + directive_handlers: dict[str, Any] | None = None, + ): + """ + 初始化 Agent。 + + 参数: + name: Agent 名称,用于日志、事件和链路标识。 + instruction: 静态系统指令,可为普通字符串或模板字符串。 + description: 智能体描述,用于外部路由节点决定是否调用。 + persona: 角色设定配置,传入 dict 会自动构造成 Persona。 + model: 默认模型名称 (如 Provider/Model) 或返回模型名的回调。 + tools: 初始工具定义列表,支持混合使用工具对象与字符串工具名。 + skills: 注入的领域知识技能,支持 ID、目录 Path、Skill 对象或动态源。 + generation_config: 默认生成配置,支持 GenerationConfig、IntentBuilder 或 dict。 + response_model: 结构化输出模型,若为空则按纯文本输出。 + memory: 是否开启长期记忆与上下文压缩,支持布尔值或 MemoryBuilder/Config。 + knowledge: 挂载的知识库,支持单个或列表,底层自动将其注册入工具链。 + config: 统一配置,合并了全局与单次运行策略,支持字典。 + guardrails: 护栏定义列表,支持可调用对象、规则字符串或护栏实例。 + capabilities: 拦截器/能力插件列表,处理整个生命周期的切面逻辑。 + executor: 核心思考大循环的执行策略。 + directive_handlers: 自定义大模型工具控制流指令处理器字典。 + """ # noqa: E501 + self.name = name + + if description: + self.description = description + elif persona: + p_obj = persona if isinstance(persona, Persona) else Persona(**persona) + self.description = f"角色:{p_obj.role},目标:{p_obj.goal}" + else: + self.description = str(instruction)[:150] if instruction else "AI Agent" + + self.instruction = instruction + + if isinstance(persona, dict): + self.persona = Persona(**persona) + else: + self.persona = persona + self.model_name = model + + self.namespace = infer_plugin_namespace() or "unknown" + + self.tool_names = [t for t in (tools or []) if isinstance(t, str)] + self.response_model = response_model + self.directive_handlers = directive_handlers or {} + if isinstance(generation_config, IntentBuilder): + generation_config = generation_config.build() + + if isinstance(generation_config, dict): + base_config = parse_as(GenerationConfig, generation_config) + else: + base_config = ( + model_copy(generation_config, deep=True) + if generation_config + else GenerationConfig() + ) + + self._raw_response_schema = None + if base_config.output.response_schema and self.response_model is None: + self._raw_response_schema = base_config.output.response_schema + base_config.output.response_schema = None + base_config.output.response_format = None + base_config.output.structured_output_strategy = None + + self.default_config = base_config + self._resolved_tools: dict[str, Any] | None = None + + self.dynamic_prompts = [] + self.tool_filters = [] + self.toolset_funcs = [] + self._event_listeners: dict[type[AgentStreamEvent], list[Callable]] = {} + from zhenxun.services.ai.guardrails import parse_guardrails + + self._guardrails = parse_guardrails(guardrails) + + self.memory_config = MemoryBuilder.resolve(memory) + + if isinstance(config, dict): + self.config = AgentConfig(**config) + else: + self.config = config or AgentConfig() + + self.runtime_config = self.config + self.engine_config = self.config + + if self.config.enable_hitl is None: + from zhenxun.services.ai.config import get_llm_config + + self.config.enable_hitl = get_llm_config().agent_settings.enable_hitl + + self.config.stateless = not self.memory_config.short_term.enable + + self.executor = executor + + self._assemble_plugins(tools, knowledge, capabilities, skills) + + def _assemble_plugins(self, tools, knowledge, capabilities, skills): + """私有方法:集中处理各类能力、知识与技能的挂载,消解冗余样板代码""" + self.tool_definitions = tools or [] + + if knowledge: + if not isinstance(knowledge, list): + knowledge = [knowledge] + self.tool_definitions.extend(knowledge) + + self.capabilities: list[AbstractCapability] = [] + + if self.memory_config.long_term.enable and self.memory_config.long_term.agentic: + from zhenxun.services.ai.context.memory.capabilities import ( + AgenticMemoryCapability, + ) + + self.capabilities.append( + AgenticMemoryCapability(self.memory_config, self.namespace) + ) + + if self.memory_config.slots.enable: + from zhenxun.services.ai.context.memory.capabilities import ( + SlotMemoryCapability, + ) + + self.capabilities.append( + SlotMemoryCapability(self.memory_config, self.namespace) + ) + + if capabilities: + for cap in capabilities: + if isinstance(cap, AbstractCapability): + self.capabilities.append(cap) + elif callable(cap): + self.capabilities.append(DynamicCapability(cap)) + + if self.config.enable_hitl: + from zhenxun.services.ai.tools.providers.builtin.hitl import HITLToolkit + + self.tool_definitions.append(HITLToolkit()) + + if skills: + from zhenxun.services.ai.tools.providers.skills.capabilities import ( + SkillCapability, + ) + + self.capabilities.append( + SkillCapability(skills=skills, namespace=self.namespace) + ) + + def tool( + self, + func: Callable | None = None, + *, + name: str | None = None, + description: str | None = None, + settings: Any | None = None, + ): + """ + 实例级工具注册装饰器。 + 将普通函数绑定为该智能体的专属工具。 + """ + + def decorator(f: Callable): + from zhenxun.services.ai.tools.core.tool import FunctionTool + from zhenxun.services.ai.tools.models import ToolOptions + + tool_name = name or f.__name__ + tool_desc = description or f.__doc__ or "未提供描述" + base_settings = settings or getattr(f, "__tool_settings__", ToolOptions()) + + func_tool = FunctionTool( + func=f, + name=tool_name, + description=tool_desc, + settings=base_settings, + ) + if self.tool_definitions is None: + self.tool_definitions = [] + self.tool_definitions.append(func_tool) + return f + + return decorator if func is None else decorator(func) + + def system_prompt(self, func: Callable | None = None): + """ + 实例级动态系统提示词注册装饰器 + """ + + def decorator(f: Callable): + if self.dynamic_prompts is None: + self.dynamic_prompts = [] + self.dynamic_prompts.append(f) + return f + + return decorator if func is None else decorator(func) + + def tool_filter(self, func: Callable | None = None): + """ + 实例级工具动态过滤装饰器 + """ + + def decorator(f: Callable): + if getattr(self, "tool_filters", None) is None: + self.tool_filters = [] + self.tool_filters.append(f) + return f + + return decorator if func is None else decorator(func) + + def toolset(self, func: Callable | None = None): + """ + 实例级动态工具集注册装饰器 + """ + + def decorator(f: Callable): + if getattr(self, "toolset_funcs", None) is None: + self.toolset_funcs = [] + self.toolset_funcs.append(f) + return f + + return decorator if func is None else decorator(func) + + def guardrail(self, func: Callable | str | Any | None = None): + """护栏装饰器/注册器 (支持传入函数或自然语言风控规则字符串)""" + if func is None: + + def decorator(f: Callable): + from zhenxun.services.ai.guardrails import parse_guardrails + + self._guardrails.extend(parse_guardrails([f])) + return f + + return decorator + else: + from zhenxun.services.ai.guardrails import parse_guardrails + + self._guardrails.extend(parse_guardrails([func])) + return func + + def on_event(self, event_type: type[AgentStreamEvent]) -> Callable: + """ + [事件门面] 生命周期事件监听器注册装饰器。 + 允许第三方开发者监听 Agent 运行时的各类事件,完美支持 Inject 依赖注入语法糖。 + """ + + def decorator(func: Callable): + if event_type not in self._event_listeners: + self._event_listeners[event_type] = [] + self._event_listeners[event_type].append(func) + return func + + return decorator + + async def __resolve_to_tools__(self) -> list[ToolExecutable]: + """协议支持:将自身 Agent 转化为可被上级调用的工具""" + from zhenxun.services.ai.tools.bridges.delegate import DelegateTool + + return [DelegateTool(self)] + + async def run( + self, + prompt: PromptInput | Task | None = None, + *, + config: AgentConfig | dict | None = None, + deps: AgentDepsT | None = None, + context: RunContext[AgentDepsT] | None = None, + **kwargs: Any, + ) -> AgentRunResult[OutputDataT]: + """ + 智能体单次运行阻塞核心入口,内部使用上下文管理器静默消费事件流直至执行结束。 + + 参数: + prompt: 用户输入的消息内容或标准数据契约任务对象 (Task)。 + deps: 强类型的外部依赖注入对象 (例如 NoneBot 的 Bot, Event)。 + context: 显式传入的运行时与会话上下文 (RunContext)。 + config: 单次运行时的动态配置覆盖字典或对象。 + kwargs: 透传的其他附加参数。 + """ + return await super().run( + prompt=prompt, + config=config, + deps=deps, + context=context, + **kwargs, + ) + + @contextlib.asynccontextmanager + async def run_stream( + self, + prompt: PromptInput | Task | None = None, + *, + config: AgentConfig | dict | None = None, + deps: AgentDepsT | None = None, + context: RunContext[AgentDepsT] | None = None, + event_bus: EventBus | None = None, + **kwargs: Any, + ) -> AsyncIterator[StreamedRunResult[OutputDataT]]: + """ + 智能体流式运行入口。 + 返回上下文管理器,可安全、解耦地获取底层事件或纯净文本结果。 + """ + override_conf = ( + AgentConfig(**config) + if isinstance(config, dict) + else (config or AgentConfig()) + ) + effective_config = self.config.merge_with(override_conf) + + if effective_config.skills: + from zhenxun.services.ai.tools.providers.skills.capabilities import ( + SkillCapability, + ) + + if effective_config.capabilities is None: + effective_config.capabilities = [] + effective_config.capabilities.append( + SkillCapability( + skills=effective_config.skills, namespace=infer_plugin_namespace() + ) + ) + bus = event_bus or EventBus() + + from zhenxun.services.ai.run.subscribers import ( + DefaultUISubscriber, + TelemetrySubscriber, + ) + + TelemetrySubscriber().attach(bus) + + if self._event_listeners: + for ev_type, callbacks in self._event_listeners.items(): + for cb in callbacks: + + def _make_handler(callback_func: Callable) -> Callable: + async def _di_handler(event: AgentStreamEvent): + from zhenxun.services.ai.run.di import DependencyInjector + + await DependencyInjector.invoke( + callback_func, {"stream_event": event}, safe_context + ) + + return _di_handler + + bus.subscribe(ev_type, _make_handler(cb)) + + if context is None: + explicit_session_id = kwargs.get("session_id") + safe_context = RunContext[AgentDepsT](session_id=explicit_session_id) + if deps is not None: + safe_context.deps = cast(AgentDepsT, deps) + else: + safe_context = context + if deps is not None and safe_context.deps is None: + safe_context.deps = cast(AgentDepsT, deps) + + if safe_context.get_bot() and safe_context.get_event(): + verbose_ui = effective_config.verbose_ui + DefaultUISubscriber(safe_context, verbose=verbose_ui).attach(bus) + + policy = getattr(self.config, "concurrency_policy", None) + if policy is None: + from zhenxun.services.ai.flow.base import ConcurrencyPolicy + + policy = ( + ConcurrencyPolicy.ALLOW + if getattr(self.config, "stateless", True) + else ConcurrencyPolicy.QUEUE + ) + + intervention_policy = getattr(self.config, "intervention_policy", None) + + from zhenxun.services.ai.utils import ContextUtils + + lock_id = ContextUtils.extract_concurrency_lock_id( + safe_context, + getattr(self.config, "concurrency_scope", None), + safe_context.session_id or "default_session", + ) + + async def _execution_task(): + from zhenxun.services.ai.flow.concurrency import apply_concurrency_policy + + cancel_token = safe_context.run.cancellation_token or CancellationToken() + safe_context.run.cancellation_token = cancel_token + + try: + async with apply_concurrency_policy( + session_id=safe_context.session_id or "default_session", + lock_id=lock_id, + policy=policy, + cancel_token=cancel_token, + intervention_policy=intervention_policy, + message=prompt, + ): + await bus.emit(AgentRunStart(agent_name=self.name)) + result = await self._run_step( + prompt=prompt, + context=safe_context, + config=effective_config, + cancellation_token=cancel_token, + event_bus=bus, + **kwargs, + ) + await bus.emit(AgentRunEnd(result=result)) + except ControlFlowExit as e: + await bus.emit(AgentRunError(error=e)) + except asyncio.CancelledError: + logger.debug(f"Agent {self.name} 执行被并发策略中断取消。") + await bus.emit( + AgentRunError( + error=ConcurrencyInterruptException("任务已被新请求打断并接管") + ) + ) + except Exception as e: + await bus.emit(AgentRunError(error=e)) + finally: + await bus.end() + + task = asyncio.create_task(_execution_task()) + result_obj = StreamedRunResult[OutputDataT](bus) + + try: + yield result_obj + finally: + if not task.done(): + task.cancel() + + def _parse_task_prompt( + self, prompt: PromptInput | Task | None + ) -> tuple[Task | None, Any | None, list[Any], Any, list[Any]]: + """解析输入意图,提取数据契约 (Task)""" + task_obj = None + final_prompt_payload = None + extra_tools = [] + run_output_type = self.response_model + task_guardrails = [] + + if isinstance(prompt, Task): + task_obj = prompt + if task_obj.response_model: + run_output_type = task_obj.response_model + if task_obj.tools: + extra_tools.extend(task_obj.tools) + if hasattr(task_obj, "_parsed_guardrails"): + task_guardrails.extend(task_obj._parsed_guardrails) + + prompt_parts = [ + f"### 📋 [任务指令]\n{task_obj.description}", + f"### 🎯 [预期产出要求]\n{task_obj.expected_output}", + ] + final_prompt_payload = "\n\n".join(prompt_parts) + elif prompt is not None: + final_prompt_payload = prompt + + return ( + task_obj, + final_prompt_payload, + extra_tools, + run_output_type, + task_guardrails, + ) + + async def on_state_init( + self, + prompt: PromptInput | Task | None = None, + context: RunContext[AgentDepsT] | None = None, + config: AgentConfig | None = None, + cancellation_token: Any = None, + event_bus: EventBus | None = None, + **kwargs: Any, + ) -> tuple[AgentState, AgentRunResources]: + """解析任务意图,初始化隔离域与基础状态载体""" + from zhenxun.services.ai.flow.agent.engine.builders import ( + AgentProfileResolver, + CapabilityBuilder, + SessionBuilder, + ) + + if context is None: + raise ValueError("RunContext 不能为空") + if config is None: + config = AgentConfig() + + ( + task_obj, + final_prompt_payload, + extra_tools, + run_output_type, + task_guardrails, + ) = self._parse_task_prompt(prompt) + + effective_memory = AgentProfileResolver.resolve_memory( + self.memory_config, config.memory + ) + + session_metadata, reader, writer = SessionBuilder.build_session_and_memory( + context, self.namespace, self.name, effective_memory + ) + + run_scoped_cap = await CapabilityBuilder.build_for_run( + agent_name=self.name, + namespace=self.namespace, + output_type=run_output_type, + raw_schema=getattr(self, "_raw_response_schema", None), + agent_guardrails=self._guardrails, + task_guardrails=task_guardrails, + task_obj=task_obj, + agent_capabilities=self.capabilities, + profile_capabilities=config.capabilities, + context=context, + ) + + resources = AgentRunResources( + run_context=context, + session_meta=session_metadata, + memory_reader=reader, + memory_writer=writer, + run_scoped_cap=run_scoped_cap, + task_obj=task_obj, + config=config, + ) + state = AgentState() + state.current_request_extra["final_prompt_payload"] = final_prompt_payload + state.current_request_extra["extra_tools"] = extra_tools + + if final_prompt_payload is not None: + if isinstance(final_prompt_payload, str): + context.run.user_input = final_prompt_payload + elif hasattr(final_prompt_payload, "extract_plain_text"): + context.run.user_input = final_prompt_payload.extract_plain_text() + else: + context.run.user_input = str(final_prompt_payload) + + context.run.agent_name = self.name + context.run.cancellation_token = cancellation_token + context.run.event_bus = event_bus + if not context.run.current_model: + context.run.current_model = ( + self.model_name() if callable(self.model_name) else self.model_name + ) + + return state, resources + + async def on_context_build( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """装配记忆与提示词上下文、解析可用工具集""" + from zhenxun.services.ai.capabilities import CombinedCapability + from zhenxun.services.ai.flow.agent.engine.builders import ( + AgentProfileResolver, + ContextBuilder, + ) + + context = resources.run_context + reader = resources.memory_reader + run_scoped_cap = ( + resources.run_scoped_cap + if isinstance(resources.run_scoped_cap, CombinedCapability) + else CombinedCapability([]) + ) + final_prompt_payload = state.current_request_extra.pop( + "final_prompt_payload", None + ) + extra_tools = state.current_request_extra.pop("extra_tools", []) + + static_prompt, dynamic_messages = await ContextBuilder.build_prompts( + instruction=self.instruction, + system_prompts=self.dynamic_prompts, + run_context=context, + run_scoped_cap=run_scoped_cap, + persona=cast(Persona | None, self.persona), + ) + + if reader: + long_term_fact = await reader.get_long_term_context( + context.run.user_input or "" + ) + if long_term_fact: + dynamic_messages.append(LLMMessage.system(long_term_fact)) + slots_fact = await reader.get_slots_context() + if slots_fact: + dynamic_messages.append(LLMMessage.system(slots_fact)) + + tool_payload = await ToolBuilder.resolve_tools( + tool_definitions=self.tool_definitions, + toolset_funcs=getattr(self, "toolset_funcs", []), + system_tools=[], + namespace=self.namespace or "unknown", + tool_filter=resources.config.tool_filter, + run_context=context, + run_scoped_cap=run_scoped_cap, + ) + effective_tools = tool_payload.tools + if extra_tools: + effective_tools.extend(extra_tools) + + cap_dynamic_config = await run_scoped_cap.get_generation_config(context) + final_gen_config = AgentProfileResolver.resolve_generation_config( + base_config=self.default_config, + cap_config=cap_dynamic_config, + profile_config=resources.config.generation_config, + ) + resources.generation_config = final_gen_config + resources.toolkits = tool_payload.toolkits + + static_prompts_list = [static_prompt] + if tool_payload.injected_prompts: + static_prompts_list.extend(tool_payload.injected_prompts) + + messages_for_run = ( + await reader.get_short_term_context( + model_name=context.run.current_model or "", + override_history=resources.config.message_history, + ) + if reader + else [] + ) + + if context.run.messages: + messages_for_run.extend(context.run.messages) + + if final_prompt_payload is not None: + from zhenxun.services.ai.message_builder import MessageBuilder + + if msgs := await MessageBuilder.normalize_to_llm_messages( + final_prompt_payload, bot=context.get_bot(), event=context.get_event() + ): + messages_for_run.append(msgs[-1]) + if resources.memory_writer: + await resources.memory_writer.save_new_messages([msgs[-1]]) + + final_tools = await ToolBuilder.prepare_effective_tools( + effective_tools, context, self.tool_filters, run_scoped_cap + ) + context.session.append_only_manager.build(static_prompts_list, final_tools) + context.session.append_only_manager.sync_messages(messages_for_run) + + state.messages = messages_for_run + state.tools = final_tools + state.static_system_prompt = static_prompts_list + state.dynamic_system_messages = dynamic_messages + state.origin_msg_len = len(messages_for_run) + + async def on_execute( + self, state: AgentState, resources: AgentRunResources + ) -> AgentRunResult[OutputDataT]: + """真正调度大模型执行器并执行记忆落盘""" + from zhenxun.services.ai.flow.agent.engine.executor import StandardAgentExecutor + + context = resources.run_context + + for tk in resources.toolkits: + if hasattr(tk, "before_llm_request"): + await DependencyInjector.invoke( + tk.before_llm_request, {"messages": state.messages}, context + ) + + config_exec = resources.config.executor if resources.config else None + executor = ( + config_exec + or self.executor + or StandardAgentExecutor(directive_handlers=self.directive_handlers) + ) + resources.model_name = context.run.current_model + raw_result: Any = await executor.run(state=state, resources=resources) + + new_msgs = raw_result.messages[state.origin_msg_len :] + if resources.memory_writer: + await resources.memory_writer.save_new_messages(new_msgs) + + final_output = getattr(raw_result, "output", None) or ( + raw_result.messages[-1].extract_text if raw_result.messages else "" + ) + + return cast( + AgentRunResult[OutputDataT], + model_construct( + AgentRunResult, + output=final_output, + messages=new_msgs, + structured_data=getattr(raw_result, "structured_data", None), + usage=getattr(raw_result, "usage", None) or UsageInfo(), + handoff=getattr(raw_result, "handoff", None), + ), + ) + + async def _run_step( + self, + prompt: PromptInput | Task | None = None, + *, + context: RunContext[AgentDepsT], + config: AgentConfig, + cancellation_token: Any = None, + event_bus: EventBus | None = None, + **kwargs: Any, + ) -> AgentRunResult[OutputDataT]: + """原子步总管:将具体的生命周期方法编织为洋葱模型管道""" + state, resources = await self.on_state_init( + prompt, + context, + config, + cancellation_token, + event_bus, + **kwargs, + ) + await self.on_context_build(state, resources) + + from zhenxun.services.ai.capabilities import CombinedCapability + + run_scoped_cap = ( + resources.run_scoped_cap + if isinstance(resources.run_scoped_cap, CombinedCapability) + else CombinedCapability([]) + ) + original_capabilities = getattr(context, "capabilities", []) + context.capabilities = run_scoped_cap.capabilities + + async def inner_run_handler() -> AgentRunResult[OutputDataT]: + return await self.on_execute(state, resources) + + try: + return await run_scoped_cap.wrap_run(context, inner_run_handler) + except ControlFlowExit as e: + raise e + except Exception as e: + raise e + finally: + context.capabilities = original_capabilities diff --git a/zhenxun/services/ai/flow/agent/bridge.py b/zhenxun/services/ai/flow/agent/bridge.py new file mode 100644 index 00000000..61ba4a32 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/bridge.py @@ -0,0 +1,142 @@ +import asyncio +from typing import Any, Generic, cast +from typing_extensions import TypeVar + +from nonebot.adapters import Bot, Event +from nonebot_plugin_alconna.uniseg import UniMessage + +from zhenxun.services.ai.core.exceptions import ( + ConcurrencyInterruptException, + ConcurrencyRejectException, + ControlFlowExit, + InterventionHandledException, +) +from zhenxun.services.ai.core.messages import UsageInfo +from zhenxun.services.ai.flow.base import BaseRunnable +from zhenxun.services.ai.run import AgentRunResult, RunContext +from zhenxun.services.ai.run.models import AgentRunEnd, AgentRunError +from zhenxun.services.ai.run.ui import UIController +from zhenxun.services.log import logger +from zhenxun.utils.message import MessageUtils + +T_Deps = TypeVar("T_Deps", default=Any) +T_Out = TypeVar("T_Out", default=str) + + +class AgentRunner(Generic[T_Out]): + """ + 智能体运行器。 + 负责将大模型的纯净数据流包装为平台交互动作(发消息、UI渲染)。 + 自带 ContextVars 隐式上下文提取魔法。 + """ + + def __init__( + self, + runnable: BaseRunnable, + context: RunContext | None = None, + **kwargs: Any, + ): + self.runnable = runnable + self.context = context or RunContext(**kwargs) + + is_stateless = ( + getattr(self.runnable.runtime_config, "stateless", True) + if hasattr(self.runnable, "runtime_config") + else True + ) + if is_stateless and self.context.session_id: + if not self.context.session_id.startswith("stateless_"): + import uuid + + self.context.session_id = ( + f"stateless_{self.context.session_id}_{uuid.uuid4().hex[:8]}" + ) + if self.context.session: + self.context.session.session_id = self.context.session_id + + @property + def _bot(self) -> Bot | None: + return self.context.get_bot() + + @property + def _event(self) -> Event | None: + return self.context.get_event() + + async def reply( + self, prompt: Any = None, reply_to: bool = False, **kwargs: Any + ) -> AgentRunResult[T_Out]: + """交互式执行:将 Agent 运行过程中的工具调用状态和最终结果自动发送给用户。""" + final_result = None + + profile = kwargs.pop("profile", None) + + try: + async with self.runnable.run_stream( + prompt=prompt, + context=self.context, + profile=profile, + **kwargs, + ) as stream_result: + async for stream_event in stream_result.stream_events(): + if isinstance(stream_event, AgentRunEnd): + final_result = stream_event.result + + elif isinstance(stream_event, AgentRunError): + raise stream_event.error + + except ControlFlowExit as e: + if isinstance(e, InterventionHandledException): + logger.info(f"✨ {self.runnable.name} 触发运行时干预: {e.message}") + if e.display_content and self._bot and self._event: + await MessageUtils.build_message(str(e.display_content)).send( + reply_to=reply_to + ) + return cast( + AgentRunResult[T_Out], AgentRunResult(output="", usage=UsageInfo()) + ) + + if isinstance(e, ConcurrencyRejectException): + logger.warning( + f"⏳ {self.runnable.name} 触发并发拒绝 (REJECT): {e.message}" + ) + return cast( + AgentRunResult[T_Out], AgentRunResult(output="", usage=UsageInfo()) + ) + + if isinstance(e, ConcurrencyInterruptException): + logger.warning( + f"🛑 {self.runnable.name} 触发并发中断 (INTERRUPT): {e.message}" + ) + return cast( + AgentRunResult[T_Out], AgentRunResult(output="", usage=UsageInfo()) + ) + + logger.debug( + f"{self.runnable.name} 控制流正常中断: {type(e).__name__} - {e}" + ) + await UIController.handle_control_flow_exit_display( + e, self.context, reply_to + ) + + raise asyncio.CancelledError() + + except Exception as e: + logger.error(f"{self.runnable.name} 运行失败: {e}", e=e) + if self._bot and self._event: + await MessageUtils.build_message(f"❌ 运行发生错误: {e}").send() + raise e + + if final_result and final_result.output and self._bot and self._event: + if isinstance(final_result.output, UniMessage): + await final_result.output.send( + self._event, bot=self._bot, reply_to=reply_to + ) + final_result.output = final_result.output.extract_plain_text() + else: + final_msg = str(final_result.output) + await MessageUtils.build_message(final_msg).send() + + if final_result is None: + raise RuntimeError("智能体运行流异常结束:未返回最终结果。") + + return cast(AgentRunResult[T_Out], final_result) diff --git a/zhenxun/services/ai/flow/agent/capabilities.py b/zhenxun/services/ai/flow/agent/capabilities.py new file mode 100644 index 00000000..f7374cb2 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/capabilities.py @@ -0,0 +1,156 @@ +import asyncio +from typing import Any, cast + +from zhenxun.services.ai.capabilities import AbstractCapability +from zhenxun.services.ai.capabilities.base import CapabilityOrdering +from zhenxun.services.ai.core.engine.structured_parser import ( + BaseOutputProcessor, + SubmitFinalResultExecutable, +) +from zhenxun.services.ai.core.exceptions import UpstreamServerException +from zhenxun.services.ai.core.messages import TaskLifecycleEvent +from zhenxun.services.ai.core.options import BaseOutputDefinition, ToolOutput +from zhenxun.services.ai.run import AgentRunResult, RunContext, Task +from zhenxun.services.log import logger + + +class OutputValidationCapability(AbstractCapability): + """输出拦截与校验能力组件 (支持纯文本及结构化护栏)""" + + def get_ordering(self) -> Any: + from zhenxun.services.ai.capabilities.builtin import ( + ReflexionCapability, + ) + + return CapabilityOrdering(wraps=[ReflexionCapability]) + + def __init__( + self, + output_type: Any | None = None, + guardrails: list[Any] | None = None, + raw_schema: dict[str, Any] | None = None, + ): + self.output_type = output_type + self.raw_schema = raw_schema + + from zhenxun.services.ai.guardrails import parse_guardrails + + self.guardrails = parse_guardrails(guardrails) + self.processor = None + self.submit_tool = None + + if self.output_type is not None: + if isinstance(self.output_type, BaseOutputDefinition): + out_type = self.output_type.type_ + tool_name_override = ( + self.output_type.name + if isinstance(self.output_type, ToolOutput) + else None + ) + else: + out_type = cast(type[Any], self.output_type) + tool_name_override = None + + self.processor = BaseOutputProcessor( + response_model=out_type, + ) + self.submit_tool = SubmitFinalResultExecutable( + self.processor, self.guardrails + ) + if tool_name_override: + self.submit_tool.name = tool_name_override + elif self.raw_schema is not None: + self.processor = BaseOutputProcessor( + response_model=None, + raw_schema=self.raw_schema, + ) + self.submit_tool = SubmitFinalResultExecutable( + self.processor, self.guardrails + ) + + async def get_system_prompts(self, context: RunContext) -> list[str]: + """动态注入结构化要求提示词""" + if self.submit_tool: + return [ + "### ⚠️ [核心任务:结构化输出要求]\n" + "当前任务处于严格的 **结构化输出模式**。\n" + "当你完成所有调查和思考后,必须且只能调用 " + f"`{self.submit_tool.name}` 工具来提交最终结果," + "禁止用纯文本直接作答。\n" + "(📌 提示:最终需要返回的数据结构要求," + "请严格查阅并遵循 " + f"`{self.submit_tool.name}` 工具的参数 Schema 定义," + "将其视为唯一的数据约束)" + ] + return [] + + async def get_tools(self, context: RunContext) -> list[Any]: + """动态挂载提交最终结果的工具""" + if self.submit_tool: + return [self.submit_tool] + return [] + + async def wrap_model_request(self, context, llm_context, handler): + """将 Processor 和 Guardrails 传给底层的 IvrCapability""" + llm_context.request.extra["output_processor"] = self.processor + llm_context.request.extra["guardrails"] = self.guardrails + return await handler(llm_context) + + async def wrap_run(self, context: RunContext, handler: Any) -> AgentRunResult[Any]: + """运行结束后,校验是否成功提取了结构化数据""" + result = await handler() + if self.output_type is not None or self.raw_schema is not None: + if result.structured_data is not None: + result.output = result.structured_data + else: + tool_name = self.submit_tool.name if self.submit_tool else "unknown" + logger.error(f"Agent 未能调用 {tool_name} 提交结构化数据。") + raise UpstreamServerException( + "模型未能输出符合要求的结构化数据。", + ) + return result + + +class TaskTrackingCapability(AbstractCapability): + """数据契约任务状态追踪与事件遥测组件""" + + def __init__(self, task: Task, agent_name: str): + self.task = task + self.agent_name = agent_name + + async def wrap_run(self, context: RunContext, handler: Any) -> AgentRunResult[Any]: + """任务生命周期追踪""" + + task_name = self.task.name or self.task.id[:8] + logger.debug(f"📋 **开始任务**: `{task_name}` (由 {self.agent_name} 执行)") + context.run.add_event(TaskLifecycleEvent(task_name=task_name, action="start")) + try: + result = await handler() + logger.debug(f"✅ **任务完成**: `{task_name}`") + context.run.add_event( + TaskLifecycleEvent(task_name=task_name, action="complete") + ) + return result + except asyncio.CancelledError as e: + logger.warning(f"⚠️ **任务被强制取消**: `{task_name}`") + context.run.add_event( + TaskLifecycleEvent( + task_name=task_name, + action="fail", + error_msg="任务执行被中止或取消", + ) + ) + raise e + except BaseException as error: + event_error = ( + error if isinstance(error, Exception) else Exception(str(error)) + ) + logger.error(f"❌ **任务失败**: `{task_name}` - {event_error}") + context.run.add_event( + TaskLifecycleEvent( + task_name=task_name, + action="fail", + error_msg=str(event_error), + ) + ) + raise error diff --git a/zhenxun/services/ai/flow/agent/engine/__init__.py b/zhenxun/services/ai/flow/agent/engine/__init__.py new file mode 100644 index 00000000..4571b6d5 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/engine/__init__.py @@ -0,0 +1,20 @@ +from .builders import ( + AgentProfileResolver, + CapabilityBuilder, + ContextBuilder, + ToolBuilder, +) +from .directive import DirectiveManager, directive, directive_manager +from .executor import BaseAgentExecutor, StandardAgentExecutor + +__all__ = [ + "AgentProfileResolver", + "BaseAgentExecutor", + "CapabilityBuilder", + "ContextBuilder", + "DirectiveManager", + "StandardAgentExecutor", + "ToolBuilder", + "directive", + "directive_manager", +] diff --git a/zhenxun/services/ai/flow/agent/engine/builders.py b/zhenxun/services/ai/flow/agent/engine/builders.py new file mode 100644 index 00000000..14cd476c --- /dev/null +++ b/zhenxun/services/ai/flow/agent/engine/builders.py @@ -0,0 +1,434 @@ +from collections.abc import Callable +import copy +import inspect +from typing import Any, cast + +from nonebot.utils import is_coroutine_callable + +from zhenxun.services.ai.capabilities import CombinedCapability +from zhenxun.services.ai.context.memory.models import MemoryConfig +from zhenxun.services.ai.core.messages import LLMMessage +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.core.templates import PromptTemplate +from zhenxun.services.ai.flow.agent.models import Persona +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.run.di import DependencyInjector +from zhenxun.services.ai.tools.engine.registry import ( + ToolCollection, + tool_provider_manager, +) +from zhenxun.services.ai.tools.models import GlobalToolFilter, ResolvedToolPayload +from zhenxun.utils.pydantic_compat import model_copy + + +class AgentProfileResolver: + """Agent 配置解析器:负责提取与合并 Agent 的运行时 Profile""" + + @staticmethod + def resolve_memory( + agent_memory_config: MemoryConfig, override_memory: Any | None + ) -> MemoryConfig: + from zhenxun.services.ai.context.memory.builder import MemoryBuilder + + if override_memory is not None: + return MemoryBuilder.resolve(override_memory) + return model_copy(agent_memory_config, deep=True) + + @staticmethod + def resolve_generation_config( + base_config: GenerationConfig, + cap_config: GenerationConfig | None, + profile_config: GenerationConfig | None, + ) -> GenerationConfig: + final_gen_config = model_copy(base_config, deep=True) + if cap_config: + final_gen_config = final_gen_config.merge_with(cap_config) + if profile_config: + final_gen_config = final_gen_config.merge_with(profile_config) + return final_gen_config + + +class CapabilityBuilder: + """拦截器能力组装器:负责合并 Agent, Task, Profile 和全局的中间件""" + + @staticmethod + async def build_for_run( + agent_name: str, + namespace: str, + output_type: Any | None, + raw_schema: dict | None, + agent_guardrails: list, + task_guardrails: list, + task_obj: Any | None, + agent_capabilities: list, + profile_capabilities: list | None, + context: RunContext, + ) -> CombinedCapability: + from zhenxun.services.ai.capabilities import ( + AbstractCapability, + DynamicCapability, + ) + from zhenxun.services.ai.flow.agent.capabilities import ( + OutputValidationCapability, + TaskTrackingCapability, + ) + + dynamic_caps = [] + combined_guardrails = agent_guardrails + task_guardrails + + if output_type is not None and output_type is not str: + dynamic_caps.append( + OutputValidationCapability(output_type, combined_guardrails) + ) + elif raw_schema is not None: + dynamic_caps.append( + OutputValidationCapability( + None, combined_guardrails, raw_schema=raw_schema + ) + ) + elif combined_guardrails: + dynamic_caps.append(OutputValidationCapability(None, combined_guardrails)) + + if task_obj: + dynamic_caps.append(TaskTrackingCapability(task_obj, agent_name)) + + run_level_caps = [] + if profile_capabilities: + for cap in profile_capabilities: + if isinstance(cap, AbstractCapability): + run_level_caps.append(cap) + elif callable(cap): + run_level_caps.append(DynamicCapability(cap)) + + from zhenxun.services.ai.run import ( + GLOBAL_CAPABILITIES, + ) + + base_caps = GLOBAL_CAPABILITIES.get("global", []).copy() + if namespace != "global" and namespace in GLOBAL_CAPABILITIES: + base_caps.extend(GLOBAL_CAPABILITIES[namespace]) + + combined_cap = CombinedCapability( + base_caps + + getattr(context, "capabilities", []) + + agent_capabilities + + run_level_caps + + dynamic_caps + ) + return cast(CombinedCapability, await combined_cap.for_run(context)) + + +class ContextBuilder: + """系统提示词与上下文记忆构建器""" + + @staticmethod + async def build_prompts( + instruction: str | PromptTemplate, + system_prompts: list[Any], + run_context: RunContext, + run_scoped_cap: CombinedCapability, + persona: Persona | None = None, + ) -> tuple[str, list[Any]]: + """解析提示词,返回 (静态系统提示词文本, 动态独立消息列表) 元组""" + + static_instructions = [] + dynamic_messages = [] + + for sp_func in system_prompts: + sig = inspect.signature(sp_func) + if len(sig.parameters) > 0: + injected_kwargs = await DependencyInjector.resolve_all( + sig=sig, + call_kwargs={}, + context=run_context, + ) + res = ( + (await sp_func(**injected_kwargs)) + if is_coroutine_callable(sp_func) + else sp_func(**injected_kwargs) + ) + else: + res = (await sp_func()) if is_coroutine_callable(sp_func) else sp_func() + if res: + if isinstance(res, LLMMessage): + dynamic_messages.append(res) + elif isinstance(res, list) and all( + isinstance(m, LLMMessage) for m in res + ): + dynamic_messages.extend(res) + else: + if isinstance(res, list): + for item in res: + if item: + dynamic_messages.append(LLMMessage.system(str(item))) + else: + dynamic_messages.append(LLMMessage.system(str(res))) + + if persona: + persona_parts = [ + f"## 扮演角色 (Role)\n{persona.role}", + f"## 核心目标 (Goal)\n{persona.goal}", + ] + if persona.backstory: + persona_parts.append(f"## 角色背景 (Backstory)\n{persona.backstory}") + static_instructions.append("\n\n".join(persona_parts)) + + if instruction: + static_instructions.append("## 本次任务指令 (Task)") + + if instruction: + if isinstance(instruction, PromptTemplate): + static_instructions.append(instruction.format_with_context(run_context)) + else: + static_instructions.append(str(instruction)) + + caps = ( + run_scoped_cap.capabilities + if run_scoped_cap + else getattr(run_context, "capabilities", []) + ) + for cap in caps: + cap_prompts = await cap.get_system_prompts(run_context) + for prompt_text in cap_prompts: + if prompt_text and prompt_text.strip(): + dynamic_messages.append(LLMMessage.system(prompt_text)) + + static_text = "\n\n".join(static_instructions) + + render_context = { + "deps": run_context.deps, + "bot": getattr(run_context.deps, "bot", None), + "event": getattr(run_context.deps, "event", None), + "matcher": getattr(run_context.deps, "matcher", None), + } + if run_context.state: + render_context.update(run_context.state) + + rendered_dynamic_messages = [] + from zhenxun.services.ai.core.messages import TextPart + + for msg in dynamic_messages: + if msg.role == "system": + new_content = [] + changed = False + for part in msg.content: + if isinstance(part, TextPart) and part.text: + try: + rendered_text = PromptTemplate(part.text).render( + **render_context + ) + new_content.append(TextPart(text=rendered_text)) + if rendered_text != part.text: + changed = True + except Exception: + new_content.append(part) + else: + new_content.append(part) + if changed: + new_msg = msg.model_copy(deep=True) + new_msg.content = new_content + rendered_dynamic_messages.append(new_msg) + else: + rendered_dynamic_messages.append(msg) + else: + rendered_dynamic_messages.append(msg) + + return ( + PromptTemplate(static_text).render(**render_context), + rendered_dynamic_messages, + ) + + +class ToolBuilder: + """系统工具集合解析与构建器""" + + @staticmethod + async def resolve_tools( + tool_definitions: list[Any], + toolset_funcs: list[Any], + system_tools: list[Any], + namespace: str, + tool_filter: GlobalToolFilter | None, + run_context: RunContext, + run_scoped_cap: CombinedCapability, + ) -> ResolvedToolPayload: + """解析、合并并过滤工具集""" + defs_to_resolve = list(tool_definitions) + + for ts_func in toolset_funcs: + sig = inspect.signature(ts_func) + injected_kwargs = {} + if len(sig.parameters) > 0: + injected_kwargs = await DependencyInjector.resolve_all( + sig=sig, + call_kwargs={}, + context=run_context, + ) + + res = ( + (await ts_func(**injected_kwargs)) + if is_coroutine_callable(ts_func) + else ts_func(**injected_kwargs) + ) + + if res is not None: + if isinstance(res, list): + defs_to_resolve.extend(res) + else: + defs_to_resolve.append(res) + + if system_tools: + for st in system_tools: + if st not in defs_to_resolve: + defs_to_resolve.append(st) + + caps = ( + run_scoped_cap.capabilities + if run_scoped_cap + else getattr(run_context, "capabilities", []) + ) + for cap in caps: + cap_tools = await cap.get_tools(run_context) + defs_to_resolve.extend(cap_tools) + + payload = await tool_provider_manager.resolve_tools( + defs_to_resolve, namespace, context=run_context + ) + + return payload + + @staticmethod + async def prepare_effective_tools( + effective_tools: list[Any], + context: RunContext, + tool_filters: list[Callable], + run_scoped_cap: CombinedCapability, + ) -> ToolCollection: + """处理生命周期:在工具发往执行器前,进行最终的 Schema 拦截和清洗""" + current_tool_defs = [] + for t_exec in effective_tools: + if hasattr(t_exec, "get_definition"): + t_def = await t_exec.get_definition(context) + if t_def: + current_tool_defs.append(t_def) + + if tool_filters: + for filter_func in tool_filters: + sig = inspect.signature(filter_func) + call_kwargs = {"tool_defs": current_tool_defs} + resolved_kwargs = await DependencyInjector.resolve_all( + sig, call_kwargs, context + ) + filtered_kwargs = { + k: v for k, v in resolved_kwargs.items() if k in sig.parameters + } + _res = ( + await filter_func(**filtered_kwargs) + if is_coroutine_callable(filter_func) + else filter_func(**filtered_kwargs) + ) + if _res is not None: + current_tool_defs = list(_res) + + _cap_res = await run_scoped_cap.prepare_tools(context, current_tool_defs) + if _cap_res is not None: + current_tool_defs = list(_cap_res) + + final_defs_map = {d.name.lower(): d for d in current_tool_defs if d} + final_effective_tools = ToolCollection() + for t_exec in effective_tools: + t_name = getattr(t_exec, "name", "unknown") + if t_name.lower() in final_defs_map: + cloned_tool = copy.copy(t_exec) + cloned_tool._dynamic_def = final_defs_map[t_name.lower()] + final_effective_tools.append(cloned_tool) + return final_effective_tools + + +class SessionBuilder: + """会话与记忆域构建器:负责隔离前缀计算和读写门面装配""" + + @staticmethod + def build_session_and_memory( + context: RunContext, + namespace: str, + agent_name: str, + effective_memory: MemoryConfig, + ) -> tuple[Any, Any, Any]: + from zhenxun.services.ai.context.memory.engine import MemoryReader, MemoryWriter + from zhenxun.services.ai.context.memory.types import SessionMetadata + from zhenxun.services.ai.utils.scope import ScopeSelector + + bot_id = None + bot_inst = context.get_bot() + if bot_inst and hasattr(bot_inst, "self_id"): + bot_id = str(bot_inst.self_id) + + selector = ScopeSelector( + user_id=context.get_user_id(), + group_id=context.get_group_id(), + platform=context.get_platform(), + bot_id=bot_id, + namespace=namespace, + agent_name=agent_name, + ) + + all_scopes = {"/"} + scope_name_mapping = {} + + if effective_memory.short_term and effective_memory.short_term.isolation: + sel = effective_memory.short_term.isolation.resolve( + deps=context.deps, + prefix="", + default_namespace=namespace, + default_agent=agent_name, + ) + all_scopes.add(sel.scope_prefix) + + for config_part in [effective_memory.slots, effective_memory.long_term]: + if config_part and hasattr(config_part, "scopes") and config_part.scopes: + for name, builder in config_part.scopes.items(): + sel = builder.resolve( + deps=context.deps, + prefix="", + default_namespace=namespace, + default_agent=agent_name, + ) + all_scopes.add(sel.scope_prefix) + scope_name_mapping[sel.scope_prefix] = name + + parts = selector.get_scope_parts() + for i in range(len(parts)): + all_scopes.add("/" + "/".join(parts[: i + 1])) + + accessible_scopes = sorted(all_scopes, key=lambda x: len(x.split("/"))) + + short_term_builder = ( + effective_memory.short_term.isolation + if effective_memory.short_term + else effective_memory.base_isolation + ) + short_term_selector = short_term_builder.resolve( + deps=context.deps, + prefix="", + default_namespace=namespace, + default_agent=agent_name, + ) + + session_metadata = SessionMetadata( + session_id=short_term_selector.scope_prefix, + selector=selector, + scope_prefix=selector.scope_prefix, + accessible_scopes=accessible_scopes, + scope_name_mapping=scope_name_mapping, + ) + reader = MemoryReader( + session_meta=session_metadata, memory_config=effective_memory + ) + writer = MemoryWriter( + session_meta=session_metadata, + memory_config=effective_memory, + context=context, + ) + + return session_metadata, reader, writer diff --git a/zhenxun/services/ai/flow/agent/engine/directive.py b/zhenxun/services/ai/flow/agent/engine/directive.py new file mode 100644 index 00000000..cbf49a9e --- /dev/null +++ b/zhenxun/services/ai/flow/agent/engine/directive.py @@ -0,0 +1,115 @@ +from collections import defaultdict +from collections.abc import Awaitable, Callable + +from zhenxun.services.ai.flow.agent.models import AgentRunResources, AgentState +from zhenxun.services.ai.run.models import AgentRunResult, HandoffPayload +from zhenxun.services.ai.tools.models import ToolResult +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_construct +from zhenxun.utils.utils import infer_plugin_namespace + +DirectiveHandlerFunc = Callable[ + [AgentState, AgentRunResources, ToolResult], Awaitable[None] +] + + +class DirectiveManager: + """工具指令路由注册中心""" + + def __init__(self): + self._handlers: dict[str, dict[str, DirectiveHandlerFunc]] = defaultdict(dict) + + def register( + self, name: str, handler: DirectiveHandlerFunc, namespace: str = "global" + ) -> None: + self._handlers[namespace][name] = handler + logger.debug(f"已注册工具副作用指令: '{name}' -> Namespace: '{namespace}'") + + def get_handler( + self, name: str, namespace: str = "global" + ) -> DirectiveHandlerFunc | None: + """优先从指定 namespace 找,找不到回退到 global""" + ns_dict = self._handlers.get(namespace, {}) + if name in ns_dict: + return ns_dict[name] + return self._handlers.get("global", {}).get(name) + + +directive_manager = DirectiveManager() + + +def directive(name: str | None = None, namespace: str | None = None): + """ + 注册一个自定义工具副作用指令处理器的装饰器。 + + 参数: + name: 指令的名称,如果不填则默认使用被装饰的函数名。 + namespace: 插件命名空间,如果不填则基于代码调用栈自动推断。 + """ + + def decorator(func: DirectiveHandlerFunc): + dir_name = name or func.__name__ + ns = namespace if namespace is not None else infer_plugin_namespace() + directive_manager.register(dir_name, func, ns) + return func + + return decorator + + +@directive("submit_structured", namespace="global") +async def handle_submit_structured( + state: AgentState, resources: AgentRunResources, tool_res: ToolResult +) -> None: + parsed_obj = ( + tool_res.directive.payload.get("parsed_obj") if tool_res.directive else None + ) + logger.info("✅ 拦截到结构化结果提交,结束循环。") + state.is_finished = True + state.final_result = model_construct( + AgentRunResult, + output=None, + messages=state.messages, + structured_data=parsed_obj, + usage=state.usage, + ) + + +@directive("end_run", namespace="global") +async def handle_end_run( + state: AgentState, resources: AgentRunResources, tool_res: ToolResult +) -> None: + output = ( + tool_res.directive.payload.get("output", tool_res.output) + if tool_res.directive + else tool_res.output + ) + logger.debug("✅ 捕获到工具发出的终止信号,提前结束推理循环。") + state.is_finished = True + state.final_result = model_construct( + AgentRunResult, + output=output, + messages=state.messages, + usage=state.usage, + ) + + +@directive("handoff", namespace="global") +async def handle_handoff( + state: AgentState, resources: AgentRunResources, tool_res: ToolResult +) -> None: + payload = tool_res.directive.payload if tool_res.directive else {} + handoff = HandoffPayload( + target=payload.get("target", "unknown"), + reason=payload.get("reason", ""), + context_data=payload.get("context_data", ""), + ) + output_text = f"已触发控制权移交 -> {handoff.target}。原因: {handoff.reason}" + logger.info(f"✅ 拦截到移交(Handoff)信号: 移交给 -> {handoff.target}。结束循环。") + state.is_finished = True + state.final_result = model_construct( + AgentRunResult, + output=output_text, + messages=state.messages, + usage=state.usage, + handoff=handoff, + ) diff --git a/zhenxun/services/ai/flow/agent/engine/executor.py b/zhenxun/services/ai/flow/agent/engine/executor.py new file mode 100644 index 00000000..12265a6f --- /dev/null +++ b/zhenxun/services/ai/flow/agent/engine/executor.py @@ -0,0 +1,691 @@ +from abc import ABC, abstractmethod +import asyncio +import json +from typing import Any, cast + +from zhenxun.services.ai.core.engine.context_renderer import ContextConverter +from zhenxun.services.ai.core.engine.token_counter import ( + parse_usage_info, + token_counter, +) +from zhenxun.services.ai.core.exceptions import ( + ControlFlowExit, + UpstreamServerException, +) +from zhenxun.services.ai.core.messages import ( + AgentMessage, + AssistantContentUnion, + AssistantMessage, + AudioPart, + ChatRequest, + ChatResponse, + FilePart, + ImagePart, + LLMMessage, + TextPart, + ToolCallPart, + ToolReturnPart, + VideoPart, +) +from zhenxun.services.ai.core.models import LLMContext +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.core.stream_events import ( + LLMEndEvent, + LLMStartEvent, + ToolStreamChunkEvent, +) +from zhenxun.services.ai.flow.agent.engine.directive import ( + DirectiveHandlerFunc, +) +from zhenxun.services.ai.flow.agent.models import AgentRunResources, AgentState +from zhenxun.services.ai.run import AgentRunResult, RunContext +from zhenxun.services.ai.tools.engine.executor import ToolExecutor +from zhenxun.services.ai.tools.models import ToolResult +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import dump_json_safely, model_construct + + +class BaseAgentExecutor(ABC): + """ + Agent 核心执行器基类 (Template Method Pattern)。 + 定义了基于生命周期的大模型控制流。第三方开发者可通过重写特定钩子, + """ + + async def run( + self, state: AgentState, resources: AgentRunResources + ) -> AgentRunResult[Any]: + """ + 核心模板方法 (Template Method)。 + 组织整个大模型推导与工具调用的生命周期循环。如无必要,请勿重写此方法。 + """ + await self.on_start(state, resources) + + try: + for cycle_index in range(resources.config.max_cycles): + state.current_cycle = cycle_index + await self.on_cycle_start(state, resources) + + await self.build_llm_request(state, resources) + await self.execute_llm(state, resources) + + await self.handle_llm_response(state, resources) + if state.is_finished: + assert state.final_result is not None + return state.final_result + + await self.filter_tool_calls(state, resources) + if state.is_finished: + assert state.final_result is not None + return state.final_result + + await self.execute_tools(state, resources) + + await self.handle_tool_results(state, resources) + if state.is_finished: + assert state.final_result is not None + return state.final_result + + return await self.on_fallback(state, resources) + except Exception as e: + raise e + + @abstractmethod + async def on_start(self, state: AgentState, resources: AgentRunResources) -> None: + """生命周期: Agent 启动时调用,用于初始化状态或资源。""" + pass + + @abstractmethod + async def on_cycle_start( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """生命周期: 每次推理循环开始时调用。可用于 Token 预估或防死循环检测。""" + pass + + @abstractmethod + async def build_llm_request( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """生命周期: 构造请求大模型的 Messages 上下文和 Extra 参数。""" + pass + + @abstractmethod + async def execute_llm( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """生命周期: 触发大模型 API 请求并返回响应。""" + pass + + @abstractmethod + async def handle_llm_response( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """ + 生命周期: 处理大模型返回的结果,解析 Token 用量, + 并将模型回复追加至对话历史。 + """ + pass + + @abstractmethod + async def filter_tool_calls( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """生命周期: 从大模型的响应中提取并过滤出需要在本地客户端执行的工具调用请求。""" + pass + + @abstractmethod + async def execute_tools( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """生命周期: 并发执行提取出的工具,并收集结果或异常。""" + pass + + @abstractmethod + async def handle_tool_results( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """ + 生命周期: 处理工具返回的结果。 + 包括异常拦截、UI 渲染、Handoff 移交指令以及将结果追加至对话历史。 + """ + pass + + @abstractmethod + async def on_fallback( + self, state: AgentState, resources: AgentRunResources + ) -> AgentRunResult[Any]: + """生命周期: 当大模型思考循环达到 max_cycles 时触发,执行兜底策略。""" + pass + + +class StandardAgentExecutor(BaseAgentExecutor): + """ + LLM 任务执行器(核心推理引擎)。 + 负责:生命周期回调触发、工具循环调用、 + 错误反思(Reflexion)、Token消耗追踪。 + """ + + def __init__( + self, directive_handlers: dict[str, DirectiveHandlerFunc] | None = None + ): + self.tool_executor = ToolExecutor() + self._directive_handlers: dict[str, DirectiveHandlerFunc] = ( + directive_handlers or {} + ) + + def _can_retry_via_llm(self, result: ToolResult) -> bool: + """通过新版的专属字段直接判断是否允许重试""" + return result.is_retryable + + def _check_follow_up( + self, state: AgentState, resources: AgentRunResources, session_info: Any + ) -> bool: + """检查追加队列,排空并合并数据到上下文,返回是否发现新消息""" + follow_ups = session_info.follow_up_queue.drain() + if follow_ups: + for fm in follow_ups: + state.messages.append(LLMMessage.user(f"💬 [用户追加指示]:{fm}")) + resources.run_context.session.append_only_manager.sync_messages( + state.messages + ) + state.is_finished = False + state.final_result = None + return True + return False + + async def _invoke_and_record_llm( + self, + state: AgentState, + resources: AgentRunResources, + messages: list[AgentMessage], + tools: list[Any] | None, + tool_choice: Any = None, + ) -> ChatResponse: + """执行 LLM 请求,处理基础指标遥测统计,并将新对话上下文追加到状态流""" + run_context = resources.run_context + cancellation_token = run_context.run.cancellation_token + + current_extra = run_context.state.copy() + current_extra["__global_max_cycles__"] = getattr( + resources.config, "global_max_cycles", None + ) + current_extra["__sys_capabilities"] = getattr(run_context, "capabilities", []) + current_extra["run_context"] = run_context + + flattened_messages = ContextConverter.flatten_to_llm_messages( + messages, run_context + ) + + if run_context.run.event_bus: + await run_context.run.event_bus.emit( + LLMStartEvent( + model_name=resources.model_name or "unknown", + messages=flattened_messages, + ) + ) + + response = await self._execute_model_request( + model_name=resources.model_name, + messages=flattened_messages, + config=resources.generation_config or GenerationConfig(), + run_context=run_context, + tools=tools, + tool_choice=tool_choice, + extra=current_extra, + cancellation_token=cancellation_token, + ) + + if run_context.run.event_bus: + await run_context.run.event_bus.emit(LLMEndEvent(response=response)) + + assistant_content = ( + response.content_parts if response.content_parts else response.text + ) + if response.thought_signature and isinstance(assistant_content, list): + for part in assistant_content: + if part.type == "thought": + if part.metadata is None: + part.metadata = {} + part.metadata["thought_signature"] = response.thought_signature + break + + assistant_message = AssistantMessage( + content=cast(list[AssistantContentUnion], response.content_parts) + ) + + if hasattr(response, "parsed_obj") and response.parsed_obj is not None: + if not isinstance(response.parsed_obj, str): + if assistant_message.metadata is None: + assistant_message.metadata = {} + assistant_message.metadata["parsed_obj"] = response.parsed_obj + + usage_obj = parse_usage_info(response.usage_info) + state.usage += usage_obj + if usage_obj.completion_tokens > 0: + assistant_message.token_cost = usage_obj.completion_tokens + + state.messages.append(assistant_message) + run_context.session.append_only_manager.sync_messages(state.messages) + + return response + + async def _execute_model_request( + self, + model_name: str | None, + messages: list[LLMMessage], + config: GenerationConfig, + run_context: RunContext, + tools: list[Any] | None = None, + tool_choice: Any = None, + extra: dict[str, Any] | None = None, + cancellation_token: Any = None, + ) -> ChatResponse: + from zhenxun.services.ai.capabilities import CombinedCapability + from zhenxun.services.ai.llm.engine.router import LLMOrchestrator + + request = ChatRequest( + messages=messages, + config=config, + tools=tools, + tool_choice=tool_choice, + extra=extra or {}, + ) + + sys_caps = request.extra.pop("__sys_capabilities", []) + llm_context = LLMContext(request=request, cancellation_token=cancellation_token) + combined_cap = CombinedCapability(sys_caps) + + async def inner_handler(ctx: LLMContext[Any, Any]) -> ChatResponse: + return await LLMOrchestrator.invoke( + request=ctx.request, + model_name=model_name, + task="chat", + override_config=config, + cancellation_token=ctx.cancellation_token, + ) + + return await combined_cap.wrap_model_request( + run_context, llm_context, inner_handler + ) + + async def on_start(self, state: AgentState, resources: AgentRunResources) -> None: + resources.run_context.run.messages = state.messages + + async def run( + self, state: AgentState, resources: AgentRunResources + ) -> AgentRunResult[Any]: + """覆盖基类的模板方法,实现灵活的 while 控制流和 FOLLOW_UP 合并""" + await self.on_start(state, resources) + from zhenxun.services.ai.run.session import session_manager + + session_info = await session_manager.get_or_create( + resources.run_context.session_id or "default_session" + ) + + try: + cycle_count = 0 + while cycle_count < resources.config.max_cycles: + state.current_cycle = cycle_count + await self.on_cycle_start(state, resources) + + await self.build_llm_request(state, resources) + await self.execute_llm(state, resources) + + await self.handle_llm_response(state, resources) + if state.is_finished: + if self._check_follow_up(state, resources, session_info): + cycle_count = 0 + continue + assert state.final_result is not None + return state.final_result + + await self.filter_tool_calls(state, resources) + if state.is_finished: + if self._check_follow_up(state, resources, session_info): + cycle_count = 0 + continue + assert state.final_result is not None + return state.final_result + + await self.execute_tools(state, resources) + + await self.handle_tool_results(state, resources) + if state.is_finished: + if self._check_follow_up(state, resources, session_info): + cycle_count = 0 + continue + assert state.final_result is not None + return state.final_result + + cycle_count += 1 + + if self._check_follow_up(state, resources, session_info): + return await self.run(state, resources) + + return await self.on_fallback(state, resources) + except Exception as e: + raise e + + async def on_cycle_start( + self, state: AgentState, resources: AgentRunResources + ) -> None: + cancellation_token = resources.run_context.run.cancellation_token + if cancellation_token: + cancellation_token.raise_if_cancelled() + + try: + est_tokens = token_counter.count_context( + state.messages, resources.model_name or "", base_overhead=0 + ) + logger.debug( + f"[TokenTracker] (Iter {state.current_cycle + 1}) " + f"预估将消耗 {est_tokens} Token " + f"(Model: {resources.model_name or 'Unknown'})" + ) + except Exception: + pass + + async def build_llm_request( + self, state: AgentState, resources: AgentRunResources + ) -> None: + run_context = resources.run_context + + from zhenxun.services.ai.run.session import session_manager + + session_info = await session_manager.get_or_create( + run_context.session_id or "default_session" + ) + steer_msgs = session_info.steer_queue.drain() + if steer_msgs: + for sm in steer_msgs: + state.messages.append(LLMMessage.user(f"💬 [用户实时修正指示]:{sm}")) + run_context.session.append_only_manager.sync_messages(state.messages) + + messages_to_send = [] + if state.static_system_prompt: + if isinstance(state.static_system_prompt, list): + for sp in state.static_system_prompt: + if sp and sp.strip(): + messages_to_send.append(LLMMessage.system(sp)) + else: + if state.static_system_prompt and state.static_system_prompt.strip(): + messages_to_send.append( + LLMMessage.system(state.static_system_prompt) + ) + + if state.dynamic_system_messages: + messages_to_send.extend(state.dynamic_system_messages) + + if ( + hasattr(run_context.run, "dynamic_prompts") + and run_context.run.dynamic_prompts + ): + for prompt_text in run_context.run.dynamic_prompts.values(): + if prompt_text and prompt_text.strip(): + messages_to_send.append(LLMMessage.system(prompt_text)) + + messages_to_send.extend(state.messages) + + state.current_request_messages = messages_to_send + + async def execute_llm( + self, state: AgentState, resources: AgentRunResources + ) -> None: + tools = state.tools + + state.current_response = await self._invoke_and_record_llm( + state=state, + resources=resources, + messages=state.current_request_messages, + tools=list(tools) if tools else None, + tool_choice=None, + ) + + async def handle_llm_response( + self, state: AgentState, resources: AgentRunResources + ) -> None: + response = state.current_response + if not response: + return + + if not response.tool_calls: + logger.debug("✅ AgentExecutor:模型未请求工具调用,推理循环结束。") + state.is_finished = True + state.final_result = model_construct( + AgentRunResult, + output=response.text, + messages=state.messages, + usage=state.usage, + ) + + async def filter_tool_calls( + self, state: AgentState, resources: AgentRunResources + ) -> None: + response = state.current_response + if not response: + return + tools = state.tools + event_bus = resources.run_context.run.event_bus + + completed_call_ids = { + p.tool_call_id + for p in response.content_parts + if isinstance(p, ToolReturnPart) + } + client_tool_calls = [] + for call in response.tool_calls: + tool_inst = tools.get(call.tool_name) if tools else None + is_server_side = call.id in completed_call_ids or ( + tool_inst and getattr(tool_inst, "execution_side", "client") == "server" + ) + + if is_server_side: + logger.debug( + "☁️ [AgentExecutor] 检测到云端工具调用: " + f"{call.tool_name},已跳过本地执行。" + ) + async with self.tool_executor._tool_stream_scope( + event_bus, + call.tool_name, + call.args if isinstance(call.args, dict) else {}, + getattr(call, "intent", None), + ) as box: + return_part = next( + ( + p + for p in response.content_parts + if isinstance(p, ToolReturnPart) + and p.tool_call_id == call.id + ), + None, + ) + if return_part: + from zhenxun.services.ai.tools.models import ToolResult + + box["result"] = ToolResult(output=return_part.output) + else: + client_tool_calls.append(call) + + if not client_tool_calls: + logger.info("✅ AgentExecutor:无本地客户端工具需执行,推理循环平滑结束。") + + state.is_finished = True + state.final_result = model_construct( + AgentRunResult, + output=response.text, + messages=state.messages, + usage=state.usage, + ) + + state.current_tool_calls = client_tool_calls + + async def execute_tools( + self, state: AgentState, resources: AgentRunResources + ) -> None: + run_context = resources.run_context + tools = state.tools + event_bus = run_context.run.event_bus + tool_calls = state.current_tool_calls + + if not tool_calls: + return + + val_tasks = [ + self.tool_executor.validate_tool_call( + call, + tools, + run_context, + event_bus=event_bus, + ) + for call in tool_calls + ] + validated_calls = await asyncio.gather(*val_tasks) + + exec_tasks = [ + self.tool_executor.execute_tool_call( + val_call, + tools, + run_context, + event_bus=event_bus, + ) + for val_call in validated_calls + ] + tool_results = await asyncio.gather(*exec_tasks, return_exceptions=True) + state.current_tool_results = tool_results + + def _assemble_tool_message( + self, + original_call: ToolCallPart, + res_or_exc: Any, + tool_res: ToolResult | None, + state: AgentState, + ) -> LLMMessage: + """负责处理异常、解析多模态、序列化,并装配为最终的工具消息载体""" + media_parts = [] + final_content = "Success" + + if isinstance(res_or_exc, BaseException): + if isinstance(res_or_exc, ControlFlowExit): + raise res_or_exc + final_content = json.dumps( + {"error": str(res_or_exc), "status": "failed"}, + ensure_ascii=False, + ) + elif tool_res is not None: + if isinstance(tool_res.output, list): + texts = [] + for item in tool_res.output: + if isinstance(item, ImagePart | AudioPart | VideoPart | FilePart): + media_parts.append(item) + elif isinstance(item, TextPart): + texts.append(item.text) + else: + texts.append(str(item)) + final_content = " ".join(texts) if texts else "Success" + elif isinstance(tool_res.output, str): + final_content = tool_res.output + else: + final_content = dump_json_safely(tool_res.output, ensure_ascii=False) + + tool_usage = getattr(tool_res, "usage", None) + if tool_usage is not None: + state.usage += tool_usage + + msg = LLMMessage.tool_response( + original_call.id, original_call.tool_name, final_content + ) + if media_parts: + msg.content.extend(media_parts) + return msg + + async def handle_tool_results( + self, state: AgentState, resources: AgentRunResources + ) -> None: + """处理所有工具执行结果,调度副作用指令并装配对话回传报文。""" + tool_calls = state.current_tool_calls + tool_results = state.current_tool_results + if not tool_calls or not tool_results: + return + + from zhenxun.services.ai.flow.agent.engine.directive import directive_manager + + for i, res_or_exc in enumerate(tool_results): + original_call = tool_calls[i] + tool_res = None + + if not isinstance(res_or_exc, BaseException): + _, raw_tool_res = res_or_exc + tool_res = raw_tool_res + + msg = self._assemble_tool_message( + original_call, res_or_exc, tool_res, state + ) + state.messages.append(msg) + + if tool_res and getattr(tool_res, "directive", None): + ns = getattr(resources.run_context.session, "namespace", "global") + handler = directive_manager.get_handler( + tool_res.directive.name, namespace=ns + ) + + if handler: + await handler(state, resources, tool_res) + if state.is_finished: + resources.run_context.session.append_only_manager.sync_messages( + state.messages + ) + return + else: + logger.warning( + f"⚠️ 未能找到名为 '{tool_res.directive.name}' " + f"的指令处理器 (Namespace: {ns})" + ) + + resources.run_context.session.append_only_manager.sync_messages(state.messages) + + async def on_fallback( + self, state: AgentState, resources: AgentRunResources + ) -> AgentRunResult[Any]: + run_context = resources.run_context + event_bus = run_context.run.event_bus + + if not resources.config.enable_fallback_summary: + raise UpstreamServerException( + f"超过最大工具调用循环次数 ({resources.config.max_cycles})。", + ) + + logger.warning( + f"AgentExecutor 达到最大循环次数 ({resources.config.max_cycles})," + "触发兜底总结机制。" + ) + + if event_bus: + await event_bus.emit( + ToolStreamChunkEvent( + tool_name="System", + content="⏳ 思考过程过于复杂,正在强制生成最终总结...", + ) + ) + + fallback_msg = LLMMessage.user( + "### 🚨 [系统强制指令]\n" + "你的任务执行已达到最大工具调用循环次数上限,当前思考流已被框架强制中断。\n" + "请**诚实地**向用户总结:你目前进行到了哪一步?遇到了什么困难导致循环耗尽?还有哪些预期步骤未能完成?\n" + "**绝对禁止**对用户撒谎声声称你已经完成了任务。严禁再次尝试调用任何工具!请直接输出纯文本结果。" + ) + state.messages.append(fallback_msg) + + fallback_response = await self._invoke_and_record_llm( + state=state, + resources=resources, + messages=state.messages, + tools=[], + tool_choice="none", + ) + + return model_construct( + AgentRunResult, + output=fallback_response.text, + messages=state.messages, + structured_data=None, + usage=state.usage, + ) diff --git a/zhenxun/services/ai/flow/agent/models.py b/zhenxun/services/ai/flow/agent/models.py new file mode 100644 index 00000000..39c47325 --- /dev/null +++ b/zhenxun/services/ai/flow/agent/models.py @@ -0,0 +1,178 @@ +""" +Agent 相关静态声明类型定义 +""" + +from collections.abc import Sequence +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.context.memory.types import SessionMetadata +from zhenxun.services.ai.core.messages import ( + AgentMessage, + ChatResponse, + LLMMessage, + ToolCallPart, + UsageInfo, +) +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.flow.base import BaseRuntimeConfig +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.tools.engine.registry import ToolCollection +from zhenxun.services.ai.tools.models import GlobalToolFilter +from zhenxun.utils.pydantic_compat import model_copy + + +class Persona(BaseModel): + """智能体人设与上下文背景""" + + role: str = Field(...) + """扮演的角色身份""" + + goal: str = Field(...) + """角色的核心目标""" + + backstory: str | None = Field(default=None) + """角色背景故事或性格设定""" + + model_config = ConfigDict(extra="ignore") # type: ignore + + +class AgentConfig(BaseRuntimeConfig): + """统一的智能体全局与单次运行配置 (Unification of Settings & Profile)""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + max_cycles: int = Field(default=10) + """工具调用最大循环次数""" + global_max_cycles: int | None = Field(default=None) + """整个会话生命周期内的绝对最大循环次数上限(覆盖全局配置)。""" + enable_parallel_calls: bool = Field(default=True) + """允许并行工具调用""" + reflexion_retries: int = Field(default=1) + """反思重试次数""" + enable_fallback_summary: bool = Field(default=True) + """达到最大循环次数时,是否触发大模型兜底总结(而不是直接报错)""" + enable_hitl: bool | None = Field(default=None) + """是否允许智能体主动挂起任务,向用户求助 (Human-in-the-Loop)。 + 若为 None 则跟随全局设置。 + """ + + message_history: Sequence[AgentMessage] | None = Field(default=None) + """初始化的底层对话历史记录。""" + tool_filter: GlobalToolFilter | None = Field(default=None) + """全局工具过滤器,限制本次运行可用的工具池。""" + memory: Any | None = Field(default=None) + """单次运行级别的记忆门面覆盖 (支持 bool, MemoryConfig, MemoryBuilder)。""" + generation_config: GenerationConfig | None = Field(default=None) + """单次运行覆盖的大模型生成配置。""" + capabilities: list[Any] | None = Field(default=None) + """仅针对本次运行动态注入的临时拦截器/能力组件列表。""" + skills: Sequence[Any] | None = Field(default=None) + """仅针对本次运行动态注入的临时技能集合。""" + executor: Any | None = Field(default=None) + """单次运行覆盖的核心执行引擎策略 (BaseAgentExecutor)。""" + + verbose_ui: bool = Field(default=False) + """是否在 UI 前端展示细粒度的工具执行中间过程。 + 在不支持流式更新的平台(如QQ)建议保持 False。""" + + def merge_with(self, other: "AgentConfig | dict | None") -> "AgentConfig": + """深度合并另一份配置,生成一个新的覆盖实例""" + + if not other: + return model_copy(self, deep=True) + + update_dict = {} + if isinstance(other, dict): + other_dict = {k: v for k, v in other.items() if v is not None} + else: + fields_set = getattr( + other, "model_fields_set", getattr(other, "__fields_set__", set()) + ) + other_dict = {} + for k in fields_set: + val = getattr(other, k) + if val is not None: + other_dict[k] = val + + for k, v in other_dict.items(): + if k in ("capabilities", "skills") and isinstance(v, list): + base_list = getattr(self, k) or [] + update_dict[k] = base_list + v + else: + update_dict[k] = v + + return model_copy(self, update=update_dict, deep=True) + + +class AgentState(BaseModel): + """大模型思考循环的有限状态机 (FSM) 流转状态""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + static_system_prompt: str | list[str] = "" + """绝对不变的系统提示词(用于前缀缓存)""" + dynamic_system_messages: list[LLMMessage] = Field(default_factory=list) + """包含变量与实时状态的动态独立提示消息列表(绝对头部注入)""" + tools: ToolCollection | None = None + """当前轮次生效的、已完成鉴权和过滤的工具集合""" + + messages: list[AgentMessage] = Field(default_factory=list) + """大模型将看到的完整历史消息列表 (执行历史)""" + usage: UsageInfo = Field(default_factory=UsageInfo) + """累计的 Token 消耗""" + structured_result: Any | None = None + """拦截到的结构化输出结果""" + early_result_output: Any | None = None + """拦截到的早期终止输出结果""" + should_terminate: bool = False + """标记是否应提前终止循环""" + handoff_triggered: Any | None = None + """标记是否触发了移交""" + is_finished: bool = False + """标记大模型循环是否彻底结束""" + final_result: Any | None = None + """最终的运行结果 (AgentRunResult)""" + origin_msg_len: int = 0 + """初始进入循环时的消息历史长度 (用于增量保存记忆)""" + current_cycle: int = 0 + """当前思考循环的轮次索引""" + + current_request_messages: list[AgentMessage] = Field(default_factory=list) + """当前即将发往大模型的实际请求消息""" + current_request_extra: dict[str, Any] = Field(default_factory=dict) + """当前请求附加的Extra控制参数""" + current_response: ChatResponse | None = None + """大模型最新返回的响应实体""" + current_tool_calls: list[ToolCallPart] = Field(default_factory=list) + """当前轮次被提取出准备执行的客户端工具调用""" + current_tool_results: list[Any] = Field(default_factory=list) + """当前轮次工具执行的结果或异常收集""" + + +class AgentRunResources(BaseModel): + """大模型执行过程中的全局静态资源与配置载体""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + run_context: RunContext + """保留依赖注入(DI)与黑板引用的全局运行时上下文""" + session_meta: SessionMetadata | None = None + """隔离会话的元信息(Session ID, 命名空间, 权限等)""" + memory_reader: Any | None = None + """用于读取短/中/长期上下文记忆的读取器""" + memory_writer: Any | None = None + """用于将对话历史安全落盘的写入器""" + run_scoped_cap: Any | None = None + """聚合了 Agent/Task/全局 的复合能力拦截器 (CombinedCapability)""" + task_obj: Any | None = None + """(如有) 解析后的结构化数据任务契约""" + toolkits: list[Any] = Field(default_factory=list) + """当前轮次生效的工具箱列表 (需要执行生命周期挂载)""" + config: AgentConfig = Field(default_factory=AgentConfig) + """Agent 全局与运行时的统一策略配置""" + generation_config: GenerationConfig | None = None + """大模型生成配置""" + model_name: str | None = None + """当前实际调用的模型名称""" diff --git a/zhenxun/services/ai/flow/base.py b/zhenxun/services/ai/flow/base.py new file mode 100644 index 00000000..8775a1b0 --- /dev/null +++ b/zhenxun/services/ai/flow/base.py @@ -0,0 +1,149 @@ +from abc import ABC, abstractmethod +import asyncio +from collections.abc import AsyncIterator +import contextlib +from enum import Enum +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast + +from pydantic import BaseModel, Field + +from zhenxun.services.ai.run.context import RunContext +from zhenxun.services.ai.run.ui import UIController + +if TYPE_CHECKING: + from zhenxun.services.ai.flow.agent.models import Persona + from zhenxun.services.ai.run.models import StreamedRunResult + +from zhenxun.services.ai.core.messages import PromptInput + +T_RunResult = TypeVar("T_RunResult") + + +class ConcurrencyPolicy(str, Enum): + """并发执行策略枚举""" + + ALLOW = "allow" + """允许并发:不做任何限制(适用于无状态或绝对独立任务)""" + REJECT = "reject" + """拒绝新请求:当前有任务在执行时,直接丢弃新任务并提醒""" + QUEUE = "queue" + """排队等待:当前有任务在执行时,新任务排队等待(先进先出)""" + INTERRUPT = "interrupt" + """中断旧任务:新任务到达时,立即强制取消并覆盖正在执行的旧任务""" + + +class ConcurrencyScope(str, Enum): + """并发作用域枚举(决定锁的粒度,解耦于会话隔离)""" + + GLOBAL = "global" + """全局互斥:整个系统同一时间只能执行一个该任务""" + GROUP = "group" + """群组互斥:同一群组内串行排队(私聊退化为用户级),防止抢话刷屏""" + USER = "user" + """用户互斥:同一用户发起的任务串行排队(允许同群不同人并行)""" + SESSION = "session" + """会话互斥:跟随记忆 SessionID 进行物理锁隔离""" + + +class InterventionPolicy(str, Enum): + """运行时消息干预策略枚举""" + + IGNORE = "ignore" + """忽略干预:丢弃在任务执行期间收到的额外消息(默认)""" + STEER = "steer" + """动态转向:将额外消息立即注入到下一轮大模型推理历史中,影响其思考方向""" + FOLLOW_UP = "follow_up" + """追加执行:将额外消息放入队列,在当前大模型意图(所有工具等)执行完毕后追加推理""" + + +class BaseRuntimeConfig(BaseModel): + """所有可执行实体(Agent/Team/Workflow)的通用基础运行时配置""" + + stateless: bool = Field(default=True) + """是否使用临时会话,不持久化历史记录""" + concurrency_policy: ConcurrencyPolicy | None = Field(default=None) + """并发执行策略。如果未显式指定,无状态(stateless=True)默认为ALLOW,有状态(stateless=False)默认为QUEUE。""" + concurrency_scope: ConcurrencyScope | None = Field(default=None) + """并发作用域,决定锁的粒度。如果未显式指定,默认为 GROUP 级排队。""" + intervention_policy: InterventionPolicy | None = Field(default=None) + """运行时干预策略,决定在大模型执行期间接收到新消息时该如何处理数据流合并。""" + + +class BaseRunnable(ABC, Generic[T_RunResult]): + """ + 所有可执行 AI 编排实体的统一基类 (Composite Pattern)。 + 统一了 Agent, Team, Workflow 的核心契约,支持物理上的任意嵌套。 + """ + + name: str + """可执行实体的名称标识""" + + description: str + """可执行实体的详细描述。用于外部路由(Router)或上层智能体(DelegateTool)决定是否调用它""" + + persona: "Persona | dict | None" = None + """(可选) 实体的角色设定 (Persona)。包含 role 和 goal, + 在多智能体路由移交时优先级最高""" + + runtime_config: BaseRuntimeConfig + """运行时配置,如是否无状态、UI输出模式等""" + + def bind(self, **kwargs: Any) -> Any: + """DI 注入语法糖:返回 Depends,自动绑定当前上下文""" + from nonebot.params import Depends + + from zhenxun.services.ai.flow.agent.bridge import AgentRunner + + async def _dependency() -> AgentRunner[Any]: + return AgentRunner[Any](self, **kwargs) + + return Depends(_dependency) + + async def reply( + self, + prompt: PromptInput | None = None, + reply_to: bool = False, + *, + context: RunContext | None = None, + **kwargs: Any, + ) -> T_RunResult: + """交互执行语法糖,自动渲染流式进度并最终将结果回复给终端用户""" + from zhenxun.services.ai.flow.agent.bridge import AgentRunner + + runner = AgentRunner(self, context=context, **kwargs) + return cast(T_RunResult, await runner.reply(prompt=prompt, reply_to=reply_to)) + + async def run( + self, + prompt: PromptInput | None = None, + *, + context: RunContext | None = None, + **kwargs: Any, + ) -> T_RunResult: + """阻塞式核心运行入口,安全捕获内部抛出的静默退出信号""" + from zhenxun.services.ai.core.exceptions import ControlFlowExit + from zhenxun.services.log import logger + + try: + async with self.run_stream( + prompt=prompt, context=context, **kwargs + ) as stream_result: + return cast(T_RunResult, await stream_result.get_run_result()) + except ControlFlowExit as e: + logger.info(f"[{self.name}] 触发底层控制流,已安全退出: {e}") + + await UIController.handle_control_flow_exit_display(e, context) + + raise asyncio.CancelledError() + + @abstractmethod + @contextlib.asynccontextmanager + async def run_stream( + self, + prompt: PromptInput | None = None, + *, + context: RunContext | None = None, + **kwargs: Any, + ) -> "AsyncIterator[StreamedRunResult[Any]]": + """流式运行入口,返回上下文管理器,用于消费底层执行流事件 (StreamedRunResult)""" + yield cast(Any, None) diff --git a/zhenxun/services/ai/flow/concurrency.py b/zhenxun/services/ai/flow/concurrency.py new file mode 100644 index 00000000..be0b90d1 --- /dev/null +++ b/zhenxun/services/ai/flow/concurrency.py @@ -0,0 +1,111 @@ +import asyncio +from contextlib import asynccontextmanager +from typing import Any + +from zhenxun.services.ai.core.exceptions import ConcurrencyRejectException +from zhenxun.services.ai.core.models import CancellationToken +from zhenxun.services.ai.flow.base import ConcurrencyPolicy + + +@asynccontextmanager +async def apply_concurrency_policy( + session_id: str, + lock_id: str, + policy: ConcurrencyPolicy, + cancel_token: CancellationToken, + intervention_policy: Any = None, + message: Any = None, +): + """应用并发策略的中央调度上下文管理器""" + from zhenxun.services.ai.run.session import LockContext, session_manager + + current_task = asyncio.current_task() + task_tuple = (cancel_token, current_task) + if session_id not in session_manager.live_tasks: + session_manager.live_tasks[session_id] = [] + session_manager.live_tasks[session_id].append(task_tuple) + + try: + exec_lock = session_manager.get_exec_lock(lock_id) + lock_ctx = session_manager.lock_contexts.setdefault(lock_id, LockContext()) + + if exec_lock.locked(): + from zhenxun.services.ai.flow.base import InterventionPolicy + + if intervention_policy in ( + InterventionPolicy.STEER, + InterventionPolicy.FOLLOW_UP, + ): + from zhenxun.services.ai.core.exceptions import ( + InterventionHandledException, + ) + + session = await session_manager.get_or_create(session_id) + + actual_msg = message + from zhenxun.services.ai.run.models import Task + + if isinstance(message, Task): + actual_msg = message.description + elif hasattr(message, "extract_plain_text"): + actual_msg = message.extract_plain_text() + + if intervention_policy == InterventionPolicy.STEER: + session.steer_queue.enqueue(str(actual_msg)) + raise InterventionHandledException( + "Steer successful", + display_content="💬 已将您的补充信息传递给正在思考的 AI...", + ) + elif intervention_policy == InterventionPolicy.FOLLOW_UP: + session.follow_up_queue.enqueue(str(actual_msg)) + raise InterventionHandledException( + "Follow-up successful", + display_content="📝 已记录,AI 处理完当前任务后即刻执行...", + ) + + if policy == ConcurrencyPolicy.ALLOW: + yield + return + + if policy == ConcurrencyPolicy.REJECT: + if exec_lock.locked(): + raise ConcurrencyRejectException( + f"并发域 {lock_id} 正忙,新请求被拒绝。" + ) + + elif policy == ConcurrencyPolicy.INTERRUPT: + if exec_lock.locked(): + if lock_ctx.cancel_token: + lock_ctx.cancel_token.cancel() + if lock_ctx.active_task and not lock_ctx.active_task.done(): + lock_ctx.active_task.cancel() + + elif policy == ConcurrencyPolicy.QUEUE: + if exec_lock.locked(): + from zhenxun.services.log import logger + + logger.info( + f"⏳ [并发控制] 锁域 {lock_id} 被占用," + "新请求已进入后台等待队列 (QUEUE)..." + ) + + async with exec_lock: + session = await session_manager.get_or_create(session_id) + session.active_task = asyncio.current_task() + session.cancel_token = cancel_token + + lock_ctx.active_task = asyncio.current_task() + lock_ctx.cancel_token = cancel_token + try: + yield + finally: + session.active_task = None + session.cancel_token = None + lock_ctx.active_task = None + lock_ctx.cancel_token = None + finally: + if session_id in session_manager.live_tasks: + if task_tuple in session_manager.live_tasks[session_id]: + session_manager.live_tasks[session_id].remove(task_tuple) + if not session_manager.live_tasks[session_id]: + del session_manager.live_tasks[session_id] diff --git a/zhenxun/services/ai/flow/team/__init__.py b/zhenxun/services/ai/flow/team/__init__.py new file mode 100644 index 00000000..87850016 --- /dev/null +++ b/zhenxun/services/ai/flow/team/__init__.py @@ -0,0 +1,7 @@ +from .models import Transition +from .team import Team + +__all__ = [ + "Team", + "Transition", +] diff --git a/zhenxun/services/ai/flow/team/capabilities.py b/zhenxun/services/ai/flow/team/capabilities.py new file mode 100644 index 00000000..699dd6a2 --- /dev/null +++ b/zhenxun/services/ai/flow/team/capabilities.py @@ -0,0 +1,131 @@ +from collections.abc import Callable, Mapping, Sequence +import inspect +from typing import Any, cast + +from nonebot.utils import is_coroutine_callable + +from zhenxun.services.ai.capabilities import AbstractCapability +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.tools.bridges.handoff import HandoffTool + + +class TeamRoutingCapability(AbstractCapability): + """团队路由能力组件:动态向所有团队成员""" + + def __init__( + self, + team_name: str, + members: list[Any], + state_flow: Mapping[str, Sequence[Any]] | Callable | None = None, + ): + self.team_name = team_name + self.members = members + self.state_flow = state_flow + + async def _get_allowed_transitions(self, context: RunContext) -> list[Any] | None: + """核心FSM解析:解析静态字典或动态执行函数获取允许的 Transition 列表""" + if self.state_flow is None: + return None + + current_speaker = context.run.agent_name or "unknown" + + if isinstance(self.state_flow, dict): + raw_targets = self.state_flow.get( + current_speaker, + [m.name for m in self.members if m.name != current_speaker], + ) + from zhenxun.services.ai.flow.team.models import Transition + + return [ + Transition(target=t) if isinstance(t, str) else t for t in raw_targets + ] + + if callable(self.state_flow): + from zhenxun.services.ai.run.di import DependencyInjector + + sig = inspect.signature(self.state_flow) + kwargs = await DependencyInjector.resolve_all( + sig, call_kwargs={}, context=context + ) + + if is_coroutine_callable(self.state_flow): + result = await cast(Callable, self.state_flow)(**kwargs) + else: + result = cast(Callable, self.state_flow)(**kwargs) + + if result is None: + return None + from zhenxun.services.ai.flow.team.models import Transition + + return [Transition(target=t) if isinstance(t, str) else t for t in result] + + return None + + async def get_tools(self, context: RunContext) -> list[Any]: + tools = [] + allowed_transitions = await self._get_allowed_transitions(context) + + for m in self.members: + if context.run.agent_name != m.name: + transition = None + if allowed_transitions is not None: + transition = next( + ( + t + for t in allowed_transitions + if getattr(t, "target", "") == m.name + ), + None, + ) + if transition is None: + continue + + if getattr(m, "persona", None): + desc = f"角色:{m.persona.role},目标:{m.persona.goal}" + else: + desc = getattr(m, "description", "") or "处理节点" + + if transition and getattr(transition, "description", ""): + desc += f" 【移交条件】:{transition.description}" + + input_schema = ( + getattr(transition, "input_schema", None) if transition else None + ) + + tools.append( + HandoffTool( + target_name=m.name, + target_description=desc, + input_schema=input_schema, + ) + ) + return tools + + async def get_system_prompts(self, context: RunContext) -> list[str]: + if context.run.agent_name != f"{self.team_name}_Router": + base_prompt = f"""### 🤝 [团队协作规范] +你是跨域协作团队 '{self.team_name}' 的一员。如果你认为当前任务超出了你的职责范畴, +或你目前已经完成了前置处理但需要其他专家的处理结果进行下一步推进, +请务必使用移交工具 (transfer_to_...) 将控制权移交给合适的队友。 +移交时必须在 `reason` 参数中详细说明你的移交原因, +并附带你已经处理好的上下文关键数据!""" + + allowed_transitions = await self._get_allowed_transitions(context) + if allowed_transitions is not None: + if not allowed_transitions: + base_prompt += """ + +⚠️ **[系统状态机规则] 当前流程已到达终点!你没有任何可移交的对象。 +请直接输出最终总结并结束当前任务,严禁尝试移交。**""" + else: + targets = [ + getattr(t, "target", "unknown") for t in allowed_transitions + ] + base_prompt += f""" + +⚠️ **[系统状态机规则] 根据当前的状态流转限制,如果你需要移交控制权, +你必须且只能从以下对象中选择: +[{", ".join(targets)}]。禁止移交给除此之外的任何实体!**""" + + return [base_prompt] + return [] diff --git a/zhenxun/services/ai/flow/team/models.py b/zhenxun/services/ai/flow/team/models.py new file mode 100644 index 00000000..9eb4b18e --- /dev/null +++ b/zhenxun/services/ai/flow/team/models.py @@ -0,0 +1,265 @@ +from collections.abc import Callable, Sequence +from enum import Enum +from typing import Any +import uuid + +from pydantic import BaseModel, ConfigDict, Field + +from zhenxun.services.ai.core.messages import AgentMessage +from zhenxun.services.ai.core.options import BaseOutputDefinition +from zhenxun.services.ai.flow.base import BaseRuntimeConfig + + +class TeamRuntimeConfig(BaseRuntimeConfig): + """Team 专属的运行时配置""" + + leader_enable_hitl: bool = Field(default=False) + """是否允许团队的隐式 Leader / Router 发起人机求助 (Human-in-the-Loop)""" + + +class RouteDecision(BaseModel): + """大模型动态路由决策的数据契约""" + + target_name: str + """选定的最合适的团队成员名称""" + reason: str = "" + """选择该成员的详细理由""" + context_data: Any = "" + """传递的上下文载荷""" + + +class Transition(BaseModel): + """ + 声明式移交契约。 + 用于定义 Team 模式下,智能体之间转移控制权的条件和目标。 + """ + + target: str + """目标智能体的名称""" + description: str = "" + """自然语言描述的移交条件(提供给大模型 LLMRouter 思考时使用)""" + input_schema: type[BaseModel] | BaseOutputDefinition | None = None + """(可选) 强类型的输入约束。如果设置, + LLMRouter 决定移交时必须且只能生成符合该 Schema 的 JSON 参数, + 并作为 context_data 传递。""" + trigger_regex: str | None = None + """(可选) 正则表达式。 + 如果用户的输入匹配此正则,将触发极速硬路由,跳过大模型思考。""" + trigger_func: Callable[..., Any] | None = None + """(可选) 自定义校验函数。返回 True 或目标名称时触发硬路由。支持依赖注入。""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class TeamAction(BaseModel): + """多智能体团队协作动作基类""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class CallAction(TeamAction): + """ + 调度动作:呼叫指定的 Agent 执行任务 + """ + + agent: str | Any + """目标 Agent 的名称(字符串)或动态生成的 Agent 实例""" + task: str | Any + """派发给该 Agent 的具体任务或提示词""" + history: Sequence[AgentMessage] | None = None + """需要传递给该 Agent 的上下文历史记录(可选)""" + kwargs: dict[str, Any] | None = None + """其他透传给 Agent.run_stream 的 kwargs(可选)""" + + +class ConcurrentCallAction(TeamAction): + """ + 并发调度动作:同时呼叫多个 Agent 执行任务 + """ + + actions: list[CallAction] + + +class FinishAction(TeamAction): + """ + 结束动作:团队协作完成,返回最终结果 + """ + + result: Any + """团队协作的最终产出""" + + +class TaskNodeStatus(str, Enum): + """团队自主任务节点状态枚举""" + + pending = "pending" + """待处理:所有前置依赖已完成,等待分配执行""" + in_progress = "in_progress" + """进行中:正在被 Member Agent 执行""" + completed = "completed" + """已完成:执行成功""" + failed = "failed" + """已失败:执行报错或由于前置依赖失败而自动失败""" + blocked = "blocked" + """阻塞中:有前置依赖任务尚未完成""" + + +class SubTaskRecord(BaseModel): + """单条子任务(工单)数据契约""" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8]) + title: str = "" + description: str = "" + assignee: str | None = None + dependencies: list[str] = Field(default_factory=list) + status: TaskNodeStatus = TaskNodeStatus.pending + result: str | None = None + notes: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + """附加元数据,供系统底层或第三方插件挂载隐式上下文,对大模型不可见""" + + +class TaskBoardState(BaseModel): + """ + Team 自主任务模式下的全局共享黑板状态 (Task Board)。 + 提供任务的 CRUD、依赖拓扑计算和格式化渲染功能。 + """ + + tasks: list[SubTaskRecord] = Field(default_factory=list) + is_goal_complete: bool = False + final_summary: str | None = None + + def create_task( + self, + title: str, + description: str = "", + assignee: str | None = None, + dependencies: list[str] | None = None, + metadata: dict[str, Any] | None = None, + ) -> SubTaskRecord: + """创建一个新任务并加入看板。Python 引擎和 Tool 均调用此方法。""" + clean_deps = [d for d in (dependencies or []) if d.strip()] + task = SubTaskRecord( + title=title, + description=description, + assignee=assignee, + dependencies=clean_deps, + metadata=metadata or {}, + ) + self.tasks.append(task) + self._update_blocked_statuses() + return task + + def get_task(self, task_id: str) -> SubTaskRecord | None: + return next( + (t for t in self.tasks if t.id == task_id or t.title == task_id), None + ) + + def update_task_status( + self, task_id: str, status: TaskNodeStatus, result: str | None = None + ) -> SubTaskRecord | None: + task = self.get_task(task_id) + if not task: + return None + task.status = status + if result is not None: + task.result = result + self._update_blocked_statuses() + return task + + def _is_blocked(self, task: SubTaskRecord) -> bool: + """检查该任务是否有尚未完成的前置依赖""" + if not task.dependencies: + return False + for dep_id in task.dependencies: + dep = self.get_task(dep_id) + if dep is None: + return True + if dep.status != TaskNodeStatus.completed: + return True + return False + + def _has_failed_dependency(self, task: SubTaskRecord) -> bool: + """检查该任务是否有已经失败的前置依赖""" + if not task.dependencies: + return False + for dep_id in task.dependencies: + dep = self.get_task(dep_id) + if dep is not None and dep.status == TaskNodeStatus.failed: + return True + return False + + def _update_blocked_statuses(self) -> None: + """重新计算所有未终结任务的阻塞状态 (基于拓扑依赖)""" + for task in self.tasks: + if task.status == TaskNodeStatus.blocked: + if self._has_failed_dependency(task): + task.status = TaskNodeStatus.failed + task.result = "自动标记失败: 前置依赖任务已失败。" + elif not self._is_blocked(task): + task.status = TaskNodeStatus.pending + elif task.status == TaskNodeStatus.pending: + if self._has_failed_dependency(task): + task.status = TaskNodeStatus.failed + task.result = "自动标记失败: 前置依赖任务已失败。" + elif self._is_blocked(task): + task.status = TaskNodeStatus.blocked + + def get_available_tasks( + self, for_assignee: str | None = None + ) -> list[SubTaskRecord]: + """获取所有当前无依赖阻塞、可立即执行的 Pending 任务""" + available = [] + for task in self.tasks: + if task.status != TaskNodeStatus.pending: + continue + if self._is_blocked(task): + continue + if for_assignee and task.assignee and task.assignee != for_assignee: + continue + available.append(task) + return available + + def all_terminal(self) -> bool: + """判断是否所有的任务都已经进入了终结状态(完成或失败)""" + if not self.tasks: + return False + return all( + t.status in (TaskNodeStatus.completed, TaskNodeStatus.failed) + for t in self.tasks + ) + + def render_board_to_string(self) -> str: + """渲染供 LLM 阅读的 Markdown 看板战报""" + if not self.tasks: + return "目前尚未创建任何任务。" + + counts: dict[str, int] = {} + for t in self.tasks: + counts[t.status.value] = counts.get(t.status.value, 0) + 1 + + parts = [f"{v} {k}" for k, v in counts.items()] + header = ( + f"### 📋 任务状态总览 (共计 {len(self.tasks)} 个任务: {', '.join(parts)}):" + ) + + lines = [header] + for t in self.tasks: + status_str = t.status.value.upper() + assignee_str = f" (指派给: {t.assignee})" if t.assignee else " (尚未指派)" + lines.append(f" [{t.id}] {t.title} - {status_str}{assignee_str}") + if t.dependencies: + lines.append(f" 依赖于: {t.dependencies}") + if t.result: + result_preview = ( + t.result[:1000] + "..." if len(t.result) > 1000 else t.result + ) + lines.append(f" 结果: {result_preview}") + if t.notes: + for note in t.notes[-3:]: + lines.append(f" 附注: {note}") + + if self.is_goal_complete and self.final_summary: + lines.append(f"\n✅ 终极目标已标记完成: {self.final_summary}") + + return "\n" + "\n".join(lines) + "\n" diff --git a/zhenxun/services/ai/flow/team/router.py b/zhenxun/services/ai/flow/team/router.py new file mode 100644 index 00000000..ac227ef8 --- /dev/null +++ b/zhenxun/services/ai/flow/team/router.py @@ -0,0 +1,247 @@ +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Mapping, Sequence +import inspect +import re +from typing import Any, cast + +from nonebot.utils import is_coroutine_callable + +from zhenxun.services.ai.core.messages import AgentMessage +from zhenxun.services.ai.core.templates import PromptTemplate +from zhenxun.services.ai.flow.team.models import RouteDecision, Transition +from zhenxun.services.ai.run import RunContext, Task +from zhenxun.services.ai.run.di import DependencyInjector +from zhenxun.services.log import logger + + +class BaseRouter(ABC): + """团队多智能体路由器基类""" + + @abstractmethod + async def route( + self, + context: RunContext, + history: Sequence[AgentMessage], + prompt: str | Task | None = None, + ) -> RouteDecision | None: + """核心路由方法""" + pass + + +class FunctionRouter(BaseRouter): + """基于纯函数的极速路由器""" + + def __init__(self, selector_func: Callable[..., Any], target: str | None = None): + """ + 初始化基于函数的极速路由器。 + + 参数: + selector_func: 用于进行路由判断的选择函数,返回布尔值或字符串目标名。 + target: 当选择函数返回 True 时,默认路由到的目标成员名称。 + """ + self.selector_func = selector_func + self.target = target + + async def route( + self, + context: RunContext, + history: Sequence[AgentMessage], + prompt: str | Task | None = None, + ) -> RouteDecision | None: + sig = inspect.signature(self.selector_func) + call_kwargs = {"prompt": prompt, "context": context, "history": history} + if isinstance(prompt, Task): + call_kwargs["task"] = prompt + + kwargs_resolved = await DependencyInjector.resolve_all( + sig, call_kwargs, context + ) + filtered_kwargs = { + k: v for k, v in kwargs_resolved.items() if k in sig.parameters + } + + if is_coroutine_callable(self.selector_func): + _async_func = cast(Callable[..., Awaitable[Any]], self.selector_func) + selected_target = await _async_func(**filtered_kwargs) + else: + _sync_func = cast(Callable[..., Any], self.selector_func) + selected_target = _sync_func(**filtered_kwargs) + + if isinstance(selected_target, bool): + if selected_target and self.target: + logger.debug(f"命中函数极速路由 -> {self.target}") + return RouteDecision(target_name=self.target, reason="") + elif selected_target is not None and isinstance(selected_target, str): + logger.debug(f"命中函数动态路由 -> {selected_target}") + return RouteDecision(target_name=selected_target, reason="") + return None + + +class RegexRouter(BaseRouter): + """基于正则表达式的极速路由器""" + + def __init__(self, pattern: str, target: str): + """ + 初始化基于正则表达式的极速路由器。 + + 参数: + pattern: 正则表达式匹配规则。 + target: 当正则表达式成功匹配用户输入时路由到的目标成员名称。 + """ + self.pattern = re.compile(pattern) + self.target = target + + async def route( + self, + context: RunContext, + history: Sequence[AgentMessage], + prompt: str | Task | None = None, + ) -> RouteDecision | None: + text_to_match = ( + prompt.description + if isinstance(prompt, Task) + else (prompt or context.run.user_input or "") + ) + + if self.pattern.search(text_to_match): + logger.debug(f"命中正则极速路由 -> {self.target}") + return RouteDecision(target_name=self.target, reason="") + return None + + +class ChainRouter(BaseRouter): + """责任链路由器:按顺序执行,直到其中一个命中""" + + def __init__(self, routers: list[BaseRouter]): + """ + 初始化责任链路由器。 + + 参数: + routers: 路由器实例列表,按顺序链式匹配,遇到首个命中的路由器即返回。 + """ + self.routers = routers + + async def route( + self, + context: RunContext, + history: Sequence[AgentMessage], + prompt: str | Task | None = None, + ) -> RouteDecision | None: + for router in self.routers: + decision = await router.route(context, history, prompt) + if decision is not None: + return decision + return None + + +class LLMRouter(BaseRouter): + """基于大模型的意图路由器""" + + def __init__( + self, + team_name: str, + members: list[Any], + leader_model: str | None = None, + leader_tools: list[Any] | None = None, + state_flow: Mapping[str, Sequence[Transition | str]] | Callable | None = None, + runtime_config: Any = None, + custom_prompt: str | None = None, + allowed_transitions: list[Transition] | None = None, + ): + """ + 初始化基于大模型的意图路由器。 + + 参数: + team_name: 当前团队的名称标识。 + members: 团队的成员列表,包含 Agent, Team 或 Workflow。 + leader_model: 用于进行意图决策的路由器大模型名称,若为空则默认继承全局配置。 + leader_tools: 挂载给意图决策路由器的额外可用工具列表。 + state_flow: 状态流转规则字典或动态流转函数,定义智能体成员之间的转接路径。 + runtime_config: 团队级别的运行时全局配置。 + custom_prompt: 自定义的系统提示词模板,用以覆盖默认的路由系统指令。 + allowed_transitions: 允许的状态移交规则与前置条件列表。 + """ + self.team_name = team_name + self.members = members + self.leader_model = leader_model + self.leader_tools = leader_tools or [] + self.state_flow = state_flow + self.runtime_config = runtime_config + self.custom_prompt = custom_prompt + self.allowed_transitions = allowed_transitions + + async def route( + self, + context: RunContext, + history: Sequence[AgentMessage], + prompt: str | Task | None = None, + ) -> RouteDecision | None: + from zhenxun.services.ai.flow.agent.agent import Agent + from zhenxun.services.ai.flow.agent.models import AgentConfig + from zhenxun.services.ai.flow.team.capabilities import TeamRoutingCapability + + default_system_prompt = """## 角色与目标 +你是一个高级任务路由器 (所在团队: {{ team_name }})。 +请根据用户的输入意图,立刻调用相应的移交工具 (transfer_to_...) +将对话物理转移给合适的专员处理。 +你必须且只能选择移交,不能自己作答。""" + + if self.allowed_transitions: + transitions_desc = "\n## 可用的移交目标及条件:\n" + for t in self.allowed_transitions: + desc = getattr(t, "description", "") or "无特定条件" + transitions_desc += ( + f"- 移交至 [{getattr(t, 'target', 'unknown')}]:{desc}\n" + ) + default_system_prompt += transitions_desc + + template = self.custom_prompt or default_system_prompt + route_prompt = PromptTemplate(template).render(team_name=self.team_name) + + routing_cap = TeamRoutingCapability( + team_name=self.team_name, members=self.members, state_flow=self.state_flow + ) + + leader_config = AgentConfig( + stateless=self.runtime_config.stateless if self.runtime_config else True, + enable_hitl=getattr(self.runtime_config, "leader_enable_hitl", False), + ) + + target_model = self.leader_model + if not target_model: + for m in self.members: + if m_model := getattr(m, "model_name", None) or getattr( + m, "model", None + ): + target_model = m_model + break + + router_agent = Agent( + name=f"{self.team_name}_Router", + instruction=route_prompt, + model=target_model, + tools=self.leader_tools, + config=leader_config, + ) + + sub_context = context.clone_for_member(router_agent.name) + sub_context.capabilities = list(sub_context.capabilities) + sub_context.capabilities.append(routing_cap) + + logger.debug("🤖 [LLMRouter] 启动 LLM 思考路由决策...") + + res = await router_agent.run( + prompt=prompt, + context=sub_context, + config=AgentConfig(message_history=history), + ) + if res.handoff: + logger.debug(f"🤖 [LLMRouter] 决策完毕: 移交给 -> {res.handoff.target}") + return RouteDecision( + target_name=res.handoff.target, + reason=res.handoff.reason, + context_data=res.handoff.context_data, + ) + + logger.warning("🤖 [LLMRouter] LLM 没有调用移交工具,放弃路由。") + return None diff --git a/zhenxun/services/ai/flow/team/runner.py b/zhenxun/services/ai/flow/team/runner.py new file mode 100644 index 00000000..d59f0b4e --- /dev/null +++ b/zhenxun/services/ai/flow/team/runner.py @@ -0,0 +1,235 @@ +import asyncio +from collections.abc import AsyncGenerator +from typing import Any + +from zhenxun.services.ai.core.exceptions import ( + AbortException, + ControlFlowExit, + LLMException, +) +from zhenxun.services.ai.core.messages import UsageInfo +from zhenxun.services.ai.flow.team.capabilities import TeamRoutingCapability +from zhenxun.services.ai.flow.team.models import ( + CallAction, + ConcurrentCallAction, + FinishAction, +) +from zhenxun.services.ai.flow.team.strategy import BaseTeamStrategy +from zhenxun.services.ai.run import AgentRunResult, RunContext +from zhenxun.services.ai.run.models import AgentRunEnd +from zhenxun.services.log import logger + + +class TeamRunner: + """ + 多智能体团队核心执行引擎。 + """ + + def __init__(self, team: Any, strategy: BaseTeamStrategy): + self.team = team + self.strategy = strategy + + async def _execute_call_action_to_queue( + self, + index: int, + action: CallAction, + context: RunContext, + session_id: str, + queue: asyncio.Queue, + ): + """ + 辅助方法:执行单一 Agent 任务, + 并将内部产生的 UI 事件与最终结果通过队列透传回主线程 + """ + if isinstance(action.agent, str): + target_agent = next( + (m for m in self.team.members if m.name == action.agent), None + ) + if not target_agent: + logger.error(f"❌ [TeamRunner] 找不到团队成员: {action.agent}") + await queue.put( + ( + "result", + ( + action.agent, + AgentRunResult( + output=f"Error: {action.agent} not found", + usage=UsageInfo(), + ), + ), + ) + ) + return + else: + target_agent = action.agent + + sub_context = context.clone_for_member(target_agent.name) + sub_context.capabilities = list(sub_context.capabilities) + + from zhenxun.services.ai.flow.team.strategy import RouteStrategy + + if isinstance(self.strategy, RouteStrategy): + routing_cap = TeamRoutingCapability( + team_name=self.team.name, + members=self.team.members, + state_flow=getattr(self.strategy, "state_flow", None), + ) + sub_context.capabilities.append(routing_cap) + + logger.debug(f"🚀 **专员 👨💼`{target_agent.name}`** 开始执行子任务...") + + agent_res = None + + try: + from zhenxun.services.ai.flow.agent.models import AgentConfig + + async with target_agent.run_stream( + prompt=action.task, + context=sub_context, + config=AgentConfig(message_history=action.history), + **(action.kwargs or {}), + ) as stream_result: + async for event in stream_result.stream_events(): + if isinstance(event, AgentRunEnd): + agent_res = event.result + else: + await queue.put(("yield_event", event)) + except ControlFlowExit as cfe: + if isinstance(cfe, AbortException): + await queue.put(("control_flow_error", cfe)) + return + else: + logger.debug( + f"Agent {target_agent.name} 触发局部控制流: " + f"{type(cfe).__name__} - {cfe}" + ) + agent_res = AgentRunResult(output=str(cfe), usage=UsageInfo()) + except Exception as e: + logger.error(f"Agent {target_agent.name} 执行崩溃: {e}") + + if isinstance(e, LLMException): + abort_msg = getattr(e, "user_friendly_message", str(e)) + display_msg = ( + f"❌ 智能体 {target_agent.name} 执行发生致命故障: {abort_msg}" + ) + abort_err = AbortException( + reason=str(e), + display=display_msg, + ) + await queue.put(("control_flow_error", abort_err)) + return + + agent_res = AgentRunResult(output=f"Error: {e}", usage=UsageInfo()) + + if agent_res and agent_res.handoff: + target_name = agent_res.handoff.target + reason = agent_res.handoff.reason + + logger.info( + f"🛣️ **路由决策**: 委派给专员 👨💼`{target_name}` (理由: {reason})" + ) + + if not agent_res: + agent_res = AgentRunResult( + output="Error: No result returned", usage=UsageInfo() + ) + + logger.debug(f"✅ **专员 👨💼`{target_agent.name}`** 完成任务!") + + await queue.put(("result", index, target_agent.name, agent_res)) + + async def run_stream( + self, prompt: Any, context: RunContext, **kwargs: Any + ) -> AsyncGenerator[Any, None]: + session_id = context.session_id or "default_team_session" + task_desc = getattr(prompt, "description", str(prompt)) + + logger.info(f"🤝 **团队 [{self.team.name}] 开始协作**: `{task_desc}`") + + plan_gen = self.strategy.generate_plan(self.team, prompt, context, **kwargs) + + send_value = None + final_result = None + cumulative_usage = UsageInfo() + + try: + while True: + try: + action = await plan_gen.asend(send_value) + except StopAsyncIteration: + break + + if isinstance(action, CallAction): + queue = asyncio.Queue() + task = asyncio.create_task( + self._execute_call_action_to_queue( + 0, action, context, session_id, queue + ) + ) + try: + while True: + msg_type, *payload = await queue.get() + if msg_type == "yield_event": + yield payload[0] + elif msg_type == "control_flow_error": + raise payload[0] + elif msg_type == "result": + idx, agent_name, agent_res = payload + send_value = agent_res + cumulative_usage += agent_res.usage + break + finally: + if not task.done(): + task.cancel() + + elif isinstance(action, ConcurrentCallAction): + queue = asyncio.Queue() + tasks = [] + for i, act in enumerate(action.actions): + tasks.append( + asyncio.create_task( + self._execute_call_action_to_queue( + i, act, context, session_id, queue + ) + ) + ) + + results_dict = {} + try: + while len(results_dict) < len(action.actions): + msg_type, *payload = await queue.get() + if msg_type == "yield_event": + yield payload[0] + elif msg_type == "control_flow_error": + for t in tasks: + t.cancel() + raise payload[0] + elif msg_type == "result": + idx, agent_name, agent_res = payload + results_dict[idx] = (agent_name, agent_res) + cumulative_usage += agent_res.usage + send_value = [ + results_dict[i] for i in range(len(action.actions)) + ] + finally: + for task in tasks: + if not task.done(): + task.cancel() + + elif isinstance(action, FinishAction): + final_result = action.result + break + else: + raise ValueError(f"TeamRunner 遇到了未知的动作类型: {type(action)}") + + except Exception as e: + raise e + + logger.info(f"🏁 **团队 [{self.team.name}]** 协作圆满结束!") + + if not isinstance(final_result, AgentRunResult): + final_result = AgentRunResult(output=final_result, usage=cumulative_usage) + else: + final_result.usage += cumulative_usage + + yield AgentRunEnd(result=final_result) diff --git a/zhenxun/services/ai/flow/team/strategy.py b/zhenxun/services/ai/flow/team/strategy.py new file mode 100644 index 00000000..58460988 --- /dev/null +++ b/zhenxun/services/ai/flow/team/strategy.py @@ -0,0 +1,641 @@ +from abc import ABC +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, cast + +from pydantic import BaseModel + +from zhenxun.services.ai.core.exceptions import AbortException +from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage +from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent +from zhenxun.services.ai.core.templates import PromptTemplate +from zhenxun.services.ai.flow.agent.agent import Agent, ToolSource +from zhenxun.services.ai.flow.agent.models import AgentConfig +from zhenxun.services.ai.flow.team.models import ( + CallAction, + ConcurrentCallAction, + FinishAction, + TeamAction, +) +from zhenxun.services.ai.flow.team.router import BaseRouter +from zhenxun.services.ai.run import RunContext, Task +from zhenxun.services.ai.tools.bridges.delegate import DelegateTool +from zhenxun.services.log import logger + +if TYPE_CHECKING: + from zhenxun.services.ai.flow.team.team import Team + + +class BaseTeamStrategy(ABC): + """多智能体团队协作策略基类""" + + default_system_prompt: str = "" + + def __init__(self, custom_prompt: str | None = None): + """ + 多智能体团队协作策略基类初始化。 + + 参数: + custom_prompt: 自定义系统提示词,用于覆盖默认的团队系统提示词模板。 + """ + self.custom_prompt = custom_prompt + + def get_prompt(self, **kwargs) -> str: + template = self.custom_prompt or self.default_system_prompt + return PromptTemplate(template).render(**kwargs) + + async def generate_plan( + self, team: "Team", prompt: str | Task | None, context: RunContext, **kwargs + ) -> AsyncGenerator[TeamAction, Any]: + """ + 核心决策生成器 (Action Yielding Pattern)。 + + 第三方开发者只需重写此方法: + 1. 使用 `yield CallAction(...)` 派发任务,系统会自动拦截并执行, + 然后将 `AgentRunResult` 通过 .asend() 传回。 + 2. 使用 `yield FinishAction(...)` 结束团队协作。 + + """ + yield FinishAction( + result="The Strategy has not implemented generate_plan() yet." + ) + + def _build_leader_agent( + self, team: "Team", name: str, instruction: str, tools: list[ToolSource] + ) -> Agent: + """ + 统一的团队 Leader / Planner 装配工厂。 + 自动处理无状态配置以及 HITL 状态继承。 + """ + leader_config = AgentConfig( + stateless=team.runtime_config.stateless if team.runtime_config else True, + enable_hitl=getattr(team.runtime_config, "leader_enable_hitl", False), + ) + + target_model = getattr(self, "leader_model", None) or getattr( + team, "model", None + ) + if not target_model: + for m in team.members: + if m_model := getattr(m, "model_name", None) or getattr( + m, "model", None + ): + target_model = m_model + break + + return Agent( + name=name, + instruction=instruction, + model=target_model, + tools=tools, + config=leader_config, + ) + + +class RouteStrategy(BaseTeamStrategy): + """路由策略:基于挂载的 Router 进行最合适的专家分发""" + + def __init__( + self, + state_flow: "Mapping[str, Sequence[str | Any]] | Callable | None" = None, + selector_func: Callable[..., str | None] | None = None, + router: BaseRouter | None = None, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ): + """ + 路由策略初始化,基于挂载的 Router 进行最合适的专家分发。 + + 参数: + state_flow: 状态流转规则字典或动态函数,定义成员之间控制流的物理走向。 + selector_func: 极速硬路由的静态选择函数,返回目标智能体名称。 + router: 自定义的动态路由器实例 (如 LLMRouter, RegexRouter 等)。 + leader_model: 路由节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给路由节点 (Leader) 的专属工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的路由系统提示词。 + """ + super().__init__(custom_prompt=custom_prompt) + self.selector_func = selector_func + self.router = router + self.leader_model = leader_model + self.leader_tools = leader_tools or [] + + if isinstance(state_flow, dict): + from zhenxun.services.ai.flow.team.models import Transition + + normalized_flow = {} + for k, targets in state_flow.items(): + normalized_targets = [] + for t in targets: + if isinstance(t, str): + normalized_targets.append(Transition(target=t)) + else: + normalized_targets.append(t) + normalized_flow[k] = normalized_targets + self.state_flow = normalized_flow + else: + self.state_flow = state_flow + + async def generate_plan( + self, team: "Team", prompt: str | Task | None, context: RunContext, **kwargs + ) -> AsyncGenerator[TeamAction, Any]: + router = self.router + if not router: + from .router import ChainRouter, FunctionRouter, LLMRouter + + routers = [] + if self.selector_func: + routers.append(FunctionRouter(self.selector_func)) + routers.append( + LLMRouter( + team_name=team.name, + members=team.members, + leader_model=self.leader_model, + leader_tools=self.leader_tools, + state_flow=self.state_flow, + runtime_config=getattr(team, "runtime_config", None), + custom_prompt=self.custom_prompt, + ) + ) + router = ChainRouter(routers) + + cycle_count = 0 + exec_config = kwargs.get("config") + max_cycles = getattr(exec_config, "max_cycles", 15) if exec_config else 15 + + logger.info(f"🛣️ [RouteStrategy] '{team.name}' 正在获取初始路由决策...") + + decision = await router.route(context, [], prompt) + if not decision: + logger.warning( + f"🚨 [RouteStrategy] Team '{team.name}' 的所有路由策略未能命中目标。" + ) + raise AbortException( + reason=f"Team '{team.name}' 无法找到合适的路由节点处理该任务", + display="🚨 团队协作失败,无法分配任务。", + ) + + current_target = decision.target_name + handoff_reason = decision.reason + context_data = decision.context_data + + while True: + cycle_count += 1 + if cycle_count > max_cycles: + logger.error( + f"🚨 [RouteStrategy] Team '{team.name}' 路由陷入死循环!" + f"已达到最大限制 {max_cycles} 次。" + ) + raise AbortException( + reason=( + f"Team '{team.name}' 路由流转超过最大次数限制" + f" ({max_cycles}次)," + "已强制熔断。" + ), + display="🚨 团队协作陷入死循环,已被系统强制中断。", + ) + + handoff_history_messages: list[AgentMessage] = [] + upstream_info = [] + if handoff_reason: + upstream_info.append(f"【移交说明】\n{handoff_reason}") + if context_data: + if isinstance(context_data, dict): + import json + + formatted_data = json.dumps( + context_data, ensure_ascii=False, indent=2 + ) + upstream_info.append( + f"【结构化上下文载荷】\n```json\n{formatted_data}\n```" + ) + else: + upstream_info.append(f"【核心上下文数据】\n{context_data}") + combined_info = "\n\n".join(upstream_info) + + if combined_info: + handoff_msg = LLMMessage.system( + f"### 🔄 [来自上游节点的移交数据]\n{combined_info}" + ) + handoff_history_messages.append(handoff_msg) + + run_result = yield CallAction( + agent=current_target, + task=prompt, + history=handoff_history_messages, + kwargs=kwargs, + ) + + if run_result.handoff: + current_target = run_result.handoff.target + handoff_reason = run_result.handoff.reason + context_data = run_result.handoff.context_data + continue + + output_str = str(run_result.output) + + fast_routed = False + if isinstance(self.state_flow, dict) and current_target in self.state_flow: + for t in self.state_flow[current_target]: + trigger_regex = getattr(t, "trigger_regex", None) + if trigger_regex: + import re + + if re.search(trigger_regex, output_str): + current_target = getattr(t, "target", current_target) + handoff_reason = "" + context_data = output_str + fast_routed = True + break + trigger_func = getattr(t, "trigger_func", None) + if trigger_func: + try: + if trigger_func(output_str): + current_target = getattr(t, "target", current_target) + handoff_reason = "" + context_data = output_str + fast_routed = True + break + except Exception: + pass + + if fast_routed: + logger.info( + f"🛣️ **路由决策**: 委派给专员 👨💼`{current_target}`" + "(系统拦截:正则/函数状态流发生转移)" + ) + continue + + yield FinishAction(result=run_result.output) + break + + +class CoordinateStrategy(BaseTeamStrategy): + """协作策略:Leader 自主规划,委派任务给 Sub-Agents 并汇总结果""" + + default_system_prompt = """## 角色与目标 +你是一个多智能体团队的协调者(Leader)。 +你可以使用你自身携带的工具先查阅、收集资料;也可以分析用户的目标将其拆解为子任务,并委派给合适的下属专员。 +当你收集齐所有需要的信息或专员报告后,请汇总生成最终回复向用户汇报。""" + + def __init__( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ): + """ + 协作策略初始化,Leader 主动拆解任务,委派给 Sub-Agents 并汇总结果。 + + 参数: + leader_model: 协调节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给协调节点 (Leader) 的专属工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的协调系统提示词。 + """ + super().__init__(custom_prompt=custom_prompt) + self.leader_model = leader_model + self.leader_tools = leader_tools or [] + + async def generate_plan( + self, team: "Team", prompt: str | Task | None, context: RunContext, **kwargs + ) -> AsyncGenerator[TeamAction, Any]: + delegation_tools = [] + for m in team.members: + persona = getattr(m, "persona", None) + desc = getattr(m, "description", "") or "处理节点" + if persona and not isinstance(persona, dict): + desc = f"角色:{persona.role},目标:{persona.goal}" + + delegation_tools.append( + DelegateTool( + runnable=m, + name=f"delegate_to_{m.name}", + description=f"将子任务委派给专员 [{m.name}] 处理。专长:{desc}", + ) + ) + + leader_tools = self.leader_tools.copy() + leader_tools.extend(delegation_tools) + + leader_agent = self._build_leader_agent( + team=team, + name=f"{team.name}_Leader", + instruction=self.get_prompt(), + tools=leader_tools, + ) + + logger.debug(f"✨ **团队 [{team.name}] Leader** 正在汇总各方报告...") + + if context.run.event_bus: + await context.run.event_bus.emit( + ToolStreamChunkEvent( + tool_name="Team Leader", + content="✨ 团队 Leader 正在汇总各方报告...", + ) + ) + + logger.debug(f"👨💼 [CoordinateStrategy] '{team.name}' 正在启动协调推理循环...") + + leader_res = yield CallAction(agent=leader_agent, task=prompt) + + yield FinishAction(result=leader_res.output) + + +class BroadcastStrategy(BaseTeamStrategy): + """广播策略:并发让所有成员处理同一个任务,最后由 Leader 总结""" + + default_system_prompt = """## 角色与目标 +你是一个多智能体团队的总结者(Leader)。 +以下是各位专家的独立处理结果,请融合各方观点,取长补短,给出一份最终的总结报告。""" + + def __init__( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ): + """ + 广播策略初始化,并发让所有成员处理同一个任务,最后由 Leader 总结。 + + 参数: + leader_model: 总结节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给总结节点 (Leader) 的专属工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的广播总结系统提示词。 + """ + super().__init__(custom_prompt=custom_prompt) + self.leader_model = leader_model + self.leader_tools = leader_tools or [] + + async def generate_plan( + self, team: "Team", prompt: str | Task | None, context: RunContext, **kwargs + ) -> AsyncGenerator[TeamAction, Any]: + task_desc_str = ( + prompt.description if isinstance(prompt, Task) else (prompt or "") + ) + + if context.run.event_bus: + await context.run.event_bus.emit( + ToolStreamChunkEvent( + tool_name="Team Broadcaster", + content=f"🚀 正在并发广播任务给 {len(team.members)} 位专家...", + ) + ) + + actions = [CallAction(agent=m.name, task=task_desc_str) for m in team.members] + results = yield ConcurrentCallAction(actions=actions) + + if context.run.event_bus: + await context.run.event_bus.emit( + ToolStreamChunkEvent( + tool_name="Team Leader", + content="✨ 所有专家汇报完毕,Leader 正在融合各方观点...", + ) + ) + + logger.debug(f"✨ **团队 [{team.name}] Leader** 正在汇总各方报告...") + + summary_text = "\n\n".join( + [f"### 【{name} 的意见】:\n{res.output}" for name, res in results] + ) + + synthesize_prompt = ( + f"**用户原始任务**: {task_desc_str}\n\n" + f"以下是各位专家的独立处理结果,请融合各方观点,给出一份最终的总结报告:\n\n" + f"{summary_text}" + ) + + leader_agent = self._build_leader_agent( + team=team, + name=f"{team.name}_Leader", + instruction=self.get_prompt(), + tools=self.leader_tools, + ) + + leader_res = yield CallAction(agent=leader_agent, task=synthesize_prompt) + + yield FinishAction(result=leader_res.output) + + +class TaskStrategy(BaseTeamStrategy): + """任务规划策略:Leader 利用工具箱在黑板上拆解任务、管理依赖并驱动 Member 执行""" + + default_system_prompt = """ +你是一个多智能体团队的项目经理(Planner)。 +请仔细阅读用户的请求,将其拆解为一个个具体的子任务, +并利用 `create_task` 建立所有任务和依赖关系(注意 `assignee` 必须严格从下方的团队成员中选择)。 +【⚠️核心执行流规范】 +1. 分配完毕后,**必须立刻停止调用任何工具,并直接输出纯文本回复** +(如:'任务已分配,等待执行'),从而结束你的当前回合。 +2. 当底层自动执行完毕后,系统会再次唤醒你并提供最新的看板结果。 +请根据结果决定是下发新任务、要求重做,还是调用 `mark_all_complete` 汇报总结。 +""" # noqa: E501 + + def __init__( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + max_iterations: int = 15, + blackboard_schema: type[BaseModel] | None = None, + initial_blackboard_state: BaseModel | None = None, + custom_prompt: str | None = None, + ): + """ + 任务规划策略初始化,Leader 利用工具箱在黑板上拆解任务、管理依赖并 + 驱动 Member 执行。 + + 参数: + leader_model: 规划节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给规划节点 (Leader) 的专属附加工具列表。 + max_iterations: 引擎驱动的状态机最大迭代/循环次数,防止死循环。 + blackboard_schema: 团队共享黑板的数据结构类型 (Pydantic Model 类)。 + initial_blackboard_state: 共享黑板的初始数据状态实例。 + custom_prompt: 自定义系统提示词,用于覆盖默认的规划系统提示词。 + """ + super().__init__(custom_prompt=custom_prompt) + self.leader_model = leader_model + self.leader_tools = leader_tools or [] + self.max_iterations = max_iterations + + self.blackboard = None + self.bb_toolkit = None + if blackboard_schema is not None: + from zhenxun.services.ai.run.blackboard import BlackboardManager + from zhenxun.services.ai.tools.providers.builtin.blackboard import ( + BlackboardToolkit, + ) + + self.blackboard = BlackboardManager( + schema=blackboard_schema, initial_state=initial_blackboard_state + ) + self.bb_toolkit = BlackboardToolkit(self.blackboard) + self.leader_tools.append(self.bb_toolkit) + + async def generate_plan( + self, team: "Team", prompt: str | Task | None, context: RunContext, **kwargs + ) -> AsyncGenerator[TeamAction, Any]: + from zhenxun.services.ai.flow.team.models import TaskBoardState, TaskNodeStatus + from zhenxun.services.ai.flow.team.task_tools import TaskPlanningToolkit + + if self.blackboard is not None: + context.session.blackboard = self.blackboard + + if self.bb_toolkit: + for m in team.members: + if not hasattr(m, "tool_definitions"): + setattr(m, "tool_definitions", []) + + m_tools = getattr(m, "tool_definitions") + if self.bb_toolkit not in m_tools: + m_tools.append(self.bb_toolkit) + + member_infos = [] + for m in team.members: + desc = getattr(m, "description", "") or "处理节点" + persona = getattr(m, "persona", None) + if persona and not isinstance(persona, dict): + desc = f"角色:{persona.role},目标:{persona.goal}" + member_infos.append( + f'\n' + f" Description: {desc}\n" + f"" + ) + + members_xml = "\n" + "\n".join(member_infos) + "\n" + + final_instruction = self.get_prompt() + "\n\n" + members_xml + + task_toolkit = TaskPlanningToolkit(members=team.members) + + leader_tools = self.leader_tools.copy() + leader_tools.append(task_toolkit) + + leader_agent = self._build_leader_agent( + team=team, + name=f"{team.name}_Planner", + instruction=final_instruction, + tools=leader_tools, + ) + + logger.debug( + f"📋 [TaskStrategy] '{team.name}' 正在启动 Engine-Driven 状态机循环..." + ) + + if "__task_board__" not in context.session.shared_state: + context.session.shared_state["__task_board__"] = ( + self.blackboard._state if self.blackboard else TaskBoardState() + ) + board = cast(TaskBoardState, context.session.shared_state["__task_board__"]) + + max_iterations = self.max_iterations + planner_prompt = prompt + + for iteration in range(max_iterations): + if board.is_goal_complete: + yield FinishAction(result=board.final_summary or "目标已标记完成。") + return + + available_tasks = board.get_available_tasks() + + if not available_tasks: + if iteration > 0: + board_str = board.render_board_to_string() + goal_str = getattr(prompt, "description", None) or ( + str(prompt) if prompt else "" + ) + planner_prompt = f"""### 🎯 用户的终极目标 (Original Goal) +{goal_str} + +### 📋 当前看板最新状态 +{board_str} + +**系统指令**:底层执行引擎的回合已结束。当前没有可立即执行的 pending 任务。 +请检查是否有 failed 的任务需要修复重新指派?或者如果所有任务均已 completed, +请立刻调用 `mark_all_complete` 汇报总结。""" + + logger.info(f"🧠 [TaskStrategy] 唤醒 Planner (Iter: {iteration})") + leader_res = yield CallAction(agent=leader_agent, task=planner_prompt) + + if board.is_goal_complete: + yield FinishAction(result=board.final_summary or leader_res.output) + return + continue + + logger.info( + f"🚀 [TaskStrategy] 引擎接管:并发执行 {len(available_tasks)} 个任务..." + ) + actions = [] + valid_tasks = [] + + for task in available_tasks: + member_agent = next( + (m for m in team.members if m.name == task.assignee), None + ) + if not member_agent: + board.update_task_status( + task.id, + TaskNodeStatus.failed, + f"执行异常: 找不到名为 '{task.assignee}' 的专家。", + ) + continue + + board.update_task_status(task.id, TaskNodeStatus.in_progress) + logger.debug(f" 🔄 [任务状态变更] `{task.title}` -> in_progress") + + task_prompt = f"### 🎯 你被指派的任务目标:\n{task.description}" + + if task.result: + task_prompt += ( + f"\n\n### 💡 项目经理的补充建议/历史反馈:\n{task.result}" + ) + if task.dependencies: + dep_results = [] + for dep_id in task.dependencies: + dep_task = board.get_task(dep_id) + if dep_task and dep_task.result: + dep_results.append( + f"【前置任务 [{dep_task.title}] 的产出】:\n" + f"{dep_task.result}" + ) + if dep_results: + task_prompt += ( + "\n\n### 📦 你的任务依赖以下前置结果,请基于此进行处理:\n" + + "\n\n".join(dep_results) + ) + + if task.metadata: + import json + + meta_str = json.dumps(task.metadata, ensure_ascii=False) + task_prompt += f"\n\n### ⚙️ 附加系统元数据约束:\n{meta_str}" + + actions.append(CallAction(agent=member_agent.name, task=task_prompt)) + valid_tasks.append(task) + + if not actions: + continue + + results = yield ConcurrentCallAction(actions=actions) + + for task, (agent_name, agent_res) in zip(valid_tasks, results): + if isinstance(agent_res, BaseException): + output_str = f"❌ 专家框架级崩溃: {agent_res}" + board.update_task_status(task.id, TaskNodeStatus.failed, output_str) + final_status = "failed" + else: + output_str = str(agent_res.output) + if output_str.startswith("Error:") or output_str.startswith("❌"): + board.update_task_status( + task.id, TaskNodeStatus.failed, output_str + ) + final_status = "failed" + else: + board.update_task_status( + task.id, TaskNodeStatus.completed, output_str + ) + final_status = "completed" + + logger.debug(f" 🔄 [任务状态变更] `{task.title}` -> {final_status}") + + yield FinishAction( + result=f"达到最大迭代次数 ({max_iterations}),任务未能在限定步数内完成。" + ) diff --git a/zhenxun/services/ai/flow/team/task_tools.py b/zhenxun/services/ai/flow/team/task_tools.py new file mode 100644 index 00000000..f2188c5f --- /dev/null +++ b/zhenxun/services/ai/flow/team/task_tools.py @@ -0,0 +1,196 @@ +from typing import Annotated, Any + +from pydantic import Field + +from zhenxun.services.ai.flow.base import BaseRunnable +from zhenxun.services.ai.run.context import RunContext +from zhenxun.services.ai.tools.core.decorators import tool +from zhenxun.services.ai.tools.core.toolkit import BaseToolkit +from zhenxun.services.ai.tools.models import ToolResult +from zhenxun.services.log import logger + +from .models import TaskBoardState, TaskNodeStatus + + +class TaskPlanningToolkit(BaseToolkit): + """ + 任务规划工具箱 (Planner Toolkit)。 + 大模型专用的黑板操作工具。大模型被剥夺了执行权,仅能拆解、指派和总结任务。 + """ + + default_prefix = "" + + default_instructions = """ +## 🛠️ 任务规划工作流指南 +你现在的角色是**项目经理 (Planner)**。你的唯一职责是拆解任务、分配人员并监控看板状态,**系统底层会自动拉起专家执行任务**。 +1. **规划**:使用 `create_task` 拆解任务,设定 `assignee`(专家名称)和 `depends_on`(依赖的其它任务ID)。 +2. **等待与监控**:每次你创建或更新任务后,请立刻停止工具调用,系统引擎会自动并发执行 pending 任务并再次唤醒你。 +3. **🩹 智能自愈与重试**:如果你被唤醒后,看到看板上有任务处于 `failed` 状态, +请仔细阅读失败结果 (result)。你可以通过 `update_task_status` 将该任务的状态重新修改为 `pending` +以触发重新执行(可以附带修改建议在 result 里),或者创建新任务替代它。 +4. **终结**:当你确认所有目标已达成时,调用 `mark_all_complete` 附上最终总结,正式结束整个流水线。 +⚠️ 警告:你没有任何执行具体业务代码或查询的工具,你只能操作任务看板! +""" # noqa: E501 + + def __init__(self, members: list[BaseRunnable], **kwargs): + super().__init__(**kwargs) + self.members = members + + def _get_board(self, context: RunContext) -> TaskBoardState: + """从运行上下文中安全的获取或初始化任务看板状态""" + if "__task_board__" not in context.session.shared_state: + context.session.shared_state["__task_board__"] = TaskBoardState() + return context.session.shared_state["__task_board__"] + + @tool(description="创建一个新任务并加入看板。") + async def create_task( + self, + title: Annotated[str, Field(description="任务的简短、可行动的标题")], + description: Annotated[ + str, Field(description="详细的任务说明,告诉执行者需要做什么以及期望的产出") + ], + assignee: Annotated[ + str, + Field( + description=( + "负责执行此任务的专家名称,必须完全匹配 " + "中提供的 id,严禁捏造" + ) + ), + ], + context: RunContext, + depends_on: Annotated[ + list[str], + Field( + description=( + "该任务依赖的前置任务的【标题(title)】列表" + "(因同一回合创建时未知ID,请务必使用前置任务的 title 作为依赖)。" + "无依赖则必须传入空数组 []" + ), + ), + ], + metadata: Annotated[ + dict[str, Any], + Field( + description="可选的附加字典,用于向执行专家传递额外的结构化约束或参数" + ), + ] = {}, + ) -> ToolResult: + board = self._get_board(context) + + valid_member_names = [m.name for m in self.members] + if assignee not in valid_member_names: + return ToolResult( + output=( + f"❌ 创建失败:未找到名为 '{assignee}' 的专家。" + f"可用专家: {valid_member_names}" + ) + ).as_error() + + task = board.create_task( + title=title, + description=description, + assignee=assignee, + dependencies=depends_on, + metadata=metadata, + ) + + logger.debug(f" 🆕 [新建任务] `{task.title}` -> 👨💼{task.assignee}") + + board_str = board.render_board_to_string() + return ToolResult( + output=( + f"✅ 任务创建成功!任务 ID: [{task.id}]," + f"状态: {task.status.value}\n\n{board_str}" + ) + ) + + @tool( + description=( + "手动强制更新任务的状态(仅在特殊情况下使用," + "因为 execute_task 会自动更新状态)。" + ) + ) + async def update_task_status( + self, + task_id: Annotated[str, Field(description="要更新的任务的唯一 ID")], + status: Annotated[ + TaskNodeStatus, + Field( + description=( + "新的任务状态,支持: pending(用于重试), completed, failed 等" + ) + ), + ], + context: RunContext, + result: Annotated[ + str, + Field( + description=( + "提供结果、失败原因,或在设为 pending 重试时给执行专家的建议" + ) + ), + ] = "", + ) -> ToolResult: + board = self._get_board(context) + + if status == TaskNodeStatus.in_progress: + return ToolResult( + output=( + "❌ 权限拒绝:你不能手动将任务状态设置为 in_progress。" + "该状态由底层执行引擎自动管理。如果你想让任务重新执行," + "请将其设置为 pending。" + ) + ).as_error() + + updated = board.update_task_status(task_id, status, result if result else None) + if not updated: + return ToolResult(output=f"❌ 找不到 ID 为 '{task_id}' 的任务。").as_error() + + task_obj = board.get_task(task_id) + task_title = task_obj.title if task_obj else "Unknown" + logger.debug(f" 🔄 [任务状态变更] `{task_title}` -> {status.value}") + + if task_obj and task_obj.status != status: + board_str = board.render_board_to_string() + return ToolResult( + output=( + "❌ 状态更新失败(触发底层状态机防呆回滚)!\n" + f"你尝试将 [{task_id}] 强制设置为 {status.value}," + f"但系统计算依赖图后将其重置为了 {task_obj.status.value}。\n" + "💡 原因分析:它的前置依赖(depends_on)可能尚未 COMPLETED," + "或者你填错了依赖项的名称/ID导致系统无法追踪。\n\n" + f"{board_str}" + ) + ).as_error() + + board_str = board.render_board_to_string() + return ToolResult( + output=f"✅ 任务 [{task_id}] 已更新为 {status.value}。\n\n{board_str}" + ) + + @tool( + description=( + "声明整体目标已完成。在调用此工具后," + "大模型将被立刻中断并直接将 summary 返回给用户。" + ) + ) + async def mark_all_complete( + self, + summary: Annotated[ + str, + Field( + description=( + "流程的最终战报。⚠️ 必须在总结中完整包含各专家产出的" + "核心交付物原文(如生成的故事、最终翻译内容等),绝对不能只说“已完成”!" + ) + ), + ], + context: RunContext, + ) -> ToolResult: + from zhenxun.services.ai.tools.models import EndRunResult + + board = self._get_board(context) + board.is_goal_complete = True + board.final_summary = summary + return EndRunResult(output=summary) diff --git a/zhenxun/services/ai/flow/team/team.py b/zhenxun/services/ai/flow/team/team.py new file mode 100644 index 00000000..0996a35d --- /dev/null +++ b/zhenxun/services/ai/flow/team/team.py @@ -0,0 +1,377 @@ +import asyncio +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any +from typing_extensions import Self + +from pydantic import BaseModel + +from zhenxun.services.ai.capabilities import AbstractCapability, DynamicCapability +from zhenxun.services.ai.core.exceptions import ConcurrencyInterruptException +from zhenxun.services.ai.core.messages import PromptInput +from zhenxun.services.ai.core.models import CancellationToken +from zhenxun.services.ai.core.stream_events import EventBus +from zhenxun.services.ai.flow.agent.agent import CapabilitySource, ToolSource +from zhenxun.services.ai.flow.agent.models import Persona +from zhenxun.services.ai.flow.base import BaseRunnable +from zhenxun.services.ai.flow.team.models import TeamRuntimeConfig, Transition +from zhenxun.services.ai.flow.team.router import BaseRouter +from zhenxun.services.ai.flow.team.strategy import ( + BaseTeamStrategy, +) +from zhenxun.services.ai.run import AgentRunResult, RunContext, Task +from zhenxun.services.ai.tools.providers.skills.models import Skill, SkillSource +from zhenxun.utils.utils import infer_plugin_namespace + + +class Team(BaseRunnable[AgentRunResult[Any]]): + """ + 多智能体动态编排与路由控制器 (Facade)。 + 继承自 BaseRunnable,支持被嵌套在其他 Team 或 Workflow 中。 + """ + + def __init__( + self, + name: str, + members: list[BaseRunnable[Any]], + model: str | Callable[[], str] | None = None, + strategy: BaseTeamStrategy | None = None, + description: str | None = None, + persona: Persona | dict | None = None, + runtime_config: TeamRuntimeConfig | dict | None = None, + capabilities: list[CapabilitySource] | None = None, + skills: Sequence[str | Path | Skill | SkillSource] | None = None, + ): + """ + 多智能体协作团队初始化。 + + 参数: + name: 团队的名称标识。 + members: 团队成员列表,可以包含 Agent、Workflow 或其他 Team。 + model: (可选) 团队的统一默认模型,将自动被内部的 Leader/Router 继承。 + strategy: (可选) 团队协作策略实例。 + 若不传入,必须随后使用 `.with_xxx()` 链式方法配置。 + description: 团队的职能描述,用于上层节点路由。 + persona: 团队的整体人设或宏观设定。 + runtime_config: 团队级别的运行时宏观配置. + """ + self.name = name + self.members = members + self.model = model + self.strategy = strategy + self.description = ( + description + or f"一个名为 {self.name} 的协作团队,包含 {len(self.members)} 个处理节点。" + ) + self.persona = persona + + self.namespace = infer_plugin_namespace() or "unknown" + + self.capabilities: list[Any] = [] + if capabilities: + for cap in capabilities: + if isinstance(cap, AbstractCapability): + self.capabilities.append(cap) + elif callable(cap): + self.capabilities.append(DynamicCapability(cap)) + + if isinstance(runtime_config, dict): + runtime_config = TeamRuntimeConfig(**runtime_config) + self.runtime_config = runtime_config or TeamRuntimeConfig(stateless=True) + + if skills: + from zhenxun.services.ai.tools.providers.skills.capabilities import ( + SkillCapability, + ) + + self.capabilities.append( + SkillCapability(skills=skills, namespace=self.namespace) + ) + + self.selector_func = ( + getattr(strategy, "selector_func", None) if strategy else None + ) + + def with_strategy(self, strategy: BaseTeamStrategy) -> Self: + """ + 挂载自定义的团队协作策略。 + + 该方法为第三方扩展策略提供了通用注入通道。 + + 参数: + strategy: 自定义的、继承自 BaseTeamStrategy 的团队协作策略实例。 + """ + self.strategy = strategy + self.selector_func = getattr(strategy, "selector_func", None) + return self + + def with_routing( + self, + state_flow: ( + Mapping[str, Sequence[Transition | str | Any]] | Callable | None + ) = None, + selector_func: Callable[..., str | None] | None = None, + router: BaseRouter | None = None, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ) -> Self: + """ + 应用路由策略,基于挂载的 Router 进行最合适的专家动态分发。 + + 路由策略初始化,通过决策大脑动态路由,将不同的输入重定向至对应的下级智能体。 + + 参数: + state_flow: 状态流转规则字典或动态函数,定义成员之间控制流的物理走向。 + selector_func: 极速硬路由的静态选择函数,返回目标智能体名称。 + router: 自定义的动态路由器实例 (如 LLMRouter, RegexRouter 等)。 + leader_model: 路由节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给路由节点 (Leader) 的专属工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的路由系统提示词。 + """ + from zhenxun.services.ai.flow.team.strategy import RouteStrategy + + self.strategy = RouteStrategy( + state_flow=state_flow, + selector_func=selector_func, + router=router, + leader_model=leader_model, + leader_tools=leader_tools, + custom_prompt=custom_prompt, + ) + self.selector_func = selector_func + return self + + def with_coordination( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ) -> Self: + """ + 应用协作策略,Leader 自主规划并主动将子任务委派给 Sub-Agents,最后汇总结果。 + + 协作策略初始化,Leader 主动拆解任务并挂载委托工具, + 委派给 Sub-Agents 并汇总结果。 + + 参数: + leader_model: 协调节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给协调节点 (Leader) 的专属附加工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的协调系统提示词。 + """ + from zhenxun.services.ai.flow.team.strategy import CoordinateStrategy + + self.strategy = CoordinateStrategy( + leader_model=leader_model, + leader_tools=leader_tools, + custom_prompt=custom_prompt, + ) + return self + + def with_broadcast( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + custom_prompt: str | None = None, + ) -> Self: + """ + 应用广播策略,并发让所有成员处理同一个任务,最后由 Leader 总结。 + + 广播策略初始化,并发让所有成员处理同一个任务,汇总多方报告,最后由 Leader 总结。 + + 参数: + leader_model: 总结节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给总结节点 (Leader) 的专属附加工具列表。 + custom_prompt: 自定义系统提示词,用于覆盖默认的广播总结系统提示词。 + """ + from zhenxun.services.ai.flow.team.strategy import BroadcastStrategy + + self.strategy = BroadcastStrategy( + leader_model=leader_model, + leader_tools=leader_tools, + custom_prompt=custom_prompt, + ) + return self + + def with_task( + self, + leader_model: str | None = None, + leader_tools: list[ToolSource] | None = None, + max_iterations: int = 15, + blackboard_schema: type[BaseModel] | None = None, + initial_blackboard_state: BaseModel | None = None, + custom_prompt: str | None = None, + ) -> Self: + """ + 应用任务规划策略,Leader 利用工具箱在黑板上拆解任务、 + 管理依赖并驱动 Member 执行。 + + 任务规划策略初始化,Leader 利用看板在黑板上拆解任务、 + 管理依赖并驱动 Member 异步推进。 + + 参数: + leader_model: 规划节点 (Leader) 使用的大模型名称,若为空则默认继承全局。 + leader_tools: 挂载给规划节点 (Leader) 的专属附加工具列表。 + max_iterations: 引擎驱动的状态机最大迭代/循环次数,防止死循环。 + blackboard_schema: 团队共享黑板的数据结构类型 (Pydantic Model 类)。 + initial_blackboard_state: 共享黑板的初始数据状态实例。 + custom_prompt: 自定义系统提示词,用于覆盖默认的规划系统提示词。 + """ + from zhenxun.services.ai.flow.team.strategy import TaskStrategy + + self.strategy = TaskStrategy( + leader_model=leader_model, + leader_tools=leader_tools, + max_iterations=max_iterations, + blackboard_schema=blackboard_schema, + initial_blackboard_state=initial_blackboard_state, + custom_prompt=custom_prompt, + ) + return self + + def _ensure_strategy(self): + if self.strategy is None: + raise RuntimeError( + f"Team '{self.name}' 尚未绑定任何协作策略!" + "请先调用 .with_routing() 等链式方法进行配置," + "或在初始化时传入 strategy 参数。" + ) + + async def run( + self, + prompt: PromptInput | Task | None = None, + *, + context: "RunContext | None" = None, + capabilities: list[CapabilitySource] | None = None, + skills: Sequence[str | Path | Skill | SkillSource] | None = None, + **kwargs: Any, + ) -> AgentRunResult[Any]: + """ + 团队级运行阻塞核心入口,内部静默分配任务给成员直至汇总结束。 + + 参数: + prompt: 派发给多智能体团队的任务描述 or 契约对象 (Task)。 + context: 显式传入的会话与运行上下文。 + capabilities: 仅针对本次团队执行动态注入的临时拦截器列表。 + kwargs: 透传的其他附加参数。 + + 返回: + AgentRunResult[Any]: 包含最终融合输出、消息历史和用量统计的运行结果对象。 + """ + self._ensure_strategy() + if skills: + from zhenxun.services.ai.tools.providers.skills.capabilities import ( + SkillCapability, + ) + + capabilities = list(capabilities) if capabilities else [] + capabilities.append( + SkillCapability(skills=skills, namespace=self.namespace) + ) + + return await super().run( + prompt=prompt, context=context, capabilities=capabilities, **kwargs + ) + + import contextlib + + @contextlib.asynccontextmanager + async def run_stream( + self, + prompt: PromptInput | Task | None = None, + *, + context: "RunContext | None" = None, + capabilities: list[CapabilitySource] | None = None, + skills: Sequence[str | Path | Skill | SkillSource] | None = None, + **kwargs: Any, + ): + self._ensure_strategy() + + if context is None: + context = RunContext() + + if not hasattr(context, "capabilities"): + context.capabilities = [] + + if hasattr(self, "capabilities") and self.capabilities: + context.capabilities.extend(self.capabilities) + + if skills: + from zhenxun.services.ai.tools.providers.skills.capabilities import ( + SkillCapability, + ) + + capabilities = list(capabilities) if capabilities else [] + capabilities.append( + SkillCapability(skills=skills, namespace=self.namespace) + ) + + if capabilities: + for cap in capabilities: + if isinstance(cap, AbstractCapability): + context.capabilities.append(cap) + elif callable(cap): + context.capabilities.append(DynamicCapability(cap)) + + from zhenxun.services.ai.flow.team.runner import TeamRunner + from zhenxun.services.ai.run import StreamedRunResult + + event_bus = EventBus() + context.run.event_bus = event_bus + assert self.strategy is not None + runner = TeamRunner(self, self.strategy) + + policy = getattr(self.runtime_config, "concurrency_policy", None) + if policy is None: + from zhenxun.services.ai.flow.base import ConcurrencyPolicy + + policy = ( + ConcurrencyPolicy.ALLOW + if getattr(self.runtime_config, "stateless", True) + else ConcurrencyPolicy.QUEUE + ) + + intervention_policy = getattr(self.runtime_config, "intervention_policy", None) + + from zhenxun.services.ai.utils import ContextUtils + + lock_id = ContextUtils.extract_concurrency_lock_id( + context, + getattr(self.runtime_config, "concurrency_scope", None), + context.session_id or "default_session", + ) + + async def _execution_task(): + from zhenxun.services.ai.flow.concurrency import apply_concurrency_policy + + cancel_token = context.run.cancellation_token or CancellationToken() + context.run.cancellation_token = cancel_token + + try: + async with apply_concurrency_policy( + session_id=context.session_id or "default_session", + lock_id=lock_id, + policy=policy, + cancel_token=cancel_token, + intervention_policy=intervention_policy, + message=prompt, + ): + async for event in runner.run_stream(prompt, context, **kwargs): + await event_bus.emit(event) + except BaseException as e: + from zhenxun.services.ai.run.models import AgentRunError + + if isinstance(e, asyncio.CancelledError): + e = ConcurrencyInterruptException("团队执行已被新请求打断并接管") + await event_bus.emit(AgentRunError(error=e)) + finally: + await event_bus.end() + + task = asyncio.create_task(_execution_task()) + result_obj = StreamedRunResult[Any](event_bus) + + try: + yield result_obj + finally: + if not task.done(): + task.cancel() diff --git a/zhenxun/services/ai/flow/workflow/__init__.py b/zhenxun/services/ai/flow/workflow/__init__.py new file mode 100644 index 00000000..504061c7 --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/__init__.py @@ -0,0 +1,27 @@ +from .auto import AutoWorkflow +from .decorators import AND, OR, entry, listen, router +from .engine import Workflow +from .nodes import ( + Condition, + Loop, + Parallel, + Router, + Step, + Steps, +) + +__all__ = [ + "AND", + "OR", + "AutoWorkflow", + "Condition", + "Loop", + "Parallel", + "Router", + "Step", + "Steps", + "Workflow", + "entry", + "listen", + "router", +] diff --git a/zhenxun/services/ai/flow/workflow/auto.py b/zhenxun/services/ai/flow/workflow/auto.py new file mode 100644 index 00000000..2a78944c --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/auto.py @@ -0,0 +1,107 @@ +import graphlib +from typing import Any + +from zhenxun.services.ai.flow.workflow.engine import Workflow +from zhenxun.services.ai.flow.workflow.nodes import NodeFactory, Parallel, Router +from zhenxun.services.log import logger + + +class AutoWorkflow(Workflow): + """ + 自动化声明式工作流 (Facade)。 + 允许开发者通过 @entry, @listen 装饰器定义类方法, + 在初始化时,底层编译器会自动分析依赖并推导为原生的 Steps 和 Parallel 节点图。 + """ + + def __init__(self, name: str | None = None, description: str = "", **kwargs: Any): + workflow_name = name or self.__class__.__name__ + compiled_steps = self._compile_graph() + + super().__init__( + name=workflow_name, steps=compiled_steps, description=description + ) + + self._auto_kwargs = kwargs + + def _compile_graph(self) -> list[Any]: + """核心图推导编译器:支持 Router 嵌套与 AND/OR 拓扑排序""" + methods_meta = {} + router_paths = set() + + for attr_name in dir(self): + if attr_name.startswith("_"): + continue + attr = getattr(self, attr_name) + if hasattr(attr, "__workflow_meta__"): + methods_meta[attr_name] = attr.__workflow_meta__ + if attr.__workflow_meta__.get("paths"): + router_paths.update(attr.__workflow_meta__["paths"]) + + if not methods_meta: + logger.warning( + f"AutoWorkflow '{self.__class__.__name__}' 没有检测到任何被装饰的方法!" + ) + return [] + + top_level_methods = { + k: v + for k, v in methods_meta.items() + if not any(t in router_paths for t in v.get("triggers", [])) + } + branch_methods = { + k: v + for k, v in methods_meta.items() + if any(t in router_paths for t in v.get("triggers", [])) + } + + ts = graphlib.TopologicalSorter() + for name, meta in top_level_methods.items(): + valid_triggers = [ + t for t in meta.get("triggers", []) if t in top_level_methods + ] + ts.add(name, *valid_triggers) + + try: + ts.prepare() + except graphlib.CycleError as e: + raise ValueError(f"AutoWorkflow 编译失败:检测到循环依赖!{e}") + + workflow_steps = [] + while ts.is_active(): + ready_nodes = ts.get_ready() + step_nodes = [] + for n in ready_nodes: + meta = top_level_methods[n] + if meta["type"] in ("router", "entry_router"): + choices = [] + for path in meta.get("paths", []): + branch_name = next( + ( + bk + for bk, bv in branch_methods.items() + if path in bv.get("triggers", []) + ), + None, + ) + if branch_name: + choices.append( + NodeFactory.build(getattr(self, branch_name), name=path) + ) + + step_nodes.append( + Router(name=n, selector=getattr(self, n), choices=choices) + ) + else: + step_nodes.append(NodeFactory.build(getattr(self, n), name=n)) + + if len(step_nodes) == 1: + workflow_steps.append(step_nodes[0]) + elif len(step_nodes) > 1: + workflow_steps.append( + Parallel(*step_nodes, name=f"Parallel_{'_'.join(ready_nodes)[:30]}") + ) + + for node in ready_nodes: + ts.done(node) + + return workflow_steps diff --git a/zhenxun/services/ai/flow/workflow/base.py b/zhenxun/services/ai/flow/workflow/base.py new file mode 100644 index 00000000..6f33e46d --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/base.py @@ -0,0 +1,253 @@ +from abc import ABC, abstractmethod +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +from zhenxun.services.ai.core.exceptions import ( + AbortException, + ControlFlowExit, + ToolFatalError, +) +from zhenxun.services.ai.flow.workflow.types import ( + AbortPolicy, + BaseFailurePolicy, + PolicyAction, + StepInput, + StepOutput, + StepType, +) +from zhenxun.services.ai.run import RunContext +from zhenxun.services.log import logger + + +class BaseNode(ABC): + """工作流节点统一抽象基类""" + + def __init__( + self, + name: str, + requires_confirmation: bool = False, + confirmation_message: str | None = None, + failure_policy: BaseFailurePolicy | None = None, + ): + """ + 初始化工作流节点基类。 + + 参数: + name: 节点的唯一名称标识。 + requires_confirmation: 标记该节点在执行前是否需要人工介入授权 (HITL),默认 False。 + confirmation_message: 挂起等待授权时,向前端/群聊展示的提示文案,默认 None。 + failure_policy: 该节点执行失败时的错误恢复与自愈策略, + 默认使用中断策略 (AbortPolicy)。 + """ # noqa: E501 + self.name = name + self.requires_confirmation = requires_confirmation + self.confirmation_message = confirmation_message + self.failure_policy = failure_policy or AbortPolicy() + + @property + @abstractmethod + def node_type(self) -> StepType: + """节点类型标识 (供子类实现)""" + pass + + async def _handle_execution_failure( + self, e: BaseException, step_input: StepInput, context: RunContext, attempt: int + ) -> tuple[str, StepOutput | None, StepInput | None, Any]: + """ + 解析执行异常并应用容错策略 + """ + if isinstance(e, asyncio.CancelledError): + raise e + + if isinstance(e, ControlFlowExit): + logger.info( + f"⏭️ [控制流拦截] Node '{self.name}' 触发中断信号: " + f"{type(e).__name__} - {e}" + ) + content = str(e) + if getattr(e, "display_content", None): + content = str(getattr(e, "display_content")) + elif getattr(e, "display", None): + content = str(getattr(e, "display")) + elif getattr(e, "result_output", None): + content = str(getattr(e, "result_output")) + + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content=content, + success=False, + stop=True, + error=str(e) + if isinstance(e, AbortException | ToolFatalError) + else None, + ) + return "break", output, None, None + + logger.warning(f"Node '{self.name}' 执行发生异常: {e}") + policy_result = await self.failure_policy.handle_failure( + self, e, step_input, context + ) + + if policy_result.action == PolicyAction.RETRY: + if policy_result.delay > 0: + await asyncio.sleep(policy_result.delay) + new_input = policy_result.new_input or step_input + logger.debug(f" 🔄 [节点重试] `{self.name}` 进行第 {attempt} 次重试...") + return "continue", None, new_input, None + + elif policy_result.action == PolicyAction.FALLBACK: + fallback_node = policy_result.fallback_node + fallback_name = getattr(fallback_node, "name", "FallbackNode") + logger.info( + f"🔀 节点 {self.name} 执行失败,触发降级路由至: {fallback_name}" + ) + return "fallback", None, None, fallback_node + + elif policy_result.action == PolicyAction.CONTINUE: + logger.warning(f"Node '{self.name}' 执行异常,已被策略自动跳过: {e}") + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content=f"节点执行失败,已通过策略自动跳过: {e}", + success=False, + stop=False, + error=str(e), + ) + return "break", output, None, None + else: + logger.error(f"Node '{self.name}' 执行崩溃,已被策略中断执行流: {e}") + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content=f"执行崩溃: {e}", + success=False, + stop=True, + error=str(e), + ) + return "break", output, None, None + + @abstractmethod + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + """子类必须实现的核心流式执行逻辑""" + yield None + + async def _forward_stream( + self, stream: AsyncIterator[Any], output_box: list[StepOutput] + ) -> AsyncIterator[Any]: + """辅助方法:转发内部流事件,并将最终的 StepOutput 拦截放入 output_box 列表中""" + async for event in stream: + if isinstance(event, StepOutput): + output_box.append(event) + else: + yield event + + async def aexecute(self, step_input: StepInput, context: RunContext) -> StepOutput: + """非流式执行(聚合流并返回最终结果),子类无需重写""" + output = None + async for event in self.aexecute_stream(step_input, context): + if isinstance(event, StepOutput): + output = event + + if output is None: + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content="节点未产生有效输出", + success=False, + ) + return output + + async def aexecute_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + """标准化模板方法:处理缓存快进、授权挂起、异常熔断与生命周期事件分发""" + logger.debug(f" ⚙️ [节点] `{self.name}` 开始执行...") + + cached_out = context.state.get("__completed_steps__", {}).get(self.name) + if ( + cached_out + and cached_out.success + and not getattr(cached_out, "is_paused", False) + ): + logger.debug(f"⏭️ 快进跳过已完成节点: {self.name}") + + yield cached_out + return + + if self.requires_confirmation: + if not context.state.get(f"__hitl_confirmed_{self.name}"): + msg = ( + self.confirmation_message + or f"⚠️ 工作流即将执行高危步骤:[{self.name}],等待授权..." + ) + logger.debug(f" ⏸️ **[节点挂起]** `{self.name}`: {msg}") + + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content="[任务已挂起,等待人工授权/输入]", + success=True, + stop=True, + is_paused=True, + pause_reason=msg, + ) + + yield output + return + + current_input = step_input + attempt = 1 + + while True: + output = None + try: + async for event in self.run_stream(current_input, context): + if isinstance(event, StepOutput): + output = event + output.step_name = self.name + output.step_type = self.node_type + else: + yield event + + if output is None: + output = StepOutput( + step_name=self.name, + step_type=self.node_type, + content="执行完毕,无数据返回", + success=True, + ) + + context.upstream_results[self.name] = output.content + + break + + except BaseException as e: + ( + action_cmd, + output, + new_input, + fallback_node, + ) = await self._handle_execution_failure( + e, current_input, context, attempt + ) + if action_cmd == "continue": + current_input = ( + new_input if new_input is not None else current_input + ) + attempt += 1 + continue + elif action_cmd == "fallback" and fallback_node: + async for evt in fallback_node.aexecute_stream( + current_input, context + ): + if isinstance(evt, StepOutput): + output = evt + else: + yield evt + break + + yield output diff --git a/zhenxun/services/ai/flow/workflow/decorators.py b/zhenxun/services/ai/flow/workflow/decorators.py new file mode 100644 index 00000000..9f1e212c --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/decorators.py @@ -0,0 +1,91 @@ +from collections.abc import Callable +from typing import Any + + +def AND(*triggers: str) -> dict[str, Any]: + """逻辑与:所有前置方法都完成才执行""" + return {"logic": "AND", "triggers": list(triggers)} + + +def OR(*triggers: str) -> dict[str, Any]: + """逻辑或:任意前置方法完成即执行""" + return {"logic": "OR", "triggers": list(triggers)} + + +def entry() -> Callable: + """ + 标记为工作流的入口节点。 + 执行工作流时会自动作为第一批任务执行。 + """ + + def decorator(func: Callable) -> Callable: + setattr( + func, + "__workflow_meta__", + { + "type": "entry", + "triggers": [], + "logic": "OR", + "paths": [], + }, + ) + return func + + return decorator + + +def listen(condition: str | dict[str, Any]) -> Callable: + """ + 监听其他节点的完成状态。 + + 用法: + @listen("step_a") + @listen(AND("step_a", "step_b")) + """ + + def decorator(func: Callable) -> Callable: + if isinstance(condition, str): + meta = { + "type": "listen", + "triggers": [condition], + "logic": "OR", + "paths": [], + } + elif isinstance(condition, dict): + meta = { + "type": "listen", + "triggers": condition["triggers"], + "logic": condition.get("logic", "OR"), + "paths": [], + } + else: + raise TypeError("listen condition 必须是字符串或 AND/OR 函数的返回值") + + setattr(func, "__workflow_meta__", meta) + return func + + return decorator + + +def router( + condition: str | dict[str, Any] | None = None, paths: list[str] | None = None +) -> Callable: + """ + 标记为路由节点。执行此方法后,会根据返回值走向对应的 paths。 + """ + + def decorator(func: Callable) -> Callable: + meta = {"type": "router", "triggers": [], "logic": "OR", "paths": paths or []} + if isinstance(condition, str): + meta["triggers"] = [condition] + elif isinstance(condition, dict): + meta["triggers"] = condition["triggers"] + meta["logic"] = condition.get("logic", "OR") + + if not condition: + meta["type"] = "entry_router" + + setattr(func, "__workflow_meta__", meta) + return func + + return decorator diff --git a/zhenxun/services/ai/flow/workflow/engine.py b/zhenxun/services/ai/flow/workflow/engine.py new file mode 100644 index 00000000..f18dfa44 --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/engine.py @@ -0,0 +1,329 @@ +import asyncio +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any +import uuid + +if TYPE_CHECKING: + from zhenxun.services.ai.flow.workflow.nodes import NodeSource + from zhenxun.services.ai.run import StreamedRunResult + + +from zhenxun.services.ai.core.exceptions import ControlFlowExit, ToolRetryError +from zhenxun.services.ai.core.messages import PromptInput, UsageInfo +from zhenxun.services.ai.core.stream_events import EventBus +from zhenxun.services.ai.flow.base import BaseRunnable, BaseRuntimeConfig +from zhenxun.services.ai.flow.workflow.nodes import Steps +from zhenxun.services.ai.flow.workflow.types import ( + StepInput, + StepOutput, + WorkflowRunResult, +) +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.tools.core.tool import FunctionTool +from zhenxun.services.log import logger + + +class Workflow(BaseRunnable[WorkflowRunResult]): + """ + 工作流顶层容器 (The Workflow Facade)。 + 继承自 BaseRunnable,支持被作为节点嵌套在 Team 或 其他工作流中。 + """ + + def __init__(self, name: str, steps: list["NodeSource"], description: str = ""): + """ + 静态图元工作流容器初始化。 + + 参数: + name: 工作流的名称标识。 + steps: 工作流的节点列表(按列表顺序构成串行或嵌套结构)。 + description: 工作流的说明描述,用于被 Agent 调用时理解其功能。 + """ + self.name = name + self.description = description + self.id = uuid.uuid4().hex + + self.root_steps = Steps(steps=steps, name=f"{self.name}_Root") + self.runtime_config = BaseRuntimeConfig(stateless=True) + self.persona = None + + def _build_result( + self, + initial_input: StepInput, + safe_context: RunContext, + final_output: StepOutput, + ) -> WorkflowRunResult: + flat_outputs = {} + + def _extract(out: StepOutput): + flat_outputs[out.step_name] = out + if out.steps: + for o in out.steps: + _extract(o) + + if final_output: + _extract(final_output) + + paused_step = next( + ( + v.step_name + for v in reversed(list(flat_outputs.values())) + if getattr(v, "is_paused", False) and v.step_name + ), + None, + ) + status = ( + "paused" + if paused_step + else ("completed" if final_output and final_output.success else "error") + ) + + return WorkflowRunResult( + workflow_id=self.id, + workflow_name=self.name, + status=status, + original_input=initial_input.input, + state=safe_context.state, + step_outputs=flat_outputs, + last_step_content=final_output.content if final_output else None, + final_output=final_output, + paused_step_name=paused_step, + ) + + def bind(self, **kwargs: Any) -> Any: + """DI 注入语法糖""" + from nonebot.params import Depends + + async def _dependency() -> "Workflow": + return self + + return Depends(_dependency) + + async def reply( + self, prompt: PromptInput | None = None, reply_to: bool = False, **kwargs: Any + ) -> WorkflowRunResult: + """ + 工作流交互执行语法糖,隐式提取上下文并自动将最终流水线产出发送回复给用户。 + + 参数: + prompt: 传入工作流入口根节点的初始参数或指令。 + reply_to: 是否将结果作为回复消息发送 (at用户或引用原消息)。 + kwargs: 追加的工作流附带参数 (additional_data)。 + + 返回: + WorkflowRunResult: 包含执行状态、断点快照、各节点产出的全量工作流结果对象。 + """ + from zhenxun.utils.message import MessageUtils + + ctx = RunContext() + bot = ctx.get_bot() + event = ctx.get_event() + + res = await self.run(prompt=prompt, context=ctx, **kwargs) + + if bot and event: + if res.status == "completed" and res.final_output: + msg = ( + str(res.final_output.content) + if res.final_output.content + else "执行完毕" + ) + await MessageUtils.build_message(msg).send(reply_to=reply_to) + elif res.status == "paused": + pause_msg = ( + f"⏸️ 工作流执行已被挂起,停在步骤: {res.paused_step_name}。" + "请提供授权或人工输入后继续。" + ) + await MessageUtils.build_message(pause_msg).send(reply_to=reply_to) + elif res.status == "error": + err_msg = res.final_output.error if res.final_output else "未知异常" + await MessageUtils.build_message( + f"❌ 工作流执行发生错误: {err_msg}" + ).send(reply_to=reply_to) + + return res + + async def run( + self, + prompt: PromptInput | None = None, + *, + context: RunContext | None = None, + **kwargs: Any, + ) -> WorkflowRunResult: + """ + 工作流单次运行阻塞核心入口,遍历所有图元节点直至终止。 + + 参数: + prompt: 传入工作流入口根节点的初始参数或指令。 + context: 显式传入的会话与运行上下文。 + kwargs: 追加的工作流附带参数 (additional_data)。 + + 返回: + WorkflowRunResult: 包含执行状态、断点快照、各节点产出的全量工作流结果对象。 + """ + session_id = ( + context.session_id if context and context.session_id else f"wf_{self.id}" + ) + safe_context = context or RunContext(session_id=session_id) + + logger.debug(f"🏭 **工作流 [{self.name}] 启动**") + + initial_input = StepInput(input=prompt) + if kwargs: + initial_input.additional_data.update(kwargs) + + try: + final_output = await self.root_steps.aexecute(initial_input, safe_context) + + logger.debug(f"🏭 **工作流 [{self.name}] 运行结束**") + + return self._build_result(initial_input, safe_context, final_output) + + except BaseException as e: + if isinstance(e, ControlFlowExit): + logger.debug(f"⏭️ 工作流执行被业务控制流安全中止: {e}") + dummy_output = StepOutput(content=str(e), success=False) + return self._build_result(initial_input, safe_context, dummy_output) + + raise e + + import contextlib + + @contextlib.asynccontextmanager + async def run_stream( + self, + prompt: PromptInput | None = None, + *, + context: RunContext | None = None, + **kwargs: Any, + ) -> AsyncIterator["StreamedRunResult[Any]"]: + """对齐 BaseRunnable 接口的流式上下文管理器""" + from zhenxun.services.ai.run import StreamedRunResult + from zhenxun.services.ai.run.models import AgentRunError + + event_bus = EventBus() + if context: + context.run.event_bus = event_bus + + async def _execution_task(): + try: + async for event in self._internal_stream(prompt, context, **kwargs): + await event_bus.emit(event) + except BaseException as e: + await event_bus.emit(AgentRunError(error=e)) + finally: + await event_bus.end() + + task = asyncio.create_task(_execution_task()) + try: + yield StreamedRunResult[Any](event_bus) + finally: + if not task.done(): + task.cancel() + + async def _internal_stream( + self, + prompt: PromptInput | None = None, + context: RunContext | None = None, + **kwargs: Any, + ) -> AsyncIterator[Any]: + """原 arun_stream 逻辑改名,供内部 _execution_task 调用""" + session_id = ( + context.session_id if context and context.session_id else f"wf_{self.id}" + ) + safe_context = context or RunContext(session_id=session_id) + + logger.debug(f"🏭 **工作流 [{self.name}] 启动**") + + initial_input = StepInput(input=prompt) + if kwargs: + initial_input.additional_data.update(kwargs) + + try: + final_output = None + async for event in self.root_steps.aexecute_stream( + initial_input, safe_context + ): + if isinstance(event, StepOutput): + final_output = event + else: + yield event + + if final_output: + logger.debug(f"🏭 **工作流 [{self.name}] 运行结束**") + + from zhenxun.services.ai.run import AgentRunResult + from zhenxun.services.ai.run.models import AgentRunEnd + + wf_result = self._build_result( + initial_input, safe_context, final_output + ) + agent_res = AgentRunResult( + output=wf_result.last_step_content, + structured_data=wf_result, + usage=UsageInfo(), + ) + yield AgentRunEnd(result=agent_res) + except Exception: + pass + + async def acontinue_run( + self, + run_result: WorkflowRunResult, + user_auth_data: dict[str, Any] | None = None, + context: RunContext | None = None, + ) -> WorkflowRunResult: + safe_context = context or RunContext(session_id=f"wf_{self.id}") + safe_context.state.update(run_result.state) + + safe_context.state["__completed_steps__"] = run_result.step_outputs.copy() + for step_name, out in run_result.step_outputs.items(): + safe_context.upstream_results[step_name] = out.content + + if run_result.paused_step_name: + safe_context.state[f"__hitl_confirmed_{run_result.paused_step_name}"] = True + if user_auth_data: + safe_context.state[f"__hitl_input_{run_result.paused_step_name}"] = ( + user_auth_data + ) + + resume_input = StepInput( + input=run_result.original_input, + previous_step_content=run_result.last_step_content, + ) + + logger.debug( + f"🚀 工作流 [{self.name}] 状态已恢复," + f"正在快进到步骤: {run_result.paused_step_name}..." + ) + + final_output = await self.root_steps.aexecute(resume_input, safe_context) + + return self._build_result(resume_input, safe_context, final_output) + + def as_tool(self, tool_name: str | None = None) -> FunctionTool: + async def _execute_workflow_tool(prompt: str, context: RunContext) -> str: + run_result = await self.run(prompt=prompt, context=context) + output = run_result.final_output + + if output and output.success: + return ( + f"工作流 [{self.name}] 执行完毕。最终流水线产出:\n{output.content}" + ) + + raise ToolRetryError( + f"工作流执行失败: {output.error if output else 'unknown'}," + "请尝试换种方式处理。" + ) + + final_tool_name = tool_name or f"trigger_workflow_{self.id}" + + tool_desc = ( + f"触发执行专属流水线: {self.name}。\n" + f"描述: {self.description}\n" + f"注意:如果你认为该工作流能完全解决用户的问题,请立刻调用此工具," + f"并将用户的诉求提炼后作为 prompt 传入。" + ) + + return FunctionTool( + func=_execute_workflow_tool, name=final_tool_name, description=tool_desc + ) diff --git a/zhenxun/services/ai/flow/workflow/nodes.py b/zhenxun/services/ai/flow/workflow/nodes.py new file mode 100644 index 00000000..58e36da1 --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/nodes.py @@ -0,0 +1,628 @@ +import asyncio +from collections.abc import AsyncIterator, Callable, Sequence +from typing import Any, cast + +from pydantic import BaseModel, Field + +from zhenxun.services.ai.core.messages import PromptInput +from zhenxun.services.ai.flow.base import BaseRunnable +from zhenxun.services.ai.flow.workflow.base import BaseNode +from zhenxun.services.ai.flow.workflow.types import ( + BaseFailurePolicy, + StepInput, + StepOutput, + StepType, +) +from zhenxun.services.ai.run import RunContext +from zhenxun.services.ai.run.di import DependencyInjector +from zhenxun.services.log import logger + +NodeSource = BaseNode | BaseRunnable | Callable +"""工作流节点来源,可以是图元、可执行引擎或原生函数""" + + +class Step(BaseNode): + """ + 工作流中的最小执行单元门面 (Facade)。 + 对外部隐藏了 AgentNode 和 FunctionNode 的具体实现。 + 当实例化 Step 时,底层会自动根据 executor 的类型返回专属的节点对象。 + """ + + def __new__(cls, *args, **kwargs): + if cls is Step: + executor = kwargs.get("executor") + if executor is None and len(args) > 1: + executor = args[1] + + from zhenxun.services.ai.flow.base import BaseRunnable + + if isinstance(executor, BaseRunnable): + return object.__new__(RunnableNode) + elif callable(executor): + return object.__new__(FunctionNode) + return object.__new__(cls) + + def __init__( + self, + name: str | None = None, + executor: NodeSource | None = None, + prompt: PromptInput | None = None, + requires_confirmation: bool = False, + confirmation_message: str | None = None, + failure_policy: BaseFailurePolicy | None = None, + ): + """ + 初始化工作流单元步骤(门面)。 + + 参数: + name: 步骤的名称,为空则自动取执行器的名称,默认 None。 + executor: 该步骤要运行的核心执行器(支持 RunnableNode 或 Callable 依赖注入)。 + prompt: 该步骤的初始输入或提示词定义,默认 None。 + requires_confirmation: 标记该节点在执行前是否需要人工介入授权,默认 False。 + confirmation_message: 挂起等待授权时展示的提示文案,默认 None。 + failure_policy: 该节点执行失败时的错误处理策略,默认使用中断策略。 + """ # noqa: E501 + actual_name = name or getattr( + executor, "name", getattr(executor, "__name__", "unnamed_step") + ) + super().__init__( + name=actual_name, + requires_confirmation=requires_confirmation, + confirmation_message=confirmation_message, + failure_policy=failure_policy, + ) + self.executor = executor + self.prompt = prompt + + @property + def node_type(self) -> StepType: + return StepType.STEP + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + if False: + yield None + raise NotImplementedError( + "This is a facade. Real execution happens in subclasses." + ) + + +class RunnableNode(Step): + """专门处理 Agent/Team/Workflow 等 BaseRunnable 状态机执行的私有节点""" + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + import copy + + from zhenxun.services.ai.flow.base import BaseRunnable + from zhenxun.services.ai.run import Task + + executor = cast(BaseRunnable, self.executor) + prompt_data = self.prompt if self.prompt is not None else step_input.input + + if isinstance(prompt_data, Task): + prompt_data = copy.copy(prompt_data) + if step_input.previous_step_content: + prev_content = str(step_input.previous_step_content) + prompt_data.description = ( + f"### 🔙 [上游节点执行输出]\n{prev_content}\n\n" + f"### 🎯 [当前需执行的任务]\n{prompt_data.description}" + ) + context.run.user_input = prompt_data.description + else: + if step_input.previous_step_content: + prompt_data = ( + f"[上游节点执行输出]:\n{step_input.previous_step_content}\n\n" + f"[当前需执行的任务]:\n{prompt_data or ''}" + ) + context.run.user_input = str(prompt_data) if prompt_data else "" + + final_result = None + sandbox_context = context.clone_for_member(self.name) + + async with executor.run_stream( + prompt=prompt_data, context=sandbox_context + ) as stream_result: + async for event in stream_result.stream_events(): + from zhenxun.services.ai.run.models import AgentRunEnd + + if isinstance(event, AgentRunEnd): + final_result = event.result + yield event + + context.state.update(sandbox_context.state) + yield StepOutput( + content=final_result.output if final_result else "无返回", + success=True, + ) + + +class FunctionNode(Step): + """专门处理 Python Callable 依赖注入与执行的私有节点""" + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + context.run.user_input = str(step_input.input) if step_input.input else "" + + executor = cast(Callable, self.executor) + res = await DependencyInjector.invoke( + executor, {"step_input": step_input}, context + ) + + if isinstance(res, StepOutput): + yield res + else: + yield StepOutput(content=res, success=True) + + +class StepMeta(BaseModel): + """承载工作流节点装饰器元数据的内部模型""" + + name: str | None = None + requires_confirmation: bool = False + confirmation_message: str | None = None + failure_policy: Any = None + + +class ConditionMeta(BaseModel): + name: str | None = None + if_true: list[Any] = Field(default_factory=list) + if_false: list[Any] = Field(default_factory=list) + + +class RouterMeta(BaseModel): + name: str | None = None + choices: list[Any] = Field(default_factory=list) + + +class Steps(BaseNode): + """串行执行的工作流容器。按照列表顺序依次执行。""" + + def __init__(self, steps: Sequence[NodeSource], name: str = "StepsGroup"): + """ + 初始化串行工作流容器。 + + 参数: + steps: 依次串行执行的节点/执行器列表。 + name: 该串行容器 of 名称,默认 "StepsGroup"。 + """ + super().__init__(name=name) + self.steps = [NodeFactory.build(step) for step in steps] + + @property + def node_type(self) -> StepType: + return StepType.STEPS + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + current_input = StepInput( + input=step_input.input, + previous_step_content=step_input.previous_step_content, + additional_data=step_input.additional_data.copy(), + ) + + all_outputs: list[StepOutput] = [] + for step_obj in self.steps: + out_box: list[StepOutput] = [] + async for event in self._forward_stream( + step_obj.aexecute_stream(current_input, context), out_box + ): + yield event + step_out = out_box[0] if out_box else None + + if step_out: + all_outputs.append(step_out) + current_input.previous_step_content = step_out.content + if step_out.stop: + break + + yield StepOutput( + content=all_outputs[-1].content if all_outputs else "No steps executed", + success=all(o.success for o in all_outputs), + is_paused=any(getattr(o, "is_paused", False) for o in all_outputs), + steps=all_outputs, + ) + + +class Condition(BaseNode): + """根据条件函数的返回结果,决定走向 steps 还是 else_steps""" + + def __init__( + self, + evaluator: Any, + steps: Sequence[NodeSource], + else_steps: Sequence[NodeSource] | None = None, + name: str = "ConditionGroup", + ): + """ + 初始化条件分支节点。 + + 参数: + evaluator: 用于评估条件真假的布尔值、表达式或可调用函数。 + steps: 当 evaluator 求值为真时,将执行的步骤序列。 + else_steps: 当 evaluator 求值为假时,将执行的备用步骤序列,默认 None。 + name: 该条件分支容器的名称,默认 "ConditionGroup"。 + """ + super().__init__(name=name) + self.evaluator = evaluator + self.steps = [NodeFactory.build(step) for step in steps] + self.else_steps = [NodeFactory.build(step) for step in (else_steps or [])] + + @property + def node_type(self) -> StepType: + return StepType.CONDITION + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + if callable(self.evaluator): + condition_result = await DependencyInjector.invoke( + self.evaluator, {"step_input": step_input}, context + ) + else: + condition_result = bool(self.evaluator) + + target_steps = self.steps if condition_result else self.else_steps + branch_name = "if" if condition_result else "else" + + if not target_steps: + yield StepOutput( + content=f"条件求值为 {condition_result},无对应步骤需执行。", + success=True, + ) + return + + steps_container = Steps( + steps=target_steps, name=f"{self.name}_{branch_name}_branch" + ) + out_box: list[StepOutput] = [] + async for event in self._forward_stream( + steps_container.aexecute_stream(step_input, context), out_box + ): + yield event + if out_box: + yield out_box[0] + + +class Router(BaseNode): + """根据选择器函数的返回值(名称),从候选项中挑选步骤执行""" + + def __init__( + self, choices: Sequence[NodeSource], selector: Any, name: str = "RouterGroup" + ): + """ + 初始化选择路由器节点。 + + 参数: + choices: 包含所有候选执行路由分支的步骤序列。 + selector: 用于决定路由流向的匹配值、或者是返回分支名称的动态选择器函数。 + name: 该路由器容器的名称,默认 "RouterGroup"。 + """ + super().__init__(name=name) + self.choices = [NodeFactory.build(c) for c in choices] + self.selector = selector + self._choice_map = {} + for c in self.choices: + if c.name: + self._choice_map[c.name] = c + + @property + def node_type(self) -> StepType: + return StepType.ROUTER + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + if callable(self.selector): + selected = await DependencyInjector.invoke( + self.selector, {"step_input": step_input}, context + ) + else: + selected = self.selector + + if not isinstance(selected, list): + selected = [selected] + + target_steps = [] + for s in selected: + if isinstance(s, str): + if s in self._choice_map: + target_steps.append(self._choice_map[s]) + else: + logger.warning(f"Router '{self.name}' 选择了未知的步骤: '{s}'") + else: + target_steps.append(NodeFactory.build(s)) + + if not target_steps: + yield StepOutput(content="没有命中任何有效路由分支。", success=True) + return + + steps_container = Steps(steps=target_steps, name=f"{self.name}_routed_steps") + out_box: list[StepOutput] = [] + async for event in self._forward_stream( + steps_container.aexecute_stream(step_input, context), out_box + ): + yield event + if out_box: + yield out_box[0] + + +class Loop(BaseNode): + """循环执行工作流,直至达到最大次数或满足结束条件""" + + def __init__( + self, + steps: Sequence[NodeSource], + max_iterations: int = 3, + end_condition: Any = None, + name: str = "LoopGroup", + ): + """ + 初始化循环控制器节点。 + + 参数: + steps: 每次循环中需要顺序运行的步骤序列。 + max_iterations: 最大允许循环执行的迭代次数上限,默认 3。 + end_condition: 决定是否可以提前终止循环的条件布尔值或可调用判定函数, + 默认 None。 + name: 该循环容器的名称,默认 "LoopGroup"。 + """ + super().__init__(name=name) + self.steps = [NodeFactory.build(step) for step in steps] + self.max_iterations = max_iterations + self.end_condition = end_condition + + @property + def node_type(self) -> StepType: + return StepType.LOOP + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + logger.debug( + f" 🔁 开始循环: [Loop] `{self.name}` (最大 {self.max_iterations} 次)" + ) + + iteration = 0 + all_results: list[StepOutput] = [] + current_input = StepInput( + input=step_input.input, + previous_step_content=step_input.previous_step_content, + additional_data=step_input.additional_data.copy(), + ) + + while iteration < self.max_iterations: + logger.debug(f" ┃ 🔄 第 {iteration + 1} 次迭代...") + + steps_container = Steps( + steps=self.steps, name=f"{self.name}_iter_{iteration + 1}" + ) + out_box: list[StepOutput] = [] + async for event in self._forward_stream( + steps_container.aexecute_stream(current_input, context), out_box + ): + yield event + iter_output = out_box[0] if out_box else None + + should_stop = False + if iter_output: + all_results.append(iter_output) + if self.end_condition: + if callable(self.end_condition): + should_stop = await DependencyInjector.invoke( + self.end_condition, + {"iteration_results": iter_output.steps or [iter_output]}, + context, + ) + else: + should_stop = bool(self.end_condition) + + iteration += 1 + if should_stop or iter_output.stop: + break + current_input.previous_step_content = iter_output.content + else: + iteration += 1 + break + + yield StepOutput( + content=all_results[-1].content if all_results else "No iterations run", + success=all(o.success for o in all_results), + is_paused=any(getattr(o, "is_paused", False) for o in all_results), + steps=all_results, + ) + + logger.debug(f" ✅ 循环结束: [Loop] `{self.name}` (共执行 {iteration} 次)") + + +class Parallel(BaseNode): + """并发执行的工作流容器。无序地并发执行内部所有步骤,并最终聚合成一个输出。""" + + def __init__(self, *args: NodeSource | str, name: str | None = None): + """ + 初始化并发工作流容器。 + + 参数: + *args: 并发执行的任务节点/执行器,支持混入字符串覆盖作为 Parallel 的名字。 + name: 该并发容器的名称,默认 "ParallelGroup"。 + """ + super().__init__(name=name or "ParallelGroup") + self.steps = [] + for arg in args: + if isinstance(arg, str): + self.name = arg + else: + self.steps.append(NodeFactory.build(arg)) + + @property + def node_type(self) -> StepType: + return StepType.PARALLEL + + async def run_stream( + self, step_input: StepInput, context: RunContext + ) -> AsyncIterator[Any]: + logger.debug(f" 🔀 [并发] `{self.name}` 开启了 {len(self.steps)} 个并发任务") + + queue = asyncio.Queue() + bg_tasks = [] + + async def worker(idx: int, s_obj: Any, c_ctx: RunContext): + try: + async for evt in s_obj.aexecute_stream(step_input, c_ctx): + await queue.put(("event", evt)) + except asyncio.CancelledError: + pass + except Exception as e: + await queue.put(("error", e, getattr(s_obj, "name", f"step_{idx}"))) + finally: + await queue.put( + ("done", idx, c_ctx.state, getattr(c_ctx, "upstream_results", {})) + ) + + for i, step_obj in enumerate(self.steps): + child_context = context.clone_for_execution() + task = asyncio.create_task(worker(i, step_obj, child_context)) + bg_tasks.append(task) + + completed = 0 + all_outputs: list[StepOutput] = [] + aggregated_content_parts = [f"## 并发执行结果汇总 [{self.name}]\n"] + has_any_failure = False + early_stopped = False + + while completed < len(self.steps): + msg_type, *data = await queue.get() + if msg_type == "event": + if isinstance(data[0], StepOutput): + out = cast(StepOutput, data[0]) + all_outputs.append(out) + if not out.success: + has_any_failure = True + status_icon = "✅ 成功" if out.success else "❌ 失败" + aggregated_content_parts.append( + f"### {status_icon}: {out.step_name}\n{out.content}" + ) + if out.stop and not early_stopped: + early_stopped = True + logger.info( + f"并行分支 '{out.step_name}' 请求终止," + "正在取消其他并发任务..." + ) + for t in bg_tasks: + if not t.done(): + t.cancel() + else: + yield data[0] + elif msg_type == "error": + err, s_name = data + logger.error(f"并发步骤 '{s_name}' 执行崩溃: {err}") + out = StepOutput( + step_name=s_name, + step_type=StepType.STEP, + content=f"执行崩溃: {err}", + success=False, + error=str(err), + ) + all_outputs.append(out) + has_any_failure = True + aggregated_content_parts.append(f"### ❌ 失败: {s_name}\n{err}") + elif msg_type == "done": + _, child_state, child_upstream_results = data + context.state.update(child_state) + context.upstream_results.update(child_upstream_results) + completed += 1 + + yield StepOutput( + content="\n\n".join(aggregated_content_parts), + success=not has_any_failure, + is_paused=any(getattr(o, "is_paused", False) for o in all_outputs), + steps=all_outputs, + stop=any(getattr(o, "stop", False) for o in all_outputs), + ) + + logger.debug(f" ✅ [并发] `{self.name}` 执行完毕") + + +class NodeFactory: + """统一节点装配工厂""" + + @classmethod + def _create_step( + cls, + executor: NodeSource, + name: str | None = None, + requires_confirmation: bool = False, + confirmation_message: str | None = None, + failure_policy: Any = None, + ) -> BaseNode: + """底层物理实例化分发""" + from zhenxun.services.ai.flow.base import BaseRunnable + + kwargs = { + "name": name, + "executor": executor, + "requires_confirmation": requires_confirmation, + "confirmation_message": confirmation_message, + "failure_policy": failure_policy, + } + if isinstance(executor, BaseRunnable): + return RunnableNode(**kwargs) + elif callable(executor): + return FunctionNode(**kwargs) + raise ValueError(f"执行器类型 {type(executor)} 无法转换为叶子节点(Step)。") + + @staticmethod + def build(item: NodeSource, name: str | None = None) -> BaseNode: + if isinstance(item, BaseNode): + if name and item.name in ( + "unnamed_step", + "StepsGroup", + "ParallelGroup", + "ConditionGroup", + "RouterGroup", + "LoopGroup", + ): + item.name = name + return item + + if isinstance(item, BaseRunnable) or callable(item): + cond_meta = getattr(item, "__workflow_condition_meta__", None) + if cond_meta: + final_name = name or cond_meta.name or "ConditionGroup" + return Condition( + evaluator=item, + steps=cond_meta.if_true, + else_steps=cond_meta.if_false, + name=final_name, + ) + + router_meta = getattr(item, "__workflow_router_meta__", None) + if router_meta: + final_name = name or router_meta.name or "RouterGroup" + return Router( + selector=item, + choices=router_meta.choices, + name=final_name, + ) + + step_meta = getattr(item, "__workflow_step_meta__", None) + if step_meta: + final_name = name or step_meta.name + return NodeFactory._create_step( + executor=item, + name=final_name, + requires_confirmation=step_meta.requires_confirmation, + confirmation_message=step_meta.confirmation_message, + failure_policy=step_meta.failure_policy, + ) + + return NodeFactory._create_step(executor=item, name=name) + + raise ValueError( + f"无法将类型 {type(item)} 装配为工作流节点。" + "支持的类型:BaseRunnable, Callable 或 BaseNode。" + ) diff --git a/zhenxun/services/ai/flow/workflow/types.py b/zhenxun/services/ai/flow/workflow/types.py new file mode 100644 index 00000000..ce5127f7 --- /dev/null +++ b/zhenxun/services/ai/flow/workflow/types.py @@ -0,0 +1,253 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class StepType(str, Enum): + FUNCTION = "Function" + STEP = "Step" + STEPS = "Steps" + LOOP = "Loop" + PARALLEL = "Parallel" + CONDITION = "Condition" + ROUTER = "Router" + + +class StepInput(BaseModel): + """传递给每个 Step 的标准输入结构""" + + input: Any = Field(default=None) + """继承自 Workflow 的初始输入""" + + previous_step_content: Any = Field(default=None) + """上一个执行步骤产生的直接输出内容""" + + additional_data: dict[str, Any] = Field(default_factory=dict) + """在生命周期中穿透传递的附加数据""" + + +class StepOutput(BaseModel): + """每个 Step 的标准输出结构""" + + step_name: str | None = None + """步骤的名称""" + step_id: str | None = None + """步骤的唯一标识""" + step_type: StepType | None = None + """步骤的节点枚举类型""" + executor_type: str | None = None + """底层执行器的类型标识""" + executor_name: str | None = None + """底层执行器的具体名称""" + + content: Any = None + """该步骤产生的直接输出内容""" + success: bool = True + """标记该步骤是否执行成功""" + error: str | None = None + """执行失败时的异常详情""" + stop: bool = False + """标记是否触发了终止信号,以阻断后续流程的执行""" + is_paused: bool = False + """标记该步骤是否因等待外力交互 (HITL) 而处于挂起状态""" + pause_reason: str | None = None + """导致步骤挂起的原因描述""" + + steps: list["StepOutput"] | None = None + """嵌套步骤的输出结果集合(如复合节点 Loop、Parallel 的内部产出)""" + + +class WorkflowRunResult(BaseModel): + """工作流运行结果(包含断点快照状态)""" + + workflow_id: str + """工作流实例运行的唯一标识""" + workflow_name: str + """工作流的名称""" + status: str + """流水线的最终运行状态 (completed, paused, error 等)""" + original_input: Any + """最初传入根节点的原始输入""" + state: dict[str, Any] + """工作流生命周期中的全局共享上下文状态字典""" + step_outputs: dict[str, StepOutput] + """平铺展开的所有经历过的节点步骤输出字典""" + last_step_content: Any + """最后一个成功执行的步骤所产出的内容""" + final_output: StepOutput | None = None + """工作流根节点最终包装的完整产出对象""" + paused_step_name: str | None = None + """若流水线处于挂起态,记录是哪个步骤引发了挂起""" + + +class PolicyAction(str, Enum): + RETRY = "retry" + CONTINUE = "continue" + ABORT = "abort" + FALLBACK = "fallback" + + +class PolicyResult(BaseModel): + """错误策略执行结果""" + + action: PolicyAction + """采取的具体恢复策略动作""" + delay: float = 0.0 + """执行延迟或重试前需要等待的缓冲秒数""" + new_input: StepInput | None = None + """用于动态纠错自愈时替换传入的新参数结构""" + fallback_node: Any | None = None + """策略裁定降级时所指定的备用工作流节点""" + healer_agent_name: str | None = None + """执行了高级自愈的大模型或修复者名称""" + + +class BaseFailurePolicy(ABC): + """错误处理策略抽象基类""" + + @abstractmethod + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + pass + + +class AbortPolicy(BaseFailurePolicy): + """直接中断策略""" + + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + return PolicyResult(action=PolicyAction.ABORT) + + +class SkipPolicy(BaseFailurePolicy): + """跳过并继续策略""" + + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + return PolicyResult(action=PolicyAction.CONTINUE) + + +class RetryPolicy(BaseFailurePolicy): + """退避重试策略""" + + def __init__(self, max_retries: int = 3, delay: float = 1.0): + """ + 初始化退避重试策略。 + + 参数: + max_retries: 最大允许重试的次数限制,默认 3。 + delay: 每次重试前需要等待和睡眠的秒数,默认 1.0。 + """ + self.max_retries = max_retries + self.delay = delay + + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + counts = context.state.setdefault("__retry_counts__", {}) + key = f"{node.name}_{id(self)}" + counts[key] = counts.get(key, 0) + 1 + + if counts[key] <= self.max_retries: + return PolicyResult(action=PolicyAction.RETRY, delay=self.delay) + return PolicyResult(action=PolicyAction.ABORT) + + +class FallbackPolicy(BaseFailurePolicy): + """降级路由策略""" + + def __init__(self, fallback_node: Any): + """ + 初始化降级路由策略。 + + 参数: + fallback_node: 当主节点发生致命故障时,直接转入执行的备用降级节点。 + """ + self.fallback_node = fallback_node + + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + return PolicyResult( + action=PolicyAction.FALLBACK, fallback_node=self.fallback_node + ) + + +class SelfHealingPolicy(BaseFailurePolicy): + """大模型高级自愈策略""" + + def __init__(self, healer_model: str, max_retries: int = 2): + """ + 初始化大模型高级自愈策略。 + + 参数: + healer_model: 用于分析错误原因并智能修复入参的大模型名称。 + max_retries: 最大尝试自愈修复的次数,默认 2。 + """ + self.healer_model = healer_model + self.max_retries = max_retries + + async def handle_failure( + self, node: Any, exception: BaseException, step_input: StepInput, context: Any + ) -> PolicyResult: + counts = context.state.setdefault("__heal_counts__", {}) + key = f"{node.name}_{id(self)}" + counts[key] = counts.get(key, 0) + 1 + + if counts[key] > self.max_retries: + from zhenxun.services.log import logger + + logger.warning(f"节点 '{node.name}' 自愈次数达上限,宣告失败。") + return PolicyResult(action=PolicyAction.ABORT) + + import copy + + from zhenxun.services.ai.llm.api import generate_structured + from zhenxun.services.log import logger + + class HealedInput(BaseModel): + """自愈后输入结构""" + + fixed_input: str = Field( + description="""修复后的输入参数 必须是完全合法的数据结构""" + ) + + prompt = f"""# Self-Healing Task + +请修复节点 `{node.name}` 的参数错误。 + +## Original Input +{step_input.input} + +## Exception +{exception} + +## Requirements +- 分析错误原因 +- 将输入修复为可被程序正确解析的格式 +- 只输出修复后的结果,不要输出额外解释 +""" + + try: + logger.info(f"🩹 触发 AI 自愈分析 (节点: {node.name})...") + res = await generate_structured( + prompt, response_model=HealedInput, model=self.healer_model + ) + + new_input = copy.copy(step_input) + new_input.input = res.fixed_input + + return PolicyResult( + action=PolicyAction.RETRY, + new_input=new_input, + healer_agent_name=self.healer_model, + ) + + except Exception as e: + logger.error(f"自愈过程发生大模型调用异常: {e}") + return PolicyResult(action=PolicyAction.ABORT) diff --git a/zhenxun/services/ai/guardrails.py b/zhenxun/services/ai/guardrails.py new file mode 100644 index 00000000..7c20b5ae --- /dev/null +++ b/zhenxun/services/ai/guardrails.py @@ -0,0 +1,431 @@ +from abc import ABC +from collections.abc import Callable +from enum import Enum +import inspect +from typing import Any + +from nonebot.utils import is_coroutine_callable +from pydantic import BaseModel, Field + +from zhenxun.services.ai.core.exceptions import ( + GuardrailFatalException, + GuardrailViolationError, +) +from zhenxun.services.ai.core.messages import ChatResponse, LLMMessage, TextPart +from zhenxun.services.ai.run.context import RunContext + + +class GuardrailAction(str, Enum): + PASS = "PASS" + """放行""" + REJECT = "REJECT" + """致命拦截:直接中断大模型思考""" + REFLECT = "REFLECT" + """打回反思:触发自愈闭环""" + MUTATE = "MUTATE" + """数据变异:就地修改数据后放行""" + + +class GuardrailResult(BaseModel): + """护栏验证结果的统一载体""" + + action: GuardrailAction = GuardrailAction.PASS + """验证动作(放行、拦截、反思、变异)""" + + feedback: str | None = None + """未通过时的校验失败反馈原因或拒绝理由""" + + mutated_text: str | None = None + """变异后的新文本内容(MUTATE 模式下生效)""" + + mutated_obj: Any | None = None + """变异后的新解析对象(MUTATE 模式下生效)""" + + @property + def success(self) -> bool: + return self.action == GuardrailAction.PASS + + +def input_guardrail(func: Callable | None = None, *, max_attempts: int = 0): + """显式标记为输入护栏。支持指定最大评估次数 (max_attempts)""" + + def decorator(f: Callable): + setattr(f, "__guardrail_type__", "input") + setattr(f, "__guardrail_max_attempts__", max_attempts) + return f + + return decorator(func) if func else decorator + + +def output_guardrail(func: Callable | None = None, *, max_attempts: int = 0): + """显式标记为输出护栏。支持指定最大评估次数 (max_attempts)""" + + def decorator(f: Callable): + setattr(f, "__guardrail_type__", "output") + setattr(f, "__guardrail_max_attempts__", max_attempts) + return f + + return decorator(func) if func else decorator + + +class BaseGuardrail(ABC): + """大一统的业务逻辑护栏抽象基类""" + + max_attempts: int = 0 + """当前护栏在单次上下文中允许触发的最大评估/拦截次数。0 代表无限制。""" + + async def validate_input( + self, messages: list[LLMMessage], context: RunContext | None = None + ) -> GuardrailResult: + """执行输入拦截与变异逻辑""" + return GuardrailResult(action=GuardrailAction.PASS) + + async def validate_output( + self, + response: ChatResponse | str, + parsed_obj: Any, + context: RunContext | None = None, + ) -> GuardrailResult: + """执行输出反思、拦截与变异逻辑""" + return GuardrailResult(action=GuardrailAction.PASS) + + +class FunctionalGuardrail(BaseGuardrail): + """包装普通 Python 函数的智能护栏""" + + def __init__(self, func: Callable[..., Any]): + self.func = func + self.guardrail_type = getattr(func, "__guardrail_type__", None) + self.max_attempts = getattr(func, "__guardrail_max_attempts__", 0) + + if not self.guardrail_type: + sig = inspect.signature(func) + is_input = False + is_output = False + for param in sig.parameters.values(): + if param.annotation == inspect.Parameter.empty: + continue + anno_str = str(param.annotation) + if "LLMMessage" in anno_str: + is_input = True + if "ChatResponse" in anno_str: + is_output = True + + if is_input and not is_output: + self.guardrail_type = "input" + elif is_output and not is_input: + self.guardrail_type = "output" + else: + raise ValueError( + "无法自动推断护栏函数 '{func.__name__}' 的作用阶段。\n" + "请使用明确的类型注解 (如 list[LLMMessage] 或 ChatResponse),\n" + "或使用 @input_guardrail / @output_guardrail 装饰器明确声明。" + ) + + def _bind_core_args( + self, sig: inspect.Signature, core_arg_dict: dict[str, Any] + ) -> dict[str, Any]: + """将框架提供的核心参数按名称或位置绑定到用户的签名上""" + from zhenxun.services.ai.run.di import DependencyInjector + + bound_kwargs = {} + + unmapped_cores = [] + for core_name, core_val in core_arg_dict.items(): + if core_name in sig.parameters: + bound_kwargs[core_name] = core_val + else: + unmapped_cores.append(core_val) + + if unmapped_cores: + val_idx = 0 + for name, param in sig.parameters.items(): + if name in ("self", "cls") or name in bound_kwargs: + continue + if DependencyInjector.can_resolve_statically(param): + continue + + bound_kwargs[name] = unmapped_cores[val_idx] + val_idx += 1 + if val_idx >= len(unmapped_cores): + break + + return bound_kwargs + + async def _execute_with_di( + self, core_args: dict[str, Any], context: RunContext | None, is_input: bool + ) -> GuardrailResult: + """统一执行带有 DI 依赖注入的护栏逻辑""" + from zhenxun.services.ai.run.di import DependencyInjector + + safe_context = context or RunContext() + + try: + sig = inspect.signature(self.func) + call_kwargs = self._bind_core_args(sig, core_args) + resolved_kwargs = await DependencyInjector.resolve_all( + sig=sig, call_kwargs=call_kwargs, context=safe_context + ) + filtered_kwargs = { + k: v for k, v in resolved_kwargs.items() if k in sig.parameters + } + + res = ( + await self.func(**filtered_kwargs) + if is_coroutine_callable(self.func) + else self.func(**filtered_kwargs) + ) + return self._parse_result(res, is_input=is_input) + except (ValueError, AssertionError) as e: + action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT + return GuardrailResult(action=action, feedback=str(e)) + except Exception as e: + from zhenxun.services.ai.core.exceptions import ControlFlowExit + + if isinstance(e, ControlFlowExit): + raise + action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT + stage_str = "输入" if is_input else "输出" + return GuardrailResult( + action=action, feedback=f"{stage_str}护栏执行异常: {e}" + ) + + async def validate_input( + self, messages: list[LLMMessage], context: RunContext | None = None + ) -> GuardrailResult: + if self.guardrail_type != "input": + return GuardrailResult(action=GuardrailAction.PASS) + return await self._execute_with_di( + {"messages": messages}, context, is_input=True + ) + + async def validate_output( + self, + response: ChatResponse | str, + parsed_obj: Any, + context: RunContext | None = None, + ) -> GuardrailResult: + if self.guardrail_type != "output": + return GuardrailResult(action=GuardrailAction.PASS) + return await self._execute_with_di( + {"response": response, "parsed_obj": parsed_obj}, context, is_input=False + ) + + def _parse_result(self, res: Any, is_input: bool) -> GuardrailResult: + """统一处理返回值类型推导""" + if isinstance(res, GuardrailResult): + return res + + if res is False: + action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT + return GuardrailResult( + action=action, + feedback=f"自定义护栏函数 '{self.func.__name__}' 校验未通过", + ) + elif isinstance(res, str): + action = GuardrailAction.REJECT if is_input else GuardrailAction.REFLECT + return GuardrailResult(action=action, feedback=res) + + return GuardrailResult(action=GuardrailAction.PASS) + + +class JudgeViolation(BaseModel): + rule: str + """违反的规则内容""" + + reason: str + """违反该规则的具体原因和证据。如果没有违反,填无""" + + +class JudgeResponse(BaseModel): + passed: bool + """文本是否完全遵守了所有的规则。如果有任何一条违反,此处必须为 False""" + + violations: list[JudgeViolation] = Field(default_factory=list) + """违反的规则列表及原因。如果没有违反,返回空列表""" + + +class LLMJudgeConfig(BaseModel): + """LLM 裁判的全局设定""" + + judge_model: str | None = None + """指定的裁判模型名称,如果为空则优先使用当前对话模型,其次为全局默认模型""" + + system_prompt_template: str | None = None + """自定义裁判 Prompt 模板。必须包含 {rules} 和 {text} 占位符""" + + max_attempts: int = 0 + """最大裁判评估次数。超过该次数后大模型裁判自动放弃并放行 (0 表示无限制)""" + + +class LLMGuardrail(BaseGuardrail): + """基于 LLM-as-a-Judge 的自然语言规则裁判护栏""" + + def __init__(self, rules: list[str], config: LLMJudgeConfig | None = None): + self.rules = rules + self.config = config or LLMJudgeConfig() + self.max_attempts = self.config.max_attempts + + async def validate_output( + self, + response: ChatResponse | str, + parsed_obj: Any, + context: RunContext | None = None, + ) -> GuardrailResult: + if not self.rules: + return GuardrailResult(action=GuardrailAction.PASS) + + text = response if isinstance(response, str) else response.text + from zhenxun.services.ai.llm.api import generate_structured + from zhenxun.services.ai.llm.manager import get_default_model + + rules_str = "\n".join([f"{i + 1}. {r}" for i, r in enumerate(self.rules)]) + if self.config.system_prompt_template: + prompt = self.config.system_prompt_template.format( + rules=rules_str, text=text + ) + else: + prompt = ( + "你是一个严格的内容风控与业务合规裁判。\n" + "请评估以下[待检测内容]是否违反了任何一条[规则列表]。\n\n" + f"### [规则列表]\n{rules_str}\n\n" + f"### [待检测内容]\n{text}\n\n" + "请严格按照规则评估。只要违反了其中任意一条," + "passed 必须为 false,并在 violations 中详细说明理由。" + ) + + model = self.config.judge_model + if not model and context and context.run.current_model: + model = context.run.current_model + if not model: + model = get_default_model("chat") + + try: + res = await generate_structured( + prompt, response_model=JudgeResponse, model=model + ) + if res.passed: + return GuardrailResult(action=GuardrailAction.PASS) + feedbacks = [ + f"违反规则: 【{v.rule}】, 原因: {v.reason}" for v in res.violations + ] + return GuardrailResult( + action=GuardrailAction.REFLECT, feedback="\n".join(feedbacks) + ) + except Exception as e: + return GuardrailResult( + action=GuardrailAction.REFLECT, feedback=f"系统护栏裁判模型异常: {e}" + ) + + +GuardrailSource = Callable[..., Any] | str | BaseGuardrail | LLMJudgeConfig +"""护栏来源(函数、自然语言规则、BaseGuardrail 实例或裁判配置)""" + + +def parse_guardrails(guardrails: list[GuardrailSource] | None) -> list[BaseGuardrail]: + """工具方法:将各种类型的 Guardrail 解析为标准的 BaseGuardrail 列表""" + v_list = [] + llm_rules = [] + judge_config = None + for v in guardrails or []: + if isinstance(v, BaseGuardrail): + v_list.append(v) + elif callable(v): + v_list.append(FunctionalGuardrail(v)) + elif isinstance(v, str): + llm_rules.append(v) + elif isinstance(v, LLMJudgeConfig): + judge_config = v + + if llm_rules: + v_list.append(LLMGuardrail(rules=llm_rules, config=judge_config)) + + return v_list + + +class GuardrailPipeline: + """护栏管线引擎""" + + def __init__(self, guardrails: list[BaseGuardrail]): + self.guardrails = guardrails + + async def run_input_pipeline( + self, messages: list[LLMMessage], context: RunContext | None = None + ) -> list[LLMMessage]: + """执行 Input 护栏拦截与变异""" + for g in self.guardrails: + if g.max_attempts > 0 and context: + counts = context.state.setdefault("__guardrail_input_counts__", {}) + g_id = id(g) + if counts.get(g_id, 0) >= g.max_attempts: + continue + counts[g_id] = counts.get(g_id, 0) + 1 + + res = await g.validate_input(messages, context) + if res.action == GuardrailAction.REJECT: + raise GuardrailFatalException( + guard_name=g.__class__.__name__, reason=res.feedback or "输入被拦截" + ) + elif ( + res.action == GuardrailAction.MUTATE + and res.mutated_text is not None + and messages + ): + text_replaced = False + for p in messages[-1].content: + if isinstance(p, TextPart): + p.text = res.mutated_text + text_replaced = True + break + if not text_replaced: + messages[-1].content.append(TextPart(text=res.mutated_text)) + return messages + + async def run_output_pipeline( + self, + response: ChatResponse | str, + parsed_obj: Any, + context: RunContext | None = None, + ) -> tuple[ChatResponse | str, Any]: + """执行 Output 护栏拦截、反思和变异""" + feedbacks = [] + current_response = response + current_obj = parsed_obj + + for g in self.guardrails: + if g.max_attempts > 0 and context: + counts = context.state.setdefault("__guardrail_output_counts__", {}) + g_id = id(g) + if counts.get(g_id, 0) >= g.max_attempts: + continue + counts[g_id] = counts.get(g_id, 0) + 1 + + res = await g.validate_output(current_response, current_obj, context) + if res.action == GuardrailAction.REJECT: + raise GuardrailFatalException( + guard_name=g.__class__.__name__, reason=res.feedback or "输出被拒绝" + ) + elif res.action == GuardrailAction.MUTATE: + if res.mutated_text is not None: + if isinstance(current_response, str): + current_response = res.mutated_text + else: + text_found = False + for p in current_response.content_parts: + if isinstance(p, TextPart): + p.text = res.mutated_text + text_found = True + break + if not text_found: + current_response.content_parts.insert( + 0, TextPart(text=res.mutated_text) + ) + if res.mutated_obj is not None: + current_obj = res.mutated_obj + elif res.action == GuardrailAction.REFLECT: + feedbacks.append(res.feedback or f"{g.__class__.__name__} 校验未通过") + + if feedbacks: + raise GuardrailViolationError("\n".join(feedbacks)) + + return current_response, current_obj diff --git a/zhenxun/services/ai/llm/__init__.py b/zhenxun/services/ai/llm/__init__.py new file mode 100644 index 00000000..6393917d --- /dev/null +++ b/zhenxun/services/ai/llm/__init__.py @@ -0,0 +1,48 @@ +""" +LLM 服务模块 - 公共 API 入口 + +提供统一的 AI 服务调用接口、核心数据契约和配置工具。 +""" + +from zhenxun.services.ai.core.exceptions import LLMException +from zhenxun.services.ai.core.messages import ( + AudioResponse, + ChatResponse, + LLMContentPart, + LLMMessage, +) +from zhenxun.services.ai.core.options import ( + TTSConfig, +) + +from .api import ( + chat, + create_image, + create_speech, + embed, + generate, + generate_structured, + rerank, +) +from .builder import ( + IntentBuilder, +) +from .manager import get_default_model + +__all__ = [ + "AudioResponse", + "ChatResponse", + "IntentBuilder", + "LLMContentPart", + "LLMException", + "LLMMessage", + "TTSConfig", + "chat", + "create_image", + "create_speech", + "embed", + "generate", + "generate_structured", + "get_default_model", + "rerank", +] diff --git a/zhenxun/services/llm/adapters/__init__.py b/zhenxun/services/ai/llm/adapters/__init__.py similarity index 51% rename from zhenxun/services/llm/adapters/__init__.py rename to zhenxun/services/ai/llm/adapters/__init__.py index d296fb33..ac57a21c 100644 --- a/zhenxun/services/llm/adapters/__init__.py +++ b/zhenxun/services/ai/llm/adapters/__init__.py @@ -4,21 +4,33 @@ LLM 适配器模块 提供不同LLM服务商的API适配器实现,统一接口调用方式。 """ -from .base import BaseAdapter, OpenAICompatAdapter, RequestData, ResponseData +from .base import BaseAdapter, RequestData, ResponseData +from .deepseek import DeepSeekAdapter +from .doubao import DoubaoAdapter from .factory import LLMAdapterFactory, get_adapter_for_api_type, register_adapter from .gemini import GeminiAdapter -from .openai import DeepSeekAdapter, OpenAIAdapter, OpenAIImageAdapter +from .glm import GLMAdapter +from .jina import JinaAdapter +from .mimo import MiMoAdapter +from .minimax import MiniMaxAdapter +from .openai import OpenAIAdapter, OpenAICompatAdapter +from .openrouter import OpenRouterAdapter LLMAdapterFactory.initialize() __all__ = [ "BaseAdapter", "DeepSeekAdapter", + "DoubaoAdapter", + "GLMAdapter", "GeminiAdapter", + "JinaAdapter", "LLMAdapterFactory", + "MiMoAdapter", + "MiniMaxAdapter", "OpenAIAdapter", "OpenAICompatAdapter", - "OpenAIImageAdapter", + "OpenRouterAdapter", "RequestData", "ResponseData", "get_adapter_for_api_type", diff --git a/zhenxun/services/ai/llm/adapters/base.py b/zhenxun/services/ai/llm/adapters/base.py new file mode 100644 index 00000000..2bc74a85 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/base.py @@ -0,0 +1,702 @@ +""" +LLM 适配器基类和通用数据结构 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import inspect +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any +import uuid + +import httpx +from pydantic import BaseModel, Field + +from zhenxun.configs.path_config import TEMP_PATH +from zhenxun.services.ai.core.engine.token_counter import parse_usage_info +from zhenxun.services.ai.core.exceptions import ( + AuthenticationException, + ConfigurationException, + ContentFilteredException, + ContextLengthExceededException, + InvalidRequestException, + LLMException, + LocationNotSupportedException, + QuotaExceededException, + RateLimitException, + ResponseParseException, + UpstreamServerException, +) +from zhenxun.services.ai.core.messages import ( + AudioResponse, + ChatRequest, + ChatResponse, + EmbeddingRequest, + EmbeddingResponse, + ImagePart, + ImageRequest, + ImageResponse, + LLMContentPart, + RerankRequest, + RerankResponse, + RerankResult, + SpeechRequest, + TextPart, + ThoughtPart, +) +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.log import logger +from zhenxun.utils.log_sanitizer import sanitize_for_logging + +if TYPE_CHECKING: + from zhenxun.services.ai.llm.adapters.handlers.base import ( + BaseAudioHandler, + BaseEmbeddingHandler, + BaseImageHandler, + BaseRerankHandler, + BaseTextHandler, + ) + + +class RequestData(BaseModel): + """标准化的请求载体,用于向上层 HTTP 客户端传递请求参数。""" + + method: str = "POST" + url: str + headers: dict[str, str] + body: dict[str, Any] + files: dict[str, Any] | list[tuple[str, Any]] | None = None + + +class ResponseData(BaseModel): + """标准化的响应载体,统一承接文本、多模态与附加元数据。""" + + content_parts: list[LLMContentPart] = Field(default_factory=list) + usage_info: dict[str, Any] | None = None + raw_response: dict[str, Any] | None = None + grounding_metadata: Any | None = None + cache_info: Any | None = None + + @property + def text(self) -> str: + """提取并拼接所有 `TextPart` 文本内容。""" + return "".join( + p.text for p in self.content_parts if isinstance(p, TextPart) + ).strip() + + @text.setter + def text(self, value: str): + """设置首个 `TextPart`,不存在则追加新的 `TextPart`。""" + for p in self.content_parts: + if isinstance(p, TextPart): + p.text = value + return + self.content_parts.append(TextPart(text=value)) + + @property + def thought_text(self) -> str | None: + """提取并拼接所有思维片段文本,未命中则返回 `None`。""" + thoughts = [ + p.thought_text for p in self.content_parts if isinstance(p, ThoughtPart) + ] + return "\n".join(thoughts).strip() if thoughts else None + + @property + def thought_signature(self) -> str | None: + """从末尾向前查找思维签名,用于后续连续推理场景。""" + for p in reversed(self.content_parts): + if ( + hasattr(p, "metadata") + and p.metadata + and "thought_signature" in p.metadata + ): + return p.metadata["thought_signature"] + return None + + @property + def images(self) -> list[bytes | Path | str]: + """收集图片内容,按 URL / 原始字节 / 本地路径顺序返回。""" + imgs = [] + for p in self.content_parts: + if isinstance(p, ImagePart): + if p.url: + imgs.append(p.url) + elif p.raw: + imgs.append(p.raw) + elif p.path: + imgs.append(p.path) + return imgs + + @images.setter + def images(self, value: list[bytes | Path | str]): + """覆盖图片片段并按输入类型重建 `ImagePart` 列表。""" + self.content_parts = [ + p for p in self.content_parts if not isinstance(p, ImagePart) + ] + for img in value: + if isinstance(img, str) and img.startswith(("http://", "https://")): + self.content_parts.append(ImagePart(url=img)) + elif isinstance(img, bytes): + self.content_parts.append(ImagePart(raw=img)) + else: + self.content_parts.append(ImagePart(path=Path(img))) + + code_execution_results: list[dict[str, Any]] | None = None + search_results: list[dict[str, Any]] | None = None + function_calls: list[dict[str, Any]] | None = None + safety_ratings: list[dict[str, Any]] | None = None + citations: list[dict[str, Any]] | None = None + + +def process_image_data(image_data: bytes) -> bytes | Path: + """处理图片二进制数据:超过 2MB 时落盘并返回文件路径。""" + max_inline_size = 2 * 1024 * 1024 + if len(image_data) > max_inline_size: + save_dir = TEMP_PATH / "llm" + save_dir.mkdir(parents=True, exist_ok=True) + file_name = f"{uuid.uuid4()}.png" + file_path = save_dir / file_name + file_path.write_bytes(image_data) + logger.info( + f"图片数据过大 ({len(image_data)} bytes),已保存到临时文件: {file_path}", + "LLMAdapter", + ) + return file_path.resolve() + return image_data + + +class BaseAdapter(ABC): + """ + LLM API适配器基类 (门面模式 Facade)。 + 负责维护厂商级别的通用配置(如 URL 拼接、请求头构建、通用错误拦截), + 而将具体的模态序列化与反序列化逻辑委派给各路 Handler。 + """ + + text_handler: "BaseTextHandler | None" = None + image_handler: "BaseImageHandler | None" = None + embedding_handler: "BaseEmbeddingHandler | None" = None + rerank_handler: "BaseRerankHandler | None" = None + audio_handler: "BaseAudioHandler | None" = None + + @property + def log_sanitization_context(self) -> str: + """用于日志清洗的上下文名称,默认 'default'""" + return "default" + + @property + @abstractmethod + def api_type(self) -> str: + """API类型标识""" + pass + + @property + @abstractmethod + def supported_api_types(self) -> list[str]: + """支持的API类型列表""" + pass + + async def prepare_payload( + self, identity: ModelIdentity, api_key: str, request: Any + ) -> RequestData: + """泛型请求构建分发入口 (Polymorphic Dispatch)""" + dispatch = { + ChatRequest: self.prepare_advanced_request, + EmbeddingRequest: self.prepare_embedding_request, + RerankRequest: self.prepare_rerank_request, + ImageRequest: self.prepare_image_request, + SpeechRequest: self.prepare_speech_request, + } + handler = dispatch.get(type(request)) + if not handler: + raise ValueError( + f"适配器 {self.api_type} 不支持的请求类型: {type(request)}" + ) + + res = handler(identity, api_key, request) + if inspect.isawaitable(res): + return await res + return res + + async def parse_payload( + self, identity: ModelIdentity, request: Any, raw_response: httpx.Response + ) -> Any: + """泛型响应解析分发入口 (Polymorphic Dispatch)""" + if isinstance(request, SpeechRequest): + res = self.parse_speech_response(identity, raw_response) + if inspect.isawaitable(res): + return await res + return res + + response_bytes = await raw_response.aread() + logger.debug(f"📦 响应体已完整读取 ({len(response_bytes)} bytes)") + try: + response_json = json.loads(response_bytes) + except json.JSONDecodeError: + raise ResponseParseException( + "API 返回了非 JSON 格式的内容,可能是 URL 路径错误或中转站配置异常。", + details={ + "raw_response": response_bytes.decode("utf-8", errors="ignore")[ + :500 + ] + }, + ) + + sanitizer_req_context = self.log_sanitization_context + sanitizer_resp_context = sanitizer_req_context.replace("_request", "_response") + if sanitizer_resp_context == sanitizer_req_context: + sanitizer_resp_context = f"{sanitizer_req_context}_response" + + sanitized_response = sanitize_for_logging( + response_json, context=sanitizer_resp_context + ) + response_json_str = json.dumps(sanitized_response, ensure_ascii=False, indent=2) + logger.debug(f"📋 响应JSON: {response_json_str}") + + dispatch = { + EmbeddingRequest: self._parse_embedding_payload, + RerankRequest: self._parse_rerank_payload, + ImageRequest: self._parse_image_payload, + ChatRequest: self._parse_chat_payload, + } + handler = dispatch.get(type(request)) + if not handler: + raise ValueError( + f"适配器 {self.api_type} 不支持的请求类型解析: {type(request)}" + ) + + return handler(identity, response_json) + + def _parse_embedding_payload( + self, identity: ModelIdentity, response_json: dict + ) -> EmbeddingResponse: + self.validate_embedding_response(response_json) + embeddings = self.parse_embedding_response(response_json) + return EmbeddingResponse( + embeddings=embeddings, + usage=parse_usage_info( + response_json.get("usage") or response_json.get("usageMetadata") + ), + model_name=identity.model_name, + ) + + def _parse_rerank_payload( + self, identity: ModelIdentity, response_json: dict + ) -> RerankResponse: + return RerankResponse(results=self.parse_rerank_response(response_json)) + + def _parse_image_payload( + self, identity: ModelIdentity, response_json: dict + ) -> ImageResponse: + response_data = self.parse_image_response(response_json) + return ImageResponse( + content_parts=response_data.content_parts, raw_response=response_json + ) + + def _parse_chat_payload( + self, identity: ModelIdentity, response_json: dict + ) -> ChatResponse: + response_data = self.parse_response(identity, response_json, is_advanced=True) + return ChatResponse( + content_parts=response_data.content_parts, + usage_info=response_data.usage_info, + raw_response=response_data.raw_response, + grounding_metadata=response_data.grounding_metadata, + ) + + async def prepare_simple_request( + self, + identity: ModelIdentity, + api_key: str, + prompt: str, + history: list[dict[str, str]] | None = None, + ) -> RequestData: + """准备简单文本生成请求 + + 默认实现:将简单请求转换为高级请求格式 + 子类可以重写此方法以提供特定的优化实现 + """ + from zhenxun.services.ai.core.messages import ( + AssistantMessage, + SystemMessage, + TextPart, + UserMessage, + ) + + messages: list[Any] = [] + + if history: + for msg in history: + role = msg.get("role", "user") + content = msg.get("content", "") + if role == "system": + messages.append(SystemMessage(content=[TextPart(text=content)])) + elif role == "assistant": + messages.append(AssistantMessage(content=[TextPart(text=content)])) + else: + messages.append(UserMessage(content=[TextPart(text=content)])) + + messages.append(UserMessage(content=[TextPart(text=prompt)])) + + config = identity.generation_config + + return await self.prepare_advanced_request( + identity=identity, + api_key=api_key, + request=ChatRequest(messages=messages, config=config), + ) + + async def prepare_advanced_request( + self, + identity: ModelIdentity, + api_key: str, + request: ChatRequest, + ) -> RequestData: + """准备高级对话请求并委派给 `text_handler` 完成序列化。""" + if self.text_handler: + return await self.text_handler.prepare_text_request( + adapter=self, + identity=identity, + api_key=api_key, + request=request, + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 TextHandler,暂不支持文本对话能力。" + ) + + def parse_response( + self, + identity: ModelIdentity, + response_json: dict[str, Any], + is_advanced: bool = False, + ) -> ResponseData: + """解析文本响应并委派给 `text_handler`。""" + if self.text_handler: + return self.text_handler.parse_text_response( + adapter=self, + identity=identity, + response_json=response_json, + is_advanced=is_advanced, + ) + raise NotImplementedError(f"API 类型 '{self.api_type}' 未装配 TextHandler。") + + async def prepare_embedding_request( + self, + identity: ModelIdentity, + api_key: str, + request: EmbeddingRequest, + ) -> RequestData: + """准备文本/多模态嵌入请求并委派给 `embedding_handler`。""" + if self.embedding_handler: + return await self.embedding_handler.prepare_embedding_request( + adapter=self, + identity=identity, + api_key=api_key, + request=request, + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 EmbeddingHandler,暂不支持向量嵌入。" + ) + + def parse_embedding_response( + self, response_json: dict[str, Any] + ) -> list[list[float]]: + """解析文本嵌入响应并委派给 `embedding_handler`。""" + if self.embedding_handler: + return self.embedding_handler.parse_embedding_response( + adapter=self, response_json=response_json + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 EmbeddingHandler。" + ) + + def prepare_rerank_request( + self, + identity: ModelIdentity, + api_key: str, + request: RerankRequest, + ) -> RequestData: + """准备重排请求并委派给 `rerank_handler`。""" + if self.rerank_handler: + return self.rerank_handler.prepare_rerank_request( + adapter=self, + identity=identity, + api_key=api_key, + request=request, + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 RerankHandler,暂不支持文本重排。" + ) + + def parse_rerank_response( + self, response_json: dict[str, Any] + ) -> list[RerankResult]: + """解析重排响应并委派给 `rerank_handler`。""" + if self.rerank_handler: + return self.rerank_handler.parse_rerank_response( + adapter=self, response_json=response_json + ) + raise NotImplementedError(f"API 类型 '{self.api_type}' 未装配 RerankHandler。") + + def prepare_image_request( + self, + identity: ModelIdentity, + api_key: str, + request: ImageRequest, + ) -> RequestData: + """准备图像请求并委派给 `image_handler`。""" + if self.image_handler: + return self.image_handler.prepare_image_request( + adapter=self, + identity=identity, + api_key=api_key, + request=request, + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 ImageHandler,暂不支持图像生成。" + ) + + def parse_image_response(self, response_json: dict[str, Any]) -> ResponseData: + """解析图像响应并委派给 `image_handler`。""" + if self.image_handler: + return self.image_handler.parse_image_response( + adapter=self, response_json=response_json + ) + raise NotImplementedError(f"API 类型 '{self.api_type}' 未装配 ImageHandler。") + + def prepare_speech_request( + self, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: + """准备语音生成请求并委派给 `audio_handler`。""" + if self.audio_handler: + return self.audio_handler.prepare_speech_request( + adapter=self, + identity=identity, + api_key=api_key, + request=request, + ) + raise NotImplementedError( + f"API 类型 '{self.api_type}' 未装配 AudioHandler,暂不支持语音生成。" + ) + + async def parse_speech_response( + self, identity: ModelIdentity, raw_response: httpx.Response + ) -> AudioResponse: + """解析语音响应并委派给 `audio_handler`。 + 注意传入的是 httpx.Response 的 raw 对象""" + if self.audio_handler: + return await self.audio_handler.parse_speech_response( + adapter=self, identity=identity, raw_response=raw_response + ) + raise NotImplementedError(f"API 类型 '{self.api_type}' 未装配 AudioHandler。") + + def validate_embedding_response(self, response_json: dict[str, Any]) -> None: + """验证嵌入接口响应,检测 `error` 并转换为统一异常。""" + if response_json.get("error"): + error_info = response_json["error"] + msg = ( + error_info.get("message", str(error_info)) + if isinstance(error_info, dict) + else str(error_info) + ) + raise UpstreamServerException( + f"嵌入API错误: {msg}", + details=response_json, + ) + + def get_api_url(self, identity: ModelIdentity, endpoint: str) -> str: + """拼接最终请求 URL,兼容 `path_prefix` 与端点前后斜杠。""" + if not identity.api_base: + raise ConfigurationException( + f"模型 {identity.model_name} 的 api_base 未设置", + ) + + base_url = identity.api_base.rstrip("/") + prefix = identity.path_prefix.strip("/") if identity.path_prefix else "" + ep = endpoint.lstrip("/") + + if prefix: + return f"{base_url}/{prefix}/{ep}" + return f"{base_url}/{ep}" + + def get_base_headers(self, api_key: str) -> dict[str, str]: + """构建默认请求头,包含 UA、JSON 类型与 Bearer 鉴权。""" + from zhenxun.utils.user_agent import get_user_agent + + headers = get_user_agent() + headers.update( + { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + ) + return headers + + def validate_response(self, response_json: dict[str, Any]) -> None: + """统一校验文本/多模态响应并映射平台错误码。""" + if response_json.get("error"): + error_info = response_json["error"] + + error_message = str(error_info) + if isinstance(error_info, dict): + error_message = error_info.get("message", error_message) + error_code = error_info.get("code", "unknown") + + if ( + error_code in ("invalid_api_key", "authentication_failed") + or "permission" in error_message.lower() + ): + raise AuthenticationException( + f"鉴权失败: {error_message}", details={"api_error": error_info} + ) + elif error_code in ("insufficient_quota", "quota_exceeded"): + raise QuotaExceededException( + f"配额耗尽: {error_message}", details={"api_error": error_info} + ) + elif error_code == "rate_limit_exceeded": + raise RateLimitException( + f"请求限流: {error_message}", details={"api_error": error_info} + ) + elif error_code in ("model_not_found", "invalid_model"): + raise ConfigurationException( + f"模型配置错误: {error_message}", + details={"api_error": error_info}, + ) + elif error_code in ( + "context_length_exceeded", + "max_tokens_exceeded", + "1261", + ): + raise ContextLengthExceededException( + f"上下文超限: {error_message}", + details={"api_error": error_info}, + ) + elif error_code in ("invalid_request_error", "invalid_parameter"): + raise InvalidRequestException( + f"请求参数错误: {error_message}", + details={"api_error": error_info}, + ) + + raise UpstreamServerException( + f"API请求报错: {error_message}", + details={"api_error": error_info}, + ) + + if "candidates" in response_json: + candidates = response_json.get("candidates", []) + if candidates: + candidate = candidates[0] + finish_reason = candidate.get("finishReason") + if finish_reason in ["SAFETY", "RECITATION"]: + raise ContentFilteredException( + f"内容被模型安全策略过滤: {finish_reason}", + details={ + "finish_reason": finish_reason, + }, + ) + + if not response_json: + raise UpstreamServerException( + "API返回空响应", + details={"response": response_json}, + ) + + def handle_http_error(self, response: httpx.Response) -> LLMException | None: + """ + 处理 HTTP 错误响应。 + 如果响应状态码表示成功 (200),返回 None;否则构造 LLMException 供外部捕获。 + """ + if response.status_code == 200: + return None + + error_text = response.content.decode("utf-8", errors="ignore") + error_status = "" + error_msg = error_text + try: + error_json = json.loads(error_text) + if isinstance(error_json, dict) and "error" in error_json: + error_info = error_json["error"] + if isinstance(error_info, dict): + error_msg = error_info.get("message", error_msg) + raw_status = error_info.get("status") or error_info.get("code") + error_status = str(raw_status) if raw_status is not None else "" + elif error_info is not None: + error_msg = str(error_info) + error_status = error_msg + except Exception: + pass + + status_upper = error_status.upper() if error_status else "" + text_upper = error_text.upper() + + if response.status_code == 400: + if ( + "FAILED_PRECONDITION" in status_upper + or "LOCATION IS NOT SUPPORTED" in text_upper + ): + return LocationNotSupportedException( + "当前地区不支持该服务", details={"response": error_text} + ) + elif "API_KEY_INVALID" in text_upper or "API KEY NOT VALID" in text_upper: + return AuthenticationException( + "API Key 无效", details={"response": error_text} + ) + elif ( + status_upper + in ["1261", "STRING_ABOVE_MAX_LENGTH", "CONTEXT_LENGTH_EXCEEDED"] + or "EXCEEDS MAX LENGTH" in text_upper + or "STRING TOO LONG" in text_upper + ): + return ContextLengthExceededException( + "上下文超长", details={"response": error_text} + ) + else: + return InvalidRequestException( + f"参数错误: {error_msg}", details={"response": error_text} + ) + elif response.status_code in [401, 403]: + if "country" in error_msg.lower() or "unsupported" in error_msg.lower(): + return LocationNotSupportedException( + "地区受限", details={"response": error_text} + ) + else: + return AuthenticationException( + "鉴权失败/权限不足", details={"response": error_text} + ) + elif response.status_code == 404: + return ConfigurationException( + "端点或模型未找到", details={"response": error_text} + ) + elif response.status_code == 429: + if ( + "RESOURCE_EXHAUSTED" in status_upper + or "INSUFFICIENT_QUOTA" in status_upper + or ("quota" in error_msg.lower() if error_msg else False) + ): + return QuotaExceededException( + "API 配额耗尽", details={"response": error_text} + ) + else: + return RateLimitException( + "请求频繁被限流", details={"response": error_text} + ) + elif response.status_code in [402, 413]: + return QuotaExceededException( + "资源耗尽/文件过大", details={"response": error_text} + ) + elif response.status_code >= 500: + return UpstreamServerException( + f"HTTP请求失败: {response.status_code} ({error_status or 'Unknown'})", + details={ + "status_code": response.status_code, + "response": error_text, + }, + ) + + return UpstreamServerException( + f"未知网络错误 {response.status_code}: {error_msg}" + ) diff --git a/zhenxun/services/ai/llm/adapters/deepseek.py b/zhenxun/services/ai/llm/adapters/deepseek.py new file mode 100644 index 00000000..8be7207b --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/deepseek.py @@ -0,0 +1,140 @@ +from typing import Any + +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, +) +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + OpenAIConfigMapper, + OpenAITextHandler, + OpenAIToolSerializer, +) +from zhenxun.services.ai.llm.adapters.openai import OpenAICompatAdapter + + +class DeepSeekToolSerializer(OpenAIToolSerializer): + """ + 专门针对 DeepSeek 的工具序列化器。 + 负责抹平 Pydantic Schema 与 DeepSeek Strict Mode 之间的差异。 + """ + + def __init__(self, api_type: str = "deepseek"): + """初始化 DeepSeek 工具序列化器。""" + super().__init__(api_type=api_type) + + def sanitize_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + from zhenxun.services.ai.llm.engine.schema_transformer import ( + DeepSeekFallbackTransformer, + OpenAIUnionFlattenTransformer, + RefComplianceTransformer, + RemoveUnsupportedKeysTransformer, + RootRefInlineTransformer, + SchemaPipeline, + StrictObjectTransformer, + TypeEnforcerTransformer, + ) + + unsupported_keys = [ + "default", + "minLength", + "maxLength", + "pattern", + "format", + "minimum", + "maximum", + "multipleOf", + "patternProperties", + "propertyNames", + "minItems", + "maxItems", + "uniqueItems", + "$schema", + "title", + ] + pipeline = SchemaPipeline( + [ + RootRefInlineTransformer(), + OpenAIUnionFlattenTransformer(), + TypeEnforcerTransformer(), + RemoveUnsupportedKeysTransformer(unsupported_keys), + StrictObjectTransformer(), + DeepSeekFallbackTransformer(), + RefComplianceTransformer(), + ] + ) + return pipeline.run(schema) + + +class DeepSeekConfigMapper(OpenAIConfigMapper): + """DeepSeek 的专属配置映射器""" + + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + """映射生成参数并处理 DeepSeek 专有 `thinking` 与响应格式差异。""" + params = super().map_config(config, model_detail, capabilities) + + if "response_format" in params: + rf = params["response_format"] + if isinstance(rf, dict) and rf.get("type") == "json_schema": + params["response_format"] = {"type": "json_object"} + + if config.common.reasoning_effort: + effort = str(config.common.reasoning_effort).lower() + if effort == "none": + params["thinking"] = {"type": "disabled"} + else: + params["thinking"] = {"type": "enabled"} + elif ( + hasattr(config, "deepseek_options") + and config.deepseek_options.thinking is not None + ): + if config.deepseek_options.thinking is True: + params["thinking"] = {"type": "enabled"} + elif config.deepseek_options.thinking is False: + params["thinking"] = {"type": "disabled"} + + return params + + +class DeepSeekTextHandler(OpenAITextHandler): + """DeepSeek 专有文本处理器,替换了特定序列化组件""" + + def __init__(self, api_type: str = "deepseek"): + """替换 OpenAI 默认组件为 DeepSeek 专用实现。""" + super().__init__(api_type=api_type) + self.serializer = DeepSeekToolSerializer(api_type=api_type) + self.mapper = DeepSeekConfigMapper(api_type=api_type) + + +class DeepSeekAdapter(OpenAICompatAdapter): + """DeepSeek 官方 API 适配器""" + + def __init__(self): + """初始化 DeepSeek 适配器并挂载文本处理器。""" + super().__init__() + self.text_handler = DeepSeekTextHandler(api_type=self.api_type) + + @property + def log_sanitization_context(self) -> str: + """返回 DeepSeek 请求日志清洗上下文。""" + return "openai_request" + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "deepseek" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["deepseek"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """返回对话端点,优先使用模型级自定义端点。""" + return "/v1/chat/completions" diff --git a/zhenxun/services/ai/llm/adapters/doubao.py b/zhenxun/services/ai/llm/adapters/doubao.py new file mode 100644 index 00000000..b4df2b26 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/doubao.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from zhenxun.services.ai.core.models import ModelIdentity + +from .handlers.openai_handlers import ( + OpenAITextHandler, +) +from .openai import OpenAICompatAdapter + + +class DoubaoAdapter(OpenAICompatAdapter): + """火山方舟 (Doubao) API 适配器""" + + def __init__(self): + """初始化 Doubao 适配器并复用 OpenAI 兼容处理器。""" + super().__init__() + self.text_handler = OpenAITextHandler(api_type=self.api_type) + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "doubao" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["doubao"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """返回对话端点,优先使用模型级自定义端点。""" + return "/v3/chat/completions" diff --git a/zhenxun/services/llm/adapters/factory.py b/zhenxun/services/ai/llm/adapters/factory.py similarity index 56% rename from zhenxun/services/llm/adapters/factory.py rename to zhenxun/services/ai/llm/adapters/factory.py index a21349e4..4c9b2b83 100644 --- a/zhenxun/services/llm/adapters/factory.py +++ b/zhenxun/services/ai/llm/adapters/factory.py @@ -2,21 +2,21 @@ LLM 适配器工厂类 """ +from __future__ import annotations + import fnmatch -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar -from ..types.exceptions import LLMErrorCode, LLMException -from ..types.models import ToolChoice -from .base import BaseAdapter, RequestData, ResponseData +import httpx -if TYPE_CHECKING: - from ..config.generation import LLMEmbeddingConfig, LLMGenerationConfig - from ..service import LLMModel - from ..types import LLMMessage +from zhenxun.services.ai.core.exceptions import ConfigurationException +from zhenxun.services.ai.core.models import ModelIdentity + +from .base import BaseAdapter, RequestData class LLMAdapterFactory: - """LLM适配器工厂类""" + """适配器注册与按 API 类型分发的统一入口。""" _adapters: ClassVar[dict[str, BaseAdapter]] = {} _api_type_mapping: ClassVar[dict[str, str]] = {} @@ -27,14 +27,26 @@ class LLMAdapterFactory: if cls._adapters: return + from .deepseek import DeepSeekAdapter + from .doubao import DoubaoAdapter from .gemini import GeminiAdapter - from .openai import DeepSeekAdapter, OpenAIAdapter, OpenAIImageAdapter + from .glm import GLMAdapter + from .jina import JinaAdapter + from .mimo import MiMoAdapter + from .minimax import MiniMaxAdapter + from .openai import OpenAIAdapter + from .openrouter import OpenRouterAdapter cls.register_adapter(OpenAIAdapter()) + cls.register_adapter(OpenRouterAdapter()) cls.register_adapter(DeepSeekAdapter()) + cls.register_adapter(JinaAdapter()) cls.register_adapter(GeminiAdapter()) + cls.register_adapter(GLMAdapter()) cls.register_adapter(SmartAdapter()) - cls.register_adapter(OpenAIImageAdapter()) + cls.register_adapter(MiMoAdapter()) + cls.register_adapter(MiniMaxAdapter()) + cls.register_adapter(DoubaoAdapter()) @classmethod def register_adapter(cls, adapter: BaseAdapter) -> None: @@ -52,9 +64,8 @@ class LLMAdapterFactory: adapter_key = cls._api_type_mapping.get(api_type) if not adapter_key: - raise LLMException( + raise ConfigurationException( f"不支持的API类型: {api_type}", - code=LLMErrorCode.UNKNOWN_API_TYPE, details={ "api_type": api_type, "supported_types": list(cls._api_type_mapping.keys()), @@ -77,12 +88,12 @@ class LLMAdapterFactory: def get_adapter_for_api_type(api_type: str) -> BaseAdapter: - """获取指定API类型的适配器""" + """按 API 类型获取适配器实例。""" return LLMAdapterFactory.get_adapter(api_type) def register_adapter(adapter: BaseAdapter) -> None: - """注册新的适配器""" + """向工厂注册新的适配器实例。""" LLMAdapterFactory.register_adapter(adapter) @@ -94,33 +105,40 @@ class SmartAdapter(BaseAdapter): @property def log_sanitization_context(self) -> str: + """返回智能路由适配器的默认日志清洗上下文。""" return "openai_request" _ROUTING_RULES: ClassVar[list[tuple[str, str]]] = [ ("*nano-banana*", "gemini"), ("*gemini*", "gemini"), + ("*deepseek*", "deepseek"), + ("*minimax*", "minimax"), + ("*gpt*", "openai_responses"), ] _DEFAULT_API_TYPE: ClassVar[str] = "openai" def __init__(self): + """初始化模型名到目标适配器的路由缓存。""" self._adapter_cache: dict[str, BaseAdapter] = {} @property def api_type(self) -> str: + """适配器主类型标识。""" return "smart" @property def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" return ["smart"] - def _get_delegate_adapter(self, model: "LLMModel") -> BaseAdapter: + def _get_delegate_adapter(self, identity: ModelIdentity) -> BaseAdapter: """ 核心路由逻辑:决定使用哪个适配器 (带缓存) """ - if model.model_detail.api_type: - return get_adapter_for_api_type(model.model_detail.api_type) + if identity.api_type and identity.api_type != "smart": + return get_adapter_for_api_type(identity.api_type) - model_name = model.model_name + model_name = identity.model_name if model_name in self._adapter_cache: return self._adapter_cache[model_name] @@ -136,48 +154,14 @@ class SmartAdapter(BaseAdapter): self._adapter_cache[model_name] = adapter return adapter - async def prepare_advanced_request( - self, - model: "LLMModel", - api_key: str, - messages: list["LLMMessage"], - config: "LLMGenerationConfig | None" = None, - tools: list[Any] | None = None, - tool_choice: "str | dict[str, Any] | ToolChoice | None" = None, + async def prepare_payload( + self, identity: ModelIdentity, api_key: str, request: Any ) -> RequestData: - adapter = self._get_delegate_adapter(model) - return await adapter.prepare_advanced_request( - model, api_key, messages, config, tools, tool_choice - ) + adapter = self._get_delegate_adapter(identity) + return await adapter.prepare_payload(identity, api_key, request) - def parse_response( - self, - model: "LLMModel", - response_json: dict[str, Any], - is_advanced: bool = False, - ) -> ResponseData: - adapter = self._get_delegate_adapter(model) - return adapter.parse_response(model, response_json, is_advanced) - - def prepare_embedding_request( - self, - model: "LLMModel", - api_key: str, - texts: list[str], - config: "LLMEmbeddingConfig", - ) -> RequestData: - adapter = self._get_delegate_adapter(model) - return adapter.prepare_embedding_request(model, api_key, texts, config) - - def parse_embedding_response( - self, response_json: dict[str, Any] - ) -> list[list[float]]: - return get_adapter_for_api_type("openai").parse_embedding_response( - response_json - ) - - def convert_generation_config( - self, config: "LLMGenerationConfig", model: "LLMModel" - ) -> dict[str, Any]: - adapter = self._get_delegate_adapter(model) - return adapter.convert_generation_config(config, model) + async def parse_payload( + self, identity: ModelIdentity, request: Any, raw_response: httpx.Response + ) -> Any: + adapter = self._get_delegate_adapter(identity) + return await adapter.parse_payload(identity, request, raw_response) diff --git a/zhenxun/services/ai/llm/adapters/gemini.py b/zhenxun/services/ai/llm/adapters/gemini.py new file mode 100644 index 00000000..e86bc11a --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/gemini.py @@ -0,0 +1,59 @@ +""" +Gemini API 适配器 +""" + +from __future__ import annotations + +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.ai.core.options import GenerationConfig + +from .base import BaseAdapter +from .handlers.gemini_handlers import ( + GeminiAudioHandler, + GeminiEmbeddingHandler, + GeminiImageHandler, + GeminiTextHandler, +) + + +class GeminiAdapter(BaseAdapter): + """Gemini API 适配器""" + + def __init__(self): + """初始化 Gemini 适配器并挂载各模态处理器。""" + super().__init__() + self.text_handler = GeminiTextHandler() + self.image_handler = GeminiImageHandler() + self.embedding_handler = GeminiEmbeddingHandler() + self.audio_handler = GeminiAudioHandler() + + @property + def log_sanitization_context(self) -> str: + """返回 Gemini 请求日志清洗上下文。""" + return "gemini_request" + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "gemini" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["gemini"] + + def get_base_headers(self, api_key: str) -> dict[str, str]: + """获取基础请求头""" + from zhenxun.utils.user_agent import get_user_agent + + headers = get_user_agent() + headers.update({"Content-Type": "application/json"}) + headers["x-goog-api-key"] = api_key + + return headers + + def _get_gemini_endpoint( + self, identity: ModelIdentity, config: GenerationConfig | None = None + ) -> str: + """返回Gemini generateContent 端点""" + return f"/v1beta/models/{identity.model_name}:generateContent" diff --git a/zhenxun/services/ai/llm/adapters/glm.py b/zhenxun/services/ai/llm/adapters/glm.py new file mode 100644 index 00000000..16ed579f --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/glm.py @@ -0,0 +1,78 @@ +from zhenxun.services.ai.core.messages import RerankRequest +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.ai.llm.adapters.base import BaseAdapter, RequestData +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + OpenAIConfigMapper, + OpenAIEmbeddingHandler, + OpenAIRerankHandler, + OpenAITextHandler, +) +from zhenxun.services.ai.llm.adapters.openai import OpenAICompatAdapter + + +class GLMRerankHandler(OpenAIRerankHandler): + """GLM 专有的重排处理器(重写了端点构建逻辑)""" + + def prepare_rerank_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: RerankRequest, + ) -> RequestData: + """构建 GLM 重排请求,统一将文档归一化为字符串列表。""" + endpoint = "/api/paas/v4/rerank" + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + safe_documents = [] + for doc in request.documents: + if isinstance(doc, dict): + safe_documents.append(doc.get("text", str(doc))) + else: + safe_documents.append(str(doc)) + + body = { + "model": identity.model_name, + "query": request.query, + "documents": safe_documents, + "top_n": request.top_n, + } + return RequestData(url=url, headers=headers, body=body) + + +class GLMTextHandler(OpenAITextHandler): + def __init__(self, api_type: str = "glm"): + super().__init__(api_type=api_type) + self.mapper = OpenAIConfigMapper(api_type=api_type) + + +class GLMAdapter(OpenAICompatAdapter): + """GLM (智谱) 大模型专有适配器 (继承 OpenAI 兼容协议处理标准聊天)""" + + def __init__(self): + """初始化 GLM 适配器并装配专有图像/重排处理器。""" + super().__init__() + self.text_handler = GLMTextHandler(api_type=self.api_type) + self.embedding_handler = OpenAIEmbeddingHandler() + self.rerank_handler = GLMRerankHandler() + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "glm" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["glm"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """返回对话端点,优先使用模型级自定义端点。""" + if None: + return None + return "/api/paas/v4/chat/completions" + + def get_embedding_endpoint(self, identity: ModelIdentity) -> str: + """返回嵌入端点。""" + return "/api/paas/v4/embeddings" diff --git a/zhenxun/services/llm/adapters/components/__init__.py b/zhenxun/services/ai/llm/adapters/handlers/__init__.py similarity index 100% rename from zhenxun/services/llm/adapters/components/__init__.py rename to zhenxun/services/ai/llm/adapters/handlers/__init__.py diff --git a/zhenxun/services/ai/llm/adapters/handlers/base.py b/zhenxun/services/ai/llm/adapters/handlers/base.py new file mode 100644 index 00000000..d1733f3e --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/handlers/base.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import asyncio +from typing import Any + +import httpx + +from zhenxun.services.ai.core.messages import ( + AudioResponse, + ChatRequest, + EmbeddingRequest, + ImageRequest, + LLMMessage, + RerankRequest, + RerankResult, + SpeechRequest, +) +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, + ToolDefinition, +) +from zhenxun.services.ai.core.options import ( + GenerationConfig, +) +from zhenxun.services.ai.llm.adapters.base import BaseAdapter, RequestData, ResponseData + + +class ConfigMapper(ABC): + @abstractmethod + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + """将通用生成配置转换为特定 API 的参数字典""" + ... + + +class MessageConverter(ABC): + @abstractmethod + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]] | dict[str, Any]: + """将通用消息列表异步转换为特定 API 的消息格式""" + ... + + +class ToolSerializer(ABC): + def serialize_tools( + self, tools: list[ToolDefinition] + ) -> list[dict[str, Any]] | None: + """将通用工具定义转换为特定 API 的工具格式 (模板方法)""" + if not tools: + return None + + serialized_tools = [] + for tool in tools: + raw_schema = tool.parameters.copy() if tool.parameters else {} + sanitized_schema = self.sanitize_schema(raw_schema) + tool_payload = self.format_tool_payload( + tool_name=tool.name, + tool_description=tool.description or "", + sanitized_schema=sanitized_schema, + ) + serialized_tools.append(tool_payload) + return serialized_tools + + @abstractmethod + def format_tool_payload( + self, tool_name: str, tool_description: str, sanitized_schema: dict[str, Any] + ) -> dict[str, Any]: + """由子类实现:格式化单一工具的 Payload""" + ... + + @abstractmethod + def sanitize_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + """对 JSON Schema 进行特定 API 的清洗和格式化""" + ... + + @abstractmethod + def serialize_server_tools( + self, tools: list[Any], capabilities: ModelCapabilities + ) -> list[dict[str, Any]]: + """将系统内置的原生云端工具转译为底层 API 载荷(执行双重能力校验)""" + ... + + +class ResponseParser(ABC): + @abstractmethod + def parse(self, response_json: dict[str, Any]) -> ResponseData: + """将特定 API 的响应解析为通用响应数据""" + ... + + +class BaseTextHandler(ABC): + """ + 文本对话生成处理器接口。 + 负责将真寻的通用消息与工具列表转换为底层 API 的请求格式,并解析响应。 + """ + + async def _resolve_and_split_tools( + self, tools: list[Any] | None + ) -> tuple[list[Any], list[Any], list[Any]]: + """ + 统一的工具解析逻辑:分离客户端工具与服务端内置工具,并发获取 Schema。 + 返回: (tool_defs, client_executables, server_tools) + """ + client_executables, server_tools, tool_defs = [], [], [] + if tools: + raw_tools = list(tools.values()) if isinstance(tools, dict) else tools + for tool in raw_tools: + if getattr(tool, "execution_side", "client") == "server": + server_tools.append(tool) + elif hasattr(tool, "get_definition"): + client_executables.append(tool) + if definition_tasks := [t.get_definition() for t in client_executables]: + tool_defs = [td for td in await asyncio.gather(*definition_tasks) if td] + return tool_defs, client_executables, server_tools + + @abstractmethod + async def prepare_text_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ChatRequest, + ) -> RequestData: ... + + @abstractmethod + def parse_text_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + response_json: dict[str, Any], + is_advanced: bool = False, + ) -> ResponseData: ... + + +class BaseEmbeddingHandler(ABC): + """ + 文本嵌入向量处理器接口。 + """ + + @abstractmethod + async def prepare_embedding_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: EmbeddingRequest, + ) -> RequestData: ... + + @abstractmethod + def parse_embedding_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> list[list[float]]: ... + + +class BaseImageHandler(ABC): + """ + 图像生成/编辑处理器接口。 + """ + + @abstractmethod + def prepare_image_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ImageRequest, + ) -> RequestData: ... + + @abstractmethod + def parse_image_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> ResponseData: ... + + +class BaseRerankHandler(ABC): + """ + 文本重排处理器接口。 + """ + + @abstractmethod + def prepare_rerank_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: RerankRequest, + ) -> RequestData: ... + + @abstractmethod + def parse_rerank_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> list[RerankResult]: ... + + +class BaseAudioHandler(ABC): + """ + 文本转语音 (TTS) 处理器接口。 + """ + + @abstractmethod + def prepare_speech_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: ... + + @abstractmethod + async def parse_speech_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + raw_response: httpx.Response, + ) -> AudioResponse: ... diff --git a/zhenxun/services/ai/llm/adapters/handlers/gemini_handlers.py b/zhenxun/services/ai/llm/adapters/handlers/gemini_handlers.py new file mode 100644 index 00000000..e820ba5c --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/handlers/gemini_handlers.py @@ -0,0 +1,1066 @@ +import base64 +import io +import json +from typing import Any +import uuid +import wave + +import httpx + +from zhenxun.services.ai.config import get_gemini_safety_threshold +from zhenxun.services.ai.core.exceptions import ( + ContentFilteredException, + InvalidRequestException, + QuotaExceededException, + RateLimitException, + ResponseParseException, +) +from zhenxun.services.ai.core.messages import ( + AssistantMessage, + AudioPart, + AudioResponse, + ChatRequest, + EmbeddingRequest, + FilePart, + ImagePart, + ImageRequest, + LLMContentPart, + LLMGroundingAttribution, + LLMGroundingMetadata, + LLMMessage, + SpeechRequest, + SystemMessage, + TextPart, + ThoughtPart, + ToolCallPart, + ToolMessage, + ToolReturnPart, + UserMessage, + VideoPart, +) +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, + ReasoningMode, +) +from zhenxun.services.ai.core.options import ( + GenerationConfig, + LLMEmbeddingConfig, + ResponseFormat, + TTSConfig, +) +from zhenxun.services.ai.llm.adapters.base import ( + BaseAdapter, + RequestData, + ResponseData, + process_image_data, +) +from zhenxun.services.ai.llm.adapters.handlers.base import ( + BaseAudioHandler, + BaseEmbeddingHandler, + BaseImageHandler, + BaseTextHandler, + ConfigMapper, + MessageConverter, + ResponseParser, + ToolSerializer, +) +from zhenxun.services.log import logger + + +class GeminiConfigMapper(ConfigMapper): + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + params: dict[str, Any] = {} + + if config.common: + if config.common.temperature is not None: + params["temperature"] = config.common.temperature + if config.common.max_tokens is not None: + params["maxOutputTokens"] = config.common.max_tokens + if config.common.top_k is not None: + params["topK"] = config.common.top_k + if config.common.top_p is not None: + params["topP"] = config.common.top_p + if config.common.stop is not None: + params["stopSequences"] = ( + config.common.stop + if isinstance(config.common.stop, list) + else [config.common.stop] + ) + + if ( + config.output.response_format == ResponseFormat.JSON + or config.output.response_mime_type == "application/json" + ): + params["responseMimeType"] = "application/json" + if config.output.response_schema: + serializer = GeminiToolSerializer() + params["responseJsonSchema"] = serializer.sanitize_schema( + config.output.response_schema + ) + elif config.output.response_mime_type: + params["responseMimeType"] = config.output.response_mime_type + if config.output.response_modalities: + params["responseModalities"] = config.output.response_modalities + + if config.tools.mode: + fc_config: dict[str, Any] = {"mode": config.tools.mode} + if config.tools.allowed_function_names and config.tools.mode == "ANY": + user_funcs = [ + name + for name in config.tools.allowed_function_names + if name not in {"code_execution", "google_search", "google_map"} + ] + if user_funcs: + fc_config["allowedFunctionNames"] = user_funcs + params["toolConfig"] = {"functionCallingConfig": fc_config} + + has_effort = bool( + config.common.reasoning_effort + and str(config.common.reasoning_effort).lower() != "none" + ) + if ( + has_effort + and capabilities + and capabilities.reasoning_mode == ReasoningMode.LEVEL + ): + thinking_config = params.setdefault("thinkingConfig", {}) + effort = str(config.common.reasoning_effort).lower() + + if capabilities.reasoning_effort_map: + effort = capabilities.reasoning_effort_map.get(effort, effort) + + thinking_config["thinkingLevel"] = effort + + if config.gemini_options.include_thoughts is not None: + thinking_config = params.setdefault("thinkingConfig", {}) + thinking_config["includeThoughts"] = config.gemini_options.include_thoughts + elif capabilities and capabilities.reasoning_visibility == "visible": + thinking_config = params.setdefault("thinkingConfig", {}) + thinking_config["includeThoughts"] = True + + if "thinkingConfig" in params and not params["thinkingConfig"]: + params.pop("thinkingConfig", None) + + image_config: dict[str, Any] = {} + + if config.media.aspect_ratio is not None: + image_config["aspectRatio"] = config.media.aspect_ratio + + if config.media.resolution is not None: + res_str = str(config.media.resolution).upper() + if "1024" in res_str: + res_str = "1K" + elif "1536" in res_str or "2048" in res_str: + res_str = "2K" + elif "4096" in res_str: + res_str = "4K" + image_config["imageSize"] = res_str + + if image_config: + params["imageConfig"] = image_config + + if config.media.quality: + quality_map = { + "low": "LOW", + "medium": "MEDIUM", + "high": "HIGH", + "standard": "MEDIUM", + "hd": "HIGH", + } + mapped_quality = quality_map.get(config.media.quality, "HIGH") + params["mediaResolution"] = f"MEDIA_RESOLUTION_{mapped_quality}" + + if config.custom_kwargs: + mapped_custom = config.custom_kwargs.copy() + for key in ("code_execution_timeout", "reflexion_retries", "__cache_ttl__"): + mapped_custom.pop(key, None) + + for unsupported in [ + "frequency_penalty", + "presence_penalty", + "repetition_penalty", + ]: + if unsupported in mapped_custom: + mapped_custom.pop(unsupported) + + params.update(mapped_custom) + + safety_settings: list[dict[str, Any]] = [] + if config.gemini_options.safety_settings: + for category, threshold in config.gemini_options.safety_settings.items(): + safety_settings.append({"category": category, "threshold": threshold}) + else: + threshold = get_gemini_safety_threshold() + for category in [ + "HARM_CATEGORY_HARASSMENT", + "HARM_CATEGORY_HATE_SPEECH", + "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_DANGEROUS_CONTENT", + ]: + safety_settings.append({"category": category, "threshold": threshold}) + + if safety_settings: + params["safetySettings"] = safety_settings + + return params + + +class GeminiMessageConverter(MessageConverter): + async def convert_part(self, part: LLMContentPart) -> dict[str, Any] | None: + """将单个内容部分转换为 Gemini API 格式""" + + def _get_gemini_resolution_dict() -> dict[str, Any]: + res_val = getattr(part, "media_resolution", None) + if res_val and isinstance(res_val, str): + value = res_val.upper() + if not value.startswith("MEDIA_RESOLUTION_"): + value = f"MEDIA_RESOLUTION_{value}" + return {"media_resolution": {"level": value}} + return {} + + if isinstance(part, TextPart): + return {"text": part.text} + + if isinstance(part, ThoughtPart): + return {"text": part.thought_text, "thought": True} + + if isinstance(part, ImagePart): + payload = { + "inlineData": { + "mimeType": part.mime_type or "image/jpeg", + "data": await part.get_base64_data(), + } + } + payload.update(_get_gemini_resolution_dict()) + return payload + + if isinstance(part, VideoPart): + payload = { + "inlineData": { + "mimeType": part.mime_type or "video/mp4", + "data": await part.get_base64_data(), + } + } + payload.update(_get_gemini_resolution_dict()) + return payload + + if isinstance(part, AudioPart): + payload = { + "inlineData": { + "mimeType": part.mime_type or "audio/mp3", + "data": await part.get_base64_data(), + } + } + payload.update(_get_gemini_resolution_dict()) + return payload + + if isinstance(part, FilePart): + payload = { + "inlineData": { + "mimeType": part.mime_type or "application/octet-stream", + "data": await part.get_base64_data(), + } + } + payload.update(_get_gemini_resolution_dict()) + return payload + + if isinstance(part, ToolCallPart): + func_call = { + "name": part.tool_name, + "args": part.args + if isinstance(part.args, dict) + else (json.loads(part.args) if part.args else {}), + } + if part.id and part.id != "unknown": + func_call["id"] = part.id + + payload = {"functionCall": func_call} + if part.metadata and "thought_signature" in part.metadata: + payload["thoughtSignature"] = part.metadata["thought_signature"] + return payload + + if isinstance(part, ToolReturnPart): + func_resp = { + "name": part.tool_name, + "response": part.output + if isinstance(part.output, dict) + else {"result": part.output}, + } + if part.tool_call_id and part.tool_call_id != "unknown": + func_resp["id"] = part.tool_call_id + + payload = {"functionResponse": func_resp} + return payload + + raise ValueError(f"不支持的内容类型: {part.type}") + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + gemini_contents: list[dict[str, Any]] = [] + + for msg in messages: + current_parts: list[dict[str, Any]] = [] + if isinstance(msg, SystemMessage): + continue + + elif isinstance(msg, UserMessage): + for part_obj in msg.content: + part_dict = await self.convert_part(part_obj) + if part_dict is not None: + current_parts.append(part_dict) + gemini_contents.append({"role": "user", "parts": current_parts}) + + elif isinstance(msg, AssistantMessage): + for part_obj in msg.content: + part_dict = await self.convert_part(part_obj) + if part_dict is None: + continue + if part_obj.metadata and "thought_signature" in part_obj.metadata: + part_dict["thoughtSignature"] = part_obj.metadata[ + "thought_signature" + ] + current_parts.append(part_dict) + + if current_parts: + gemini_contents.append({"role": "model", "parts": current_parts}) + + elif isinstance(msg, ToolMessage): + from zhenxun.services.ai.core.messages import ToolReturnPart + + for part_obj in msg.content: + if isinstance(part_obj, ToolReturnPart): + result_obj = part_obj.output + if isinstance(result_obj, str): + try: + result_obj = json.loads(result_obj) + except json.JSONDecodeError: + pass + if not isinstance(result_obj, dict): + result_obj = {"result": result_obj} + + func_resp = { + "name": part_obj.tool_name, + "response": result_obj, + } + if part_obj.tool_call_id and part_obj.tool_call_id != "unknown": + func_resp["id"] = part_obj.tool_call_id + + current_parts.append({"functionResponse": func_resp}) + else: + part_dict = await self.convert_part(part_obj) + if part_dict is not None: + current_parts.append(part_dict) + + if current_parts: + if gemini_contents and gemini_contents[-1]["role"] == "user": + gemini_contents[-1]["parts"].extend(current_parts) + else: + gemini_contents.append({"role": "user", "parts": current_parts}) + + return gemini_contents + + def convert_messages(self, messages: list[LLMMessage]) -> list[dict[str, Any]]: + raise NotImplementedError("Use convert_messages_async for Gemini") + + +class GeminiToolSerializer(ToolSerializer): + def sanitize_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + from zhenxun.services.ai.llm.engine.schema_transformer import ( + GeminiCyclicRefTransformer, + GeminiDeepRefInlineTransformer, + GeminiEnumTransformer, + GeminiFormatTransformer, + GeminiNullableUnionTransformer, + RefComplianceTransformer, + RemoveUnsupportedKeysTransformer, + SchemaPipeline, + ) + + unsupported_keys = [ + "exclusiveMinimum", + "exclusiveMaximum", + "default", + "title", + "additionalProperties", + "schema", + "$schema", + "id", + "propertyNames", + "patternProperties", + "$defs", + "definitions", + ] + pipeline = SchemaPipeline( + [ + GeminiDeepRefInlineTransformer(), + GeminiCyclicRefTransformer(schema), + GeminiEnumTransformer(), + GeminiNullableUnionTransformer(), + GeminiFormatTransformer(), + RemoveUnsupportedKeysTransformer(unsupported_keys), + RefComplianceTransformer(), + ] + ) + return pipeline.run(schema) + + def format_tool_payload( + self, tool_name: str, tool_description: str, sanitized_schema: dict[str, Any] + ) -> dict[str, Any]: + return { + "name": tool_name, + "description": tool_description, + "parameters": sanitized_schema, + } + + def serialize_server_tools( + self, tools: list[Any], capabilities: ModelCapabilities + ) -> list[dict[str, Any]]: + """Gemini 接口的专门序列化,增加基于 capabilities 的鉴权""" + res = [] + for t in tools: + type_id = getattr(t, "type_id", "unknown") + if type_id not in capabilities.supported_native_tools: + continue + if type_id == "web_search": + res.append({"googleSearch": {}}) + elif type_id == "code_execution": + res.append({"codeExecution": {}}) + elif type_id == "file_search": + res.append({"fileSearch": {}}) + elif type_id == "google_map": + res.append({"googleMaps": {}}) + elif type_id == "url_context": + res.append({"urlContext": {}}) + return res + + +class GeminiResponseParser(ResponseParser): + def validate_response(self, response_json: dict[str, Any]) -> None: + if error := response_json.get("error"): + code = error.get("code") + message = error.get("message", "") + status = error.get("status") + details = error.get("details", []) + + if code == 429 or status == "RESOURCE_EXHAUSTED": + is_quota = any( + d.get("reason") in ("QUOTA_EXCEEDED", "SERVICE_DISABLED") + for d in details + if isinstance(d, dict) + ) + if is_quota or "quota" in message.lower(): + raise QuotaExceededException( + f"Gemini配额耗尽: {message}", + details=error, + ) + raise RateLimitException( + f"Gemini速率限制: {message}", + details=error, + ) + + if code == 400 or status in ("INVALID_ARGUMENT", "FAILED_PRECONDITION"): + raise InvalidRequestException( + f"Gemini参数错误: {message}", + details=error, + ) + + if prompt_feedback := response_json.get("promptFeedback"): + if block_reason := prompt_feedback.get("blockReason"): + raise ContentFilteredException( + f"内容被安全过滤: {block_reason}", + details={ + "block_reason": block_reason, + "safety_ratings": prompt_feedback.get("safetyRatings"), + }, + ) + + def parse(self, response_json: dict[str, Any]) -> ResponseData: + self.validate_response(response_json) + + if "image_generation" in response_json and isinstance( + response_json["image_generation"], dict + ): + candidates_source = response_json["image_generation"] + else: + candidates_source = response_json + + candidates = candidates_source.get("candidates", []) + usage_info = response_json.get("usageMetadata") + + if not candidates: + return ResponseData(raw_response=response_json) + + candidate = candidates[0] + thought_signature: str | None = None + + content_data = candidate.get("content", {}) + parts = content_data.get("parts", []) + + content_parts: list[Any] = [] + thought_summary_parts: list[str] = [] + answer_parts = [] + + for part in parts: + part_signature = part.get("thoughtSignature") + if part_signature and thought_signature is None: + thought_signature = part_signature + part_metadata: dict[str, Any] | None = None + if part_signature: + part_metadata = {"thought_signature": part_signature} + + if part.get("thought") is True: + t_text = part.get("text", "") + thought_summary_parts.append(t_text) + content_parts.append( + ThoughtPart(thought_text=t_text, metadata=part_metadata) + ) + + elif "text" in part: + answer_parts.append(part["text"]) + c_part = TextPart(text=part["text"], metadata=part_metadata) + content_parts.append(c_part) + + elif "thoughtSummary" in part: + thought_summary_parts.append(part["thoughtSummary"]) + content_parts.append( + ThoughtPart( + thought_text=part["thoughtSummary"], metadata=part_metadata + ) + ) + + elif "inlineData" in part: + inline_data = part["inlineData"] + if "data" in inline_data: + decoded = base64.b64decode(inline_data["data"]) + processed_img = process_image_data(decoded) + content_parts.append( + ImagePart(raw=processed_img) + if isinstance(processed_img, bytes) + else ImagePart(path=processed_img) + ) + + elif "functionCall" in part or "toolCall" in part: + fc_data = part.get("functionCall") or part.get("toolCall") + fc_sig = part_signature + try: + call_id = fc_data.get("id", "") + if not call_id: + call_id = f"call_{uuid.uuid4().hex[:16]}" + tc_part = ToolCallPart( + id=call_id, + tool_name=fc_data.get("name") + or fc_data.get("toolType", "unknown"), + args=fc_data.get("args", {}), + ) + if fc_sig: + tc_part.metadata = {"thought_signature": fc_sig} + content_parts.append(tc_part) + except Exception as e: + logger.warning( + f"解析Gemini functionCall时出错: {fc_data}, 错误: {e}" + ) + + elif "functionResponse" in part or "toolResponse" in part: + resp_data = part.get("functionResponse") or part.get("toolResponse") + try: + call_id = resp_data.get("id", "") + if not call_id: + call_id = f"call_{uuid.uuid4().hex[:16]}" + tc_part = ToolReturnPart( + tool_call_id=call_id, + tool_name=resp_data.get("name") + or resp_data.get("toolType", ""), + output=resp_data.get("response", {}), + ) + content_parts.append(tc_part) + except Exception as e: + logger.warning(f"解析Gemini toolResponse时出错: {e}") + + content_parts.sort(key=lambda p: 1 if isinstance(p, ThoughtPart) else 0) + + grounding_metadata_obj = None + if grounding_data := candidate.get("groundingMetadata"): + try: + sep_content = None + sep_field = grounding_data.get("searchEntryPoint") + if isinstance(sep_field, dict): + sep_content = sep_field.get("renderedContent") + + attributions = [] + if chunks := grounding_data.get("groundingChunks"): + for chunk in chunks: + if web := chunk.get("web"): + attributions.append( + LLMGroundingAttribution( + title=web.get("title"), + uri=web.get("uri"), + snippet=web.get("snippet"), + confidence_score=None, + ) + ) + + grounding_metadata_obj = LLMGroundingMetadata( + web_search_queries=grounding_data.get("webSearchQueries"), + grounding_attributions=attributions or None, + search_suggestions=grounding_data.get("searchSuggestions"), + search_entry_point=sep_content, + map_widget_token=grounding_data.get("googleMapsWidgetContextToken"), + ) + except Exception as e: + logger.warning(f"无法解析Grounding元数据: {grounding_data}, {e}") + + return ResponseData( + content_parts=content_parts, + usage_info=usage_info, + raw_response=response_json, + grounding_metadata=grounding_metadata_obj, + ) + + +class GeminiTextHandler(BaseTextHandler): + """Gemini 文本对话处理器""" + + def __init__(self): + self.converter = GeminiMessageConverter() + self.serializer = GeminiToolSerializer() + self.mapper = GeminiConfigMapper() + self.parser = GeminiResponseParser() + + async def prepare_text_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ChatRequest, + ) -> RequestData: + messages = request.messages + config = request.config + tools = request.tools + effective_config = config if config is not None else identity.generation_config + + ( + tool_defs, + client_executables, + server_tools, + ) = await self._resolve_and_split_tools(tools) + + from zhenxun.services.ai.config import get_llm_config + + gemini_settings = get_llm_config().provider_settings.gemini + + if server_tools and client_executables: + has_mixed_tools_cap = identity.capabilities.has_feature("mixed_tools") + if not gemini_settings.allow_mixed_tools or not has_mixed_tools_cap: + server_tool_names = [ + getattr(t, "name", "unknown") for t in server_tools + ] + reason = ( + "全局开关 (allow_mixed_tools) 已关闭" + if not gemini_settings.allow_mixed_tools + else f"模型 {identity.model_name} 原生不支持工具混用" + ) + logger.warning( + "🌐 [Gemini Adapter] 检测到请求中混用了" + "本地自定义工具与云端内置工具," + f"但{reason}。" + f"自动拦截并屏蔽云端内置工具 {server_tool_names} 以防协议冲突。" + ) + server_tools = [] + + has_function_tools = len(client_executables) > 0 + + is_structured = False + if effective_config and effective_config.output: + if ( + effective_config.output.response_schema + or effective_config.output.response_format == ResponseFormat.JSON + or effective_config.output.response_mime_type == "application/json" + ): + is_structured = True + + has_reasoning_cap = False + if identity.capabilities and identity.capabilities.reasoning_mode in ( + ReasoningMode.BUDGET, + ReasoningMode.LEVEL, + ): + has_reasoning_cap = True + + if ( + has_function_tools or is_structured or has_reasoning_cap + ) and effective_config: + if effective_config.common.reasoning_effort is None: + if has_function_tools or is_structured: + reason_desc = "工具调用" if has_function_tools else "结构化输出" + logger.debug( + f"检测到{reason_desc},自动为模型 " + f"{identity.model_name} 开启思维链增强" + ) + else: + logger.debug( + f"模型 {identity.model_name} 声明原生支持思维链,自动开启思维链" + ) + effective_config.common.reasoning_effort = "medium" + effective_config.gemini_options.include_thoughts = True + + endpoint = getattr(adapter, "_get_gemini_endpoint")(identity, effective_config) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + system_instruction_parts: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, SystemMessage): + for part in msg.content: + part_dict = await self.converter.convert_part(part) + if part_dict is not None: + system_instruction_parts.append(part_dict) + continue + + gemini_contents = await self.converter.convert_messages_async(messages) + + body: dict[str, Any] = {"contents": gemini_contents} + + if system_instruction_parts: + body["systemInstruction"] = {"parts": system_instruction_parts} + + all_tools_for_request = [] + + if server_tools: + server_payloads = self.serializer.serialize_server_tools( + server_tools, identity.capabilities + ) + if server_payloads: + all_tools_for_request.extend(server_payloads) + if identity.capabilities.has_feature("server_side_tool_invocations"): + body.setdefault("toolConfig", {}).update( + { + "includeServerSideToolInvocations": True, + } + ) + + has_user_functions = False + if client_executables: + function_declarations = self.serializer.serialize_tools(tool_defs) + + if function_declarations: + all_tools_for_request.append( + {"functionDeclarations": function_declarations} + ) + has_user_functions = True + + if all_tools_for_request: + body["tools"] = all_tools_for_request + + tool_config_updates: dict[str, Any] = {} + if effective_config and effective_config.gemini_options.retrieval_config: + tool_config_updates["retrievalConfig"] = ( + effective_config.gemini_options.retrieval_config + ) + + if tool_config_updates: + body.setdefault("toolConfig", {}).update(tool_config_updates) + + converted_params: dict[str, Any] = {} + if effective_config: + converted_params = self.mapper.map_config( + effective_config, None, identity.capabilities + ) + + if converted_params: + if "toolConfig" in converted_params: + tool_config_payload = converted_params.pop("toolConfig") + fc_config = tool_config_payload.get("functionCallingConfig") + should_apply_fc = has_user_functions or ( + fc_config and fc_config.get("mode") == "NONE" + ) + if should_apply_fc: + body.setdefault("toolConfig", {}).update(tool_config_payload) + elif fc_config and fc_config.get("mode") != "AUTO": + logger.debug( + "Gemini: 忽略针对纯内置工具的 functionCallingConfig (API限制)" + ) + + if "safetySettings" in converted_params: + body["safetySettings"] = converted_params.pop("safetySettings") + + if converted_params: + body["generationConfig"] = converted_params + + return RequestData(url=url, headers=headers, body=body) + + def parse_text_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + response_json: dict[str, Any], + is_advanced: bool = False, + ) -> ResponseData: + return self.parser.parse(response_json) + + +class GeminiEmbeddingHandler(BaseEmbeddingHandler): + """Gemini 文本嵌入处理器""" + + async def prepare_embedding_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: EmbeddingRequest, + ) -> RequestData: + batch = request.batch + config = request.config or LLMEmbeddingConfig() + api_model_name = identity.model_name + if not api_model_name.startswith("models/"): + api_model_name = f"models/{api_model_name}" + + base_url = ( + identity.api_base.rstrip("/") + if identity.api_base + else "https://generativelanguage.googleapis.com" + ) + url = f"{base_url}/v1beta/{api_model_name}:batchEmbedContents" + headers = adapter.get_base_headers(api_key) + + from zhenxun.services.ai.llm.adapters.handlers.gemini_handlers import ( + GeminiMessageConverter, + ) + + converter = GeminiMessageConverter() + + requests_payload = [] + for payload in batch.payloads: + gemini_parts = [] + text_prefix = "" + + if config.task_type == "RETRIEVAL_DOCUMENT": + title_str = config.title if config.title else "none" + text_prefix = f"title: {title_str} | text: " + elif config.task_type: + task_mapping = { + "RETRIEVAL_QUERY": "search result", + "QUESTION_ANSWERING": "question answering", + "FACT_VERIFICATION": "fact checking", + "CODE_RETRIEVAL_QUERY": "code retrieval", + } + mapped_task = task_mapping.get(str(config.task_type), "search result") + text_prefix = f"task: {mapped_task} | query: " + + for i, part in enumerate(payload.parts): + part_dict = await converter.convert_part(part) + if part_dict: + if text_prefix and "text" in part_dict and i == 0: + part_dict["text"] = text_prefix + part_dict["text"] + gemini_parts.append(part_dict) + + if not gemini_parts: + gemini_parts.append({"text": text_prefix + " "}) + + request_item: dict[str, Any] = { + "model": api_model_name, + "content": {"parts": gemini_parts}, + } + + if config.output_dimensionality: + request_item["output_dimensionality"] = config.output_dimensionality + + requests_payload.append(request_item) + + body = {"requests": requests_payload} + return RequestData(url=url, headers=headers, body=body) + + def parse_embedding_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> list[list[float]]: + adapter.validate_embedding_response(response_json) + if "embeddings" not in response_json or not isinstance( + response_json["embeddings"], list + ): + raise ResponseParseException( + "Gemini嵌入响应缺少'embeddings'字段或格式不正确", + details=response_json, + ) + for item in response_json["embeddings"]: + if "values" not in item: + raise ResponseParseException( + "Gemini嵌入响应的条目中缺少'values'字段", + details=response_json, + ) + + try: + embeddings_data = response_json["embeddings"] + return [item["values"] for item in embeddings_data] + except Exception as e: + logger.error( + f"解析Gemini嵌入响应时发生未知错误: {e}. 响应: {response_json}" + ) + raise ResponseParseException( + f"解析Gemini嵌入响应失败: {e}", + cause=e, + ) + + +class GeminiImageHandler(BaseImageHandler): + """Gemini 图像生成处理器""" + + def prepare_image_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ImageRequest, + ) -> RequestData: + prompt = request.prompt + images = request.images + config = request.config + endpoint = getattr(adapter, "_get_gemini_endpoint")(identity, config) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + parts: list[dict[str, Any]] = [{"text": prompt}] + + if images: + for img in images: + if isinstance(img, bytes): + img_bytes = img + elif hasattr(img, "read_bytes"): + img_bytes = img.read_bytes() + elif isinstance(img, str) and img.startswith("data:image"): + b64_data = img.split(",", 1)[1] + img_bytes = base64.b64decode(b64_data) + else: + raise InvalidRequestException( + "Gemini 图像编辑仅支持 bytes/Path/base64 URI", + ) + mime_type = "image/jpeg" + if img_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + mime_type = "image/png" + elif img_bytes.startswith(b"GIF87a") or img_bytes.startswith(b"GIF89a"): + mime_type = "image/gif" + elif img_bytes.startswith(b"RIFF") and img_bytes[8:12] == b"WEBP": + mime_type = "image/webp" + + b64_str = base64.b64encode(img_bytes).decode("utf-8") + parts.append( + { + "inline_data": {"mime_type": mime_type, "data": b64_str}, + } + ) + + body: dict[str, Any] = {"contents": [{"parts": parts}]} + + if config is None: + config = GenerationConfig() + + mapper = GeminiConfigMapper() + gen_config = mapper.map_config(config, None, identity.capabilities) + + if gen_config: + body["generationConfig"] = gen_config + + return RequestData(url=url, headers=headers, body=body) + + def parse_image_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> ResponseData: + parser = GeminiResponseParser() + return parser.parse(response_json) + + +class GeminiAudioHandler(BaseAudioHandler): + """Gemini 文本转语音处理器""" + + def prepare_speech_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: + input_text = request.input_text + config = request.config or TTSConfig() + + config_voice = config.gemini_options.voice_id + voice = ( + config_voice + or request.voice + or identity.capabilities.default_voice_id + or "Aoede" + ) + + endpoint = getattr(adapter, "_get_gemini_endpoint")(identity, None) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + speech_config: dict[str, Any] = {} + if config.gemini_options.multi_speaker and config.gemini_options.second_voice: + speech_config["multiSpeakerVoiceConfig"] = { + "speakerVoiceConfigs": [ + { + "speaker": "Speaker1", + "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}, + }, + { + "speaker": "Speaker2", + "voiceConfig": { + "prebuiltVoiceConfig": { + "voiceName": config.gemini_options.second_voice + } + }, + }, + ] + } + else: + speech_config["voiceConfig"] = {"prebuiltVoiceConfig": {"voiceName": voice}} + + body = { + "contents": [{"parts": [{"text": input_text}]}], + "generationConfig": { + "responseModalities": ["AUDIO"], + "speechConfig": speech_config, + }, + } + return RequestData(url=url, headers=headers, body=body) + + async def parse_speech_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + raw_response: httpx.Response, + ) -> AudioResponse: + resp_bytes = await raw_response.aread() + data = json.loads(resp_bytes) + adapter.validate_response(data) + + b64_data = "" + try: + b64_data = data["candidates"][0]["content"]["parts"][0]["inlineData"][ + "data" + ] + except (KeyError, IndexError): + raise ResponseParseException("Gemini 响应中未找到音频数据", details=data) + + audio_bytes = base64.b64decode(b64_data) + + wav_io = io.BytesIO() + with wave.open(wav_io, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(24000) + wav_file.writeframes(audio_bytes) + + from zhenxun.services.ai.core.messages import UsageInfo + + return AudioResponse( + audio_bytes=wav_io.getvalue(), + audio_format="wav", + usage=UsageInfo(), + model_name=identity.model_name, + ) diff --git a/zhenxun/services/ai/llm/adapters/handlers/mimo_handlers.py b/zhenxun/services/ai/llm/adapters/handlers/mimo_handlers.py new file mode 100644 index 00000000..3e209737 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/handlers/mimo_handlers.py @@ -0,0 +1,199 @@ +import base64 +import json +from typing import Any + +import httpx + +from zhenxun.services.ai.core.messages import ( + AudioPart, + AudioResponse, + ImagePart, + LLMMessage, + SpeechRequest, + TextPart, + UsageInfo, + VideoPart, +) +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, +) +from zhenxun.services.ai.core.options import GenerationConfig, TTSConfig +from zhenxun.services.ai.llm.adapters.base import BaseAdapter, RequestData +from zhenxun.services.ai.llm.adapters.handlers.base import BaseAudioHandler +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + OpenAIConfigMapper, + OpenAIMessageConverter, + OpenAITextHandler, + OpenAIToolSerializer, +) + + +class MiMoToolSerializer(OpenAIToolSerializer): + """MiMo 工具序列化器,负责拦截并构造独有的 web_search 工具""" + + def serialize_server_tools( + self, tools: list[Any], capabilities: ModelCapabilities + ) -> list[dict[str, Any]]: + res = [] + for t in tools: + type_id = getattr(t, "type_id", "unknown") + if type_id not in capabilities.supported_native_tools: + continue + if type_id == "web_search": + res.append( + { + "type": "web_search", + "max_keyword": getattr(t, "max_keyword", 3), + "force_search": getattr(t, "force_search", True), + "limit": getattr(t, "limit", 1), + } + ) + return res + + +class MiMoConfigMapper(OpenAIConfigMapper): + """MiMo 配置映射器,处理深度思考参数差异""" + + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + params = super().map_config(config, model_detail, capabilities) + + if config.common.reasoning_effort: + effort = str(config.common.reasoning_effort).lower() + if effort == "none": + params["thinking"] = {"type": "disabled"} + else: + params["thinking"] = {"type": "enabled"} + elif ( + hasattr(config, "deepseek_options") + and config.deepseek_options.thinking is not None + ): + if config.deepseek_options.thinking is True: + params["thinking"] = {"type": "enabled"} + elif config.deepseek_options.thinking is False: + params["thinking"] = {"type": "disabled"} + + return params + + +class MiMoMessageConverter(OpenAIMessageConverter): + """MiMo 消息转换器,拦截处理特有的音视频多模态结构""" + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + openai_messages = await super().convert_messages_async(messages) + + for o_msg, o_orig in zip(openai_messages, messages): + if o_msg["role"] == "user": + content_parts = [] + for part in o_orig.content: + if isinstance(part, TextPart): + content_parts.append({"type": "text", "text": part.text}) + elif isinstance(part, ImagePart): + src = ( + part.url + if part.url + else await part.get_data_uri(part.mime_type or "image/jpeg") + ) + content_parts.append( + {"type": "image_url", "image_url": {"url": src}} + ) + elif isinstance(part, VideoPart): + src = ( + part.url + if part.url + else await part.get_data_uri(part.mime_type or "video/mp4") + ) + content_parts.append( + { + "type": "video_url", + "video_url": {"url": src}, + "fps": getattr(part, "fps", 2), + "media_resolution": getattr( + part, "media_resolution", "default" + ), + } + ) + elif isinstance(part, AudioPart): + src = ( + part.url + if part.url + else await part.get_data_uri(part.mime_type or "audio/mp3") + ) + content_parts.append( + {"type": "input_audio", "input_audio": {"data": src}} + ) + o_msg["content"] = content_parts + return openai_messages + + +class MiMoTextHandler(OpenAITextHandler): + """MiMo 文本对话处理器集成""" + + def __init__(self, api_type: str = "mimo"): + super().__init__(api_type=api_type) + self.converter = MiMoMessageConverter(api_type=api_type) + self.serializer = MiMoToolSerializer(api_type=api_type) + self.mapper = MiMoConfigMapper(api_type=api_type) + + +class MiMoAudioHandler(BaseAudioHandler): + """MiMo TTS 接口实现:挂载在 Chat Completions 端点上""" + + def prepare_speech_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: + input_text = request.input_text + config = request.config or TTSConfig() + + config_voice = ( + config.mimo_options.voice_id if hasattr(config, "mimo_options") else None + ) + voice = ( + config_voice + or request.voice + or identity.capabilities.default_voice_id + or "mimo_default" + ) + + url = adapter.get_api_url(identity, "/v1/chat/completions") + headers = adapter.get_base_headers(api_key) + + body = { + "model": identity.model_name, + "messages": [{"role": "assistant", "content": input_text}], + "audio": { + "format": config.response_format + if config.response_format in ("wav", "pcm16") + else "wav", + "voice": voice, + }, + } + return RequestData(url=url, headers=headers, body=body) + + async def parse_speech_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + raw_response: httpx.Response, + ) -> AudioResponse: + data = json.loads(await raw_response.aread()) + adapter.validate_response(data) + audio_b64 = data["choices"][0]["message"]["audio"]["data"] + return AudioResponse( + audio_bytes=base64.b64decode(audio_b64), + audio_format="wav", + usage=UsageInfo(), + model_name=identity.model_name, + ) diff --git a/zhenxun/services/ai/llm/adapters/handlers/openai_handlers.py b/zhenxun/services/ai/llm/adapters/handlers/openai_handlers.py new file mode 100644 index 00000000..eb9e221a --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/handlers/openai_handlers.py @@ -0,0 +1,1209 @@ +import base64 +import binascii +import json +from pathlib import Path +from typing import Any + +import httpx +import json_repair + +from zhenxun.services.ai.core.exceptions import ( + AuthenticationException, + ConfigurationException, + ContentFilteredException, + ContextLengthExceededException, + InvalidRequestException, + QuotaExceededException, + RateLimitException, + ResponseParseException, +) +from zhenxun.services.ai.core.messages import ( + AssistantMessage, + AudioResponse, + ChatRequest, + EmbeddingRequest, + ImagePart, + ImageRequest, + LLMMessage, + RerankDocument, + RerankRequest, + RerankResult, + SpeechRequest, + SystemMessage, + TextPart, + ThoughtPart, + ToolCallPart, + ToolMessage, + UserMessage, +) +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, +) +from zhenxun.services.ai.core.options import ( + GenerationConfig, + LLMEmbeddingConfig, + ResponseFormat, + StructuredOutputStrategy, +) +from zhenxun.services.ai.llm.adapters.base import ( + BaseAdapter, + RequestData, + ResponseData, + process_image_data, +) +from zhenxun.services.ai.llm.adapters.handlers.base import ( + BaseAudioHandler, + BaseEmbeddingHandler, + BaseImageHandler, + BaseRerankHandler, + BaseTextHandler, + ConfigMapper, + MessageConverter, + ResponseParser, + ToolSerializer, +) +from zhenxun.services.log import logger + + +class OpenAIConfigMapper(ConfigMapper): + def __init__(self, api_type: str = "openai"): + self.api_type = api_type + + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + params: dict[str, Any] = {} + strategy = config.output.structured_output_strategy + if strategy is None: + strategy = ( + StructuredOutputStrategy.TOOL_CALL + if self.api_type == "deepseek" + else StructuredOutputStrategy.NATIVE + ) + + if config.common: + if config.common.temperature is not None: + params["temperature"] = config.common.temperature + if config.common.max_tokens is not None: + params["max_tokens"] = config.common.max_tokens + if config.common.top_k is not None: + params["top_k"] = config.common.top_k + if config.common.top_p is not None: + params["top_p"] = config.common.top_p + if config.common.frequency_penalty is not None: + params["frequency_penalty"] = config.common.frequency_penalty + if config.common.presence_penalty is not None: + params["presence_penalty"] = config.common.presence_penalty + if config.common.stop is not None: + params["stop"] = config.common.stop + + if config.common.repetition_penalty is not None: + if self.api_type == "openai": + pass + else: + params["repetition_penalty"] = config.common.repetition_penalty + + if config.common.reasoning_effort: + effort = str(config.common.reasoning_effort).lower() + + if capabilities and capabilities.reasoning_effort_map: + effort = capabilities.reasoning_effort_map.get(effort, effort) + + if effort != "none": + params["reasoning_effort"] = effort + + if isinstance(config.output.response_format, dict): + params["response_format"] = config.output.response_format + elif ( + config.output.response_format == ResponseFormat.JSON + and strategy == StructuredOutputStrategy.NATIVE + ): + if config.output.response_schema: + serializer = OpenAIToolSerializer(api_type=self.api_type) + sanitized = serializer.sanitize_schema(config.output.response_schema) + params["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "structured_response", + "schema": sanitized, + "strict": True, + }, + } + else: + params["response_format"] = {"type": "json_object"} + + if config.custom_kwargs: + mapped_custom = config.custom_kwargs.copy() + mapped_custom.pop("__cache_ttl__", None) + if "repetition_penalty" in mapped_custom and self.api_type == "openai": + mapped_custom.pop("repetition_penalty") + + params.update(mapped_custom) + + return params + + +class OpenAIMessageConverter(MessageConverter): + def __init__(self, api_type: str = "openai"): + self.api_type = api_type + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + openai_messages: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, SystemMessage): + openai_msg: dict[str, Any] = {"role": "system"} + elif isinstance(msg, UserMessage): + openai_msg: dict[str, Any] = {"role": "user"} + elif isinstance(msg, AssistantMessage): + openai_msg: dict[str, Any] = {"role": "assistant"} + elif isinstance(msg, ToolMessage): + openai_msg: dict[str, Any] = {"role": "tool"} + else: + openai_msg: dict[str, Any] = {"role": msg.role} + + if isinstance(msg, ToolMessage): + returns = msg.tool_returns + if returns: + openai_msg["tool_call_id"] = returns[0].tool_call_id + openai_msg["name"] = returns[0].tool_name + out_val = returns[0].output + openai_msg["content"] = ( + out_val + if isinstance(out_val, str) + else json.dumps(out_val, ensure_ascii=False) + ) + else: + if len(msg.content) == 1 and isinstance(msg.content[0], TextPart): + openai_msg["content"] = msg.content[0].text + else: + content_parts = [] + for part in msg.content: + if isinstance(part, TextPart): + content_parts.append({"type": "text", "text": part.text}) + elif isinstance(part, ImagePart): + if part.url is not None: + content_parts.append( + { + "type": "image_url", + "image_url": {"url": part.url}, + } + ) + else: + data_uri = await part.get_data_uri("image/png") + content_parts.append( + { + "type": "image_url", + "image_url": {"url": data_uri}, + } + ) + openai_msg["content"] = content_parts + + if isinstance(msg, AssistantMessage): + thought_text = "\n".join( + p.thought_text + for p in msg.content + if isinstance(p, ThoughtPart) and p.thought_text + ).strip() + + if thought_text: + openai_msg["reasoning_content"] = thought_text + else: + openai_msg["reasoning_content"] = "" + + if isinstance(msg, AssistantMessage) and msg.tool_calls: + assistant_tool_calls = [] + for call in msg.tool_calls: + assistant_tool_calls.append( + { + "id": call.id, + "type": "function", + "function": { + "name": call.tool_name, + "arguments": call.args + if isinstance(call.args, str) + else json.dumps(call.args, ensure_ascii=False), + }, + } + ) + openai_msg["tool_calls"] = assistant_tool_calls + + if not openai_msg.get("content") and not isinstance( + openai_msg.get("content"), str + ): + openai_msg["content"] = "" + + if ( + openai_msg.get("content") == "" + and not openai_msg.get("tool_calls") + and "tool_call_id" not in openai_msg + ): + continue + + openai_messages.append(openai_msg) + return openai_messages + + +class OpenAIToolSerializer(ToolSerializer): + def __init__(self, api_type: str = "openai"): + self.api_type = api_type + + def sanitize_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + from zhenxun.services.ai.llm.engine.schema_transformer import ( + OpenAIUnionFlattenTransformer, + RefComplianceTransformer, + RemoveUnsupportedKeysTransformer, + RootRefInlineTransformer, + SchemaPipeline, + StrictObjectTransformer, + TypeEnforcerTransformer, + ) + + unsupported_keys = [ + "default", + "minLength", + "maxLength", + "pattern", + "format", + "minimum", + "maximum", + "multipleOf", + "patternProperties", + "propertyNames", + "minItems", + "maxItems", + "uniqueItems", + "$schema", + "title", + ] + pipeline = SchemaPipeline( + [ + RootRefInlineTransformer(), + OpenAIUnionFlattenTransformer(), + TypeEnforcerTransformer(), + RemoveUnsupportedKeysTransformer(unsupported_keys), + StrictObjectTransformer(), + RefComplianceTransformer(), + ] + ) + return pipeline.run(schema) + + def format_tool_payload( + self, tool_name: str, tool_description: str, sanitized_schema: dict[str, Any] + ) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": tool_name, + "description": tool_description, + "parameters": sanitized_schema, + "strict": True, + }, + } + + def serialize_server_tools( + self, tools: list[Any], capabilities: ModelCapabilities + ) -> list[dict[str, Any]]: + """标准 OpenAI 协议 (/v1/chat/completions) 不支持原生云端工具传递""" + return [] + + +class OpenAIResponseParser(ResponseParser): + def validate_response(self, response_json: dict[str, Any]) -> None: + if response_json.get("error"): + error_info = response_json["error"] + error_message = str(error_info) + if isinstance(error_info, dict): + error_message = error_info.get("message", error_message) + error_code = error_info.get("code", "unknown") + + if ( + error_code in ("invalid_api_key", "authentication_failed") + or "permission" in error_message.lower() + ): + raise AuthenticationException( + f"鉴权失败: {error_message}", details={"api_error": error_info} + ) + elif error_code in ("insufficient_quota", "quota_exceeded"): + raise QuotaExceededException( + f"配额耗尽: {error_message}", details={"api_error": error_info} + ) + elif error_code == "rate_limit_exceeded": + raise RateLimitException( + f"请求限流: {error_message}", details={"api_error": error_info} + ) + elif error_code in ("model_not_found", "invalid_model"): + raise ConfigurationException( + f"模型配置错误: {error_message}", + details={"api_error": error_info}, + ) + elif error_code in ("context_length_exceeded", "max_tokens_exceeded"): + raise ContextLengthExceededException( + f"上下文超限: {error_message}", + details={"api_error": error_info}, + ) + elif error_code in ("invalid_request_error", "invalid_parameter"): + raise InvalidRequestException( + f"请求参数错误: {error_message}", + details={"api_error": error_info}, + ) + + raise ResponseParseException( + f"API请求失败: {error_message}", + details={"api_error": error_info}, + ) + + def parse(self, response_json: dict[str, Any]) -> ResponseData: + self.validate_response(response_json) + + choices = response_json.get("choices", []) + if not choices: + return ResponseData(raw_response=response_json) + + choice = choices[0] + message = choice.get("message", {}) + content = message.get("content", "") + reasoning_content = message.get("reasoning_content", None) + reasoning_details = message.get("reasoning_details", None) + refusal = message.get("refusal") + + if refusal: + raise ContentFilteredException( + f"模型拒绝生成请求: {refusal}", + details={"refusal": refusal}, + ) + + if content: + content = content.strip() + + images_payload: list[bytes | Path] = [] + if content and content.startswith("{") and content.endswith("}"): + try: + content_json = json.loads(content) + if "b64_json" in content_json: + b64_str = content_json["b64_json"] + if isinstance(b64_str, str) and b64_str.startswith("data:"): + b64_str = b64_str.split(",", 1)[1] + decoded = base64.b64decode(b64_str) + images_payload.append(process_image_data(decoded)) + content = "[图片已生成]" + elif "data" in content_json and isinstance(content_json["data"], str): + b64_str = content_json["data"] + if b64_str.startswith("data:"): + b64_str = b64_str.split(",", 1)[1] + decoded = base64.b64decode(b64_str) + images_payload.append(process_image_data(decoded)) + content = "[图片已生成]" + + except (json.JSONDecodeError, KeyError, binascii.Error): + pass + elif ( + "images" in message + and isinstance(message["images"], list) + and message["images"] + ): + for image_info in message["images"]: + if image_info.get("type") == "image_url": + image_url_obj = image_info.get("image_url", {}) + url_str = image_url_obj.get("url", "") + if url_str.startswith("data:image"): + try: + b64_data = url_str.split(",", 1)[1] + decoded = base64.b64decode(b64_data) + images_payload.append(process_image_data(decoded)) + except (IndexError, binascii.Error) as e: + logger.warning(f"解析OpenRouter Base64图片数据失败: {e}") + + if images_payload: + content = content if content else "[图片已生成]" + + content_parts = [] + if content: + content_parts.append(TextPart(text=content)) + if reasoning_details: + combined_thought_text = "" + for detail in reasoning_details: + if isinstance(detail, dict) and "text" in detail: + combined_thought_text += detail["text"] + if combined_thought_text: + content_parts.append( + ThoughtPart( + thought_text=combined_thought_text, + metadata={"raw_reasoning_details": reasoning_details}, + ) + ) + elif reasoning_content: + content_parts.append(ThoughtPart(thought_text=reasoning_content)) + for img in images_payload: + content_parts.append( + ImagePart(raw=img) if isinstance(img, bytes) else ImagePart(path=img) + ) + + if message_tool_calls := message.get("tool_calls"): + for tc_data in message_tool_calls: + try: + if tc_data.get("type") == "function": + raw_arguments = tc_data["function"]["arguments"] + + content_parts.append( + ToolCallPart( + id=tc_data["id"], + tool_name=tc_data["function"]["name"], + args=raw_arguments, + ) + ) + except KeyError as e: + logger.warning( + f"解析OpenAI工具调用数据时缺少键: {tc_data}, 错误: {e}" + ) + except Exception as e: + logger.warning( + f"解析OpenAI工具调用数据时出错: {tc_data}, 错误: {e}" + ) + + usage_info = response_json.get("usage") + + return ResponseData( + content_parts=content_parts, + usage_info=usage_info, + raw_response=response_json, + ) + + +class ResponsesConfigMapper(OpenAIConfigMapper): + """针对 OpenAI Responses API 的配置映射器""" + + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + params = super().map_config(config, model_detail, capabilities) + + if "reasoning_effort" in params: + effort_val = params.pop("reasoning_effort") + params["reasoning"] = {"effort": effort_val, "summary": "auto"} + else: + params["reasoning"] = {"summary": "auto"} + + if "response_format" in params: + fmt = params.pop("response_format") + if isinstance(fmt, dict) and fmt.get("type") == "json_schema": + json_schema_dict = fmt.get("json_schema", {}) + params["text"] = { + "format": { + "type": "json_schema", + "name": json_schema_dict.get("name", "structured_response"), + "strict": json_schema_dict.get("strict", True), + "schema": json_schema_dict.get("schema", {}), + } + } + elif isinstance(fmt, dict): + params["text"] = {"format": fmt} + + return params + + +class ResponsesMessageConverter(MessageConverter): + """针对 OpenAI Responses API 的消息转换器""" + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + input_items: list[dict[str, Any]] = [] + for msg in messages: + role = msg.role + + if isinstance(msg, ToolMessage): + returns = msg.tool_returns + if returns: + input_items.append( + { + "type": "function_call_output", + "call_id": returns[0].tool_call_id, + "output": returns[0].output + if isinstance(returns[0].output, str) + else json.dumps(returns[0].output, ensure_ascii=False), + } + ) + continue + + content_list: list[dict[str, Any]] = [] + for part in msg.content: + if part is None: + continue + + if isinstance(part, TextPart): + c_type = "output_text" if role == "assistant" else "input_text" + content_list.append({"type": c_type, "text": part.text}) + elif isinstance(part, ImagePart): + if part.url is not None: + content_list.append( + {"type": "input_image", "image_url": part.url} + ) + else: + data_uri = await part.get_data_uri("image/png") + content_list.append( + {"type": "input_image", "image_url": data_uri} + ) + elif isinstance(part, dict): + part_type = part.get("type") + if part_type == "text": + c_type = "output_text" if role == "assistant" else "input_text" + content_list.append( + {"type": c_type, "text": part.get("text", "")} + ) + elif part_type in {"image", "image_url"}: + image_src = part.get("image_url") or part.get("url", "") + content_list.append( + {"type": "input_image", "image_url": image_src} + ) + + if content_list: + input_items.append({"role": role, "content": content_list}) + + if isinstance(msg, AssistantMessage) and msg.tool_calls: + for tc in msg.tool_calls: + input_items.append( + { + "type": "function_call", + "call_id": tc.id, + "name": tc.tool_name, + "arguments": tc.args + if isinstance(tc.args, str) + else json.dumps(tc.args, ensure_ascii=False), + } + ) + + return input_items + + +class ResponsesToolSerializer(OpenAIToolSerializer): + """针对 OpenAI Responses API 的工具序列化器""" + + def format_tool_payload( + self, tool_name: str, tool_description: str, sanitized_schema: dict[str, Any] + ) -> dict[str, Any]: + return { + "type": "function", + "name": tool_name, + "description": tool_description, + "parameters": sanitized_schema, + "strict": True, + } + + def serialize_server_tools( + self, tools: list[Any], capabilities: ModelCapabilities + ) -> list[dict[str, Any]]: + """OpenAI Responses API 的专门序列化,增加基于 capabilities 的鉴权""" + res = [] + for t in tools: + type_id = getattr(t, "type_id", "unknown") + if type_id not in capabilities.supported_native_tools: + continue + if type_id == "web_search": + payload = {"type": "web_search"} + if getattr(t, "domain_filters", None): + payload["filters"] = t.domain_filters + res.append(payload) + elif type_id == "code_execution": + res.append({"type": "code_interpreter"}) + elif type_id == "computer_use": + res.append( + { + "type": "computer_use", + "display_width_px": getattr(t, "display_width_px", 1024), + "display_height_px": getattr(t, "display_height_px", 768), + } + ) + elif type_id == "file_search": + res.append({"type": "file_search"}) + return res + + +class ResponsesResponseParser(OpenAIResponseParser): + """针对 OpenAI Responses API 的响应解析器""" + + def parse(self, response_json: dict[str, Any]) -> ResponseData: + content_parts: list[Any] = [] + text_content = "" + thought_content = "" + + for item in response_json.get("output", []): + if item.get("type") == "message" and item.get("role") == "assistant": + for content_item in item.get("content", []): + if content_item.get("type") == "output_text": + text_content += content_item.get("text", "") + elif content_item.get("type") == "refusal": + raise ContentFilteredException( + f"模型拒绝生成: {content_item.get('refusal')}", + ) + elif item.get("type") == "function_call": + content_parts.append( + ToolCallPart( + id=item.get("call_id", ""), + tool_name=item.get("name", ""), + args=item.get("arguments", "{}"), + ) + ) + elif item.get("type") == "reasoning": + for summary_item in item.get("summary", []): + if summary_item.get("type") == "summary_text": + thought_content += summary_item.get("text", "") + + if text_content: + content_parts.insert(0, TextPart(text=text_content)) + if thought_content: + content_parts.insert(0, ThoughtPart(thought_text=thought_content)) + + return ResponseData( + content_parts=content_parts, + usage_info=response_json.get("usage"), + raw_response=response_json, + ) + + +class OpenAITextHandler(BaseTextHandler): + """标准 OpenAI 协议的文本对话处理器""" + + def __init__(self, api_type: str = "openai"): + self.api_type = api_type + self.converter = OpenAIMessageConverter(api_type=api_type) + self.serializer = OpenAIToolSerializer(api_type=api_type) + self.mapper = OpenAIConfigMapper(api_type=api_type) + self.parser = OpenAIResponseParser() + + def _build_base_body( + self, identity: ModelIdentity, messages: list[Any] + ) -> dict[str, Any]: + return { + "model": identity.model_name, + "messages": messages, + } + + async def prepare_text_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ChatRequest, + ) -> RequestData: + endpoint = getattr(adapter, "get_chat_endpoint")(identity) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + messages = request.messages + tools = request.tools + tool_choice = request.tool_choice + config = request.config + effective_config = config if config is not None else identity.generation_config + structured_strategy = ( + effective_config.output.structured_output_strategy + if effective_config and effective_config.output + else None + ) + if structured_strategy is None: + structured_strategy = ( + StructuredOutputStrategy.TOOL_CALL + if identity.api_type == "deepseek" + and identity.model_name == "deepseek-chat" + else StructuredOutputStrategy.NATIVE + ) + + tool_defs, _, server_tools = await self._resolve_and_split_tools(tools) + + openai_tools: list[dict[str, Any]] | None = None + if tool_defs: + openai_tools = self.serializer.serialize_tools(tool_defs) + + final_tool_choice = tool_choice + if final_tool_choice is None and effective_config: + mode = effective_config.tools.mode + if mode == "ANY": + allowed = effective_config.tools.allowed_function_names + if allowed: + if len(allowed) == 1: + if isinstance(self, OpenAIResponsesTextHandler): + final_tool_choice = {"type": "function", "name": allowed[0]} + else: + final_tool_choice = { + "type": "function", + "function": {"name": allowed[0]}, + } + else: + logger.warning( + "OpenAI API 不支持多个 allowed_function_names," + "降级为 required。" + ) + final_tool_choice = "required" + else: + final_tool_choice = "required" + elif mode == "NONE": + final_tool_choice = "none" + elif mode == "AUTO": + final_tool_choice = "auto" + + if ( + structured_strategy == StructuredOutputStrategy.TOOL_CALL + and effective_config + and effective_config.output + and effective_config.output.response_schema + ): + sanitized_schema = self.serializer.sanitize_schema( + effective_config.output.response_schema + ) + structured_tool = { + "type": "function", + "function": { + "name": "return_structured_response", + "description": "Output the final structured response.", + "parameters": sanitized_schema, + }, + } + structured_tool["function"]["strict"] = True + + if isinstance(self, OpenAIResponsesTextHandler): + func_data = structured_tool.pop("function") + structured_tool.update(func_data) + final_tool_choice = { + "type": "function", + "name": "return_structured_response", + } + else: + final_tool_choice = { + "type": "function", + "function": {"name": "return_structured_response"}, + } + + if openai_tools is None: + openai_tools = [] + openai_tools.append(structured_tool) + + converted_messages = await self.converter.convert_messages_async(messages) + body = self._build_base_body(identity, converted_messages) + + if openai_tools: + body["tools"] = openai_tools + if final_tool_choice is not None and openai_tools: + body["tool_choice"] = final_tool_choice + + config_params = {} + if effective_config: + config_params = self.mapper.map_config( + effective_config, None, identity.capabilities + ) + body.update(config_params) + + if server_tools: + if openai_tools is None: + openai_tools = [] + server_payloads = self.serializer.serialize_server_tools( + server_tools, identity.capabilities + ) + if server_payloads: + openai_tools.extend(server_payloads) + if openai_tools: + body["tools"] = openai_tools + + if "tools" not in body and "tool_choice" in body: + body.pop("tool_choice") + + return RequestData(url=url, headers=headers, body=body) + + def parse_text_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + response_json: dict[str, Any], + is_advanced: bool = False, + ) -> ResponseData: + response_data = self.parser.parse(response_json) + + tool_calls = [ + p for p in response_data.content_parts if isinstance(p, ToolCallPart) + ] + if tool_calls: + target_tool = next( + ( + tc + for tc in tool_calls + if tc.tool_name == "return_structured_response" + ), + None, + ) + if target_tool: + args_data = target_tool.args + if isinstance(args_data, str): + response_data.text = json_repair.repair_json(args_data) + else: + response_data.text = json.dumps(args_data, ensure_ascii=False) + response_data.content_parts = [ + p + for p in response_data.content_parts + if not ( + isinstance(p, ToolCallPart) + and p.tool_name == "return_structured_response" + ) + ] + + return response_data + + +class OpenAIResponsesTextHandler(OpenAITextHandler): + """OpenAI v1/responses 协议的文本对话处理器""" + + def __init__(self, api_type: str = "openai_responses"): + super().__init__(api_type=api_type) + self.converter = ResponsesMessageConverter() + self.serializer = ResponsesToolSerializer(api_type=api_type) + self.mapper = ResponsesConfigMapper(api_type=api_type) + self.parser = ResponsesResponseParser() + + def _build_base_body( + self, identity: ModelIdentity, messages: list[Any] + ) -> dict[str, Any]: + return { + "model": identity.model_name, + "input": messages, + } + + +class OpenAIEmbeddingHandler(BaseEmbeddingHandler): + """OpenAI 嵌入向量处理器""" + + async def prepare_embedding_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: EmbeddingRequest, + ) -> RequestData: + batch = request.batch + config = request.config or LLMEmbeddingConfig() + texts = batch.to_text_only(f"{identity.model_name} (API: {adapter.api_type})") + + endpoint = getattr(adapter, "get_embedding_endpoint")(identity) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + body = { + "model": identity.model_name, + "input": texts, + } + + if config.output_dimensionality: + body["dimensions"] = config.output_dimensionality + if config.task_type: + body["task"] = config.task_type + if config.encoding_format and config.encoding_format != "float": + body["encoding_format"] = config.encoding_format + + return RequestData(url=url, headers=headers, body=body) + + def parse_embedding_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> list[list[float]]: + adapter.validate_response(response_json) + try: + data = response_json.get("data", []) + if not data: + raise ResponseParseException( + "嵌入响应中没有数据", + details=response_json, + ) + embeddings = [] + for item in data: + if "embedding" in item: + embeddings.append(item["embedding"]) + else: + raise ResponseParseException( + "嵌入响应格式错误:缺少embedding字段", + details=item, + ) + return embeddings + except Exception as e: + logger.error(f"解析嵌入响应失败: {e}", e=e) + raise ResponseParseException( + f"解析嵌入响应失败: {e}", + cause=e, + ) + + +class OpenAIImageHandler(BaseImageHandler): + """OpenAI 图像生成处理器""" + + def prepare_image_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ImageRequest, + ) -> RequestData: + headers = adapter.get_base_headers(api_key) + prompt = request.prompt + images = request.images + config = request.config + + body: dict[str, Any] = { + "model": identity.model_name, + "prompt": prompt, + "response_format": "b64_json", + } + + if config: + if config.media.resolution: + res_str = str(config.media.resolution).upper() + aspect_ratio = ( + str(config.media.aspect_ratio).upper() + if config.media.aspect_ratio + else "" + ) + + if res_str == "1K": + if "16:9" in aspect_ratio or "3:2" in aspect_ratio: + res_str = "1536x1024" + elif "9:16" in aspect_ratio or "2:3" in aspect_ratio: + res_str = "1024x1536" + else: + res_str = "1024x1024" + elif res_str == "2K": + if "16:9" in aspect_ratio or "3:2" in aspect_ratio: + res_str = "2048x1152" + elif "9:16" in aspect_ratio or "2:3" in aspect_ratio: + res_str = "1152x2048" + else: + res_str = "2048x2048" + elif res_str == "4K": + if "9:16" in aspect_ratio or "2:3" in aspect_ratio: + res_str = "2160x3840" + elif "1:1" in aspect_ratio: + res_str = "2880x2880" + else: + res_str = "3840x2160" + + body["size"] = res_str + + if config.media.quality: + body["quality"] = config.media.quality + + if not images: + endpoint = "/v1/images/generations" + url = adapter.get_api_url(identity, endpoint) + return RequestData(url=url, headers=headers, body=body) + else: + endpoint = "/v1/images/edits" + url = adapter.get_api_url(identity, endpoint) + files = [] + file_key = "image[]" if len(images) > 1 else "image" + + for i, img_source in enumerate(images): + img_bytes = None + if isinstance(img_source, bytes): + img_bytes = img_source + elif hasattr(img_source, "read_bytes"): + img_bytes = img_source.read_bytes() + elif isinstance(img_source, str) and img_source.startswith( + "data:image" + ): + b64_data = img_source.split(",", 1)[1] + img_bytes = base64.b64decode(b64_data) + else: + raise InvalidRequestException( + "OpenAI 图像编辑仅支持 bytes/Path/base64 URI", + ) + + files.append((file_key, (f"image_{i}.png", img_bytes, "image/png"))) + + headers.pop("Content-Type", None) + return RequestData(url=url, headers=headers, body=body, files=files) + + def parse_image_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> ResponseData: + adapter.validate_response(response_json) + + images_data: list[bytes | Path | str] = [] + data_list = response_json.get("data", []) + + for item in data_list: + if "b64_json" in item: + try: + b64_str = item["b64_json"] + if b64_str.startswith("data:"): + b64_str = b64_str.split(",", 1)[1] + img = base64.b64decode(b64_str) + images_data.append(process_image_data(img)) + except Exception as exc: + logger.error(f"Base64 解码失败: {exc}") + elif "url" in item: + images_data.append(item["url"]) + + content_parts = [] + for img in images_data: + if isinstance(img, str) and img.startswith("http"): + content_parts.append(ImagePart(url=img)) + elif isinstance(img, bytes): + content_parts.append(ImagePart(raw=img)) + elif isinstance(img, str): + content_parts.append(ImagePart(path=Path(img))) + else: + content_parts.append(ImagePart(path=img)) + + if not content_parts: + raise ResponseParseException("OpenAI 图像生成响应中未找到有效的图片数据") + + return ResponseData( + content_parts=content_parts, + raw_response=response_json, + ) + + +class OpenAIRerankHandler(BaseRerankHandler): + """OpenAI (扩展) 重排处理器""" + + def prepare_rerank_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: RerankRequest, + ) -> RequestData: + endpoint = "/v1/rerank" + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + safe_documents = [] + for doc in request.documents: + if isinstance(doc, dict): + safe_documents.append(doc.get("text", str(doc))) + else: + safe_documents.append(str(doc)) + + body = { + "model": identity.model_name, + "query": request.query, + "documents": safe_documents, + "top_n": request.top_n, + } + return RequestData(url=url, headers=headers, body=body) + + def parse_rerank_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> list[RerankResult]: + adapter.validate_response(response_json) + results = [] + for item in response_json.get("results", []): + doc = item.get("document", {}) + r_doc = ( + RerankDocument(text=doc.get("text"), image=doc.get("image")) + if isinstance(doc, dict) + else RerankDocument(text=str(doc)) + ) + results.append( + RerankResult( + index=item["index"], + relevance_score=item["relevance_score"], + document=r_doc, + ) + ) + return results + + +class OpenAIAudioHandler(BaseAudioHandler): + """OpenAI 文本转语音处理器""" + + def prepare_speech_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: + endpoint = "/v1/audio/speech" + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + config_voice = ( + request.config.openai_options.voice_id if request.config else None + ) + voice = ( + config_voice + or request.voice + or identity.capabilities.default_voice_id + or "alloy" + ) + body = { + "model": identity.model_name, + "input": request.input_text, + "voice": voice, + "response_format": request.config.response_format + if request.config + else "mp3", + "speed": request.config.speed if request.config else 1.0, + } + return RequestData(url=url, headers=headers, body=body) + + async def parse_speech_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + raw_response: httpx.Response, + ) -> AudioResponse: + from zhenxun.services.ai.core.messages import AudioResponse, UsageInfo + + audio_bytes = await raw_response.aread() + return AudioResponse( + audio_bytes=audio_bytes, + audio_format="mp3", + usage=UsageInfo(), + model_name=identity.model_name, + ) + + +class CompositeOpenAITextHandler(BaseTextHandler): + """ + OpenAI 复合文本对话处理器 (Composite Pattern)。 + 内部包装标准协议与 responses 协议 Handler,根据模型配置在请求时动态路由 + """ + + def __init__(self, api_type: str = "openai"): + self.api_type = api_type + self._standard_handler = OpenAITextHandler(api_type=api_type) + self._responses_handler = OpenAIResponsesTextHandler( + api_type="openai_responses" + ) + + def _get_active_handler(self, identity: ModelIdentity) -> BaseTextHandler: + current_api_type = identity.api_type + if current_api_type == "openai_responses": + return self._responses_handler + return self._standard_handler + + async def prepare_text_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ChatRequest, + ) -> RequestData: + handler = self._get_active_handler(identity) + return await handler.prepare_text_request(adapter, identity, api_key, request) + + def parse_text_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + response_json: dict[str, Any], + is_advanced: bool = False, + ) -> ResponseData: + handler = self._get_active_handler(identity) + return handler.parse_text_response( + adapter, identity, response_json, is_advanced + ) diff --git a/zhenxun/services/ai/llm/adapters/jina.py b/zhenxun/services/ai/llm/adapters/jina.py new file mode 100644 index 00000000..42dc2989 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/jina.py @@ -0,0 +1,129 @@ +from zhenxun.services.ai.core.messages import EmbeddingRequest +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.ai.core.options import LLMEmbeddingConfig +from zhenxun.services.ai.llm.adapters.base import BaseAdapter, RequestData +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + OpenAIEmbeddingHandler, + OpenAIRerankHandler, +) +from zhenxun.services.ai.llm.adapters.openai import OpenAICompatAdapter + + +class JinaEmbeddingHandler(OpenAIEmbeddingHandler): + """Jina 专属文本/多模态嵌入处理器""" + + async def prepare_embedding_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: EmbeddingRequest, + ) -> RequestData: + batch = request.batch + config = request.config or LLMEmbeddingConfig() + endpoint = getattr(adapter, "get_embedding_endpoint")(identity) + url = adapter.get_api_url(identity, endpoint) + headers = adapter.get_base_headers(api_key) + + is_omni = "omni" in identity.model_name.lower() + inputs_payload = [] + + if is_omni: + from zhenxun.services.ai.core.messages import ( + AudioPart, + FilePart, + ImagePart, + TextPart, + VideoPart, + ) + from zhenxun.services.log import logger + + for payload in batch.payloads: + jina_content = [] + for part in payload.parts: + if isinstance(part, TextPart): + jina_content.append({"text": part.text}) + elif isinstance(part, ImagePart): + if part.url: + jina_content.append({"image": part.url}) + else: + jina_content.append( + {"image": await part.get_data_uri("image/png")} + ) + elif isinstance(part, AudioPart): + if part.url: + jina_content.append({"audio": part.url}) + else: + jina_content.append( + {"audio": await part.get_data_uri("audio/mp3")} + ) + elif isinstance(part, VideoPart): + if part.url: + jina_content.append({"video": part.url}) + else: + jina_content.append( + {"video": await part.get_data_uri("video/mp4")} + ) + elif isinstance(part, FilePart): + logger.warning( + f"Jina 暂不明确支持 Base64 内联 " + f"{type(part).__name__},已忽略。" + ) + + if not jina_content: + jina_content.append({"text": " "}) + + inputs_payload.append({"content": jina_content}) + else: + inputs_payload = batch.to_text_only( + f"{identity.model_name} (API: {adapter.api_type})" + ) + + body = { + "model": identity.model_name, + "input": inputs_payload, + } + + if config.output_dimensionality: + body["dimensions"] = config.output_dimensionality + + if config.task_type: + task_mapping = { + "RETRIEVAL_QUERY": "retrieval.query", + "RETRIEVAL_DOCUMENT": "retrieval.passage", + "SEMANTIC_SIMILARITY": "text-matching", + "CLASSIFICATION": "classification", + "CLUSTERING": "clustering", + } + body["task"] = task_mapping.get(config.task_type, config.task_type) + + if config.encoding_format and config.encoding_format != "float": + body["embedding_type"] = config.encoding_format + + return RequestData(url=url, headers=headers, body=body) + + +class JinaAdapter(OpenAICompatAdapter): + """Jina API 专有适配器 (仅支持 Embedding 和 Rerank)""" + + def __init__(self): + super().__init__() + self.text_handler = None + self.embedding_handler = JinaEmbeddingHandler() + self.rerank_handler = OpenAIRerankHandler() + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "jina" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["jina"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + raise NotImplementedError("Jina API 专精于检索,暂不支持常规对话生成。") + + def get_embedding_endpoint(self, identity: ModelIdentity) -> str: + return "/v1/embeddings" diff --git a/zhenxun/services/ai/llm/adapters/mimo.py b/zhenxun/services/ai/llm/adapters/mimo.py new file mode 100644 index 00000000..031bcdd3 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/mimo.py @@ -0,0 +1,30 @@ +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.ai.llm.adapters.handlers.mimo_handlers import ( + MiMoAudioHandler, + MiMoTextHandler, +) +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + OpenAIImageHandler, +) +from zhenxun.services.ai.llm.adapters.openai import OpenAICompatAdapter + + +class MiMoAdapter(OpenAICompatAdapter): + """小米 MiMo 大模型专有适配器""" + + def __init__(self): + super().__init__() + self.text_handler = MiMoTextHandler(api_type=self.api_type) + self.image_handler = OpenAIImageHandler() + self.audio_handler = MiMoAudioHandler() + + @property + def api_type(self) -> str: + return "mimo" + + @property + def supported_api_types(self) -> list[str]: + return ["mimo"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + return "/v1/chat/completions" diff --git a/zhenxun/services/ai/llm/adapters/minimax.py b/zhenxun/services/ai/llm/adapters/minimax.py new file mode 100644 index 00000000..b4b7fd20 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/minimax.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from zhenxun.services.ai.core.exceptions import ResponseParseException +from zhenxun.services.ai.core.messages import ( + AssistantMessage, + AudioResponse, + LLMMessage, + SpeechRequest, + ThoughtPart, +) +from zhenxun.services.ai.core.models import ( + ModelCapabilities, + ModelDetail, + ModelIdentity, +) +from zhenxun.services.ai.core.options import GenerationConfig, TTSConfig +from zhenxun.services.ai.llm.adapters.base import BaseAdapter, RequestData +from zhenxun.services.ai.llm.adapters.handlers.base import BaseAudioHandler + +from .handlers.openai_handlers import ( + OpenAIConfigMapper, + OpenAIMessageConverter, + OpenAITextHandler, +) +from .openai import OpenAICompatAdapter + + +class MiniMaxAudioHandler(BaseAudioHandler): + """MiniMax 专有文本转语音处理器""" + + def prepare_speech_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: SpeechRequest, + ) -> RequestData: + input_text = request.input_text + config = request.config or TTSConfig() + + config_voice = config.minimax_options.voice_id + voice = ( + config_voice + or request.voice + or identity.capabilities.default_voice_id + or "female-shaonv" + ) + + endpoint = "/v1/t2a_v2" + base_url = ( + identity.api_base.rstrip("/") + if identity.api_base + else "https://api.minimaxi.com" + ) + if base_url.endswith("/v1"): + base_url = base_url[:-3] + url = f"{base_url}{endpoint}" + + headers = adapter.get_base_headers(api_key) + + voice_setting: dict[str, Any] = {"voice_id": voice} + if config.speed != 1.0: + voice_setting["speed"] = config.speed + if config.minimax_options.vol is not None: + voice_setting["vol"] = config.minimax_options.vol + if config.minimax_options.pitch is not None: + voice_setting["pitch"] = config.minimax_options.pitch + if config.minimax_options.emotion is not None: + voice_setting["emotion"] = config.minimax_options.emotion + + target_format = config.response_format + if target_format not in ("mp3", "pcm", "flac", "wav"): + target_format = "mp3" + + audio_setting = { + "sample_rate": 32000, + "bitrate": 128000, + "format": target_format, + "channel": 1, + } + + body: dict[str, Any] = { + "model": identity.model_name, + "text": input_text, + "stream": False, + "voice_setting": voice_setting, + "audio_setting": audio_setting, + } + + if config.minimax_options.timbre_weights: + body["timbre_weights"] = config.minimax_options.timbre_weights + body["voice_setting"]["voice_id"] = "" + + if config.minimax_options.pronunciation_dict: + body["pronunciation_dict"] = config.minimax_options.pronunciation_dict + + return RequestData(url=url, headers=headers, body=body) + + async def parse_speech_response( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + raw_response: httpx.Response, + ) -> AudioResponse: + resp_bytes = await raw_response.aread() + data = json.loads(resp_bytes) + + base_resp = data.get("base_resp", {}) + if base_resp.get("status_code", 0) != 0: + raise ResponseParseException( + f"MiniMax 语音合成失败: {base_resp.get('status_msg')}", + details=data, + ) + + try: + audio_hex = data["data"]["audio"] + audio_bytes = bytes.fromhex(audio_hex) + except (KeyError, ValueError) as e: + raise ResponseParseException( + f"解析 MiniMax 语音 Hex 数据失败: {e}", details=data + ) + + extra_info = data.get("extra_info", {}) + audio_format = extra_info.get("audio_format", "mp3") + usage_chars = extra_info.get("usage_characters", 0) + + from zhenxun.services.ai.core.messages import AudioResponse, UsageInfo + + usage = UsageInfo() + usage.prompt_tokens = usage_chars + + return AudioResponse( + audio_bytes=audio_bytes, + audio_format=audio_format, + usage=usage, + model_name=identity.model_name, + raw_response=data, + ) + + +class MiniMaxMessageConverter(OpenAIMessageConverter): + """MiniMax 消息转换器,处理特有的 reasoning_details 格式回传""" + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + openai_messages = await super().convert_messages_async(messages) + + assistant_msgs = [m for m in messages if isinstance(m, AssistantMessage)] + ast_idx = 0 + + for o_msg in openai_messages: + if o_msg.get("role") == "assistant": + if ast_idx < len(assistant_msgs): + orig_ast = assistant_msgs[ast_idx] + ast_idx += 1 + + if "reasoning_content" in o_msg: + del o_msg["reasoning_content"] + + thought_parts = [ + p for p in orig_ast.content if isinstance(p, ThoughtPart) + ] + if thought_parts: + part = thought_parts[0] + raw_details = ( + part.metadata.get("raw_reasoning_details") + if part.metadata + else None + ) + + if raw_details: + o_msg["reasoning_details"] = raw_details + else: + o_msg["reasoning_details"] = [ + {"type": "reasoning.text", "text": part.thought_text} + ] + + return openai_messages + + +class MiniMaxConfigMapper(OpenAIConfigMapper): + """MiniMax 专属配置映射器""" + + def map_config( + self, + config: GenerationConfig, + model_detail: ModelDetail | None = None, + capabilities: ModelCapabilities | None = None, + ) -> dict[str, Any]: + params = super().map_config(config, model_detail, capabilities) + params["reasoning_split"] = True + return params + + +class MiniMaxTextHandler(OpenAITextHandler): + """MiniMax 复合文本处理器""" + + def __init__(self, api_type: str = "minimax"): + super().__init__(api_type=api_type) + self.converter = MiniMaxMessageConverter(api_type=api_type) + self.mapper = MiniMaxConfigMapper(api_type=api_type) + + +class MiniMaxAdapter(OpenAICompatAdapter): + """ + MiniMax API 适配器。 + """ + + def __init__(self): + """初始化 MiniMax 适配器并挂载专属处理器。""" + super().__init__() + self.text_handler = MiniMaxTextHandler(api_type=self.api_type) + self.audio_handler = MiniMaxAudioHandler() + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "minimax" + + @property + def supported_api_types(self) -> list[str]: + """当前适配器支持的 API 类型列表。""" + return ["minimax"] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """根据官方兼容要求,重写获取端点,允许自定义覆盖""" + return "/v1/chat/completions" + + def _get_base_url(self, identity: ModelIdentity) -> str: + base_url = ( + identity.api_base.rstrip("/") + if identity.api_base + else "https://api.minimaxi.com" + ) + if base_url.endswith("/v1"): + base_url = base_url[:-3] + return base_url diff --git a/zhenxun/services/ai/llm/adapters/openai.py b/zhenxun/services/ai/llm/adapters/openai.py new file mode 100644 index 00000000..60377377 --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/openai.py @@ -0,0 +1,122 @@ +""" +OpenAI API 适配器 + +支持 OpenAI、智谱AI 等 OpenAI 兼容的 API 服务。 +""" + +from __future__ import annotations + +from abc import abstractmethod + +from zhenxun.services.ai.core.models import ModelIdentity + +from .base import ( + BaseAdapter, + RequestData, +) +from .handlers.openai_handlers import ( + CompositeOpenAITextHandler, + OpenAIAudioHandler, + OpenAIEmbeddingHandler, + OpenAIImageHandler, + OpenAIRerankHandler, +) + + +class OpenAICompatAdapter(BaseAdapter): + """ + OpenAI 兼容 API 适配器基类。 + 保留端点获取等基础逻辑。 + """ + + @property + def log_sanitization_context(self) -> str: + """返回 OpenAI 系列请求的日志清洗上下文。""" + return "openai_request" + + @abstractmethod + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """子类必须实现,返回 chat completions 的端点""" + pass + + def get_embedding_endpoint(self, identity: ModelIdentity) -> str: + """返回 embeddings 的默认端点""" + return "/v1/embeddings" + + async def prepare_simple_request( + self, + identity: ModelIdentity, + api_key: str, + prompt: str, + history: list[dict[str, str]] | None = None, + ) -> RequestData: + """准备简单文本生成请求""" + from zhenxun.services.ai.core.messages import ( + AssistantMessage, + SystemMessage, + TextPart, + UserMessage, + ) + + messages = [] + if history: + for msg in history: + role = msg.get("role", "user") + content = msg.get("content", "") + if role == "system": + messages.append(SystemMessage(content=[TextPart(text=content)])) + elif role == "assistant": + messages.append(AssistantMessage(content=[TextPart(text=content)])) + else: + messages.append(UserMessage(content=[TextPart(text=content)])) + messages.append(UserMessage(content=[TextPart(text=prompt)])) + config = identity.generation_config + + from zhenxun.services.ai.core.messages import ChatRequest + + request = ChatRequest(messages=messages, config=config) + return await self.prepare_advanced_request( + identity=identity, + api_key=api_key, + request=request, + ) + + +class OpenAIAdapter(OpenAICompatAdapter): + """OpenAI 系列适配器,统一装配文本/图像/嵌入/重排处理链。""" + + def __init__(self): + """初始化并挂载复合文本处理器与通用多模态处理器。""" + super().__init__() + self.text_handler = CompositeOpenAITextHandler(api_type=self.api_type) + self.image_handler = OpenAIImageHandler() + self.embedding_handler = OpenAIEmbeddingHandler() + self.rerank_handler = OpenAIRerankHandler() + self.audio_handler = OpenAIAudioHandler() + + @property + def api_type(self) -> str: + """适配器主类型标识。""" + return "openai" + + @property + def supported_api_types(self) -> list[str]: + """支持的 API 类型及别名。""" + return [ + "openai", + "openai_responses", + ] + + def get_chat_endpoint(self, identity: ModelIdentity) -> str: + """返回聊天完成端点""" + current_api_type = identity.api_type + + if current_api_type == "openai_responses": + return "/v1/responses" + if current_api_type == "doubao": + return "/api/v3/chat/completions" + return "/v1/chat/completions" + + def get_embedding_endpoint(self, identity: ModelIdentity) -> str: + """返回嵌入端点。""" + return "/v1/embeddings" diff --git a/zhenxun/services/ai/llm/adapters/openrouter.py b/zhenxun/services/ai/llm/adapters/openrouter.py new file mode 100644 index 00000000..64f4554c --- /dev/null +++ b/zhenxun/services/ai/llm/adapters/openrouter.py @@ -0,0 +1,202 @@ +import base64 +from pathlib import Path +from typing import Any + +from zhenxun.services.ai.core.exceptions import LLMException +from zhenxun.services.ai.core.messages import ( + ImagePart, + ImageRequest, + LLMMessage, + ThoughtPart, +) +from zhenxun.services.ai.core.models import ModelIdentity +from zhenxun.services.ai.llm.adapters.base import ( + BaseAdapter, + RequestData, + ResponseData, + process_image_data, +) +from zhenxun.services.ai.llm.adapters.handlers.base import BaseImageHandler +from zhenxun.services.ai.llm.adapters.handlers.openai_handlers import ( + CompositeOpenAITextHandler, + OpenAIMessageConverter, +) +from zhenxun.services.ai.llm.adapters.openai import OpenAIAdapter + + +class OpenRouterMessageConverter(OpenAIMessageConverter): + """OpenRouter 专有消息转换器:处理 reasoning_details 的无损回传""" + + async def convert_messages_async( + self, messages: list[LLMMessage] + ) -> list[dict[str, Any]]: + openai_messages = await super().convert_messages_async(messages) + + assistant_msgs = [m for m in messages if getattr(m, "role", "") == "assistant"] + ast_idx = 0 + + for o_msg in openai_messages: + if o_msg.get("role") == "assistant": + if ast_idx < len(assistant_msgs): + orig_ast = assistant_msgs[ast_idx] + ast_idx += 1 + + thought_parts = [ + p for p in orig_ast.content if isinstance(p, ThoughtPart) + ] + if thought_parts: + part = thought_parts[0] + raw_details = ( + part.metadata.get("raw_reasoning_details") + if part.metadata + else None + ) + + if raw_details: + o_msg["reasoning_details"] = raw_details + o_msg.pop("reasoning_content", None) + o_msg.pop("reasoning", None) + + return openai_messages + + +class OpenRouterTextHandler(CompositeOpenAITextHandler): + """OpenRouter 专有文本处理器,挂载专有 Converter""" + + def __init__(self, api_type: str = "openrouter"): + super().__init__(api_type=api_type) + self._standard_handler.converter = OpenRouterMessageConverter(api_type=api_type) + + +class OpenRouterImageHandler(BaseImageHandler): + """OpenRouter 专有的图像生成处理器""" + + def prepare_image_request( + self, + adapter: BaseAdapter, + identity: ModelIdentity, + api_key: str, + request: ImageRequest, + ) -> RequestData: + headers = adapter.get_base_headers(api_key) + + endpoint = "/v1/chat/completions" + url = adapter.get_api_url(identity, endpoint) + + body: dict[str, Any] = { + "model": identity.model_name, + "modalities": ["image", "text"], + } + + if request.images: + content_list: list[dict[str, Any]] = [ + {"type": "text", "text": request.prompt} + ] + for img_source in request.images: + img_bytes = None + if isinstance(img_source, bytes): + img_bytes = img_source + elif hasattr(img_source, "read_bytes"): + img_bytes = img_source.read_bytes() + elif isinstance(img_source, str) and img_source.startswith( + "data:image" + ): + content_list.append( + {"type": "image_url", "image_url": {"url": img_source}} + ) + continue + else: + raise LLMException( + "OpenRouter 图像生成仅支持 bytes/Path/base64 URI" + ) + + if img_bytes: + mime_type = "image/jpeg" + if img_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + mime_type = "image/png" + elif img_bytes.startswith(b"GIF87a") or img_bytes.startswith( + b"GIF89a" + ): + mime_type = "image/gif" + elif img_bytes.startswith(b"RIFF") and img_bytes[8:12] == b"WEBP": + mime_type = "image/webp" + + b64_str = base64.b64encode(img_bytes).decode("utf-8") + content_list.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime_type};base64,{b64_str}"}, + } + ) + body["messages"] = [{"role": "user", "content": content_list}] + else: + body["messages"] = [{"role": "user", "content": request.prompt}] + + if request.config: + image_config = {} + if request.config.media.aspect_ratio: + image_config["aspect_ratio"] = str(request.config.media.aspect_ratio) + if request.config.media.resolution: + image_config["image_size"] = str( + request.config.media.resolution + ).upper() + + if image_config: + body["image_config"] = image_config + + return RequestData(url=url, headers=headers, body=body) + + def parse_image_response( + self, adapter: BaseAdapter, response_json: dict[str, Any] + ) -> ResponseData: + adapter.validate_response(response_json) + + images_data = [] + choices = response_json.get("choices", []) + + if choices: + message = choices[0].get("message", {}) + if "images" in message: + for img_data in message["images"]: + img_url_obj = img_data.get("image_url", {}) + url_str = img_url_obj.get("url", "") + if url_str.startswith("data:image"): + try: + b64_data = url_str.split(",", 1)[1] + decoded = base64.b64decode(b64_data) + images_data.append(process_image_data(decoded)) + except Exception: + pass + elif url_str: + images_data.append(url_str) + + content_parts = [] + for img in images_data: + if isinstance(img, str) and img.startswith("http"): + content_parts.append(ImagePart(url=img)) + elif isinstance(img, bytes): + content_parts.append(ImagePart(raw=img)) + else: + content_parts.append(ImagePart(path=Path(img))) + + if not content_parts: + raise LLMException("OpenRouter 图像生成响应中未找到有效的图片数据") + + return ResponseData(content_parts=content_parts, raw_response=response_json) + + +class OpenRouterAdapter(OpenAIAdapter): + """OpenRouter 平台适配器""" + + def __init__(self): + super().__init__() + self.text_handler = OpenRouterTextHandler(api_type=self.api_type) + self.image_handler = OpenRouterImageHandler() + + @property + def api_type(self) -> str: + return "openrouter" + + @property + def supported_api_types(self) -> list[str]: + return ["openrouter"] diff --git a/zhenxun/services/ai/llm/api.py b/zhenxun/services/ai/llm/api.py new file mode 100644 index 00000000..1d7cfe9f --- /dev/null +++ b/zhenxun/services/ai/llm/api.py @@ -0,0 +1,526 @@ +""" +LLM 服务的高级 API 接口 - 便捷函数入口 (无状态) +""" + +from pathlib import Path +from typing import Any, Literal, TypeVar, overload + +from pydantic import BaseModel + +from zhenxun.services.ai.core.exceptions import ( + LLMException, + UpstreamServerException, + get_user_friendly_error_message, +) +from zhenxun.services.ai.core.messages import ( + AudioResponse, + ChatRequest, + ChatResponse, + EmbeddingRequest, + EmbeddingResponse, + ImageRequest, + ImageResponse, + LLMMessage, + PromptInput, + RerankRequest, + RerankResult, + SpeechRequest, +) +from zhenxun.services.ai.core.models import ModelName +from zhenxun.services.ai.core.options import ( + GenerationConfig, + LLMEmbeddingConfig, + TTSConfig, +) +from zhenxun.services.ai.guardrails import GuardrailSource +from zhenxun.services.ai.llm.engine.router import LLMOrchestrator +from zhenxun.services.log import logger + +from .builder import IntentBuilder + +T = TypeVar("T", bound=BaseModel) + + +async def chat( + message: PromptInput | list[LLMMessage], + *, + model: ModelName = None, + instruction: str | None = None, + config: GenerationConfig | IntentBuilder | None = None, + timeout: float | None = None, +) -> ChatResponse: + """ + 无状态的聊天对话便捷函数,单次执行后立即销毁上下文。 + + 示例: + response = await chat("你好", model="OpenAI/gpt-4o", instruction="你是一个助手") + print(response.text) + + 参数: + message: 用户输入的消息内容,支持多种格式。 + model: 要使用的模型名称,如果为None则使用默认模型。 + instruction: 系统指令,用于指导AI的行为和回复风格。 + config: (可选) 配置构建器 IntentBuilder 或 GenerationConfig 对象。 + timeout: (可选) HTTP 请求超时时间(秒)。 + + 返回: + ChatResponse: 包含AI回复内容、使用信息和工具调用等的完整响应对象。 + + 异常: + LLMException: 当网络超时、模型不存在或 API 返回错误时抛出,建议外层捕获。 + """ + try: + from zhenxun.services.ai.message_builder import MessageBuilder + + messages = await MessageBuilder.normalize_to_llm_messages( + message, instruction=instruction + ) + return await generate( + messages=messages, + model=model, + config=config, + timeout=timeout, + ) + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"执行 chat 函数失败: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"聊天执行失败: {friendly_msg}").with_traceback( + None + ) from None + + +@overload +async def embed( + input_batch: PromptInput, + *, + model: ModelName = None, + task: Literal[ + "general", "query", "document", "similarity", "classification", "clustering" + ] = "general", + dimensions: int | None = None, + multimodal: bool | list[str] = False, + config: LLMEmbeddingConfig | None = None, +) -> EmbeddingResponse: ... + + +@overload +async def embed( + input_batch: list[Any], + *, + model: ModelName = None, + task: Literal[ + "general", "query", "document", "similarity", "classification", "clustering" + ] = "general", + dimensions: int | None = None, + multimodal: bool | list[str] = False, + config: LLMEmbeddingConfig | None = None, +) -> EmbeddingResponse: ... + + +async def embed( + input_batch: PromptInput | list[Any], + *, + model: ModelName = None, + task: Literal[ + "general", "query", "document", "similarity", "classification", "clustering" + ] = "general", + dimensions: int | None = None, + multimodal: bool | list[str] = False, + config: LLMEmbeddingConfig | None = None, +) -> EmbeddingResponse: + """ + 无状态的向量嵌入便捷函数,支持文本批量与图文多模态融合 (Fused Embeddings)。 + + 参数: + input_batch: 要生成嵌入的内容。传入单条字符串/消息视为单向量;传入多条视为批量。 + model: 要使用的嵌入模型名称,如果为None则使用默认模型。 + task: 生成意图 + (query检索词 / document目标文档 / similarity相似度 等),将自动翻译到底层。 + dimensions: 强制降低返回的向量维度 (降维)。 + multimodal: 是否开启多模态嵌入提取。默认 False (极速安全的纯文本模式)。 + config: 嵌入配置对象。 + + 返回: + EmbeddingResponse: 包含向量和 Token 消耗统计的富响应对象。 + """ + final_config = config or LLMEmbeddingConfig() + if multimodal is not False: + final_config.multimodal = multimodal + + from zhenxun.services.ai.message_builder import MessageBuilder + + batch = await MessageBuilder.normalize_to_embed_batch( + input_batch, config=final_config + ) + + if not batch.payloads: + from zhenxun.services.ai.core.messages import UsageInfo + + return EmbeddingResponse( + embeddings=[], usage=UsageInfo(), model_name=str(model) + ) + + if dimensions is not None: + final_config.output_dimensionality = dimensions + + if task != "general": + task_map = { + "query": "RETRIEVAL_QUERY", + "document": "RETRIEVAL_DOCUMENT", + "similarity": "SEMANTIC_SIMILARITY", + "classification": "CLASSIFICATION", + "clustering": "CLUSTERING", + } + final_config.task_type = task_map.get(task) + + try: + request = EmbeddingRequest(batch=batch, config=final_config) + return await LLMOrchestrator.invoke(request, model_name=model, task="embedding") + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"文本嵌入失败: {e} | 建议: {friendly_msg}", e=e) + raise UpstreamServerException( + f"文本嵌入失败: {friendly_msg}", + cause=e, + ).with_traceback(None) from None + + +async def rerank( + query: str, + documents: list[str | dict[str, str]], + top_n: int = 3, + *, + model: ModelName = None, +) -> list[RerankResult]: + """ + 无状态的文本重排便捷函数。 + + 参数: + query: 用户查询问题 + documents: 候选文档列表 (支持纯文本或 {"image": "url", "text": "xxx"} 图文格式) + top_n: 返回匹配度最高的前 n 个文档 + model: 重排模型名称 (如 BAAI/bge-reranker-v2-m3) + """ + try: + request = RerankRequest(query=query, documents=documents, top_n=top_n) + response = await LLMOrchestrator.invoke( + request, model_name=model, task="rerank" + ) + return response.results + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"文档重排失败: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"文档重排失败: {friendly_msg}").with_traceback( + None + ) from None + + +async def generate_structured( + message: PromptInput | list[LLMMessage], + response_model: type[T], + *, + guardrails: list[GuardrailSource] | None = None, + model: ModelName = None, + config: GenerationConfig | IntentBuilder | None = None, + max_retries: int | None = None, + error_prompt_template: str | None = None, + instruction: str | None = None, + timeout: float | None = None, +) -> T: + """ + 请求大模型生成结构化数据,并自动验证/解析为指定的 Pydantic 模型。 + + 示例: + class UserInfo(BaseModel): + name: str + info = await generate_structured("提取张三的信息", response_model=UserInfo) + + 参数: + message: 输入的消息内容,支持纯文本、UniMessage、消息对象列表等。 + response_model: 目标结构化输出的强类型 Pydantic 模型类。 + guardrails: 护栏来源列表,支持自然语言规则、自定义校验函数 + model: 强制指定调用的模型路由或名称,若为空则使用默认模型。 + config: 大模型生成的通用配置或意图构建器。 + max_retries: 格式解析或护栏校验失败时的最大自我反思重试次数(IVR),若为空则使用全局配置。 + error_prompt_template: 自定义校验失败时引导大模型自我修正的提示词模板。 + instruction: 注入到系统提示词中的全局任务指令或前置设定。 + timeout: 本次 API 请求的超时时间限制(秒)。 + + 返回: + T: 解析验证通过后的 Pydantic 模型实例。 + """ # noqa: E501 + try: + from zhenxun.services.ai.config import get_llm_config + from zhenxun.services.ai.core.engine.structured_parser import ( + BaseOutputProcessor, + ) + from zhenxun.services.ai.core.options import ( + OutputFormatConfig, + ResponseFormat, + StructuredOutputStrategy, + ) + + if max_retries is None: + max_retries = get_llm_config().client_settings.structured_retries + + from zhenxun.services.ai.guardrails import parse_guardrails + + parsed_guardrails = parse_guardrails(guardrails) + + output_processor = BaseOutputProcessor( + response_model=response_model, + error_template=error_prompt_template, + ) + json_schema = output_processor.get_json_schema() + + structured_config = GenerationConfig( + output=OutputFormatConfig( + response_format=ResponseFormat.JSON, + response_schema=json_schema, + structured_output_strategy=StructuredOutputStrategy.NATIVE, + ) + ) + + prompt_parts: list[str] = [] + if instruction: + prompt_parts.append(instruction) + + import json + + schema_str = json.dumps(json_schema, ensure_ascii=False, indent=2) + prompt_parts.append( + "### ⚠️ [结构化输出要求]\n" + "请严格按照以下 JSON Schema 格式进行回复,禁止包含任何额外纯文本解释:\n" + f"```json\n{schema_str}\n```" + ) + + system_prompt = "\n\n".join(prompt_parts) if prompt_parts else None + + from zhenxun.services.ai.message_builder import MessageBuilder + + messages = await MessageBuilder.normalize_to_llm_messages( + message if message is not None else [], instruction=system_prompt + ) + + if isinstance(config, IntentBuilder): + config = config.build() + + final_config = ( + structured_config.merge_with(config) if config else structured_config + ) + + from zhenxun.services.ai.capabilities.builtin import ( + ReflexionCapability, + ) + + extra_context = { + "output_processor": output_processor, + "guardrails": parsed_guardrails, + "max_retries": max_retries, + "__sys_capabilities": [ReflexionCapability()], + } + + response = await generate( + messages=messages, + model=model, + config=final_config, + timeout=timeout, + extra=extra_context, + ) + + if not hasattr(response, "parsed_obj") or response.parsed_obj is None: + raise LLMException("结构化输出失败:中间件未返回解析后的对象。") + + return response.parsed_obj + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"生成结构化响应失败: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"生成结构化响应失败: {friendly_msg}").with_traceback( + None + ) from None + + +async def generate( + messages: list[LLMMessage], + *, + model: ModelName = None, + config: GenerationConfig | IntentBuilder | None = None, + timeout: float | None = None, + extra: dict[str, Any] | None = None, +) -> ChatResponse: + """ + [内部 API/高级用法] 直接传入底层消息实体列表生成响应。一般业务插件推荐使用 `chat`。 + + 参数: + messages: 完整的消息历史列表,包括系统指令、用户消息和助手回复。 + model: 要使用的模型名称,如果为None则使用默认模型。 + config: (可选) 生成配置对象,将与默认配置合并后传递。 + + 返回: + ChatResponse: 包含AI回复内容、使用信息和工具调用等的完整响应对象。 + """ + try: + resolved_config: GenerationConfig | None = None + if isinstance(config, IntentBuilder): + resolved_config = config.build() + else: + resolved_config = config + + request = ChatRequest( + messages=messages, + config=resolved_config, + timeout=timeout, + extra=extra or {}, + ) + + sys_caps = request.extra.pop("__sys_capabilities", []) + run_ctx = request.extra.pop("run_context", None) + + if sys_caps: + from zhenxun.services.ai.capabilities import CombinedCapability + from zhenxun.services.ai.core.models import LLMContext + from zhenxun.services.ai.run import RunContext + + run_context = run_ctx or RunContext() + llm_context = LLMContext(request=request) + combined_cap = CombinedCapability(sys_caps) + + async def inner_handler(ctx: LLMContext[Any, Any]) -> ChatResponse: + return await LLMOrchestrator.invoke( + ctx.request, + model_name=model, + task="chat", + override_config=resolved_config, + ) + + return await combined_cap.wrap_model_request( + run_context, llm_context, inner_handler + ) + else: + return await LLMOrchestrator.invoke( + request, model_name=model, task="chat", override_config=resolved_config + ) + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"生成响应失败: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"生成响应失败: {friendly_msg}").with_traceback( + None + ) from None + + +@overload +async def create_image( + prompt: str | Any, + *, + images: None = None, + model: ModelName = None, + config: GenerationConfig | IntentBuilder | None = None, +) -> ImageResponse: + """根据文本提示生成一张新图片。""" + ... + + +@overload +async def create_image( + prompt: str | Any, + *, + images: list[Path | bytes | str] | Path | bytes | str, + model: ModelName = None, + config: GenerationConfig | IntentBuilder | None = None, +) -> ImageResponse: + """在给定图片的基础上,根据文本提示进行编辑或重新生成。""" + ... + + +async def create_image( + prompt: str | Any, + *, + images: list[Path | bytes | str] | Path | bytes | str | None = None, + model: ModelName = None, + config: GenerationConfig | IntentBuilder | None = None, +) -> ImageResponse: + """ + 多模态图片生成/编辑函数。 + + 示例: + res = await create_image("画一只猫", model="OpenAI/dall-e-3") + img_bytes = res.images[0] + + 说明: + - 若 `images` 为 None,执行文本生成图片 (Text-to-Image)。 + - 若提供 `images`,执行图像编辑 (Image-to-Image)。 + """ + text_prompt = getattr(prompt, "extract_plain_text", lambda: str(prompt))() + + image_list = [] + if images: + if isinstance(images, list): + image_list.extend(images) + else: + image_list.append(images) + + if isinstance(config, IntentBuilder): + config = config.build() + config = config or GenerationConfig() + + try: + request = ImageRequest( + prompt=text_prompt, + images=image_list if image_list else None, + config=config, + ) + return await LLMOrchestrator.invoke( + request, model_name=model, task="image", override_config=config + ) + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"图片生成执行发生未知错误: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"图片生成失败: {friendly_msg}").with_traceback( + None + ) from None + + +async def create_speech( + text: str, + voice: str | None = None, + *, + model: ModelName = None, + config: TTSConfig | None = None, +) -> AudioResponse: + """ + 通用文本转语音便捷函数。 + + 参数: + text: 待合成的文本内容。 + voice: 快捷音色指定,若为空则自动使用目标模型的缺省最优音色。 + model: 指定生成语音的模型名称。 + config: 语音生成的额外设置。 + + 示例: + res = await create_speech("你好,世界", voice="alloy", model="OpenAI/tts-1") + Path("out.mp3").write_bytes(res.audio_bytes) + """ + if not text: + raise LLMException("TTS 输入文本不能为空") + + try: + request = SpeechRequest(input_text=text, voice=voice, config=config) + return await LLMOrchestrator.invoke(request, model_name=model, task="tts") + except LLMException as e: + raise e.with_traceback(None) from None + except Exception as e: + friendly_msg = get_user_friendly_error_message(e) + logger.error(f"语音生成执行发生未知错误: {e} | 建议: {friendly_msg}", e=e) + raise LLMException(f"语音生成失败: {friendly_msg}").with_traceback( + None + ) from None diff --git a/zhenxun/services/ai/llm/builder.py b/zhenxun/services/ai/llm/builder.py new file mode 100644 index 00000000..d1bc81fb --- /dev/null +++ b/zhenxun/services/ai/llm/builder.py @@ -0,0 +1,228 @@ +""" +LLM 生成配置相关类和函数 +""" + +from typing import Any, Literal +from typing_extensions import Self + +from zhenxun.services.ai.core.exceptions import ConfigurationException +from zhenxun.services.ai.core.options import ( + GenerationConfig, + ResponseFormat, +) +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import model_json_schema, model_validate + + +class GeminiIntentNamespace: + """Gemini 专属高级参数构建域""" + + def __init__(self, builder: "IntentBuilder"): + self._builder = builder + + def set_safety_threshold(self, threshold: str) -> "IntentBuilder": + """强制设置 Gemini 安全阈值 (如 BLOCK_NONE, BLOCK_ONLY_HIGH)""" + self._builder._config.gemini_options.safety_settings = { + "HARM_CATEGORY_HARASSMENT": threshold, + "HARM_CATEGORY_HATE_SPEECH": threshold, + "HARM_CATEGORY_SEXUALLY_EXPLICIT": threshold, + "HARM_CATEGORY_DANGEROUS_CONTENT": threshold, + } + return self._builder + + +class OpenAIIntentNamespace: + """OpenAI 专属高级参数构建域""" + + def __init__(self, builder: "IntentBuilder"): + self._builder = builder + + def enable_server_storage(self, store: bool = True) -> "IntentBuilder": + """设置是否在 OpenAI 服务端留存请求记录""" + self._builder._config.openai_options.store = store + return self._builder + + +class DeepSeekIntentNamespace: + """DeepSeek 专属高级参数构建域""" + + def __init__(self, builder: "IntentBuilder"): + self._builder = builder + + def disable_thinking(self) -> "IntentBuilder": + """显式关闭 DeepSeek 的思维链""" + self._builder._config.deepseek_options.thinking = False + return self._builder + + +class IntentBuilder: + """ + 基于能力意图声明的构建器 (Intent-Driven Builder)。 + 完全屏蔽底层厂商参数差异,面向开发者提供 Fluent API。 + """ + + def __init__(self): + self._config = GenerationConfig() + + @property + def gemini(self) -> GeminiIntentNamespace: + return GeminiIntentNamespace(self) + + @property + def openai(self) -> OpenAIIntentNamespace: + return OpenAIIntentNamespace(self) + + @property + def deepseek(self) -> DeepSeekIntentNamespace: + return DeepSeekIntentNamespace(self) + + def with_reasoning(self, level: str | None = None) -> Self: + """ + 跨厂商统一的思考/推理等级意图声明。 + 自动向下转换为底层合法参数,并阻止不兼容模型的非法调用。 + """ + if level: + self._config.common.reasoning_effort = level + if level.lower() != "none": + self._config.gemini_options.include_thoughts = True + self._config.deepseek_options.thinking = True + else: + self._config.deepseek_options.thinking = False + return self + + def with_local_cache(self, ttl: int = 3600) -> Self: + """ + 显式开启本次 LLM 网络请求的极速本地缓存。 + 对于相同模型、相同参数、相同 Prompt 的请求,将直接返回本地记忆,免去网络开销。 + 适用于 Embedding、确定性的结构化抽取或工作流节点。 + """ + self._config.custom_kwargs["__cache_ttl__"] = ttl + return self + + def with_json_output(self) -> Self: + """ + 基础结构化意图:要求大模型输出通用 JSON 格式(不校验 Schema)。 + """ + self._config.output.response_format = ResponseFormat.JSON + self._config.output.response_mime_type = "application/json" + self._config.output.structured_output_strategy = "native" + return self + + def require_structured_output(self, schema: Any, strict: bool = True) -> Self: + """ + 强制要求结构化输出意图。 + 支持自动处理 Pydantic 模型并转换为厂商所需的 JSON Schema。 + """ + import inspect + + from pydantic import BaseModel + + from zhenxun.services.ai.core.options import StructuredOutputStrategy + + self._config.output.response_format = ResponseFormat.JSON + self._config.output.response_mime_type = "application/json" + if schema: + if inspect.isclass(schema) and issubclass(schema, BaseModel): + self._config.output.response_schema = model_json_schema(schema) + else: + from typing import cast + + self._config.output.response_schema = cast(dict[str, Any], schema) + if strict: + self._config.output.structured_output_strategy = ( + StructuredOutputStrategy.NATIVE + ) + return self + + def config_core( + self, + temperature: float | None = None, + max_tokens: int | None = None, + top_p: float | None = None, + ) -> Self: + """ + 配置底层核心采样参数(如 temperature, max_tokens 等)。 + """ + if temperature is not None: + self._config.common.temperature = temperature + if max_tokens is not None: + self._config.common.max_tokens = max_tokens + if top_p is not None: + self._config.common.top_p = top_p + return self + + def with_safety_level(self, level: str = "moderate") -> Self: + """ + 安全合规意图。 + level 取值: 'strict' (最严格), 'moderate' (中等), 'none' (完全无限制)。 + """ + from zhenxun.services.ai.config import get_gemini_safety_threshold + + if level == "strict": + self.gemini.set_safety_threshold("BLOCK_LOW_AND_ABOVE") + elif level == "none": + self.gemini.set_safety_threshold("BLOCK_NONE") + else: + self.gemini.set_safety_threshold(get_gemini_safety_threshold()) + return self + + def with_image_generation_params( + self, aspect_ratio: str = "16:9", resolution: str = "1K" + ) -> Self: + """ + 生图意图:统一配置图像生成的比例与分辨率。 + """ + self._config.media.aspect_ratio = aspect_ratio + self._config.media.resolution = resolution + return self + + def with_vision_optimization( + self, quality: Literal["low", "medium", "high", "standard", "hd"] = "high" + ) -> Self: + """ + 视觉优化意图。 + """ + self._config.media.quality = quality + return self + + def with_provider_raw_kwargs(self, provider_name: str, **kwargs) -> Self: + """厂商逃生舱:直接注入特有参数""" + provider_name = provider_name.lower() + if provider_name == "openai": + for k, v in kwargs.items(): + setattr(self._config.openai_options, k, v) + elif provider_name == "gemini": + for k, v in kwargs.items(): + setattr(self._config.gemini_options, k, v) + else: + self._config.custom_kwargs.update(kwargs) + return self + + def build(self) -> GenerationConfig: + """构建最终的配置对象""" + return self._config + + +def validate_override_params( + override_config: dict[str, Any] | GenerationConfig | None, +) -> GenerationConfig: + """验证和标准化覆盖参数""" + if override_config is None: + return GenerationConfig() + + if isinstance(override_config, GenerationConfig): + return override_config + + if isinstance(override_config, dict): + try: + return model_validate(GenerationConfig, override_config) + except Exception as e: + logger.warning(f"覆盖配置参数验证失败: {e}") + raise ConfigurationException( + f"无效的覆盖配置参数: {e}", + cause=e, + ) + + raise ConfigurationException( + f"不支持的配置类型: {type(override_config)}", + ) diff --git a/zhenxun/services/ai/llm/engine/__init__.py b/zhenxun/services/ai/llm/engine/__init__.py new file mode 100644 index 00000000..7af38464 --- /dev/null +++ b/zhenxun/services/ai/llm/engine/__init__.py @@ -0,0 +1,3 @@ +""" +LLM 执行引擎模块。 +""" diff --git a/zhenxun/services/ai/llm/engine/middlewares.py b/zhenxun/services/ai/llm/engine/middlewares.py new file mode 100644 index 00000000..0b0980ed --- /dev/null +++ b/zhenxun/services/ai/llm/engine/middlewares.py @@ -0,0 +1,641 @@ +import asyncio +import hashlib +import json +import re +import time +from typing import Any, ClassVar, cast + +from aiocache import SimpleMemoryCache +import httpx + +from zhenxun.services.ai.core.exceptions import ( + ConfigurationException, + LLMException, + NetworkTimeoutException, + UpstreamServerException, +) +from zhenxun.services.ai.core.messages import ( + AudioPart, + AudioResponse, + ChatRequest, + ChatResponse, + EmbeddingRequest, + EmbeddingResponse, + FilePart, + ImagePart, + ImageRequest, + ImageResponse, + RerankRequest, + RerankResponse, + SpeechRequest, + TextPart, + VideoPart, +) +from zhenxun.services.ai.core.models import ( + LLMContext, + ModelCapabilities, + ModelIdentity, + ModelModality, +) +from zhenxun.services.ai.core.options import ( + GenerationConfig, +) +from zhenxun.services.ai.core.protocols.middleware import LLMMiddleware, NextCall +from zhenxun.services.ai.llm.adapters.base import ( + BaseAdapter, + RequestData, + process_image_data, +) +from zhenxun.services.ai.llm.system.models import RetryConfig +from zhenxun.services.ai.llm.system.network import ( + HealthManager, + LLMHttpClient, +) +from zhenxun.services.log import logger +from zhenxun.utils.http_utils import AsyncHttpx +from zhenxun.utils.log_sanitizer import sanitize_for_logging +from zhenxun.utils.pydantic_compat import ( + dump_json_safely, + model_copy, + model_dump, + parse_as, +) + +_LLM_API_CACHE = SimpleMemoryCache(namespace="zhenxun_llm_api_cache") + + +class MiddlewarePipeline: + """中间件管线组装器""" + + def __init__(self): + self.middlewares: list[LLMMiddleware] = [] + + def add_middleware(self, middleware: LLMMiddleware) -> None: + """按顺序追加中间件,先加入的将处在调用链的最外层""" + self.middlewares.append(middleware) + + def build(self, terminal_handler: NextCall[Any, Any]) -> NextCall[Any, Any]: + handler = terminal_handler + for middleware in reversed(self.middlewares): + + def _wrap( + mw: LLMMiddleware[Any, Any], next_c: NextCall[Any, Any] + ) -> NextCall[Any, Any]: + async def _handler(context: LLMContext[Any, Any]) -> Any: + return await mw(context, next_c) + + return _handler + + handler = _wrap(middleware, handler) + return handler + + +class LLMCacheMiddleware: + """ + 大模型极速缓存中间件: + 只在开发者显式配置了 __cache_ttl__ 时生效。 + 拦截高成本的 API 网络请求,直接返回本地缓存。 + """ + + _RESPONSE_TYPE_MAP: ClassVar[dict[type, type]] = { + ChatRequest: ChatResponse, + EmbeddingRequest: EmbeddingResponse, + ImageRequest: ImageResponse, + SpeechRequest: AudioResponse, + RerankRequest: RerankResponse, + } + + def __init__(self, model_name: str): + self.model_name = model_name + + def _generate_cache_key(self, context: LLMContext[Any, Any]) -> str: + """构造绝对纯净的请求哈希键,剔除时间戳等干扰项""" + payload = { + "model": self.model_name, + "type": type(context.request).__name__, + "request": context.request.get_cache_hash_payload(), + } + + json_str = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.md5(json_str.encode("utf-8")).hexdigest() + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + ttl = None + if hasattr(context.request, "config") and context.request.config: + ttl = getattr(context.request.config, "custom_kwargs", {}).get( + "__cache_ttl__" + ) + + if ttl is None: + return await next_call(context) + + cache_key = self._generate_cache_key(context) + cached_data = await _LLM_API_CACHE.get(cache_key) + + if cached_data is not None: + logger.debug( + f"⚡ [LLMCache] 命中本地极速缓存 - " + f"model: {self.model_name}, type: {type(context.request).__name__}" + ) + + response_type = self._RESPONSE_TYPE_MAP.get(type(context.request)) + if response_type: + cached_resp = parse_as(response_type, cached_data) + else: + cached_resp = cached_data + + if isinstance(cached_resp, ChatResponse): + cached_resp.usage_info = { + "is_cache_hit": True, + "total_tokens": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "promptTokenCount": 0, + "candidatesTokenCount": 0, + "totalTokenCount": 0, + } + + return cached_resp + + response = await next_call(context) + + await _LLM_API_CACHE.set(cache_key, model_dump(response), ttl=ttl) + + return response + + +class FailoverAndRetryMiddleware: + """ + 故障转移与重试中间件: + 结合了密钥轮询 (Key Selection) 与异常退避重试 (Retry) 逻辑。 + """ + + def __init__( + self, + retry_config: RetryConfig, + health_manager: HealthManager, + provider_name: str, + api_keys: list[str], + ): + self.retry_config = retry_config + self.health_manager = health_manager + self.provider_name = provider_name + self.api_keys = api_keys + self._failed_keys: set[str] = set() + + def _raise_with_masked_key(self, e: LLMException, api_key: str) -> None: + """辅助函数:掩码 API Key 并原样抛出异常,防止密钥泄露""" + masked = f"{api_key[:8]}..." if api_key else "unknown" + if isinstance(e.details, dict): + e.details["api_key"] = masked + raise e.with_traceback(None) from None + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + last_exception: Exception | None = None + is_routed = context.request.extra.get("_is_routed_call", False) + max_retries = 0 if is_routed else self.retry_config.max_retries + total_attempts = max_retries + 1 + + for attempt in range(total_attempts): + selected_key = await self.health_manager.get_next_available_key( + self.provider_name, + self.api_keys, + exclude_keys=self._failed_keys, + strict_mode=is_routed, + ) + + if not selected_key: + raise ConfigurationException( + f"提供商 {self.provider_name} 无可用 API Key" + ) + + context.runtime_state["api_key"] = selected_key + context.runtime_state["provider_name"] = self.provider_name + try: + context.runtime_state["attempt"] = attempt + 1 + return await next_call(context) + + except LLMException as e: + last_exception = e + + await self.health_manager.record_key_failure( + self.provider_name, selected_key, e + ) + + if e.should_rotate_key: + self._failed_keys.add(selected_key) + + if not e.is_retryable: + self._raise_with_masked_key(e, selected_key) + + if attempt == total_attempts - 1: + self._raise_with_masked_key(e, selected_key) + + wait_time = self.retry_config.retry_delay + if self.retry_config.exponential_backoff: + wait_time *= 2**attempt + + logger.warning( + f"请求失败,{wait_time:.2f}秒后重试" + f" (第{attempt + 1}/{max_retries}次重试): {e}" + ) + await asyncio.sleep(wait_time) + + except Exception as e: + logger.error(f"非预期异常,停止重试: {e}", e=e) + raise e.with_traceback(None) from None + + if last_exception: + raise last_exception.with_traceback(None) from None + raise LLMException("重试循环异常结束").with_traceback(None) from None + + +class LoggingMiddleware: + """ + 日志中间件: + 职责归位后,统一负责 HTTP Payload 的生成、安全脱敏以及完整生命周期的日志记录。 + """ + + def __init__( + self, + provider_name: str, + model_name: str, + adapter: BaseAdapter, + identity: ModelIdentity, + log_context: str = "Generation", + ): + self.provider_name = provider_name + self.model_name = model_name + self.adapter = adapter + self.identity = identity + self.log_context = log_context + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + attempt = context.runtime_state.get("attempt", 1) + api_key = context.runtime_state.get("api_key", "unknown") + masked_key = f"{api_key[:8]}..." + + logger.info( + f"🌐 发起LLM请求 (尝试 {attempt}) - {self.provider_name}/{self.model_name} " + f"[{self.log_context}] Key: {masked_key}" + ) + + request_data = await self.adapter.prepare_payload( + identity=self.identity, + api_key=api_key, + request=context.request, + ) + context.runtime_state["request_data"] = request_data + + logger.debug(f"📡 请求URL: {request_data.url}") + logger.debug(f"📋 请求头: {dict(request_data.headers)}") + + if self.identity.api_type == "smart": + from zhenxun.services.ai.llm.adapters.factory import SmartAdapter + + smart_adapter = cast(SmartAdapter, self.adapter) + delegate_adapter = smart_adapter._get_delegate_adapter(self.identity) + sanitizer_req_context = f"{delegate_adapter.api_type}_request" + else: + sanitizer_req_context = self.adapter.log_sanitization_context + + sanitized_body = sanitize_for_logging( + request_data.body, context=sanitizer_req_context + ) + + if request_data.files and isinstance(sanitized_body, dict): + file_info: list[str] = [] + file_count = 0 + if isinstance(request_data.files, list): + file_count = len(request_data.files) + for key, value in request_data.files: + filename = ( + value[0] + if isinstance(value, tuple) and len(value) > 0 + else "..." + ) + file_info.append(f"{key}='{filename}'") + elif isinstance(request_data.files, dict): + file_count = len(request_data.files) + file_info = list(request_data.files.keys()) + sanitized_body["[MULTIPART_FILES]"] = f"Count: {file_count} | {file_info}" + + request_body_str = dump_json_safely( + sanitized_body, ensure_ascii=False, indent=2 + ) + logger.debug(f"📦 请求体: {request_body_str}") + + try: + start_time = time.monotonic() + response = await next_call(context) + duration = (time.monotonic() - start_time) * 1000 + logger.debug(f"🎯 LLM响应成功 [{self.log_context}] 耗时: {duration:.2f}ms") + return response + except Exception as e: + raise e.with_traceback(None) from None + + +class HttpExecutionMiddleware: + """ + 终端 HTTP 执行中间件: + 只负责将上游构建好的 Payload 发送出去,并拦截纯粹的 HTTP 网络故障。 + """ + + def __init__( + self, + http_client: LLMHttpClient, + identity: ModelIdentity, + health_manager: HealthManager, + adapter: BaseAdapter, + ): + self.http_client = http_client + self.identity = identity + self.health_manager = health_manager + self.adapter = adapter + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + api_key = context.runtime_state["api_key"] + provider_name = self.identity.provider_name + route_id = f"{self.identity.provider_name}/{self.identity.model_name}" + + request_data: RequestData = context.runtime_state["request_data"] + + if context.cancellation_token: + context.cancellation_token.raise_if_cancelled() + + start_time = time.monotonic() + try: + method = getattr(request_data, "method", "POST").upper() + req_kwargs = { + "headers": request_data.headers, + "timeout": context.request.timeout, + } + + if method in ("POST", "PUT", "PATCH"): + if request_data.files: + req_kwargs["data"] = request_data.body + req_kwargs["files"] = request_data.files + else: + req_kwargs["content"] = json.dumps( + request_data.body, ensure_ascii=False + ) + elif method == "GET" and request_data.body: + req_kwargs["params"] = request_data.body + + post_task = asyncio.create_task( + self.http_client.request(method, request_data.url, **req_kwargs) + ) + + if context.cancellation_token: + context.cancellation_token.link_future(post_task) + + raw_engine_output = await post_task + + logger.debug(f"📥 HTTP响应状态码: {raw_engine_output.status_code}") + if exception := self.adapter.handle_http_error(raw_engine_output): + error_text = raw_engine_output.content.decode("utf-8", errors="ignore") + logger.debug(f"💥 完整错误响应: {error_text}") + raise exception.with_traceback(None) from None + + latency = (time.monotonic() - start_time) * 1000 + await self.health_manager.record_key_success(provider_name, api_key) + await self.health_manager.record_route_success(route_id, latency) + + return await self.adapter.parse_payload( + identity=self.identity, + request=context.request, + raw_response=raw_engine_output, + ) + + except asyncio.CancelledError: + logger.warning(f"网络请求已被取消: {request_data.url}") + raise + except httpx.TimeoutException as e: + await self.health_manager.record_route_failure(route_id, e) + raise NetworkTimeoutException(f"HTTP请求超时: {e}", cause=e) + except httpx.NetworkError as e: + await self.health_manager.record_route_failure(route_id, e) + raise UpstreamServerException(f"网络连接中断: {e}", cause=e) + except LLMException as e: + if e.should_failover: + await self.health_manager.record_route_failure(route_id, e) + raise e.with_traceback(None) from None + except Exception as e: + logger.error(f"解析响应失败或发生未知错误: {e}") + masked_key = ( + f"{api_key[:8]}...{api_key[-4:] if len(api_key) > 12 else '***'}" + if api_key + else "N/A" + ) + raise UpstreamServerException( + f"网络请求异常: {type(e).__name__} - {e}", + details={"api_key": masked_key}, + cause=e, + ).with_traceback(None) from None + + +class ModalityFilterMiddleware: + """模态过滤中间件:负责自动剔除当前模型不支持的多模态输入""" + + def __init__(self, model_name: str, capabilities: ModelCapabilities): + self.model_name = model_name + self.capabilities = capabilities + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + request = context.request + if isinstance(request, ChatRequest): + filtered_messages = [] + _warned_image, _warned_audio, _warned_video = False, False, False + for msg in request.messages: + new_content = [] + for part in msg.content: + if ( + isinstance(part, ImagePart) + and ModelModality.IMAGE + not in self.capabilities.input_modalities + ): + if not _warned_image: + logger.warning( + f"模型 {self.model_name} 不支持图像输入," + "已自动将图片替换为占位符" + ) + _warned_image = True + new_content.append(TextPart(text="<图片>")) + elif ( + isinstance(part, AudioPart) + and ModelModality.AUDIO + not in self.capabilities.input_modalities + ): + if not _warned_audio: + logger.warning( + f"模型 {self.model_name} 不支持音频输入," + "已自动将音频替换为占位符" + ) + _warned_audio = True + new_content.append(TextPart(text="<音频>")) + elif ( + isinstance(part, VideoPart) + and ModelModality.VIDEO + not in self.capabilities.input_modalities + ): + if not _warned_video: + logger.warning( + f"模型 {self.model_name} 不支持视频输入," + "已自动将视频替换为占位符" + ) + _warned_video = True + new_content.append(TextPart(text="<视频>")) + elif ( + isinstance(part, FilePart) + and ModelModality.FILE not in self.capabilities.input_modalities + ): + new_content.append(TextPart(text="<文件>")) + else: + new_content.append(part) + + filtered_messages.append( + model_copy(msg, update={"content": new_content}) + ) + context.request = model_copy( + request, update={"messages": filtered_messages} + ) + return await next_call(context) + + +class ConfigMergeMiddleware: + """配置合并中间件:合并覆盖配置,统一填充默认参数""" + + def __init__(self, generation_config: GenerationConfig | None): + self.generation_config = generation_config + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + request = context.request + updates = {} + + if hasattr(request, "tools") and getattr(request, "tools", None) is not None: + tools = getattr(request, "tools") + updates["tools"] = ( + list(tools.values()) + if isinstance(tools, dict) + else (tools if isinstance(tools, list) else [tools]) + ) + + if hasattr(request, "config"): + req_config = getattr(request, "config", None) + if isinstance(req_config, GenerationConfig) and self.generation_config: + updates["config"] = self.generation_config.merge_with(req_config) + elif ( + req_config is None + and self.generation_config + and hasattr(request, "messages") + ): + updates["config"] = self.generation_config + + if updates: + context.request = model_copy(request, update=updates) + + return await next_call(context) + + +class ResponseRescueMiddleware: + """响应挽救中间件:对于没有按要求返回图片链接的模型,尝试进行正则兜底下载""" + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + response = await next_call(context) + request = context.request + + if isinstance(request, ChatRequest) and isinstance(response, ChatResponse): + gen_config = request.config + policy = gen_config.validation_policy if gen_config else None + should_rescue_image = policy and policy.get("require_image") + if ( + should_rescue_image + and not response.images + and response.text + and gen_config + ): + markdown_matches = re.findall( + r"(!?\[.*?\]\((https?://[^\)]+)\))", response.text + ) + if markdown_matches: + logger.info( + f"检测到 {len(markdown_matches)} 个链接,尝试自动下载清洗。" + ) + current_text = response.text + other_parts = [ + p for p in response.content_parts if not isinstance(p, TextPart) + ] + downloaded_urls = set() + for full_tag, url in markdown_matches: + try: + if url not in downloaded_urls: + content = await AsyncHttpx.get_content(url) + processed = process_image_data(content) + if isinstance(processed, bytes): + img_part = ImagePart(raw=processed) + else: + img_part = ImagePart(path=processed) + other_parts.append(img_part) + downloaded_urls.add(url) + current_text = current_text.replace(full_tag, "") + except Exception as exc: + logger.warning(f"自动下载图片失败: {url}, 错误: {exc}") + response.content_parts = [ + TextPart(text=current_text.strip()), + *other_parts, + ] + return response + + +class OutputValidationMiddleware: + """输出验证中间件:负责策略校验与自定义格式验证)""" + + async def __call__( + self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any] + ) -> Any: + response = await next_call(context) + request = context.request + + if isinstance(request, ChatRequest) and isinstance(response, ChatResponse): + gen_config = request.config + if not gen_config: + return response + + if gen_config.response_validator: + try: + gen_config.response_validator(response) + except Exception as exc: + raise LLMException( + f"响应内容未通过自定义验证器: {exc}", + details={"validator_error": str(exc)}, + ).with_traceback(None) from None + + policy = gen_config.validation_policy + if policy and policy.get("require_image") and not response.images: + prompt_had_image = any( + isinstance(p, ImagePart) + for msg in request.messages + for p in msg.content + ) + if not prompt_had_image: + logger.debug("提示词中未包含图片,跳过要求图片返回的重试特判。") + else: + raise LLMException( + "响应验证失败:要求返回图片但未找到图片数据。", + details={"policy": policy, "text_response": response.text}, + ) + return response diff --git a/zhenxun/services/ai/llm/engine/router.py b/zhenxun/services/ai/llm/engine/router.py new file mode 100644 index 00000000..e862b32d --- /dev/null +++ b/zhenxun/services/ai/llm/engine/router.py @@ -0,0 +1,162 @@ +from abc import ABC, abstractmethod +from typing import Any + +from zhenxun.services.ai.core.exceptions import ( + ConfigurationException, + LLMException, + UpstreamServerException, +) +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.llm.manager import ( + _get_group_name, + _resolve_model_group, + get_default_model, + get_model_instance, + list_available_models, +) +from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities +from zhenxun.services.ai.llm.system.network import health_manager +from zhenxun.services.log import logger + + +class BaseModelRouter(ABC): + @abstractmethod + async def route( + self, + request: Any, + model_names: list[str], + task: str, + override_config: GenerationConfig | dict | None, + cancellation_token: Any | None, + ) -> Any: + pass + + +class FallbackRouter(BaseModelRouter): + """主备故障转移路由器""" + + async def route( + self, + request: Any, + model_names: list[str], + task: str, + override_config: GenerationConfig | dict | None, + cancellation_token: Any | None, + ) -> Any: + errors = [] + all_nodes_bypassed = True + + is_routed_call = len(model_names) > 1 + request.extra["_is_routed_call"] = is_routed_call + start_idx = request.extra.get("_working_route_index", 0) + indices_to_try = list(range(start_idx, len(model_names))) + list( + range(0, start_idx) + ) + + for idx in indices_to_try: + m_name = model_names[idx] + + if not health_manager.is_route_healthy(m_name, strict_mode=is_routed_call): + logger.debug(f"👉 [Orchestrator] 节点 '{m_name}' 熔断中,已跳过") + errors.append(f"{m_name}(熔断中)") + continue + + caps = get_model_capabilities(m_name) + if not caps.supports_task(task): + errors.append(f"{m_name}(Unsupported Task: {task})") + continue + + all_nodes_bypassed = False + try: + if len(model_names) > 1: + if idx != start_idx: + logger.debug(f"🔄 [Orchestrator] 切换至备用节点: '{m_name}'...") + + async with await get_model_instance( + m_name, override_config, task=task + ) as instance: + response = await instance.invoke(request, cancellation_token) + request.extra["_working_route_index"] = idx + if run_ctx := request.extra.get("run_context"): + run_ctx.state["_working_route_index"] = idx + return response + except LLMException as e: + if not e.should_failover: + logger.warning( + f"🚫 [Orchestrator] 节点 '{m_name}' " + f"返回不可恢复错误 ({e.__class__.__name__}),停止故障转移。" + ) + raise e + logger.warning( + f"⚠️ [Orchestrator] 节点 '{m_name}' " + f"错误 ({e.__class__.__name__}),触发故障转移..." + ) + errors.append(f"{m_name}({e.__class__.__name__})") + except Exception as e: + logger.warning( + f"⚠️ [Orchestrator] 节点 '{m_name}' 发生未知异常,触发故障转移: {e}" + ) + errors.append(f"{m_name}(Error)") + + if all_nodes_bypassed and len(model_names) > 1: + fallback_model = health_manager.get_best_fallback_route(model_names) + logger.warning( + f"⚠️ [Orchestrator] 路由组所有节点均已宕机!" + f"强制放行 '{fallback_model}' 探活..." + ) + try: + async with await get_model_instance( + fallback_model, override_config, task=task + ) as instance: + return await instance.invoke(request, cancellation_token) + except Exception as e: + errors.append(f"{fallback_model}(保底探活彻底失败:{e})") + + err_msg = f"所有路由尝试均已失败: {', '.join(errors)}" + raise UpstreamServerException(err_msg) + + +class BaseOrchestrator: + """顶层大模型请求编排器,负责组解析与路由策略委派。""" + + def __init__(self, router: BaseModelRouter | None = None): + self.router = router or FallbackRouter() + + async def invoke( + self, + request: Any, + model_name: str | None = None, + task: str = "chat", + override_config: GenerationConfig | dict | None = None, + cancellation_token: Any | None = None, + ) -> Any: + resolved_model_name = model_name + if resolved_model_name is None: + resolved_model_name = get_default_model(task) + if resolved_model_name is None: + available_models = list_available_models() + if not available_models: + raise ConfigurationException("未配置任何AI模型") + resolved_model_name = available_models[0]["full_name"] + logger.warning(f"未指定模型,使用第一个可用模型: {resolved_model_name}") + + group_name = _get_group_name(resolved_model_name) + if group_name is not None: + model_names = _resolve_model_group(group_name) + if not model_names: + raise ConfigurationException( + f"模型路由组 '{group_name}' 解析失败或为空,请检查配置。" + ) + else: + model_names = [resolved_model_name] + + return await self.router.route( + request=request, + model_names=model_names, + task=task, + override_config=override_config, + cancellation_token=cancellation_token, + ) + + +LLMOrchestrator = BaseOrchestrator() diff --git a/zhenxun/services/ai/llm/engine/schema_transformer.py b/zhenxun/services/ai/llm/engine/schema_transformer.py new file mode 100644 index 00000000..7549f503 --- /dev/null +++ b/zhenxun/services/ai/llm/engine/schema_transformer.py @@ -0,0 +1,356 @@ +from abc import ABC, abstractmethod +import copy +from typing import Any + + +class BaseSchemaTransformer(ABC): + """JSON Schema 节点处理器基类""" + + @abstractmethod + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + """处理单个 Schema 节点并返回修改后的节点""" + pass + + +class SchemaPipeline: + """JSON Schema 转换管道执行器""" + + def __init__(self, transformers: list[BaseSchemaTransformer]): + self.transformers = transformers + + def run(self, schema: dict[str, Any]) -> dict[str, Any]: + """执行管道,递归遍历并处理整个 Schema AST""" + if not isinstance(schema, dict): + return schema + schema_copy = copy.deepcopy(schema) + return self._walk(schema_copy, is_root=True) + + def _walk(self, node: Any, is_root: bool = False) -> Any: + """核心递归遍历算法,定向深入标准的 Schema 容器键""" + if isinstance(node, list): + return [self._walk(item, is_root=False) for item in node] + if not isinstance(node, dict): + return node + + current_node = node + for transformer in self.transformers: + current_node = transformer.process_node(current_node, is_root=is_root) + if not isinstance(current_node, dict): + return current_node + + for dict_key in ["properties", "patternProperties", "$defs", "definitions"]: + if dict_key in current_node and isinstance(current_node[dict_key], dict): + current_node[dict_key] = { + k: self._walk(v, is_root=False) + for k, v in current_node[dict_key].items() + } + + for list_key in ["anyOf", "allOf", "oneOf", "prefixItems"]: + if list_key in current_node and isinstance(current_node[list_key], list): + current_node[list_key] = [ + self._walk(v, is_root=False) for v in current_node[list_key] + ] + + for single_key in ["items", "additionalProperties", "contains"]: + if single_key in current_node and isinstance( + current_node[single_key], dict + ): + current_node[single_key] = self._walk( + current_node[single_key], is_root=False + ) + + return current_node + + +class RootRefInlineTransformer(BaseSchemaTransformer): + """将根节点的 $ref 展开,保留 $defs 供内部递归使用""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if is_root and "$ref" in node: + ref_path = node.pop("$ref") + ref_name = ref_path.split("/")[-1] + defs = node.get("$defs") or node.get("definitions") or {} + if ref_name in defs: + def_content = defs[ref_name].copy() + for k, v in def_content.items(): + if k not in node: + node[k] = v + return node + + +class RemoveUnsupportedKeysTransformer(BaseSchemaTransformer): + """移除不支持的键""" + + def __init__(self, keys_to_remove: list[str]): + self.keys_to_remove = keys_to_remove + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + for key in self.keys_to_remove: + node.pop(key, None) + return node + + +class GeminiEnumTransformer(BaseSchemaTransformer): + """将 const 转换为 enum (Gemini 专用)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if "const" in node: + node["enum"] = [node.pop("const")] + return node + + +class GeminiNullableUnionTransformer(BaseSchemaTransformer): + """处理 anyOf 和 type 列表中的 null,转换为 nullable: True (Gemini 专用)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if "type" in node and isinstance(node["type"], list): + types_list = node["type"] + if "null" in types_list: + node["nullable"] = True + types_list = [t for t in types_list if t != "null"] + node["type"] = types_list[0] if len(types_list) == 1 else types_list + + if "anyOf" in node and isinstance(node["anyOf"], list): + any_of = node["anyOf"] + has_null = any( + isinstance(x, dict) and x.get("type") == "null" for x in any_of + ) + if has_null: + node["nullable"] = True + new_any_of = [ + x + for x in any_of + if not (isinstance(x, dict) and x.get("type") == "null") + ] + if len(new_any_of) == 1: + node.update(new_any_of[0]) + node.pop("anyOf", None) + else: + node["anyOf"] = new_any_of + return node + + +class GeminiFormatTransformer(BaseSchemaTransformer): + """清理不支持的 format 格式 (Gemini 专用)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if node.get("format") and node["format"] not in ["enum", "date-time"]: + node.pop("format", None) + return node + + +class StrictObjectTransformer(BaseSchemaTransformer): + """ + 强制对象必须关闭 additionalProperties, + 并将所有 properties 设为 required (OpenAI/DeepSeek 专用) + """ + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if node.get("type") == "object" or "properties" in node: + node["type"] = "object" + node["additionalProperties"] = False + if "properties" not in node: + node["properties"] = {} + node["required"] = list(node["properties"].keys()) + return node + + +class OpenAIUnionFlattenTransformer(BaseSchemaTransformer): + """ + 将 anyOf/allOf/oneOf 拍平,选取第一个非 null 的类型作为降级方案 + (OpenAI 严格模式不支持复杂 Union) + """ + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + for union_key in ["anyOf", "allOf", "oneOf"]: + if union_key in node: + union_list = node.pop(union_key) + if isinstance(union_list, list) and len(union_list) > 0: + fallback = {} + for item in union_list: + if isinstance(item, dict) and item.get("type") != "null": + fallback = item + break + if not fallback and isinstance(union_list[0], dict): + fallback = union_list[0] + for k, v in fallback.items(): + if k not in node: + node[k] = v + return node + + +class TypeEnforcerTransformer(BaseSchemaTransformer): + """强制赋予空节点类型 (默认 string)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if "type" not in node and "properties" not in node and "$ref" not in node: + node["type"] = "string" + return node + + +class DeepSeekFallbackTransformer(BaseSchemaTransformer): + """非根空对象转为字符串 (DeepSeek 专用避坑)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if node.get("type") == "object" and not is_root and not node.get("properties"): + node["type"] = "string" + for k in ["properties", "required", "additionalProperties"]: + node.pop(k, None) + node["description"] = ( + f"{node.get('description', '')} (Please provide a JSON string)".strip() + ) + return node + + +class GeminiCyclicRefTransformer(BaseSchemaTransformer): + """ + 识别并处理循环引用: + Gemini 要求循环引用的 $ref 决不能出现在父级的 required 列表中 + """ + + def __init__(self, full_schema: dict): + self.cyclic_refs = self._detect_cycles(full_schema) + + def _detect_cycles(self, schema: dict) -> set[str]: + defs = schema.get("$defs") or schema.get("definitions") or {} + cyclic = set() + + def check_cycle(def_name: str, visited: set, path: list): + if def_name in path: + for node in path[path.index(def_name) :]: + cyclic.add(f"#/$defs/{node}") + cyclic.add(f"#/definitions/{node}") + return + if def_name in visited: + return + visited.add(def_name) + node = defs.get(def_name, {}) + + def find_refs(n: Any) -> list[str]: + refs = [] + if isinstance(n, dict): + if "$ref" in n: + refs.append(n["$ref"]) + for v in n.values(): + refs.extend(find_refs(v)) + elif isinstance(n, list): + for v in n: + refs.extend(find_refs(v)) + return refs + + for ref in find_refs(node): + if ref.startswith("#/$defs/") or ref.startswith("#/definitions/"): + next_def = ref.split("/")[-1] + check_cycle(next_def, visited, [*path, def_name]) + + visited_set = set() + for name in defs: + check_cycle(name, visited_set, []) + return cyclic + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if "properties" in node and isinstance(node.get("required"), list): + new_required = [] + for req_key in node["required"]: + prop = node.get("properties", {}).get(req_key, {}) + is_cyclic = False + if prop.get("$ref") in self.cyclic_refs: + is_cyclic = True + elif prop.get("type") == "array" and isinstance( + prop.get("items"), dict + ): + if prop["items"].get("$ref") in self.cyclic_refs: + is_cyclic = True + if not is_cyclic: + new_required.append(req_key) + if not new_required: + node.pop("required") + else: + node["required"] = new_required + return node + + +class RefComplianceTransformer(BaseSchemaTransformer): + """如果包含 $ref,同级不允许出现任何非 $ 开头的键 (如 description, title)""" + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if "$ref" in node: + keys_to_remove = [k for k in node.keys() if not k.startswith("$")] + for k in keys_to_remove: + node.pop(k) + return node + + +class GeminiDeepRefInlineTransformer(BaseSchemaTransformer): + """ + 深度展开所有 $ref,并清理 $defs (Gemini Function Calling 专用)。 + 大模型 API 网关层面严格拒绝 $defs 与 $ref,所以进行全量物理替换。 + 如果遇到循环引用,将安全退化为 string 类型防止栈溢出死循环。 + """ + + def process_node( + self, node: dict[str, Any], is_root: bool = False + ) -> dict[str, Any]: + if not is_root: + return node + + defs = {} + if "$defs" in node: + defs.update(node["$defs"]) + if "definitions" in node: + defs.update(node["definitions"]) + + def _resolve_refs(current: Any, visited: set) -> Any: + if isinstance(current, list): + return [_resolve_refs(item, visited) for item in current] + elif isinstance(current, dict): + if "$ref" in current: + ref_path = current["$ref"] + ref_name = ref_path.split("/")[-1] + if ref_name in defs: + if ref_name in visited: + return { + "type": "string", + "description": "Cyclic reference omitted", + } + + new_visited = visited | {ref_name} + resolved = _resolve_refs(defs[ref_name], new_visited) + + result = current.copy() + result.pop("$ref") + for k, v in resolved.items(): + if k not in result: + result[k] = v + return result + return {k: _resolve_refs(v, visited) for k, v in current.items()} + return current + + node = _resolve_refs(node, set()) + node.pop("$defs", None) + node.pop("definitions", None) + return node diff --git a/zhenxun/services/ai/llm/engine/service.py b/zhenxun/services/ai/llm/engine/service.py new file mode 100644 index 00000000..0a831251 --- /dev/null +++ b/zhenxun/services/ai/llm/engine/service.py @@ -0,0 +1,280 @@ +""" +LLM 模型实现类 + +包含 LLM 模型的抽象基类和具体实现,负责与各种 AI 提供商的 API 交互。 +""" + +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel + +from zhenxun.services.ai.config import ProviderConfig, get_llm_config +from zhenxun.services.ai.core.exceptions import ConfigurationException +from zhenxun.services.ai.core.messages import ( + AudioResponse, + BaseRequest, + ChatRequest, + ChatResponse, + EmbeddingRequest, + EmbeddingResponse, + ImageRequest, + ImageResponse, + RerankRequest, + RerankResponse, + SpeechRequest, +) +from zhenxun.services.ai.core.models import ( + CancellationToken, + LLMContext, + ModelCapabilities, + ModelDetail, + ModelIdentity, +) +from zhenxun.services.ai.core.options import ( + GenerationConfig, +) +from zhenxun.services.ai.core.protocols.llm import ( + SupportsChat, + SupportsImageGeneration, + SupportsReranking, + SupportsSpeechSynthesis, + SupportsTextEmbedding, +) +from zhenxun.services.ai.core.protocols.middleware import LLMMiddleware +from zhenxun.services.ai.llm.system.models import RetryConfig +from zhenxun.services.ai.llm.system.network import HealthManager, LLMHttpClient +from zhenxun.services.log import logger + +T = TypeVar("T", bound=BaseModel) + + +class LLMModel( + SupportsChat, + SupportsTextEmbedding, + SupportsSpeechSynthesis, + SupportsReranking, + SupportsImageGeneration, +): + """LLM 模型实现类""" + + def __init__( + self, + provider_config: ProviderConfig, + model_detail: ModelDetail, + health_manager: HealthManager, + http_client: LLMHttpClient, + capabilities: ModelCapabilities, + config_override: GenerationConfig | None = None, + ): + self.provider_config = provider_config + self.model_detail = model_detail + self.health_manager = health_manager + self.http_client: LLMHttpClient = http_client + self.capabilities = capabilities + self._generation_config = config_override + + self.provider_name = provider_config.name + self.api_type = model_detail.api_type or provider_config.api_type + self.api_base = provider_config.api_base + self.path_prefix = model_detail.path_prefix + self.api_keys = ( + [provider_config.api_key] + if isinstance(provider_config.api_key, str) + else provider_config.api_key + ) + self.model_name = model_detail.model_name + self.temperature = model_detail.temperature + self.generation_max_tokens = model_detail.generation_max_tokens + + self._is_closed = False + self._ref_count = 0 + self.identity = ModelIdentity( + provider_name=self.provider_name, + model_name=self.model_name, + api_type=self.api_type, + api_base=self.api_base, + path_prefix=self.path_prefix, + capabilities=self.capabilities, + generation_config=self._generation_config, + ) + + from zhenxun.services.ai.llm.engine.middlewares import MiddlewarePipeline + + self.pipeline = MiddlewarePipeline() + self._setup_default_pipeline() + + def add_middleware(self, middleware: LLMMiddleware) -> None: + """注册一个中间件到处理管道的最外层""" + self.pipeline.add_middleware(middleware) + + def _setup_default_pipeline(self) -> None: + from zhenxun.services.ai.llm.adapters.factory import get_adapter_for_api_type + from zhenxun.services.ai.llm.engine.middlewares import ( + ConfigMergeMiddleware, + FailoverAndRetryMiddleware, + LLMCacheMiddleware, + LoggingMiddleware, + ModalityFilterMiddleware, + OutputValidationMiddleware, + ResponseRescueMiddleware, + ) + + client_settings = get_llm_config().client_settings + retry_config = RetryConfig( + max_retries=client_settings.max_retries, + retry_delay=client_settings.retry_delay, + ) + adapter = get_adapter_for_api_type(self.api_type) + + self.pipeline.add_middleware(LLMCacheMiddleware(self.model_name)) + self.pipeline.add_middleware(ConfigMergeMiddleware(self._generation_config)) + self.pipeline.add_middleware( + ModalityFilterMiddleware(self.model_name, self.capabilities) + ) + self.pipeline.add_middleware( + FailoverAndRetryMiddleware( + retry_config, self.health_manager, self.provider_name, self.api_keys + ) + ) + self.pipeline.add_middleware(OutputValidationMiddleware()) + self.pipeline.add_middleware(ResponseRescueMiddleware()) + self.pipeline.add_middleware( + LoggingMiddleware( + self.provider_name, self.model_name, adapter, self.identity + ) + ) + + async def _select_api_key(self, failed_keys: set[str] | None = None) -> str: + """选择可用的API密钥(使用轮询策略)""" + if not self.api_keys: + raise ConfigurationException( + f"提供商 {self.provider_name} 没有配置API密钥", + ) + + selected_key = await self.health_manager.get_next_available_key( + self.provider_name, self.api_keys, failed_keys + ) + + if not selected_key: + raise ConfigurationException( + f"提供商 {self.provider_name} 的所有API密钥当前都不可用", + details={ + "total_keys": len(self.api_keys), + "failed_keys": len(failed_keys or set()), + }, + ) + + return selected_key + + async def close(self): + """标记模型实例的当前使用周期结束""" + if self._is_closed: + return + self._is_closed = True + logger.debug( + f"LLMModel实例的使用周期已结束: {self} (共享HTTP客户端状态不受影响)" + ) + + async def __aenter__(self): + if self._is_closed: + logger.debug( + f"Re-entering context for closed LLMModel {self}. " + f"Resetting _is_closed to False." + ) + self._is_closed = False + self._check_not_closed() + self._ref_count += 1 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """异步上下文管理器出口""" + _ = exc_type, exc_val, exc_tb + self._ref_count -= 1 + if self._ref_count <= 0: + self._ref_count = 0 + await self.close() + + def _check_not_closed(self): + """检查实例是否已关闭""" + if self._is_closed: + raise RuntimeError(f"LLMModel实例已关闭: {self}") + + async def invoke( + self, + request: BaseRequest, + cancellation_token: CancellationToken | None = None, + ) -> Any: + """ + 大一统命令执行核心入口 (Command Pattern)。 + 整合所有中间件执行管线,屏蔽具体模态差异。 + """ + self._check_not_closed() + + context = LLMContext( + request=request, + cancellation_token=cancellation_token, + ) + + from zhenxun.services.ai.llm.adapters.factory import get_adapter_for_api_type + from zhenxun.services.ai.llm.engine.middlewares import HttpExecutionMiddleware + + adapter = get_adapter_for_api_type(self.api_type) + execution_middleware = HttpExecutionMiddleware( + http_client=self.http_client, + identity=self.identity, + health_manager=self.health_manager, + adapter=adapter, + ) + + async def terminal_handler(ctx: LLMContext[Any, Any]) -> Any: + async def _noop(c: LLMContext[Any, Any]) -> Any: + raise RuntimeError("HttpExecutionMiddleware 不应调用 next_call") + + return await execution_middleware(ctx, _noop) + + handler = self.pipeline.build(terminal_handler) + return await handler(context) + + async def generate_response( + self, + request: ChatRequest, + cancellation_token: CancellationToken | None = None, + ) -> ChatResponse: + return await self.invoke(request, cancellation_token) + + async def generate_embeddings( + self, + request: EmbeddingRequest, + ) -> EmbeddingResponse: + return await self.invoke(request) + + async def rerank( + self, + request: RerankRequest, + ) -> RerankResponse: + return await self.invoke(request) + + async def generate_image( + self, + request: ImageRequest, + ) -> ImageResponse: + return await self.invoke(request) + + async def generate_speech( + self, + request: SpeechRequest, + ) -> AudioResponse: + return await self.invoke(request) + + def __str__(self) -> str: + status = "closed" if self._is_closed else "active" + return f"LLMModel({self.provider_name}/{self.model_name}, {status})" + + def __repr__(self) -> str: + status = "closed" if self._is_closed else "active" + return ( + f"LLMModel(provider={self.provider_name}, model={self.model_name}, " + f"api_type={self.api_type}, status={status})" + ) diff --git a/zhenxun/services/ai/llm/manager.py b/zhenxun/services/ai/llm/manager.py new file mode 100644 index 00000000..e31271c8 --- /dev/null +++ b/zhenxun/services/ai/llm/manager.py @@ -0,0 +1,311 @@ +""" +LLM 模型管理器 +对外提供统一的配置查询、模型发现与实例化入口。 +""" + +from typing import Any + +from zhenxun.services.ai.config import ( + ProviderConfig, + get_ai_config, + get_llm_config, +) +from zhenxun.services.ai.core.exceptions import ConfigurationException +from zhenxun.services.ai.core.models import ModelDetail +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities +from zhenxun.services.ai.llm.system.network import health_manager +from zhenxun.services.log import logger +from zhenxun.utils.manager.priority_manager import PriorityLifecycle +from zhenxun.utils.pydantic_compat import model_dump + +_RESOLVED_GROUP_CACHE: dict[str, list[str]] = {} +"""路由组解析缓存,避免每次调用重复打印剔除警告并提升性能""" + + +def clear_resolved_group_cache() -> None: + global _RESOLVED_GROUP_CACHE + _RESOLVED_GROUP_CACHE.clear() + + +def parse_provider_model_string(name_str: str | None) -> tuple[str | None, str | None]: + """解析 'ProviderName/ModelName' 格式的字符串""" + if not name_str or "/" not in name_str: + return None, None + parts = name_str.split("/", 1) + if len(parts) == 2 and parts[0].strip() and parts[1].strip(): + return parts[0].strip(), parts[1].strip() + return None, None + + +def _get_group_name(name_str: str) -> str | None: + """判断名称是否是组名,如果是则提取并返回组名,否则返回 None""" + name_str = name_str.strip() + if "/" not in name_str: + return name_str + return None + + +def get_default_api_base_for_type(api_type: str) -> str | None: + """根据API类型获取默认的API基础地址""" + default_api_bases = { + "openai": "https://api.openai.com", + "doubao": "https://ark.cn-beijing.volces.com/api", + "deepseek": "https://api.deepseek.com", + "jina": "https://api.jina.ai", + "glm": "https://open.bigmodel.cn", + "gemini": "https://generativelanguage.googleapis.com", + "openrouter": "https://openrouter.ai/api", + "smart": None, + "openai_responses": None, + } + return default_api_bases.get(api_type) + + +def get_configured_providers() -> list[ProviderConfig]: + """从配置中获取Provider列表""" + ai_config = get_ai_config() + providers = ai_config.get("PROVIDERS", []) + if not isinstance(providers, list): + logger.error("配置项 AI.PROVIDERS 的值不是一个列表,将使用空列表。") + return [] + valid_providers = [] + for i, item in enumerate(providers): + if isinstance(item, ProviderConfig): + if not item.api_base: + default_api_base = get_default_api_base_for_type(item.api_type) + if default_api_base: + item.api_base = default_api_base + valid_providers.append(item) + else: + logger.warning( + f"配置文件中第 {i + 1} 项未能正确解析为 ProviderConfig 对象,已跳过。" + ) + return valid_providers + + +def find_model_config( + provider_name: str, model_name: str +) -> tuple[ProviderConfig, ModelDetail] | None: + """在配置中查找指定 Provider 与 ModelDetail。""" + providers = get_configured_providers() + for provider in providers: + if provider.name.lower() == provider_name.lower(): + for model_detail in provider.models: + if model_detail.model_name.lower() == model_name.lower(): + return provider, model_detail + return None + + +def _resolve_model_group(group_name: str, visited: set | None = None) -> list[str]: + """递归解析模型组,展开为扁平的真实模型列表,并防止循环嵌套。""" + global _RESOLVED_GROUP_CACHE + if visited is None and group_name in _RESOLVED_GROUP_CACHE: + return _RESOLVED_GROUP_CACHE[group_name] + if visited is None: + visited = set() + if group_name in visited: + logger.warning(f"检测到模型路由组嵌套死循环: {group_name},已安全跳过该分支。") + return [] + visited.add(group_name) + llm_config = get_llm_config() + if group_name not in llm_config.model_groups: + logger.warning(f"模型路由组 '{group_name}' 不存在于配置中。") + return [] + resolved_models = [] + for item in llm_config.model_groups[group_name]: + item = item.strip() + sub_group = _get_group_name(item) + if sub_group: + resolved_models.extend(_resolve_model_group(sub_group, visited.copy())) + else: + prov_mod = parse_provider_model_string(item) + if prov_mod[0] and prov_mod[1]: + if find_model_config(prov_mod[0], prov_mod[1]): + if item not in resolved_models: + resolved_models.append(item) + else: + logger.warning( + f"⚠️ [Router] 路由组 '{group_name}' 中的模型 " + f"'{item}' 未在配置,已被自动剔除!" + ) + else: + logger.warning(f"路由组 '{group_name}' 包含无效格式的项目 '{item}'。") + if len(visited) == 1: + _RESOLVED_GROUP_CACHE[group_name] = resolved_models + return resolved_models + + +def _get_model_identifiers(provider_name: str, model_detail: ModelDetail) -> list[str]: + """获取模型的所有可用标识符""" + return [f"{provider_name}/{model_detail.model_name}"] + + +def list_available_models() -> list[dict[str, Any]]: + """列出所有已配置的可用模型及其信息。""" + providers = get_configured_providers() + model_list = [] + for provider in providers: + for model_detail in provider.models: + caps = get_model_capabilities(model_detail.model_name) + model_info = { + "provider_name": provider.name, + "model_name": model_detail.model_name, + "full_name": f"{provider.name}/{model_detail.model_name}", + "api_type": provider.api_type or "auto-detect", + "api_base": provider.api_base, + "is_available": model_detail.is_available, + "is_embedding_model": caps.is_embedding_model, + "max_input_tokens": caps.max_input_tokens, + "available_identifiers": _get_model_identifiers( + provider.name, model_detail + ), + } + model_list.append(model_info) + return model_list + + +def list_embedding_models() -> list[dict[str, Any]]: + """列出所有支持嵌入能力的模型。""" + all_models = list_available_models() + return [model for model in all_models if model.get("is_embedding_model", False)] + + +def list_model_identifiers() -> dict[str, list[str]]: + """列出所有模型的可用标识符映射。""" + providers = get_configured_providers() + result = {} + for provider in providers: + for model_detail in provider.models: + full_name = f"{provider.name}/{model_detail.model_name}" + identifiers = _get_model_identifiers(provider.name, model_detail) + result[full_name] = identifiers + return result + + +def get_default_model(task: str = "chat") -> str | None: + """根据任务类型获取默认模型名称""" + config = get_llm_config() + return getattr(config.default_models, task, None) + + +async def get_key_usage_stats() -> dict[str, Any]: + """获取所有 Provider 的 Key 使用统计。""" + providers = get_configured_providers() + stats = {} + for provider in providers: + keys = ( + [provider.api_key] + if isinstance(provider.api_key, str) + else provider.api_key + ) + provider_stats = {} + provider_state = health_manager.state.providers.get(provider.name) + if provider_state: + for k in keys: + stat_data = provider_state.api_keys.get(k) + if stat_data: + provider_stats[health_manager._get_key_id(k)] = model_dump( + stat_data + ) + stats[provider.name] = { + "total_keys": len( + [provider.api_key] + if isinstance(provider.api_key, str) + else provider.api_key + ), + "key_stats": provider_stats, + } + return stats + + +async def reset_key_status(provider_name: str, api_key: str | None = None) -> bool: + """重置指定 Provider 的 Key 状态。""" + providers = get_configured_providers() + target_provider = None + for provider in providers: + if provider.name.lower() == provider_name.lower(): + target_provider = provider + break + if not target_provider: + logger.error(f"未找到Provider: {provider_name}") + return False + provider_keys = ( + [target_provider.api_key] + if isinstance(target_provider.api_key, str) + else target_provider.api_key + ) + if api_key: + if api_key in provider_keys: + await health_manager.reset_key_status(target_provider.name, api_key) + logger.info(f"已重置Provider '{provider_name}' 的指定Key状态") + return True + else: + logger.error(f"指定的Key不属于Provider '{provider_name}'") + return False + else: + for key in provider_keys: + await health_manager.reset_key_status(target_provider.name, key) + logger.info(f"已重置Provider '{provider_name}' 的所有Key状态") + return True + + +async def get_model_instance( + provider_model_name: str | None = None, + override_config: dict[str, Any] | GenerationConfig | None = None, + task: str = "chat", +) -> Any: + """作为门面 API,解析字符串并调用底层的 get_or_create_model""" + resolved_model_name_str = provider_model_name + if resolved_model_name_str is None: + resolved_model_name_str = get_default_model(task) + if resolved_model_name_str is None: + available_models_list = list_available_models() + if not available_models_list: + raise ConfigurationException("未配置任何AI模型") + resolved_model_name_str = available_models_list[0]["full_name"] + logger.warning(f"未指定模型,使用第一个可用模型: {resolved_model_name_str}") + + prov_name_str, mod_name_str = parse_provider_model_string(resolved_model_name_str) + if not prov_name_str or not mod_name_str: + raise ConfigurationException(f"无效的模型名称格式: '{resolved_model_name_str}'") + + config_tuple_found = find_model_config(prov_name_str, mod_name_str) + if not config_tuple_found: + raise ConfigurationException(f"未找到模型: '{resolved_model_name_str}'. ") + + provider_config_found, model_detail_found = config_tuple_found + + from zhenxun.services.ai.llm.system.cache import get_or_create_model + + return await get_or_create_model( + provider_config_found, model_detail_found, override_config + ) + + +def clear_all_cache() -> None: + """ + 清空模型实例缓存与路由组解析缓存。 + """ + from zhenxun.services.ai.llm.system.cache import clear_model_cache + + clear_model_cache() + clear_resolved_group_cache() + logger.debug("已清空全局模型实例与路由组缓存") + + +@PriorityLifecycle.on_startup(priority=10) +async def _init_llm_config_on_startup(): + """启动时初始化 LLM 配置、密钥状态并预热工具提供者管理器。""" + logger.info("正在初始化 LLM 配置并加载遥测状态...") + try: + from zhenxun.services.ai.config import get_llm_config + from zhenxun.services.ai.llm.system.network import health_manager + from zhenxun.services.ai.tools.engine.registry import tool_provider_manager + + get_llm_config() + await health_manager.initialize() + await tool_provider_manager.initialize() + + except Exception as e: + logger.error(f"LLM 配置或遥测状态初始化时发生错误: {e}", e=e) diff --git a/zhenxun/services/ai/llm/system/__init__.py b/zhenxun/services/ai/llm/system/__init__.py new file mode 100644 index 00000000..4ddf32af --- /dev/null +++ b/zhenxun/services/ai/llm/system/__init__.py @@ -0,0 +1,3 @@ +""" +LLM 运维与调度管理模块。 +""" diff --git a/zhenxun/services/ai/llm/system/cache.py b/zhenxun/services/ai/llm/system/cache.py new file mode 100644 index 00000000..ffd1e342 --- /dev/null +++ b/zhenxun/services/ai/llm/system/cache.py @@ -0,0 +1,173 @@ +import hashlib +import time +from typing import Any + +from zhenxun.services.ai.config import ProviderConfig, get_llm_config +from zhenxun.services.ai.core.exceptions import ConfigurationException, LLMException +from zhenxun.services.ai.core.models import ModelDetail, ModelModality +from zhenxun.services.ai.core.options import GenerationConfig +from zhenxun.services.ai.llm.builder import validate_override_params +from zhenxun.services.ai.llm.engine.service import LLMModel +from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities +from zhenxun.services.ai.llm.system.network import health_manager, http_client_manager +from zhenxun.services.log import logger +from zhenxun.utils.pydantic_compat import dump_json_safely, model_copy, model_dump + +_model_cache: dict[str, tuple[LLMModel, float]] = {} +_cache_ttl = 3600 +_max_cache_size = 10 + + +def _make_cache_key( + provider_name: str, + model_name: str, + override_config: dict | GenerationConfig | None, +) -> str: + """生成缓存键""" + config_str = ( + dump_json_safely(override_config, sort_keys=True) if override_config else "None" + ) + key_data = f"{provider_name}/{model_name}:{config_str}" + return hashlib.md5(key_data.encode()).hexdigest() + + +def _get_cached_model(cache_key: str) -> LLMModel | None: + """从缓存获取模型""" + if cache_key in _model_cache: + model, created_time = _model_cache[cache_key] + current_time = time.time() + + if current_time - created_time > _cache_ttl: + del _model_cache[cache_key] + logger.debug(f"模型缓存已过期: {cache_key}") + return None + + if model._is_closed: + logger.debug( + f"缓存的模型 {cache_key} ({model.provider_name}/{model.model_name}) " + f"处于_is_closed=True状态,重置为False以供复用。" + ) + model._is_closed = False + + logger.debug( + f"使用缓存的模型: {cache_key} -> {model.provider_name}/{model.model_name}" + ) + return model + return None + + +def _cache_model(cache_key: str, model: LLMModel): + """缓存模型实例""" + current_time = time.time() + + if len(_model_cache) >= _max_cache_size: + oldest_key = min(_model_cache.keys(), key=lambda k: _model_cache[k][1]) + del _model_cache[oldest_key] + + _model_cache[cache_key] = (model, current_time) + + +def clear_model_cache(): + """仅清空内存中的模型实例缓存。""" + global _model_cache + _model_cache.clear() + logger.debug("已清空模型实例缓存") + + +async def get_or_create_model( + provider_config_found: ProviderConfig, + model_detail_found: ModelDetail, + override_config: dict[str, Any] | GenerationConfig | None = None, +) -> Any: + """组装或获取底层 LLMModel 状态机实例 (不包含任何字符串解析)。""" + prov_name_str = provider_config_found.name + mod_name_str = model_detail_found.model_name + + cache_key = _make_cache_key(prov_name_str, mod_name_str, override_config) + cached_model = _get_cached_model(cache_key) + + def _get_clean_log_config(cfg: GenerationConfig) -> dict: + """辅助函数:剔除超长 Schema 以防止日志刷屏""" + log_dict = model_dump(cfg, exclude_none=True) + if "output" in log_dict and isinstance(log_dict["output"], dict): + if "response_schema" in log_dict["output"]: + log_dict["output"]["response_schema"] = ( + "