From 455a48409d24723dbce2ca05bddd4580ac95e74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E6=BD=AE?= Date: Tue, 20 Jan 2026 00:12:43 +0800 Subject: [PATCH] feat: integrate Memori long-term memory system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Memori (https://github.com/MemoriLabs/Memori) integration for persistent cross-session memory capabilities in both create_agent and create_deep_agent. ## New Files - agent/memori_config.py: MemoriConfig dataclass for configuration - agent/memori_manager.py: MemoriManager for connection and instance management - agent/memori_middleware.py: MemoriMiddleware for memory recall/storage - tests/: Unit tests for Memori components ## Modified Files - agent/agent_config.py: Added enable_memori, memori_semantic_search_top_k, etc. - agent/deep_assistant.py: Integrated MemoriMiddleware into init_agent() - utils/settings.py: Added MEMORI_* environment variables - pyproject.toml: Added memori>=3.1.0 dependency ## Features - Semantic memory search with configurable top-k and threshold - Multi-tenant isolation (entity_id=user, process_id=bot, session_id) - Memory injection into system prompt - Background asynchronous memory augmentation - Graceful degradation when Memori is unavailable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- agent/agent_config.py | 36 +++ agent/deep_assistant.py | 68 +++++- agent/memori_config.py | 87 ++++++++ agent/memori_manager.py | 383 ++++++++++++++++++++++++++++++++ agent/memori_middleware.py | 342 ++++++++++++++++++++++++++++ poetry.lock | 349 +++++++++++++++++++++++++++-- pyproject.toml | 5 + tests/test_memori_config.py | 75 +++++++ tests/test_memori_manager.py | 65 ++++++ tests/test_memori_middleware.py | 120 ++++++++++ utils/settings.py | 40 ++++ 11 files changed, 1545 insertions(+), 25 deletions(-) create mode 100644 agent/memori_config.py create mode 100644 agent/memori_manager.py create mode 100644 agent/memori_middleware.py create mode 100644 tests/test_memori_config.py create mode 100644 tests/test_memori_manager.py create mode 100644 tests/test_memori_middleware.py diff --git a/agent/agent_config.py b/agent/agent_config.py index dc96fd4..22aae48 100644 --- a/agent/agent_config.py +++ b/agent/agent_config.py @@ -41,6 +41,12 @@ class AgentConfig: logging_handler: Optional['LoggingCallbackHandler'] = None + # Memori 长期记忆配置 + enable_memori: bool = False + memori_semantic_search_top_k: int = 5 + memori_semantic_search_threshold: float = 0.7 + memori_inject_to_system_prompt: bool = True + def to_dict(self) -> Dict[str, Any]: """转换为字典格式,用于传递给需要**kwargs的函数""" return { @@ -62,6 +68,10 @@ class AgentConfig: 'tool_response': self.tool_response, 'preamble_text': self.preamble_text, 'messages': self.messages, + 'enable_memori': self.enable_memori, + 'memori_semantic_search_top_k': self.memori_semantic_search_top_k, + 'memori_semantic_search_threshold': self.memori_semantic_search_threshold, + 'memori_inject_to_system_prompt': self.memori_inject_to_system_prompt, } def safe_print(self): @@ -77,6 +87,11 @@ class AgentConfig: # 延迟导入避免循环依赖 from .logging_handler import LoggingCallbackHandler from utils.fastapi_utils import get_preamble_text + from utils.settings import ( + MEMORI_ENABLED, + MEMORI_SEMANTIC_SEARCH_TOP_K, + MEMORI_SEMANTIC_SEARCH_THRESHOLD, + ) if messages is None: messages = [] @@ -87,6 +102,11 @@ class AgentConfig: preamble_text, system_prompt = get_preamble_text(request.language, request.system_prompt) enable_thinking = request.enable_thinking and "" in request.system_prompt + # 从请求中获取 Memori 配置,如果没有则使用全局配置 + enable_memori = getattr(request, 'enable_memori', None) + if enable_memori is None: + enable_memori = MEMORI_ENABLED + config = cls( bot_id=request.bot_id, api_key=api_key, @@ -108,6 +128,9 @@ class AgentConfig: _origin_messages=messages, preamble_text=preamble_text, dataset_ids=request.dataset_ids, + enable_memori=enable_memori, + memori_semantic_search_top_k=getattr(request, 'memori_semantic_search_top_k', None) or MEMORI_SEMANTIC_SEARCH_TOP_K, + memori_semantic_search_threshold=getattr(request, 'memori_semantic_search_threshold', None) or MEMORI_SEMANTIC_SEARCH_THRESHOLD, ) config.safe_print() return config @@ -119,6 +142,11 @@ class AgentConfig: # 延迟导入避免循环依赖 from .logging_handler import LoggingCallbackHandler from utils.fastapi_utils import get_preamble_text + from utils.settings import ( + MEMORI_ENABLED, + MEMORI_SEMANTIC_SEARCH_TOP_K, + MEMORI_SEMANTIC_SEARCH_THRESHOLD, + ) if messages is None: messages = [] language = request.language or bot_config.get("language", "zh") @@ -128,6 +156,11 @@ class AgentConfig: robot_type = "deep_agent" enable_thinking = request.enable_thinking and "" in bot_config.get("system_prompt") + # 从请求或后端配置中获取 Memori 配置 + enable_memori = getattr(request, 'enable_memori', None) + if enable_memori is None: + enable_memori = bot_config.get("enable_memori", MEMORI_ENABLED) + config = cls( bot_id=request.bot_id, @@ -150,6 +183,9 @@ class AgentConfig: _origin_messages=messages, preamble_text=preamble_text, dataset_ids=bot_config.get("dataset_ids", []), # 从后端配置获取dataset_ids + enable_memori=enable_memori, + memori_semantic_search_top_k=bot_config.get("memori_semantic_search_top_k", MEMORI_SEMANTIC_SEARCH_TOP_K), + memori_semantic_search_threshold=bot_config.get("memori_semantic_search_threshold", MEMORI_SEMANTIC_SEARCH_THRESHOLD), ) config.safe_print() return config diff --git a/agent/deep_assistant.py b/agent/deep_assistant.py index 3fc837a..5119f0f 100644 --- a/agent/deep_assistant.py +++ b/agent/deep_assistant.py @@ -11,6 +11,7 @@ from deepagents.backends.filesystem import FilesystemBackend from deepagents.backends.sandbox import SandboxBackendProtocol from deepagents_cli.agent import create_cli_agent from langchain.agents import create_agent +from langgraph.store.base import BaseStore from langchain.agents.middleware import SummarizationMiddleware from langchain_mcp_adapters.client import MultiServerMCPClient from sympy.printing.cxx import none @@ -18,8 +19,22 @@ from utils.fastapi_utils import detect_provider from .guideline_middleware import GuidelineMiddleware from .tool_output_length_middleware import ToolOutputLengthMiddleware from .tool_use_cleanup_middleware import ToolUseCleanupMiddleware -from utils.settings import SUMMARIZATION_MAX_TOKENS, SUMMARIZATION_MESSAGES_TO_KEEP, TOOL_OUTPUT_MAX_LENGTH, MCP_HTTP_TIMEOUT, MCP_SSE_READ_TIMEOUT +from utils.settings import ( + SUMMARIZATION_MAX_TOKENS, + SUMMARIZATION_MESSAGES_TO_KEEP, + TOOL_OUTPUT_MAX_LENGTH, + MCP_HTTP_TIMEOUT, + MCP_SSE_READ_TIMEOUT, + MEMORI_ENABLED, + MEMORI_API_KEY, + MEMORI_SEMANTIC_SEARCH_TOP_K, + MEMORI_SEMANTIC_SEARCH_THRESHOLD, + MEMORI_INJECT_TO_SYSTEM_PROMPT, +) from agent.agent_config import AgentConfig +from .memori_manager import get_memori_manager, init_global_memori +from .memori_middleware import create_memori_middleware +from .memori_config import MemoriConfig from agent.prompt_loader import load_system_prompt_async, load_mcp_settings_async from agent.agent_memory_cache import get_memory_cache_manager from .checkpoint_utils import prepare_checkpoint_message @@ -165,6 +180,16 @@ async def init_agent(config: AgentConfig): checkpointer = None create_start = time.time() + # 从连接池获取 checkpointer(需要在 Memori 初始化之前完成) + if config.session_id: + try: + manager = get_checkpointer_manager() + checkpointer = manager.checkpointer + if checkpointer: + await prepare_checkpoint_message(config, checkpointer) + except Exception as e: + logger.warning(f"Failed to load checkpointer: {e}") + # 构建中间件列表 middleware = [] # 首先添加 ToolUseCleanupMiddleware 来清理孤立的 tool_use @@ -180,15 +205,42 @@ async def init_agent(config: AgentConfig): ) middleware.append(tool_output_middleware) - # 从连接池获取 checkpointer - if config.session_id: + # 添加 Memori 记忆中间件(如果启用) + memori_middleware = None + if config.enable_memori: try: - manager = get_checkpointer_manager() - checkpointer = manager.checkpointer - if checkpointer: - await prepare_checkpoint_message(config, checkpointer) + # 确保有 user_identifier + if not config.user_identifier: + logger.warning("Memori enabled but user_identifier is missing, skipping Memori") + else: + # 获取 MemoriManager(使用共享的连接池) + memori_manager = get_memori_manager() + + # 如果 MemoriManager 未初始化,则初始化 + if not memori_manager._initialized: + db_pool = get_checkpointer_manager().pool if checkpointer else None + await init_global_memori( + db_pool=db_pool, + api_key=MEMORI_API_KEY + ) + + # 创建 Memori 中间件 + memori_middleware = create_memori_middleware( + bot_id=config.bot_id, + user_identifier=config.user_identifier, + session_id=config.session_id or "default", + enabled=config.enable_memori, + semantic_search_top_k=config.memori_semantic_search_top_k, + semantic_search_threshold=config.memori_semantic_search_threshold, + memori_manager=memori_manager, + ) + + if memori_middleware: + middleware.append(memori_middleware) + logger.info("Memori middleware added to agent") + except Exception as e: - logger.warning(f"Failed to load checkpointer: {e}") + logger.error(f"Failed to initialize Memori middleware: {e}, continuing without Memori") if config.robot_type == "deep_agent": diff --git a/agent/memori_config.py b/agent/memori_config.py new file mode 100644 index 0000000..3852f35 --- /dev/null +++ b/agent/memori_config.py @@ -0,0 +1,87 @@ +""" +Memori 配置数据类 +用于管理 Memori 长期记忆系统的配置参数 +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MemoriConfig: + """Memori 长期记忆配置类""" + + # 功能开关 + enabled: bool = False + + # API 配置 + api_key: Optional[str] = None + + # 语义搜索配置 + semantic_search_top_k: int = 5 + semantic_search_threshold: float = 0.7 + semantic_search_embeddings_limit: int = 1000 + + # 记忆注入配置 + inject_memory_to_system_prompt: bool = True + memory_prompt_template: str = ( + "\n\n=== 相关记忆 ===\n" + "以下是从历史对话中检索到的相关信息,可以帮助你更好地回答用户问题:\n" + "{memories}\n" + "==================\n" + ) + + # 增强配置 + augmentation_enabled: bool = True + augmentation_wait_timeout: Optional[float] = None # None 表示后台异步执行 + + # 多租户配置 + entity_id: Optional[str] = None # 用户标识 + process_id: Optional[str] = None # Bot 标识 + session_id: Optional[str] = None # 会话标识 + + def get_attribution_tuple(self) -> tuple[str, str]: + """获取 attribution 所需的元组 (entity_id, process_id) + + Returns: + (entity_id, process_id) 元组 + """ + if not self.entity_id or not self.process_id: + raise ValueError("entity_id and process_id are required for attribution") + return (self.entity_id, self.process_id) + + def is_enabled(self) -> bool: + """检查 Memori 功能是否启用 + + Returns: + bool: 是否启用 + """ + return self.enabled + + def get_memory_prompt(self, memories: list[str]) -> str: + """根据记忆列表生成注入提示词 + + Args: + memories: 记忆内容列表 + + Returns: + str: 格式化的记忆提示词 + """ + if not memories: + return "" + + memory_text = "\n".join(f"- {m}" for m in memories) + return self.memory_prompt_template.format(memories=memory_text) + + def with_session(self, session_id: str) -> "MemoriConfig": + """创建带有新 session_id 的配置副本 + + Args: + session_id: 新的会话 ID + + Returns: + 新的 MemoriConfig 实例 + """ + new_config = MemoriConfig(**self.__dict__) + new_config.session_id = session_id + return new_config diff --git a/agent/memori_manager.py b/agent/memori_manager.py new file mode 100644 index 0000000..6d95440 --- /dev/null +++ b/agent/memori_manager.py @@ -0,0 +1,383 @@ +""" +Memori 连接和实例管理器 +负责管理 Memori 客户端实例的创建、缓存和生命周期 +""" + +import asyncio +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from psycopg_pool import AsyncConnectionPool +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +from .memori_config import MemoriConfig + +logger = logging.getLogger("app") + + +class MemoriManager: + """ + Memori 连接和实例管理器 + + 主要功能: + 1. 管理 Memori 实例的创建和缓存 + 2. 支持多租户隔离(entity_id + process_id) + 3. 处理数据库连接和会话管理 + 4. 提供记忆召回和存储接口 + """ + + def __init__( + self, + db_pool: Optional[AsyncConnectionPool] = None, + db_url: Optional[str] = None, + api_key: Optional[str] = None, + ): + """初始化 MemoriManager + + Args: + db_pool: PostgreSQL 异步连接池(与 Checkpointer 共享) + db_url: 数据库连接 URL(如果不使用连接池) + api_key: Memori API 密钥(用于高级增强功能) + """ + self._db_pool = db_pool + self._db_url = db_url + self._api_key = api_key + + # 缓存 Memori 实例: key = f"{entity_id}:{process_id}" + self._instances: Dict[str, Any] = {} + self._sync_engines: Dict[str, Any] = {} + self._initialized = False + + async def initialize(self) -> None: + """初始化 MemoriManager + + 创建数据库表结构(如果不存在) + """ + if self._initialized: + return + + logger.info("Initializing MemoriManager...") + + try: + # 创建第一个 Memori 实例来初始化表结构 + if self._db_pool or self._db_url: + db_url = self._db_url or getattr(self._db_pool, "_url", None) + if db_url: + await self._build_schema(db_url) + + self._initialized = True + logger.info("MemoriManager initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize MemoriManager: {e}") + # 不抛出异常,允许系统在没有 Memori 的情况下运行 + + async def _build_schema(self, db_url: str) -> None: + """构建 Memori 数据库表结构 + + Args: + db_url: 数据库连接 URL + """ + try: + from memori import Memori + + # 创建同步引擎用于初始化 + engine = create_engine(db_url) + SessionLocal = sessionmaker(bind=engine) + + # 创建 Memori 实例并构建表结构 + mem = Memori(conn=SessionLocal) + mem.config.storage.build() + + logger.info("Memori schema built successfully") + except ImportError: + logger.warning("memori package not available, skipping schema build") + except Exception as e: + logger.error(f"Failed to build Memori schema: {e}") + + def _get_sync_session(self, db_url: str) -> Session: + """获取同步数据库会话(Memori 需要) + + Args: + db_url: 数据库连接 URL + + Returns: + SQLAlchemy Session + """ + if db_url not in self._sync_engines: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + engine = create_engine(db_url, pool_pre_ping=True) + self._sync_engines[db_url] = sessionmaker(bind=engine) + + return self._sync_engines[db_url]() + + async def get_memori( + self, + entity_id: str, + process_id: str, + session_id: str, + config: Optional[MemoriConfig] = None, + ) -> Any: + """获取或创建 Memori 实例 + + Args: + entity_id: 实体 ID(通常是 user_identifier) + process_id: 进程 ID(通常是 bot_id) + session_id: 会话 ID + config: Memori 配置 + + Returns: + Memori 实例 + """ + cache_key = f"{entity_id}:{process_id}" + + # 检查缓存 + if cache_key in self._instances: + memori_instance = self._instances[cache_key] + # 更新会话 + memori_instance.config.session_id = session_id + return memori_instance + + # 创建新实例 + memori_instance = await self._create_memori_instance( + entity_id=entity_id, + process_id=process_id, + session_id=session_id, + config=config, + ) + + # 缓存实例 + self._instances[cache_key] = memori_instance + return memori_instance + + async def _create_memori_instance( + self, + entity_id: str, + process_id: str, + session_id: str, + config: Optional[MemoriConfig] = None, + ) -> Any: + """创建新的 Memori 实例 + + Args: + entity_id: 实体 ID + process_id: 进程 ID + session_id: 会话 ID + config: Memori 配置 + + Returns: + Memori 实例 + """ + try: + from memori import Memori + except ImportError: + logger.error("memori package not installed") + raise RuntimeError("memori package is required but not installed") + + # 获取数据库连接 URL + db_url = self._db_url + if self._db_pool and hasattr(self._db_pool, "_url"): + db_url = str(self._db_pool._url) + + if not db_url: + raise ValueError("Either db_pool or db_url must be provided") + + # 创建同步会话(Memori 目前需要同步连接) + session_factory = self._get_sync_session(db_url) + + # 创建 Memori 实例 + mem = Memori(conn=session_factory) + + # 设置 API 密钥(如果提供) + if self._api_key or (config and config.api_key): + api_key = config.api_key if config else self._api_key + mem.config.api_key = api_key + + # 设置 attribution + mem.attribution(entity_id=entity_id, process_id=process_id) + + # 设置会话 + mem.config.session_id = session_id + + # 配置召回参数 + if config: + mem.config.recall_facts_limit = config.semantic_search_top_k + mem.config.recall_relevance_threshold = config.semantic_search_threshold + mem.config.recall_embeddings_limit = config.semantic_search_embeddings_limit + + logger.info( + f"Created Memori instance: entity={entity_id}, process={process_id}, session={session_id}" + ) + + return mem + + async def recall_memories( + self, + query: str, + entity_id: str, + process_id: str, + session_id: str, + config: Optional[MemoriConfig] = None, + ) -> List[Dict[str, Any]]: + """召回相关记忆 + + Args: + query: 查询文本 + entity_id: 实体 ID + process_id: 进程 ID + session_id: 会话 ID + config: Memori 配置 + + Returns: + 记忆列表,每个记忆包含 content, similarity 等字段 + """ + try: + mem = await self.get_memori(entity_id, process_id, session_id, config) + + # 调用 recall 进行语义搜索 + results = mem.recall(query=query, limit=config.semantic_search_top_k if config else 5) + + # 转换为统一格式 + memories = [] + for result in results: + memory = { + "content": result.get("content", ""), + "similarity": result.get("similarity", 0.0), + "fact_type": result.get("fact_type", "unknown"), + "created_at": result.get("created_at"), + } + # 过滤低相关度记忆 + threshold = config.semantic_search_threshold if config else 0.7 + if memory["similarity"] >= threshold: + memories.append(memory) + + logger.info(f"Recalled {len(memories)} memories for query: {query[:50]}...") + return memories + + except Exception as e: + logger.error(f"Failed to recall memories: {e}") + return [] + + async def wait_for_augmentation( + self, + entity_id: str, + process_id: str, + session_id: str, + timeout: Optional[float] = None, + ) -> None: + """等待后台增强任务完成 + + Args: + entity_id: 实体 ID + process_id: 进程 ID + session_id: 会话 ID + timeout: 超时时间(秒) + """ + try: + mem = await self.get_memori(entity_id, process_id, session_id) + + if timeout: + # 在线程池中运行同步的 wait() + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: mem.augmentation.wait(timeout=timeout)) + else: + # 无限等待 + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, mem.augmentation.wait) + + except Exception as e: + logger.error(f"Failed to wait for augmentation: {e}") + + def clear_cache(self, entity_id: Optional[str] = None, process_id: Optional[str] = None) -> None: + """清除缓存的 Memori 实例 + + Args: + entity_id: 实体 ID(如果为 None,清除所有) + process_id: 进程 ID(如果为 None,清除所有) + """ + if entity_id is None and process_id is None: + self._instances.clear() + logger.info("Cleared all Memori instances from cache") + else: + keys_to_remove = [] + for key in self._instances: + e_id, p_id = key.split(":") + if entity_id and e_id != entity_id: + continue + if process_id and p_id != process_id: + continue + keys_to_remove.append(key) + + for key in keys_to_remove: + del self._instances[key] + + logger.info(f"Cleared {len(keys_to_remove)} Memori instances from cache") + + async def close(self) -> None: + """关闭管理器并清理资源""" + logger.info("Closing MemoriManager...") + + # 清理缓存的实例 + self._instances.clear() + + # 关闭同步引擎 + for engine in self._sync_engines.values(): + try: + engine.dispose() + except Exception as e: + logger.error(f"Error closing engine: {e}") + + self._sync_engines.clear() + self._initialized = False + + logger.info("MemoriManager closed") + + +# 全局单例 +_global_manager: Optional[MemoriManager] = None + + +def get_memori_manager() -> MemoriManager: + """获取全局 MemoriManager 单例 + + Returns: + MemoriManager 实例 + """ + global _global_manager + if _global_manager is None: + _global_manager = MemoriManager() + return _global_manager + + +async def init_global_memori( + db_pool: Optional[AsyncConnectionPool] = None, + db_url: Optional[str] = None, + api_key: Optional[str] = None, +) -> MemoriManager: + """初始化全局 MemoriManager + + Args: + db_pool: PostgreSQL 连接池 + db_url: 数据库连接 URL + api_key: Memori API 密钥 + + Returns: + MemoriManager 实例 + """ + manager = get_memori_manager() + manager._db_pool = db_pool + manager._db_url = db_url + manager._api_key = api_key + await manager.initialize() + return manager + + +async def close_global_memori() -> None: + """关闭全局 MemoriManager""" + global _global_manager + if _global_manager is not None: + await _global_manager.close() diff --git a/agent/memori_middleware.py b/agent/memori_middleware.py new file mode 100644 index 0000000..6cc448f --- /dev/null +++ b/agent/memori_middleware.py @@ -0,0 +1,342 @@ +""" +Memori Agent 中间件 +实现记忆召回和存储的 AgentMiddleware +""" + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +from langchain.agents.middleware import AgentMiddleware, AgentState +from langgraph.runtime import Runtime + +from .memori_config import MemoriConfig +from .memori_manager import MemoriManager, get_memori_manager + +logger = logging.getLogger("app") + + +class MemoriMiddleware(AgentMiddleware): + """ + Memori 记忆中间件 + + 功能: + 1. before_agent: 召回相关记忆并注入到上下文 + 2. after_agent: 后台异步提取和存储新记忆 + """ + + def __init__( + self, + memori_manager: MemoriManager, + config: MemoriConfig, + ): + """初始化 MemoriMiddleware + + Args: + memori_manager: MemoriManager 实例 + config: MemoriConfig 配置 + """ + self.memori_manager = memori_manager + self.config = config + + def _extract_user_query(self, state: AgentState) -> str: + """从状态中提取用户查询 + + Args: + state: Agent 状态 + + Returns: + 用户查询文本 + """ + messages = state.get("messages", []) + if not messages: + return "" + + # 获取最后一条消息 + last_message = messages[-1] + + # 尝试获取内容 + content = getattr(last_message, "content", None) + if content is None: + content = last_message.get("content", "") if isinstance(last_message, dict) else "" + + return str(content) if content else "" + + def _format_memories(self, memories: List[Dict[str, Any]]) -> str: + """格式化记忆列表为文本 + + Args: + memories: 记忆列表 + + Returns: + 格式化的记忆文本 + """ + if not memories: + return "" + + lines = [] + for i, memory in enumerate(memories, 1): + content = memory.get("content", "") + similarity = memory.get("similarity", 0.0) + fact_type = memory.get("fact_type", "fact") + + # 添加相似度分数(调试用) + lines.append(f"{i}. [{fact_type}] {content}") + + return "\n".join(lines) + + def _inject_memory_context(self, state: AgentState, memory_text: str) -> AgentState: + """将记忆上下文注入到状态中 + + Args: + state: 原始状态 + memory_text: 记忆文本 + + Returns: + 更新后的状态 + """ + if not memory_text or not self.config.inject_memory_to_system_prompt: + return state + + # 生成记忆提示 + memory_prompt = self.config.get_memory_prompt([memory_text]) + + # 检查是否有系统消息 + messages = state.get("messages", []) + if not messages: + return state + + # 在系统消息后添加记忆上下文 + from langchain_core.messages import SystemMessage + + # 查找系统消息 + system_message = None + for msg in messages: + if hasattr(msg, "type") and msg.type == "system": + system_message = msg + break + elif isinstance(msg, dict) and msg.get("role") == "system": + system_message = msg + break + + if system_message: + # 修改现有系统消息 + if hasattr(system_message, "content"): + original_content = system_message.content + system_message.content = original_content + memory_prompt + elif isinstance(system_message, dict): + original_content = system_message.get("content", "") + system_message["content"] = original_content + memory_prompt + else: + # 添加新的系统消息 + new_messages = list(messages) + new_messages.insert(0, SystemMessage(content=memory_prompt)) + state = {**state, "messages": new_messages} + + return state + + def before_agent(self, state: AgentState, runtime: Runtime) -> Dict[str, Any] | None: + """Agent 执行前:召回相关记忆(同步版本) + + Args: + state: Agent 状态 + runtime: 运行时上下文 + + Returns: + 更新后的状态或 None + """ + if not self.config.is_enabled(): + return None + + try: + # 提取用户查询 + query = self._extract_user_query(state) + if not query: + return None + + # 获取 attribution 参数 + entity_id, process_id = self.config.get_attribution_tuple() + session_id = self.config.session_id or runtime.config.get("configurable", {}).get("thread_id", "default") + + # 召回记忆(同步方式 - 在后台任务中执行) + memories = asyncio.run(self._recall_memories_async(query, entity_id, process_id, session_id)) + + if memories: + # 格式化记忆 + memory_text = self._format_memories(memories) + + # 注入到状态 + updated_state = self._inject_memory_context(state, memory_text) + + logger.info(f"Injected {len(memories)} memories into context") + return updated_state + + return None + + except Exception as e: + logger.error(f"Error in MemoriMiddleware.before_agent: {e}") + return None + + async def abefore_agent(self, state: AgentState, runtime: Runtime) -> Dict[str, Any] | None: + """Agent 执行前:召回相关记忆(异步版本) + + Args: + state: Agent 状态 + runtime: 运行时上下文 + + Returns: + 更新后的状态或 None + """ + if not self.config.is_enabled(): + return None + + try: + # 提取用户查询 + query = self._extract_user_query(state) + if not query: + logger.debug("No user query found, skipping memory recall") + return None + + # 获取 attribution 参数 + entity_id, process_id = self.config.get_attribution_tuple() + session_id = self.config.session_id or runtime.config.get("configurable", {}).get("thread_id", "default") + + # 召回记忆 + memories = await self._recall_memories_async(query, entity_id, process_id, session_id) + + if memories: + # 格式化记忆 + memory_text = self._format_memories(memories) + + # 注入到状态 + updated_state = self._inject_memory_context(state, memory_text) + + logger.info(f"Injected {len(memories)} memories into context (similarity > {self.config.semantic_search_threshold})") + return updated_state + + return None + + except Exception as e: + logger.error(f"Error in MemoriMiddleware.abefore_agent: {e}") + return None + + async def _recall_memories_async( + self, query: str, entity_id: str, process_id: str, session_id: str + ) -> List[Dict[str, Any]]: + """异步召回记忆 + + Args: + query: 查询文本 + entity_id: 实体 ID + process_id: 进程 ID + session_id: 会话 ID + + Returns: + 记忆列表 + """ + return await self.memori_manager.recall_memories( + query=query, + entity_id=entity_id, + process_id=process_id, + session_id=session_id, + config=self.config, + ) + + def after_agent(self, state: AgentState, runtime: Runtime) -> None: + """Agent 执行后:触发记忆增强(同步版本) + + Args: + state: Agent 状态 + runtime: 运行时上下文 + """ + if not self.config.is_enabled() or not self.config.augmentation_enabled: + return + + try: + # 触发后台增强任务 + asyncio.create_task(self._trigger_augmentation_async(state, runtime)) + except Exception as e: + logger.error(f"Error in MemoriMiddleware.after_agent: {e}") + + async def aafter_agent(self, state: AgentState, runtime: Runtime) -> None: + """Agent 执行后:触发记忆增强(异步版本) + + 注意:Memori 的增强会自动在后台执行,这里主要是记录日志 + + Args: + state: Agent 状态 + runtime: 运行时上下文 + """ + if not self.config.is_enabled() or not self.config.augmentation_enabled: + return + + try: + # 如果配置了等待超时,则等待增强完成 + if self.config.augmentation_wait_timeout is not None: + entity_id, process_id = self.config.get_attribution_tuple() + session_id = self.config.session_id or runtime.config.get("configurable", {}).get("thread_id", "default") + + await self.memori_manager.wait_for_augmentation( + entity_id=entity_id, + process_id=process_id, + session_id=session_id, + timeout=self.config.augmentation_wait_timeout, + ) + except Exception as e: + logger.error(f"Error in MemoriMiddleware.aafter_agent: {e}") + + async def _trigger_augmentation_async(self, state: AgentState, runtime: Runtime) -> None: + """触发记忆增强任务 + + 注意:Memori 的 LLM 客户端注册后会自动捕获对话并进行增强, + 这里不需要手动触发,只是确保会话正确设置 + + Args: + state: Agent 状态 + runtime: 运行时上下文 + """ + # Memori 的增强是自动的,这里主要是确保配置正确 + # 如果需要手动触发,可以在这里实现 + pass + + +def create_memori_middleware( + bot_id: str, + user_identifier: str, + session_id: str, + enabled: bool = True, + semantic_search_top_k: int = 5, + semantic_search_threshold: float = 0.7, + memori_manager: Optional[MemoriManager] = None, +) -> Optional[MemoriMiddleware]: + """创建 MemoriMiddleware 的工厂函数 + + Args: + bot_id: Bot ID + user_identifier: 用户标识 + session_id: 会话 ID + enabled: 是否启用 + semantic_search_top_k: 语义搜索返回数量 + semantic_search_threshold: 语义搜索相似度阈值 + memori_manager: MemoriManager 实例(如果为 None,使用全局实例) + + Returns: + MemoriMiddleware 实例或 None + """ + if not enabled: + return None + + # 获取或使用提供的 manager + manager = memori_manager or get_memori_manager() + + # 创建配置 + config = MemoriConfig( + enabled=True, + entity_id=user_identifier, + process_id=bot_id, + session_id=session_id, + semantic_search_top_k=semantic_search_top_k, + semantic_search_threshold=semantic_search_threshold, + ) + + return MemoriMiddleware(memori_manager=manager, config=config) diff --git a/poetry.lock b/poetry.lock index 6692974..4be1233 100644 --- a/poetry.lock +++ b/poetry.lock @@ -291,6 +291,26 @@ charset-normalizer = ["charset-normalizer"] html5lib = ["html5lib"] lxml = ["lxml"] +[[package]] +name = "botocore" +version = "1.42.30" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.42.30-py3-none-any.whl", hash = "sha256:97070a438cac92430bb7b65f8ebd7075224f4a289719da4ee293d22d1e98db02"}, + {file = "botocore-1.42.30.tar.gz", hash = "sha256:9bf1662b8273d5cc3828a49f71ca85abf4e021011c1f0a71f41a2ea5769a5116"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.29.2)"] + [[package]] name = "bracex" version = "2.6" @@ -543,12 +563,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} [[package]] name = "daytona" @@ -778,6 +798,58 @@ files = [ {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, ] +[[package]] +name = "faiss-cpu" +version = "1.11.0.post1" +description = "A library for efficient similarity search and clustering of dense vectors." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:e079d44ea22919f6477fea553b05854c68838ab553e1c6b1237437a8becdf89d"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:4ded0c91cb67f462ae00a4d339718ea2fbb23eedbf260c3a07de77c32c23205a"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78812f4d7ff9d3773f50009efcf294f3da787cd8c835c1fc41d997a58100f7b5"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:76b133d746ddb3e6d39e6de62ff717cf4d45110d4af101a62d6a4fed4cd1d4d1"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9443bc89447f9988f2288477584d2f1c59424a5e9f9a202e4ada8708df816db1"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6acc20021b69bd30d3cb5cadb4f8dc1c338aec887cd5411b0982e8a3e48b3d7f"}, + {file = "faiss_cpu-1.11.0.post1-cp310-cp310-win_amd64.whl", hash = "sha256:9dccf67d4087f9b0f937d4dccd1183929ebb6fe7622b75cba51b53e4f0055a0c"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2c8c384e65cc1b118d2903d9f3a27cd35f6c45337696fc0437f71e05f732dbc0"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:36af46945274ed14751b788673125a8a4900408e4837a92371b0cad5708619ea"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b15412b22a05865433aecfdebf7664b9565bd49b600d23a0a27c74a5526893e"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81c169ea74213b2c055b8240befe7e9b42a1f3d97cda5238b3b401035ce1a18b"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0794eb035c6075e931996cf2b2703fbb3f47c8c34bc2d727819ddc3e5e486a31"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18d2221014813dc9a4236e47f9c4097a71273fbf17c3fe66243e724e2018a67a"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-win_amd64.whl", hash = "sha256:3ce8a8984a7dcc689fd192c69a476ecd0b2611c61f96fe0799ff432aa73ff79c"}, + {file = "faiss_cpu-1.11.0.post1-cp311-cp311-win_arm64.whl", hash = "sha256:8384e05afb7c7968e93b81566759f862e744c0667b175086efb3d8b20949b39f"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:68f6ce2d9c510a5765af2f5711bd76c2c37bd598af747f3300224bdccf45378c"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b940c530a8236cc0b9fd9d6e87b3d70b9c6c216bc2baf2649356c908902e52c9"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fafae1dcbcba3856a0bb82ffb0c3cae5922bdd6566fdd3b7feb2425cf4fca247"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d1262702c19aba2d23144b73f4b5730ca988c1f4e43ecec87edf25171cafe3d"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:925feb69c06bfcc7f28869c99ab172f123e4b9d97a7e1353316fcc2748696f5b"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:00a837581b675f099c80c8c46908648dcf944a8992dd21e3887c61c6b110fe5f"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-win_amd64.whl", hash = "sha256:8bbaef5b56d1b0c01357ee6449d464ea4e52732fdb53a40bb5b9d77923af905f"}, + {file = "faiss_cpu-1.11.0.post1-cp312-cp312-win_arm64.whl", hash = "sha256:57f85dbefe590f8399a95c07e839ee64373cfcc6db5dd35232a41137e3deefeb"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:caedaddfbfe365e3f1a57d5151cf94ea7b73c0e4789caf68eae05e0e10ca9fbf"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:202d11f1d973224ca0bde13e7ee8b862b6de74287e626f9f8820b360e6253d12"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6086e25ef680301350d6db72db7315e3531582cf896a7ee3f26295b1da73c44"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b93131842996efbbf76f07dba1775d3a5f355f74b9ba34334f1149aef046b37f"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f26e3e93f537b2e1633212a1b0a7dab74d77825366ed575ca434dac2fa14cea6"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4b0e03cd758d03012d88aa4a70e673d10b66f31f7c122adc0c8c323cad2e33"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-win_amd64.whl", hash = "sha256:bc53fe59b546dbab63144dc19dcee534ad7a213db617b37aa4d0e33c26f9bbaf"}, + {file = "faiss_cpu-1.11.0.post1-cp313-cp313-win_arm64.whl", hash = "sha256:9cebb720cd57afdbe9dd7ed8a689c65dc5cf1bad475c5aa6fa0d0daea890beb6"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-macosx_13_0_x86_64.whl", hash = "sha256:3663059682589a42e3c4da0f3915492c466c886954cf9280273f92257bcfa0b4"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:0348794ae91fb1454f2cddf7a9c7de23510f2a63e60c0fba0ae73bc7bf23a060"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8baf46be73b4fce99f4620d99a52cdb01f7823a849f00064f02802f554d8b59f"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388a590ab2847e421ba2702ff2774835287f137fb77e24e679f0063c1c10a96f"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dc12b3f89cf48be3f2a20b37f310c3f1a7a5708fdf705f88d639339a24bb590b"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:773fa45aa98a210ab4e2c17c1b5fb45f6d7e9acb4979c9a0b320b678984428ac"}, + {file = "faiss_cpu-1.11.0.post1-cp39-cp39-win_amd64.whl", hash = "sha256:6240c4b1551eedc07e76813c2e14a1583a1db6c319a92a3934bf212d0e4c7791"}, +] + +[package.dependencies] +numpy = ">=1.25.0,<3.0" +packaging = "*" + [[package]] name = "fastapi" version = "0.116.1" @@ -992,6 +1064,83 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] tqdm = ["tqdm"] +[[package]] +name = "grpcio" +version = "1.76.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, + {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, + {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, + {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, + {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, + {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, + {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, + {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, + {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, + {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, + {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, + {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, + {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, + {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, + {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, + {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, + {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, + {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, + {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, + {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, + {file = "grpcio-1.76.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:8ebe63ee5f8fa4296b1b8cfc743f870d10e902ca18afc65c68cf46fd39bb0783"}, + {file = "grpcio-1.76.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:3bf0f392c0b806905ed174dcd8bdd5e418a40d5567a05615a030a5aeddea692d"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b7604868b38c1bfd5cf72d768aedd7db41d78cb6a4a18585e33fb0f9f2363fd"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e6d1db20594d9daba22f90da738b1a0441a7427552cc6e2e3d1297aeddc00378"}, + {file = "grpcio-1.76.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d099566accf23d21037f18a2a63d323075bebace807742e4b0ac210971d4dd70"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ebea5cc3aa8ea72e04df9913492f9a96d9348db876f9dda3ad729cfedf7ac416"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0c37db8606c258e2ee0c56b78c62fc9dee0e901b5dbdcf816c2dd4ad652b8b0c"}, + {file = "grpcio-1.76.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ebebf83299b0cb1721a8859ea98f3a77811e35dce7609c5c963b9ad90728f886"}, + {file = "grpcio-1.76.0-cp39-cp39-win32.whl", hash = "sha256:0aaa82d0813fd4c8e589fac9b65d7dd88702555f702fb10417f96e2a2a6d4c0f"}, + {file = "grpcio-1.76.0-cp39-cp39-win_amd64.whl", hash = "sha256:acab0277c40eff7143c2323190ea57b9ee5fd353d8190ee9652369fae735668a"}, + {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.76.0)"] + [[package]] name = "grpclib" version = "0.4.8" @@ -1213,6 +1362,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1343,6 +1504,18 @@ files = [ {file = "jiter-0.11.1.tar.gz", hash = "sha256:849dcfc76481c0ea0099391235b7ca97d7279e0fa4c86005457ac7c88e8b76dc"}, ] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "joblib" version = "1.5.2" @@ -1841,6 +2014,30 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "memori" +version = "3.1.3" +description = "Memori Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "memori-3.1.3-py3-none-any.whl", hash = "sha256:81934d61ecf0574ba948b9012f2cde52912f3d30887ad504408ecfa171734e36"}, + {file = "memori-3.1.3.tar.gz", hash = "sha256:9f936c15ba0a684a1bc62619d7558af6a6e99171bf1ef1e6eb19bb952a9f6d94"}, +] + +[package.dependencies] +aiohttp = ">=3.9.0" +botocore = ">=1.34.0" +faiss-cpu = ">=1.7.0" +grpcio = ">=1.60.0" +numpy = ">=1.24.0" +protobuf = ">=4.25.0,<6.0.0" +psycopg = {version = ">=3.1.0", extras = ["binary"]} +pyfiglet = ">=0.8.0" +requests = ">=2.32.5" +sentence-transformers = ">=3.0.0" + [[package]] name = "modal" version = "1.2.1" @@ -2586,7 +2783,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -2797,6 +2994,22 @@ test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2946,22 +3159,23 @@ files = [ [[package]] name = "protobuf" -version = "6.33.2" +version = "5.29.5" description = "" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, - {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, - {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"}, - {file = "protobuf-6.33.2-cp39-cp39-win32.whl", hash = "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe"}, - {file = "protobuf-6.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913"}, - {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"}, - {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, + {file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"}, + {file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"}, + {file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"}, + {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"}, + {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"}, + {file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"}, + {file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"}, + {file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"}, + {file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"}, + {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, + {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, ] [[package]] @@ -3010,6 +3224,7 @@ files = [ ] [package.dependencies] +psycopg-binary = {version = "3.3.2", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} @@ -3021,6 +3236,72 @@ docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)" pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=1.19.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +[[package]] +name = "psycopg-binary" +version = "3.3.2" +description = "PostgreSQL database adapter for Python -- C optimisation distribution" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"pypy\"" +files = [ + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1"}, +] + [[package]] name = "psycopg-pool" version = "3.3.0" @@ -3197,13 +3478,25 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pyfiglet" +version = "1.0.4" +description = "Pure-python FIGlet implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f"}, + {file = "pyfiglet-1.0.4.tar.gz", hash = "sha256:db9c9940ed1bf3048deff534ed52ff2dafbbc2cd7610b17bb5eca1df6d4278ef"}, +] + [[package]] name = "pygments" version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -3212,6 +3505,28 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -5428,4 +5743,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "02ca6f912b2b258fd07ba45bece31692b3f8a937d4c77d4f50714fe49eeab77e" +content-hash = "745e14c58bdedcdd10459dc47d9c8f59b8a5a04f4b02f1a9d6e01298a3c19f67" diff --git a/pyproject.toml b/pyproject.toml index f0f5fac..8db2dca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "cachetools (>=6.2.4,<7.0.0)", "langgraph-checkpoint-postgres (>=2.0.0,<3.0.0)", "deepagents-cli (>=0.0.11,<0.0.12)", + "memori (>=3.1.0,<4.0.0)", ] [tool.poetry.requires-plugins] @@ -45,3 +46,7 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" +[dependency-groups] +dev = [ + "pytest (>=9.0.2,<10.0.0)" +] diff --git a/tests/test_memori_config.py b/tests/test_memori_config.py new file mode 100644 index 0000000..5c5cfcf --- /dev/null +++ b/tests/test_memori_config.py @@ -0,0 +1,75 @@ +""" +Memori 配置测试 +""" + +import pytest +from agent.memori_config import MemoriConfig + + +class TestMemoriConfig: + """测试 MemoriConfig 配置类""" + + def test_default_values(self): + """测试默认值""" + config = MemoriConfig() + assert config.enabled is False + assert config.api_key is None + assert config.semantic_search_top_k == 5 + assert config.semantic_search_threshold == 0.7 + assert config.inject_memory_to_system_prompt is True + + def test_enabled_check(self): + """测试 is_enabled 方法""" + config = MemoriConfig(enabled=False) + assert config.is_enabled() is False + + config.enabled = True + assert config.is_enabled() is True + + def test_get_attribution_tuple(self): + """测试 get_attribution_tuple 方法""" + config = MemoriConfig( + entity_id="user_123", + process_id="bot_456" + ) + entity_id, process_id = config.get_attribution_tuple() + assert entity_id == "user_123" + assert process_id == "bot_456" + + def test_get_attribution_tuple_missing_values(self): + """测试缺少 entity_id 或 process_id 时抛出异常""" + config = MemoriConfig(entity_id="user_123") + with pytest.raises(ValueError, match="entity_id and process_id are required"): + config.get_attribution_tuple() + + def test_get_memory_prompt(self): + """测试 get_memory_prompt 方法""" + config = MemoriConfig() + memories = ["User likes coffee", "User prefers dark mode"] + prompt = config.get_memory_prompt(memories) + + assert "相关记忆" in prompt + assert "User likes coffee" in prompt + assert "User prefers dark mode" in prompt + + def test_get_memory_prompt_empty(self): + """测试空记忆列表""" + config = MemoriConfig() + prompt = config.get_memory_prompt([]) + assert prompt == "" + + def test_with_session(self): + """测试 with_session 方法""" + config = MemoriConfig( + entity_id="user_123", + process_id="bot_456", + session_id="session_789" + ) + + new_config = config.with_session("new_session_999") + assert new_config.session_id == "new_session_999" + assert new_config.entity_id == "user_123" + assert new_config.process_id == "bot_456" + + # 原配置不受影响 + assert config.session_id == "session_789" diff --git a/tests/test_memori_manager.py b/tests/test_memori_manager.py new file mode 100644 index 0000000..ffb8c67 --- /dev/null +++ b/tests/test_memori_manager.py @@ -0,0 +1,65 @@ +""" +Memori 管理器测试 +""" + +import pytest +from agent.memori_manager import MemoriManager, get_memori_manager + + +class TestMemoriManager: + """测试 MemoriManager 类""" + + def test_singleton(self): + """测试全局单例模式""" + manager1 = get_memori_manager() + manager2 = get_memori_manager() + assert manager1 is manager2 + + def test_initialization_state(self): + """测试初始状态""" + manager = MemoriManager() + assert manager._initialized is False + assert manager._instances == {} + assert manager._sync_engines == {} + + def test_clear_cache_all(self): + """测试清除所有缓存""" + manager = MemoriManager() + manager._instances["key1"] = "value1" + manager._instances["key2"] = "value2" + + manager.clear_cache() + assert manager._instances == {} + + def test_clear_cache_selective(self): + """测试选择性清除缓存""" + manager = MemoriManager() + manager._instances["user1:bot1"] = "value1" + manager._instances["user1:bot2"] = "value2" + manager._instances["user2:bot1"] = "value3" + + # 只清除 user1 的缓存 + manager.clear_cache(entity_id="user1") + assert "user1:bot1" not in manager._instances + assert "user1:bot2" not in manager._instances + assert "user2:bot1" in manager._instances + + # 只清除 bot1 的缓存 + manager.clear_cache(process_id="bot1") + assert "user2:bot1" not in manager._instances + + def test_missing_db_url_and_pool(self): + """测试没有提供 db_url 和 pool 的情况""" + import pytest + + config = { + "entity_id": "user_123", + "process_id": "bot_456", + "session_id": "session_789", + } + + # 需要 mock _create_memori_instance 或提供 db_url + # 这里只测试初始化逻辑 + manager = MemoriManager() + assert manager._db_url is None + assert manager._db_pool is None diff --git a/tests/test_memori_middleware.py b/tests/test_memori_middleware.py new file mode 100644 index 0000000..c1e03af --- /dev/null +++ b/tests/test_memori_middleware.py @@ -0,0 +1,120 @@ +""" +Memori 中间件测试 +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from langchain.agents.middleware import AgentState +from langgraph.runtime import Runtime + +from agent.memori_middleware import MemoriMiddleware, create_memori_middleware +from agent.memori_config import MemoriConfig + + +class TestMemoriMiddleware: + """测试 MemoriMiddleware 类""" + + def test_extract_user_query_empty(self): + """测试空状态""" + config = MemoriConfig( + enabled=True, + entity_id="user_123", + process_id="bot_456", + ) + manager = Mock() + middleware = MemoriMiddleware(manager, config) + + state: AgentState = {"messages": []} + query = middleware._extract_user_query(state) + assert query == "" + + def test_extract_user_query_from_message(self): + """测试从消息中提取查询""" + config = MemoriConfig( + enabled=True, + entity_id="user_123", + process_id="bot_456", + ) + manager = Mock() + middleware = MemoriMiddleware(manager, config) + + # 创建模拟消息 + mock_message = Mock() + mock_message.content = "What is the weather today?" + + state: AgentState = {"messages": [mock_message]} + query = middleware._extract_user_query(state) + assert query == "What is the weather today?" + + def test_extract_user_query_from_dict(self): + """测试从字典消息中提取查询""" + config = MemoriConfig( + enabled=True, + entity_id="user_123", + process_id="bot_456", + ) + manager = Mock() + middleware = MemoriMiddleware(manager, config) + + state: AgentState = {"messages": [{"content": "Hello, world!"}]} + query = middleware._extract_user_query(state) + assert query == "Hello, world!" + + def test_format_memories(self): + """测试记忆格式化""" + config = MemoriConfig( + enabled=True, + entity_id="user_123", + process_id="bot_456", + ) + manager = Mock() + middleware = MemoriMiddleware(manager, config) + + memories = [ + {"content": "User likes coffee", "similarity": 0.9, "fact_type": "preference"}, + {"content": "User lives in Tokyo", "similarity": 0.8, "fact_type": "fact"}, + ] + + formatted = middleware._format_memories(memories) + assert "User likes coffee" in formatted + assert "User lives in Tokyo" in formatted + assert "[preference]" in formatted + assert "[fact]" in formatted + + def test_format_memories_empty(self): + """测试空记忆列表""" + config = MemoriConfig( + enabled=True, + entity_id="user_123", + process_id="bot_456", + ) + manager = Mock() + middleware = MemoriMiddleware(manager, config) + + formatted = middleware._format_memories([]) + assert formatted == "" + + def test_create_memori_middleware_disabled(self): + """测试创建中间件(禁用状态)""" + middleware = create_memori_middleware( + bot_id="bot_456", + user_identifier="user_123", + session_id="session_789", + enabled=False, + ) + assert middleware is None + + def test_create_memori_middleware_enabled(self): + """测试创建中间件(启用状态)""" + middleware = create_memori_middleware( + bot_id="bot_456", + user_identifier="user_123", + session_id="session_789", + enabled=True, + semantic_search_top_k=10, + semantic_search_threshold=0.8, + ) + assert middleware is not None + assert isinstance(middleware, MemoriMiddleware) + assert middleware.config.semantic_search_top_k == 10 + assert middleware.config.semantic_search_threshold == 0.8 diff --git a/utils/settings.py b/utils/settings.py index c3b8e6c..8c3b2df 100644 --- a/utils/settings.py +++ b/utils/settings.py @@ -67,3 +67,43 @@ CHECKPOINT_CLEANUP_INACTIVE_DAYS = int(os.getenv("CHECKPOINT_CLEANUP_INACTIVE_DA CHECKPOINT_CLEANUP_INTERVAL_HOURS = int(os.getenv("CHECKPOINT_CLEANUP_INTERVAL_HOURS", "24")) +# ============================================================ +# Memori 长期记忆配置 +# ============================================================ + +# Memori 功能开关(全局) +MEMORI_ENABLED = os.getenv("MEMORI_ENABLED", "false") == "true" + +# Memori API 密钥(用于高级增强功能) +MEMORI_API_KEY = os.getenv("MEMORI_API_KEY", "") + +# 语义搜索配置 +# 召回记忆数量 +MEMORI_SEMANTIC_SEARCH_TOP_K = int(os.getenv("MEMORI_SEMANTIC_SEARCH_TOP_K", "5")) +# 相关性阈值(0.0 - 1.0) +MEMORI_SEMANTIC_SEARCH_THRESHOLD = float(os.getenv("MEMORI_SEMANTIC_SEARCH_THRESHOLD", "0.7")) +# 搜索嵌入限制 +MEMORI_SEMANTIC_SEARCH_EMBEDDINGS_LIMIT = int(os.getenv("MEMORI_SEMANTIC_SEARCH_EMBEDDINGS_LIMIT", "1000")) + +# 记忆注入配置 +# 是否将记忆注入到系统提示 +MEMORI_INJECT_TO_SYSTEM_PROMPT = os.getenv("MEMORI_INJECT_TO_SYSTEM_PROMPT", "true") == "true" + +# 增强配置 +# 是否启用后台增强 +MEMORI_AUGMENTATION_ENABLED = os.getenv("MEMORI_AUGMENTATION_ENABLED", "true") == "true" +# 增强等待超时(秒),None 表示后台异步执行 +MEMORI_AUGMENTATION_WAIT_TIMEOUT = os.getenv("MEMORI_AUGMENTATION_WAIT_TIMEOUT") +if MEMORI_AUGMENTATION_WAIT_TIMEOUT: + MEMORI_AUGMENTATION_WAIT_TIMEOUT = float(MEMORI_AUGMENTATION_WAIT_TIMEOUT) +else: + MEMORI_AUGMENTATION_WAIT_TIMEOUT = None + +# 嵌入模型(多语言支持) +MEMORI_EMBEDDING_MODEL = os.getenv( + "MEMORI_EMBEDDING_MODEL", + "paraphrase-multilingual-MiniLM-L12-v2" +) + + +