diff --git a/examples/components/README.md b/examples/components/README.md index 03c1734..fdee0bf 100644 --- a/examples/components/README.md +++ b/examples/components/README.md @@ -11,7 +11,6 @@ This examples provides of using various AgentScope-Bricks components in differe - `create_component.py` - Component creation and configuration examples #### Advanced Integration -- `memory_with_llm.py` - Long-term Memory integrated with LLM models - `rag_with_llm.py` - RAG with LLM call - `search_with_llm.py` - Search components with LLM processing @@ -26,7 +25,6 @@ export PYTHONPATH=$PYTHONPATH:/path/to/agentscope-bricks/project 2. Run individual examples: ```shell python llm_service.py # Basic LLM streaming service -python memory_with_llm.py # Memory + LLM integration python rag_with_llm.py # RAG with LLM python search_with_llm.py # Search with LLM ``` diff --git a/examples/components/memory_with_llm.py b/examples/components/memory_with_llm.py deleted file mode 100644 index 0c814da..0000000 --- a/examples/components/memory_with_llm.py +++ /dev/null @@ -1,203 +0,0 @@ -# -*- coding: utf-8 -*- -# type: ignore - -import asyncio -import os -import time -from typing import List - -from agentscope_bricks.components.memory.modelstudio_memory import ( - AddMemory, - SearchMemory, - ListMemory, - DeleteMemory, - AddMemoryInput, - SearchMemoryInput, - ListMemoryInput, - DeleteMemoryInput, - AddMemoryOutput, - SearchMemoryOutput, - ListMemoryOutput, - DeleteMemoryOutput, - Message, -) -from agentscope_bricks.models.llm import BaseLLM -from agentscope_bricks.utils.schemas.oai_llm import Parameters - -# ============= Configuration ============= -DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "") -# Configure the end user's ID to ensure their memory is isolated from others. -END_USER_ID = os.getenv("END_USER_ID", "default") - - -# Check required environment variables -if not DASHSCOPE_API_KEY: - raise ValueError("DASHSCOPE_API_KEY environment variable is not set") - -# ============= Component Initialization ============= -add_memory = AddMemory() -search_memory = SearchMemory() -list_memory = ListMemory() -delete_memory = DeleteMemory() -llm = BaseLLM() - - -# ============= Example Data ============= -def get_example_messages() -> List[Message]: - """Get example conversation messages for memory storage.""" - return [ - Message(role="user", content="每天上午11点提醒我点外卖。"), - Message(role="assistant", content="没问题"), - Message( - role="user", - content=[ - { - "type": "image_url", - "image_url": { - "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg", # noqa E501 - }, - "description": "【标题】白板文字记录与会议纪要提醒\n【内容】白板内容涉及近代教育体系建立的相关知识点,包括维新派活动、清末新政、资产阶级革命派活动等。具体内容如下:\n- 维新派活动:(1)近代学制特点;(2)废科举、兴学堂的过程。\n- 清末新政:行政设立学部和提学使司。\n- 资产阶级革命派活动:爱国学社、中华革命党。\n- 近代学制特点:目的、系统性、内容、班级授课与学年制度、对待儿童方式、课程比重等。", # noqa E501 - }, - { - "type": "text", - "text": "记录一下白板这些文字,明天10点提醒我整理会议纪要。", - }, - ], - ), - Message(role="assistant", content="好的"), - ] - - -# ============= Memory Operations ============= -async def add_memory_example() -> AddMemoryOutput: - """Add conversation messages to memory.""" - return await add_memory.arun( - AddMemoryInput( - user_id=END_USER_ID, - messages=get_example_messages(), - source="rayneo", - timestamp=int(time.time()), - meta_data={ - "location_name": "北京", - "geo_coordinate": "116.481499,39.990475", - "media_desc": [ - { - "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg", # noqa E501 - "description": "【标题】白板文字记录与会议纪要提醒\n【内容】白板内容涉及近代教育体系建立的相关知识点,包括维新派活动、清末新政、资产阶级革命派活动等。具体内容如下:\n- 维新派活动:(1)近代学制特点;(2)废科举、兴学堂的过程。\n- 清末新政:行政设立学部和提学使司。\n- 资产阶级革命派活动:爱国学社、中华革命党。\n- 近代学制特点:目的、系统性、内容、班级授课与学年制度、对待儿童方式、课程比重等。", # noqa E501 - }, - ], - }, - ), - ) - - -async def list_memory_example() -> ListMemoryOutput: - """List all memory nodes for a user.""" - return await list_memory.arun( - ListMemoryInput( - user_id=END_USER_ID, - page_num=1, - page_size=10, - ), - ) - - -async def search_memory_example(messages: List[Message]) -> SearchMemoryOutput: - """Search for relevant memories based on query.""" - return await search_memory.arun( - SearchMemoryInput( - user_id=END_USER_ID, - messages=messages, - top_k=5, - min_score=0, - ), - ) - - -async def delete_memory_example(memory_node_id: str) -> DeleteMemoryOutput: - """Delete a specific memory node.""" - return await delete_memory.arun( - DeleteMemoryInput( - user_id=END_USER_ID, - memory_node_id=memory_node_id, - ), - ) - - -# ============= LLM Integration ============= -async def get_llm_response( - search_result: SearchMemoryOutput, - user_query: str, -) -> None: - """Get LLM response based on retrieved memories.""" - system_prompt = f"""You are an experienced Assistant. Please answer the - question based on the retrieved memories. - -Retrieved memories: -{chr(10).join([f"- {node.content}" for node in search_result.memory_nodes])} -""" - - llm_messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_query}, - ] - - parameters = Parameters( - stream=True, - stream_options={"include_usage": True}, - ) - async for chunk in llm.astream( - model="qwen-max", - messages=llm_messages, - parameters=parameters, - ): - print(chunk.choices[0].delta, end="\n", flush=True) - - -# ============= Main Execution ============= -async def main() -> None: - """Main execution function.""" - try: - # 1. Add memory - print("\n=== Adding Memory ===") - add_result = await add_memory_example() - print("Add Memory Result:", add_result) - - time.sleep(5) - # 2. Delete the newly added memory node - print("\n=== Deleting Memory ===") - if add_result.memory_nodes: - memory_node_id = add_result.memory_nodes[0].memory_node_id - if memory_node_id: - delete_result = await delete_memory_example(memory_node_id) - print("Delete Memory Result:", delete_result) - - time.sleep(5) - # 3. List memory - print("\n=== Listing Memory ===") - list_result = await list_memory_example() - print("List Memory Result:") - print(f"Request ID: {list_result.request_id}") - for node in list_result.memory_nodes: - print(f"Memory Node ID: {node.memory_node_id}") - print(f"Memory Node Content: {node.content}") - - time.sleep(5) - # 4. Search memory - user_query = "明天需要提醒我什么事?" - print("\n=== Searching Memory ===") - search_result = await search_memory_example( - [Message(role="user", content=user_query)], - ) - print("Search Memory Result:", search_result) - - # 5. Get LLM response - print("\n=== Getting LLM Response ===") - await get_llm_response(search_result, user_query=user_query) - - except Exception as e: - print("Error during execution:", e) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/memory/__init__.py b/examples/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/memory/memory_demo.py b/examples/memory/memory_demo.py new file mode 100644 index 0000000..7e93583 --- /dev/null +++ b/examples/memory/memory_demo.py @@ -0,0 +1,675 @@ +# -*- coding: utf-8 -*- +import asyncio +import logging +import os +import sys +import time +from typing import List, Tuple + +from agentscope_bricks.components.memory.modelstudio_memory import ( + AddMemory, + SearchMemory, + ListMemory, + DeleteMemory, + CreateProfileSchema, + GetUserProfile, + GetUserProfileInput, + Message, + AddMemoryInput, + SearchMemoryInput, + ListMemoryInput, + DeleteMemoryInput, + CreateProfileSchemaInput, + ProfileAttribute, + MemoryAPIError, + MemoryAuthenticationError, + MemoryNotFoundError, + MemoryValidationError, +) +from agentscope_bricks.models.llm import BaseLLM +from agentscope_bricks.utils.schemas.oai_llm import Parameters + +# ===== 配置日志,过滤掉冗长的调试信息 ===== +# 从环境变量读取日志级别,默认为 WARNING +LOG_LEVEL = os.getenv("LOG_LEVEL", "WARNING").upper() +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.WARNING), + format=( + "%(levelname)s: %(message)s" + if LOG_LEVEL == "WARNING" + else "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + ), +) + +# 特别禁用某些组件的详细日志(除非明确设置为 DEBUG) +if LOG_LEVEL != "DEBUG": + logging.getLogger("agentscope_bricks").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + +def require_env(name: str) -> str: + value = os.getenv(name) + if not value: + print( + f"[ERROR] Required environment variable not set: {name}", + file=sys.stderr, + ) + sys.exit(1) + return value + + +def get_env(name: str, default: str) -> str: + value = os.getenv(name, default) + return value + + +def truncate(text: str, length: int = 120) -> str: + if text is None: + return "" + if len(text) <= length: + return text + return text[: length - 3] + "..." + + +def print_section(title: str) -> None: + bar = "=" * 70 + print(f"\n{bar}\n{title}\n{bar}") + + +def print_info(message: str) -> None: + print(f"[system_info] {message}") + + +def print_warn(message: str) -> None: + print(f"[warn] {message}") + + +def print_success(message: str) -> None: + print(f"[success] {message}") + + +def print_error(message: str) -> None: + print(f"[ERROR] {message}") + + +def format_api_error(error: MemoryAPIError) -> str: + """格式化 API 错误信息以便显示""" + parts = [] + + # 提取错误消息主体(不包括 __str__ 方法添加的额外信息) + error_message = str(error).split(" | ")[0] + parts.append(f"错误信息: {error_message}") + + if error.error_code: + parts.append(f"错误代码: {error.error_code}") + + if error.status_code: + parts.append(f"HTTP 状态码: {error.status_code}") + + if error.request_id: + parts.append(f"Request ID: {error.request_id}") + + return "\n ".join(parts) + + +async def step_create_profile_schema( + create_profile_schema: CreateProfileSchema, +) -> str: + """创建用户画像 Schema""" + print_info("用户画像 Schema 用于定义用户有哪些字段(如年龄、爱好)。") + print("") + + payload = CreateProfileSchemaInput( + name="用户画像(示例)", + description="用于演示的用户基础画像 Schema", + attributes=[ + ProfileAttribute(name="年龄", description="用户年龄"), + ProfileAttribute(name="爱好", description="兴趣偏好"), + ], + ) + + # 展示示例参数 + print_info("请求参数:") + print_info(f" · Schema 名称:{payload.name}") + print_info(f" · Schema 描述:{payload.description}") + print_info(" · 字段定义:") + for idx, attr in enumerate(payload.attributes, start=1): + print_info(f" [{idx}] {attr.name} - {attr.description}") + print("") + + result = await create_profile_schema.arun(payload) + print_success("✓ 已创建用户画像 Schema") + print_info(f" Schema ID:{result.profile_schema_id}") + print_info(f" 请求ID:{result.request_id}") + print("") + + return result.profile_schema_id + + +def example_messages() -> List[Message]: + return [ + Message( + role="user", + content="每天上午9点提醒我喝水,下午3点复习笔记。", + ), + Message(role="assistant", content="好的,我已经记录下来。"), + Message( + role="user", + content="还有明天记得提醒我给诺成老师买个生日礼物,\ + 诺成老师今年30岁了,比我大三岁。我们的爱好相同,\ + 经常一起踢球,所以我打算给诺成老师买一个精美的足球", + ), + Message(role="assistant", content="好的,我明天会提醒你"), + ] + + +async def step_add_memory( + add_memory: AddMemory, + end_user_id: str, + profile_schema_id: str, +) -> List[str]: + """添加对话记忆到记忆服务""" + print_info("我们将一段对话提交到记忆服务,服务会自动完成两件事:") + print_info(" 1️⃣ 抽取并保存记忆条目(memory nodes)") + print_info(" 2️⃣ 从对话中提取用户画像信息(年龄、爱好等)") + print("") + + now_ts = int(time.time()) + msgs = example_messages() + payload = AddMemoryInput( + user_id=end_user_id, + messages=msgs, + timestamp=now_ts, + profile_schema=profile_schema_id, + meta_data={ + "location_name": "杭州", + "geo_coordinate": "120.1551,30.2741", + }, + ) + + # 展示示例参数 + print_info("📥 请求参数:") + print_info(f" · 用户ID:{payload.user_id}") + print_info(f" · Profile Schema ID:{truncate(profile_schema_id, 50)}") + + # 格式化时间戳 + timestamp_str = time.strftime( + "%Y-%m-%d %H:%M:%S", + time.localtime(payload.timestamp), + ) + print_info(f" · 时间戳:{timestamp_str}") + print_info(f" · 对话消息数:{len(payload.messages)} 条") + + # 格式化元数据 + location = payload.meta_data.get("location_name") + coordinate = payload.meta_data.get("geo_coordinate") + print_info(f" · 元数据:位置={location}, 坐标={coordinate}") + print("") + + print_info("💬 对话内容(注意画像信息):") + for idx, m in enumerate(payload.messages, start=1): + role_icon = "👤" if m.role == "user" else "🤖" + content_str = str(m.content) + + # 突出显示包含画像信息的对话 + if "30岁" in content_str or "踢球" in content_str: + print(f" {role_icon} [{m.role}] {truncate(content_str, 100)} 🎯") + else: + print(f" {role_icon} [{m.role}] {truncate(content_str, 100)}") + print("") + print_info(" 🎯 = 包含可提取的画像信息(年龄、爱好)") + print("") + + add_result = await add_memory.arun(payload) + + # 调试:打印返回结果类型 + print_info( + f"🔍 调试信息:memory_nodes 类型 = {type(add_result.memory_nodes)}", + ) + + # 兼容处理:如果 memory_nodes 不是列表,转换为列表 + if isinstance(add_result.memory_nodes, list): + memory_nodes_list = add_result.memory_nodes + else: + # 如果是单个对象,包装成列表 + memory_nodes_list = ( + [add_result.memory_nodes] if add_result.memory_nodes else [] + ) + + node_ids = [ + n.memory_node_id for n in memory_nodes_list if n.memory_node_id + ] + + if node_ids: + print_success(f"✓ 成功新增 {len(node_ids)} 条记忆条目") + print_info(f" 请求ID:{add_result.request_id}") + print("") + print_info("📝 生成的记忆条目:") + print("") + for idx, node in enumerate(memory_nodes_list, start=1): + print(f" [{idx}] {truncate(node.content, 100)}") + print(f" ID: {node.memory_node_id}") + if idx < len(memory_nodes_list): + print("") + print("") + else: + print_warn("⚠ 未返回任何记忆条目 ID,稍后删除步骤将跳过。") + + return node_ids + + +async def step_list_memory( + list_memory: ListMemory, + end_user_id: str, + page_num: int = 1, + page_size: int = 10, +) -> List[str]: + """列出用户的所有记忆条目(分页)""" + print_info("列出该用户当前保存的所有记忆条目(分页查询)。") + print("") + + payload = ListMemoryInput( + user_id=end_user_id, + page_num=page_num, + page_size=page_size, + ) + + # 展示示例参数 + print_info("请求参数:") + print_info(f" · 用户ID:{payload.user_id}") + print_info(f" · 页码:{payload.page_num}") + print_info(f" · 每页数量:{payload.page_size}") + print("") + + result = await list_memory.arun(payload) + total_pages = ( + (result.total + result.page_size - 1) // result.page_size + if result.page_size + else 1 + ) + + print_success(f"✓ 列表获取成功 (请求ID: {result.request_id})") + print_info( + f"📊 分页信息:第 \ + {result.page_num}/{total_pages} 页,\ + 每页 {result.page_size} 条,共 {result.total} 条", + ) + print("") + + if not result.memory_nodes: + print_info("(当前页无记忆条目)") + return [] + + print_info(f"📝 记忆条目列表(当前页共 {len(result.memory_nodes)} 条):") + print("") + + existing_ids = [] + for idx, node in enumerate(result.memory_nodes, start=1): + existing_ids.append(node.memory_node_id or "") + print(f" [{idx}] {truncate(node.content, 100)}") + print(f" ID: {node.memory_node_id}") + if idx < len(result.memory_nodes): + print("") + + print("") + return [nid for nid in existing_ids if nid] + + +async def step_search_memory_with_llm( + search_memory: SearchMemory, + llm: BaseLLM, + end_user_id: str, +) -> Tuple[List[str], str]: + """检索记忆并使用大模型生成个性化回答""" + user_query = "今天和明天需要提醒我做什么?" + + print_info( + "我们将用一个自然语言问题来检索相关记忆,然后让大模型基于这些记忆生成个性化回答。", + ) + print("") + + # 1. 检索记忆 + print_info("🔍 第一步:检索相关记忆") + payload = SearchMemoryInput( + user_id=end_user_id, + messages=[Message(role="user", content=user_query)], + top_k=5, + min_score=0, + ) + + print_info("检索参数:") + print_info(f" · 用户ID:{payload.user_id}") + print_info(f" · 用户问题:{user_query}") + print_info(f" · 返回条数:top_k={payload.top_k}") + print_info(f" · 最低分数:min_score={payload.min_score}") + print("") + + search_result = await search_memory.arun(payload) + print_success(f"✓ 检索完成 (请求ID: {search_result.request_id})") + + if not search_result.memory_nodes: + print_warn("未找到相关记忆条目") + return [], user_query + + print_info(f"找到 {len(search_result.memory_nodes)} 条相关记忆:") + print("") + + hit_ids = [] + for idx, node in enumerate(search_result.memory_nodes, start=1): + hit_ids.append(node.memory_node_id or "") + print(f" [{idx}] {truncate(node.content, 100)}") + print(f" ID: {node.memory_node_id}") + + print("") + print("─" * 70) + print("") + + # 2. 使用大模型生成回答 + print_info("🤖 第二步:基于检索到的记忆,让大模型生成个性化回答") + print("") + + context_lines = [ + f"- {node.content}" for node in search_result.memory_nodes + ] + system_prompt = ( + "你是一名助理。根据以下检索到的记忆回答用户问题。\n\n" + + "记忆内容:\n" + + ("\n".join(context_lines) if context_lines else "(无检索结果)") + ) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_query}, + ] + + params = Parameters(stream=True, stream_options={"include_usage": True}) + model_name = "qwen-max" + + print_info(f"模型:{model_name}(流式输出)") + print_info(f"问题:{user_query}") + print("") + print_success("模型回答:") + print("") + print(" ", end="") + + async for chunk in llm.astream( + model=model_name, + messages=messages, + parameters=params, + ): + if chunk.choices: + delta = getattr(chunk.choices[0], "delta", None) + if delta is not None: + text = str(delta) + print(text, end="", flush=True) + + print("") + print("") + + return [hid for hid in hit_ids if hid], user_query + + +async def step_get_user_profile( + get_user_profile: GetUserProfile, + schema_id: str, + end_user_id: str, +) -> None: + """获取并展示用户画像信息""" + print_info("🎯 用户画像功能展示") + print("") + print_info( + "💡 说明:记忆服务会自动从对话中提取用户信息,填充到画像字段中。", + ) + print_info(" 例如:从 '诺成老师今年30岁,比我大三岁' 可推断出用户27岁") + print_info(" 从 '我们经常一起踢球' 可推断出用户爱好是足球") + print("") + + payload = GetUserProfileInput(schema_id=schema_id, user_id=end_user_id) + + # 展示示例参数 + print_info("📥 请求参数:") + print_info(f" · Schema ID:{truncate(payload.schema_id, 50)}") + print_info(f" · 用户ID:{payload.user_id}") + print("") + + result = await get_user_profile.arun(payload) + print_success(f"✓ 已获取用户画像 (请求ID: {result.request_id})") + print("") + + # 显示 Schema 信息 + print_info("📋 Schema 信息:") + schema_name = result.profile.schema_name or "(未设置)" + schema_desc = result.profile.schema_description or "(未设置)" + print_info(f" 名称: {schema_name}") + print_info(f" 描述: {schema_desc}") + print("") + + # 显示用户画像 + if result.profile.attributes: + print_info( + f"👤 用户画像(共 {len(result.profile.attributes)} 个字段):", + ) + print("") + + for idx, attr in enumerate(result.profile.attributes, start=1): + value_display = attr.value if attr.value else "(暂未提取)" + + print_info(f" [{idx}] {attr.name}") + print_info(f" 值: {value_display}") + print_info(f" ID: {attr.id}") + + # 分隔线(最后一个除外) + if idx < len(result.profile.attributes): + print("") + + print("") + + # 如果有字段被填充,添加说明 + has_values = any(attr.value for attr in result.profile.attributes) + if has_values: + print_success( + "💡 提示:上述画像信息是记忆服务自动从对话中提取的!", + ) + else: + print_info( + "💡 提示:画像字段暂未填充,随着更多对话的积累,会逐步完善。", + ) + print("") + else: + print_info("(暂无画像字段)") + print("") + + +async def step_delete_memory( + delete_memory: DeleteMemory, + end_user_id: str, + node_ids: List[str], +) -> None: + """删除指定的记忆条目""" + print_info("删除刚才新增的记忆条目,演示数据清理功能。") + print("") + + if not node_ids: + print_warn("⚠ 没有可删除的条目,跳过该步骤。") + return + + # 展示示例参数 + print_info("请求参数:") + print_info(f" · 用户ID:{end_user_id}") + print_info(f" · 待删除条目数:{len(node_ids)}") + print("") + + print_info(f"🗑️ 正在删除 {len(node_ids)} 条记忆...") + print("") + + for idx, node_id in enumerate(node_ids, start=1): + result = await delete_memory.arun( + DeleteMemoryInput(user_id=end_user_id, memory_node_id=node_id), + ) + print_success( + f" ✓ [{idx}/{len(node_ids)}] 已删除:{truncate(node_id, 50)}", + ) + print_info(f" 请求ID:{result.request_id}") + + print("") + print_success(f"✓ 全部删除完成,共删除 {len(node_ids)} 条记忆") + + +async def main() -> None: + # Required envs + require_env("DASHSCOPE_API_KEY") + end_user_id = get_env("END_USER_ID", "demo_user_test_001") + + # Initialize components + add_memory = AddMemory() + search_memory = SearchMemory() + list_memory = ListMemory() + delete_memory = DeleteMemory() + create_profile_schema = CreateProfileSchema() + get_user_profile = GetUserProfile() + llm = BaseLLM() + + try: + print_section("Demo 0: Create Profile Schema") + try: + schema_id = await step_create_profile_schema(create_profile_schema) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 创建用户画像 Schema 失败:") + print_error(f" {format_api_error(e)}") + print_error( + "\n💡 建议:请检查 API Key 是否正确,或查看 Request ID 联系技术支持", + ) + return + + print_section("Demo 1: Add Memory") + try: + node_ids = await step_add_memory( + add_memory, + end_user_id, + schema_id, + ) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 添加记忆失败:") + print_error(f" {format_api_error(e)}") + print_error( + "\n💡 建议:请检查参数是否正确,或查看 Request ID 联系技术支持", + ) + return + + # Wait for consistency + print("") + print_info("⏳ 等待记忆生成(3秒)...") + await asyncio.sleep(3) + print("") + + # 2. List memory + print_section("Demo 2: List Memory") + try: + await step_list_memory(list_memory, end_user_id) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 列出记忆失败:") + print_error(f" {format_api_error(e)}") + # 非关键步骤,可以继续 + + print_section("Demo 3: Search Memory + LLM Answer") + try: + _hits, _query = await step_search_memory_with_llm( + search_memory, + llm, + end_user_id, + ) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 搜索记忆失败:") + print_error(f" {format_api_error(e)}") + # 非关键步骤,可以继续 + + # 等待用户画像提取完成 + print("") + print_info("⏳ 等待用户画像提取完成(2秒)...") + print_info(" 记忆服务正在从对话中提取用户信息(年龄、爱好等)...") + await asyncio.sleep(2) + print("") + + print_section("Demo 4: Get User Profile (展示自动提取的用户画像)") + try: + await step_get_user_profile( + get_user_profile, + schema_id, + end_user_id, + ) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + MemoryNotFoundError, + ) as e: + print_error("❌ 获取用户画像失败:") + print_error(f" {format_api_error(e)}") + # 非关键步骤,可以继续 + + print_section("Demo 5: Delete Memory") + try: + await step_delete_memory(delete_memory, end_user_id, node_ids) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 删除记忆失败:") + print_error(f" {format_api_error(e)}") + # 非关键步骤,可以继续 + + # Wait for consistency + print("") + print_info("⏳ 等待删除生效(2秒)...") + await asyncio.sleep(2) + print("") + + print_section("Demo 6: List Memory Again (验证删除)") + try: + await step_list_memory(list_memory, end_user_id) + except ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryValidationError, + ) as e: + print_error("❌ 列出记忆失败:") + print_error(f" {format_api_error(e)}") + + print("") + print("=" * 70) + print_success("🎉 所有演示步骤已完成!") + print("=" * 70) + + finally: + # 清理资源:关闭所有 HTTP 连接 + print("") + print_info("🔄 正在清理资源...") + await add_memory.close() + await search_memory.close() + await list_memory.close() + await delete_memory.close() + await create_profile_schema.close() + await get_user_profile.close() + print_info("✓ 资源清理完成") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/memory/memory_demo.sh b/examples/memory/memory_demo.sh new file mode 100644 index 0000000..4414555 --- /dev/null +++ b/examples/memory/memory_demo.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 可选:首次运行时打开以下注释进行依赖安装 +# python3 -m venv .venv +# source .venv/bin/activate +# pip install -r requirements.txt + +# ===== 明文环境变量(示例值请替换) ===== +export LLM_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1" +export DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" +export MEMORY_SERVICE_ENDPOINT="https://dashscope.aliyuncs.com/api/v2/apps/memory" + +# ===== 日志配置 ===== +# 禁用详细日志,保持控制台输出清晰 +export LOG_LEVEL="${LOG_LEVEL:-WARNING}" +export PYTHONUNBUFFERED=1 # 确保 Python 输出实时显示(不缓冲) + +# ===== END_USER_ID 配置 ===== +# 方式1:直接在这里指定用户ID(如果需要固定ID,请取消注释并填写) +# END_USER_ID="your_custom_user_id" + +# 方式2:留空或不设置,自动生成格式:modelstudio_memory_user_MMDD_UUID(4位) +END_USER_ID="${END_USER_ID:-}" + +# 动态生成逻辑:如果 END_USER_ID 为空,则自动生成 +if [ -z "$END_USER_ID" ]; then + MMDD=$(date +%m%d) + # 生成4位随机UUID片段(使用uuidgen的前4位,或使用随机数) + if command -v uuidgen &> /dev/null; then + UUID4=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 4) + else + # 如果没有uuidgen,使用随机数生成4位十六进制 + UUID4=$(printf '%04x' $((RANDOM % 65536))) + fi + export END_USER_ID="modelstudio_memory_user_${MMDD}_${UUID4}" + echo "[INFO] Generated END_USER_ID: $END_USER_ID" +else + echo "[INFO] Using existing END_USER_ID: $END_USER_ID" +fi + + +# memory_demo.sh 与 run_memory_demo.py 应位于同一目录 +WORK_DIR="${PWD}" +if [ ! -f "$WORK_DIR/memory_demo.py" ]; then + echo "[ERROR] memory_demo.py not found in $WORK_DIR" >&2 + echo "Please run this script in the same directory as memory_demo.py" >&2 + exit 1 +fi + +# 检查 API Key 是否设置 +if [ -z "$DASHSCOPE_API_KEY" ]; then + echo "[ERROR] DASHSCOPE_API_KEY is empty. Please set it before running." >&2 + exit 1 +fi + +# 为支持绝对包导入(agentscope_bricks.*),将仓库根目录加入 PYTHONPATH +REPO_ROOT="$(cd "$WORK_DIR/../.." && pwd)" +export PYTHONPATH="${REPO_ROOT}:${PYTHONPATH:-}" + +python "$WORK_DIR/memory_demo.py" | cat + + diff --git a/src/agentscope_bricks/components/memory/__init__.py b/src/agentscope_bricks/components/memory/__init__.py index e69de29..5cc97cb 100644 --- a/src/agentscope_bricks/components/memory/__init__.py +++ b/src/agentscope_bricks/components/memory/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +""" +Memory components for AgentScope Bricks. + +This package provides various memory implementations: +- modelstudio_memory: ModelStudio Memory service for cloud-based memory storage +- local_memory: Local in-memory storage +- redis_memory: Redis-based persistent memory storage + +Each memory implementation provides components for storing and retrieving +conversation history and user information. +""" + +__all__ = [] diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory.py b/src/agentscope_bricks/components/memory/modelstudio_memory.py deleted file mode 100644 index d300911..0000000 --- a/src/agentscope_bricks/components/memory/modelstudio_memory.py +++ /dev/null @@ -1,386 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from typing import List, Dict, Any, Optional - -import aiohttp -from pydantic import BaseModel, Field - -from agentscope_bricks.base.component import Component - -# ENV-PRE -MEMORY_SERVICE_ENDPOINT = os.getenv( - "MEMORY_SERVICE_ENDPOINT", - "https://dashscope.aliyuncs.com/api/v2/apps/memory", -) -ADD_MEMORY_URL = f"{MEMORY_SERVICE_ENDPOINT}/add" -SEARCH_MEMORY_URL = f"{MEMORY_SERVICE_ENDPOINT}/search" -LIST_MEMORY_URL = f"{MEMORY_SERVICE_ENDPOINT}/list" -DELETE_MEMORY_URL = f"{MEMORY_SERVICE_ENDPOINT}/delete" - - -class Message(BaseModel): - role: str - content: Any - - -class AddMemoryInput(BaseModel): - user_id: str = Field(..., description="end user id") - messages: List[Message] = Field(..., description="conversation messages") - timestamp: int = Field(..., description="timestamp of the memory") - meta_data: Optional[Dict[str, Any]] = Field( - None, - description="metadata including location and media description", - ) - - class Config: - extra = "allow" # Allow extra fields - - -class MemoryNode(BaseModel): - memory_node_id: Optional[str] = None - content: str - - -class AddMemoryOutput(BaseModel): - memory_nodes: List[MemoryNode] = Field( - ..., - description="generated memory nodes", - ) - - -class SearchFilters(BaseModel): - tags: Optional[List[str]] = Field(None, description="filter by tags") - - -class SearchMemoryInput(BaseModel): - user_id: str = Field(..., description="end user id") - messages: List[Message] = Field(..., description="conversation messages") - top_k: Optional[int] = Field( - 100, - description="number of results to return", - ) - min_score: Optional[float] = Field( - 0.0, - description="minimum score threshold", - ) - - class Config: - extra = "allow" # Allow extra fields - - -class SearchMemoryOutput(BaseModel): - memory_nodes: List[MemoryNode] = Field( - ..., - description="retrieved memory nodes", - ) - request_id: str = Field(..., description="request id") - - -class ListMemoryInput(BaseModel): - user_id: str = Field(..., description="end user id") - page_num: Optional[int] = Field(1, description="page number") - page_size: Optional[int] = Field( - 10, - description="number of items per page", - ) - - class Config: - extra = "allow" # Allow extra fields - - -class ListMemoryOutput(BaseModel): - memory_nodes: List[MemoryNode] = Field( - ..., - description="retrieved memory nodes", - ) - page_size: int = Field(..., description="number of items per page") - page_num: int = Field(..., description="current page number") - total: int = Field(..., description="total number of memory nodes") - request_id: str = Field(..., description="request id") - - -class DeleteMemoryInput(BaseModel): - user_id: str = Field(..., description="end user id") - memory_node_id: str = Field(..., description="memory node id to delete") - - class Config: - extra = "allow" # Allow extra fields - - -class DeleteMemoryOutput(BaseModel): - request_id: str = Field(..., description="request id") - - -class AddMemory(Component[AddMemoryInput, AddMemoryOutput]): - """ - Memory Component for storing conversation history as memory nodes. - """ - - name = "add_memory" - description = "Store conversation messages as memory nodes" - - def __init__(self) -> None: - super().__init__() - self.service_id = os.getenv("MODELSTUDIO_SERVICE_ID", "memory_service") - self.add_memory_url = ADD_MEMORY_URL - self.api_key = os.getenv("DASHSCOPE_API_KEY") - if not self.api_key: - raise ValueError( - "DASHSCOPE_API_KEY environment variable is required", - ) - - async def _arun( - self, - args: AddMemoryInput, - **kwargs: Any, - ) -> AddMemoryOutput: - """ - Add memory nodes - - Args: - args: AddMemoryInput - **kwargs: Additional parameters - - Returns: - AddMemoryOutput: Memory output - """ - try: - # Build request body - all fields including extra fields will be - # included - payload = args.model_dump(exclude_none=True) - - # Send request - async with aiohttp.ClientSession() as session: - async with session.post( - self.add_memory_url, - json=payload, - headers={ - "Content-Type": "application/json", - "User-Agent": "agentscope-bricks", - "Authorization": f"Bearer {self.api_key}", - }, - ) as response: - if response.status != 200: - error_text = await response.text() - raise Exception( - f"Add memory failed with status " - f"{response.status}: {error_text}", - ) - - result = await response.json() - return AddMemoryOutput( - memory_nodes=[ - MemoryNode(**node) - for node in result.get("memory_nodes", []) - ], - ) - - except Exception as e: - raise Exception(f"Error in AddMemory: {str(e)}") - - -class SearchMemory(Component[SearchMemoryInput, SearchMemoryOutput]): - """ - Memory Component for searching relevant memories based on conversation - context. - """ - - name = "search_memory" - description = "Search for relevant memories based on conversation context" - - def __init__(self) -> None: - super().__init__() - self.service_id = os.getenv("MODELSTUDIO_SERVICE_ID", "memory_service") - self.search_memory_url = SEARCH_MEMORY_URL - self.api_key = os.getenv("DASHSCOPE_API_KEY") - if not self.api_key: - raise ValueError( - "DASHSCOPE_API_KEY environment variable is required", - ) - - async def _arun( - self, - args: SearchMemoryInput, - **kwargs: Any, - ) -> SearchMemoryOutput: - """ - Search memory nodes - - Args: - args: SearchMemoryInput - **kwargs: Additional parameters - - Returns: - SearchMemoryOutput: Search output - """ - try: - # Build request body - all fields including extra fields will be - # included - payload = args.model_dump(exclude_none=True) - - # Send request - async with aiohttp.ClientSession() as session: - async with session.post( - self.search_memory_url, - json=payload, - headers={ - "Content-Type": "application/json", - "User-Agent": "agentscope-bricks", - "Authorization": f"Bearer {self.api_key}", - }, - ) as response: - if response.status != 200: - error_text = await response.text() - raise Exception( - f"Search memory failed with status " - f"{response.status}: {error_text}", - ) - - result = await response.json() - return SearchMemoryOutput( - memory_nodes=[ - MemoryNode(**node) - for node in result.get("memory_nodes", []) - ], - request_id=result.get("request_id", ""), - ) - - except Exception as e: - raise Exception(f"Error in SearchMemory: {str(e)}") - - -class ListMemory(Component[ListMemoryInput, ListMemoryOutput]): - """ - Memory Component for listing memory nodes for a user. - """ - - name = "list_memory" - description = "List memory nodes for a user" - - def __init__(self) -> None: - super().__init__() - self.service_id = os.getenv("MODELSTUDIO_SERVICE_ID", "memory_service") - self.list_memory_url = LIST_MEMORY_URL - self.api_key = os.getenv("DASHSCOPE_API_KEY") - if not self.api_key: - raise ValueError( - "DASHSCOPE_API_KEY environment variable is required", - ) - - async def _arun( - self, - args: ListMemoryInput, - **kwargs: Any, - ) -> ListMemoryOutput: - """ - List memory nodes for a user - - Args: - args: ListMemoryInput - **kwargs: Additional parameters - - Returns: - ListMemoryOutput: List memory output - """ - try: - # Build request body - all fields including extra fields will be - # included - payload = args.model_dump(exclude_none=True) - - # Send request - async with aiohttp.ClientSession() as session: - async with session.post( - self.list_memory_url, - json=payload, - headers={ - "Content-Type": "application/json", - "User-Agent": "agentscope-bricks", - "Authorization": f"Bearer {self.api_key}", - }, - ) as response: - if response.status != 200: - error_text = await response.text() - raise Exception( - f"List memory failed with status " - f"{response.status}: {error_text}", - ) - - result = await response.json() - return ListMemoryOutput( - memory_nodes=[ - MemoryNode(**node) - for node in result.get("memory_nodes", []) - ], - page_size=result.get("page_size", 10), - page_num=result.get("page_num", 1), - total=result.get("total", 0), - request_id=result.get("request_id", ""), - ) - - except Exception as e: - raise Exception(f"Error in ListMemory: {str(e)}") - - -class DeleteMemory(Component[DeleteMemoryInput, DeleteMemoryOutput]): - """ - Memory Component for deleting a specific memory node. - """ - - name = "delete_memory" - description = "Delete a specific memory node" - - def __init__(self) -> None: - super().__init__() - self.service_id = os.getenv("MODELSTUDIO_SERVICE_ID", "memory_service") - self.delete_memory_url = DELETE_MEMORY_URL - self.api_key = os.getenv("DASHSCOPE_API_KEY") - if not self.api_key: - raise ValueError( - "DASHSCOPE_API_KEY environment variable is required", - ) - - async def _arun( - self, - args: DeleteMemoryInput, - **kwargs: Any, - ) -> DeleteMemoryOutput: - """ - Delete a memory node - - Args: - args: DeleteMemoryInput - **kwargs: Additional parameters - - Returns: - DeleteMemoryOutput: Delete memory output - """ - try: - # Build request body - all fields including extra fields will be - # included - payload = args.model_dump(exclude_none=True) - - # Send request - async with aiohttp.ClientSession() as session: - async with session.post( - self.delete_memory_url, - json=payload, - headers={ - "Content-Type": "application/json", - "User-Agent": "agentscope-bricks", - "Authorization": f"Bearer {self.api_key}", - }, - ) as response: - if response.status != 200: - error_text = await response.text() - raise Exception( - f"Delete memory failed with status " - f"{response.status}: {error_text}", - ) - - result = await response.json() - return DeleteMemoryOutput( - request_id=result.get("request_id", ""), - ) - - except Exception as e: - raise Exception(f"Error in DeleteMemory: {str(e)}") diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/__init__.py b/src/agentscope_bricks/components/memory/modelstudio_memory/__init__.py new file mode 100644 index 0000000..d226f2c --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/__init__.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +ModelStudio Memory Components. + +This package provides components for interacting with the ModelStudio Memory +service. + +Components: + - AddMemory: Store conversation messages as memory nodes + - SearchMemory: Search for relevant memories + - ListMemory: List memory nodes with pagination + - DeleteMemory: Delete a specific memory node + - CreateProfileSchema: Create a user profile schema + - GetUserProfile: Retrieve a user profile + +Models: + All Pydantic models for input/output are available in the models submodule. + +Exceptions: + Custom exceptions for better error handling are available in the + exceptions submodule. + +Configuration: + Configuration can be managed through environment variables or by + providing a MemoryServiceConfig instance. +""" + +# Configuration +from .config import MemoryServiceConfig + +# Exceptions +from .exceptions import ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryNetworkError, + MemoryNotFoundError, + MemoryValidationError, +) + +# Components +from .core import ( + AddMemory, + SearchMemory, + ListMemory, + DeleteMemory, + CreateProfileSchema, + GetUserProfile, +) + +# Models - Import commonly used models for convenience +from .models import ( + AddMemoryInput, + AddMemoryOutput, + CreateProfileSchemaInput, + CreateProfileSchemaOutput, + DeleteMemoryInput, + DeleteMemoryOutput, + GetUserProfileInput, + GetUserProfileOutput, + ListMemoryInput, + ListMemoryOutput, + MemoryNode, + Message, + ProfileAttribute, + SearchMemoryInput, + SearchMemoryOutput, + UserProfile, + UserProfileAttribute, +) + +__all__ = [ + # Core Components + "AddMemory", + "SearchMemory", + "ListMemory", + "DeleteMemory", + "CreateProfileSchema", + "GetUserProfile", + # Configuration + "MemoryServiceConfig", + # Exceptions + "MemoryAPIError", + "MemoryAuthenticationError", + "MemoryNetworkError", + "MemoryNotFoundError", + "MemoryValidationError", + # Models + "Message", + "MemoryNode", + "AddMemoryInput", + "AddMemoryOutput", + "SearchMemoryInput", + "SearchMemoryOutput", + "ListMemoryInput", + "ListMemoryOutput", + "DeleteMemoryInput", + "DeleteMemoryOutput", + "ProfileAttribute", + "CreateProfileSchemaInput", + "CreateProfileSchemaOutput", + "UserProfileAttribute", + "UserProfile", + "GetUserProfileInput", + "GetUserProfileOutput", +] diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/base.py b/src/agentscope_bricks/components/memory/modelstudio_memory/base.py new file mode 100644 index 0000000..ab04634 --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/base.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +""" +Base class for ModelStudio Memory components. +""" +import logging +from types import TracebackType +from typing import Any, Dict, Optional, Type + +import aiohttp + +from agentscope_bricks.components.memory.modelstudio_memory.config import ( + MemoryServiceConfig, +) +from agentscope_bricks.components.memory.modelstudio_memory.exceptions import ( + MemoryAPIError, + MemoryAuthenticationError, + MemoryNetworkError, + MemoryNotFoundError, + MemoryValidationError, +) + +logger = logging.getLogger(__name__) + + +class ModelStudioMemoryBase: + """ + Base class for ModelStudio Memory API components. + + This class provides common functionality for all memory components, + including: + - Configuration management + - HTTP request handling with error handling + - Common headers generation + - Session management + + Attributes: + config: Configuration for the memory service + """ + + def __init__(self, config: Optional[MemoryServiceConfig] = None): + """ + Initialize the base memory component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + + Raises: + ValueError: If required configuration is missing + """ + self.config = config or MemoryServiceConfig.from_env() + self._session: Optional[aiohttp.ClientSession] = None + + def _get_headers(self) -> Dict[str, str]: + """ + Get common HTTP headers for API requests. + + Returns: + Dictionary of HTTP headers + """ + return { + "Content-Type": "application/json", + "User-Agent": "agentscope-bricks", + "Authorization": f"Bearer {self.config.api_key}", + } + + async def _get_session(self) -> aiohttp.ClientSession: + """ + Get or create an aiohttp session. + + Returns: + An aiohttp ClientSession + """ + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def _request( + self, + method: str, + url: str, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Common HTTP request handler with comprehensive error handling. + + Args: + method: HTTP method (GET, POST, DELETE, etc.) + url: Request URL + **kwargs: Additional arguments for the request + + Returns: + Response JSON as dictionary + + Raises: + MemoryAuthenticationError: If authentication fails (401) + MemoryNotFoundError: If resource not found (404) + MemoryAPIError: For other API errors + MemoryNetworkError: For network-related errors + """ + try: + session = await self._get_session() + logger.debug(f"Making {method} request to {url}") + + async with session.request( + method, + url, + headers=self._get_headers(), + **kwargs, + ) as response: + # Handle successful response + if response.status == 200: + result = await response.json() + logger.debug( + f"Request successful: {method} {url}", + ) + return result + + # Handle error responses (4XX, 5XX) + # Try to parse JSON error response first + error_data = None + try: + error_data = await response.json() + except Exception: + # If JSON parsing fails, fall back to text + error_text = await response.text() + error_data = {"message": error_text} + + # Extract error information + error_code = error_data.get("code", "Unknown") + error_message = error_data.get("message", "Unknown error") + request_id = error_data.get("request_id", "") + + # Format error log + error_log = ( + f"API Error - Status: {response.status}, " + f"Code: {error_code}, Message: {error_message}, " + f"Request ID: {request_id}" + ) + logger.error(error_log) + + # Raise appropriate exception based on status code + if response.status == 401 or response.status == 403: + raise MemoryAuthenticationError( + error_message, + status_code=response.status, + error_code=error_code, + request_id=request_id, + ) + elif response.status == 404: + raise MemoryNotFoundError( + error_message, + status_code=response.status, + error_code=error_code, + request_id=request_id, + ) + elif response.status == 400: + raise MemoryValidationError( + error_message, + status_code=response.status, + error_code=error_code, + request_id=request_id, + ) + elif 400 <= response.status < 500: + # Other 4XX errors + raise MemoryValidationError( + error_message, + status_code=response.status, + error_code=error_code, + request_id=request_id, + ) + else: + # 5XX server errors + raise MemoryAPIError( + error_message, + status_code=response.status, + error_code=error_code, + request_id=request_id, + ) + + except aiohttp.ClientError as e: + logger.exception(f"Network error: {str(e)}") + raise MemoryNetworkError( + f"Network error during {method} request to {url}: {str(e)}", + ) from e + except ( + MemoryAuthenticationError, + MemoryNotFoundError, + MemoryValidationError, + MemoryAPIError, + ): + # Re-raise our custom exceptions (already have proper error info) + raise + except Exception as e: + logger.exception(f"Unexpected error: {str(e)}") + raise MemoryAPIError( + f"Unexpected error during {method} request to {url}: {str(e)}", + ) from e + + async def close(self) -> None: + """ + Close the HTTP session. + + Should be called when the component is no longer needed to clean up + resources. + """ + if self._session and not self._session.closed: + await self._session.close() + logger.debug("Session closed") + + async def __aenter__(self) -> "ModelStudioMemoryBase": + """Support async context manager.""" + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + """Support async context manager.""" + await self.close() diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/config.py b/src/agentscope_bricks/components/memory/modelstudio_memory/config.py new file mode 100644 index 0000000..f734b61 --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/config.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" +Configuration management for ModelStudio Memory service. +""" +import os +from dataclasses import dataclass + + +# Default endpoint +DEFAULT_MEMORY_SERVICE_ENDPOINT = ( + "https://dashscope.aliyuncs.com/api/v2/apps/memory" +) + + +@dataclass +class MemoryServiceConfig: + """ + Configuration for ModelStudio Memory Service. + + Attributes: + api_key: DashScope API key for authentication + service_endpoint: Base URL for the memory service API + service_id: Service identifier + """ + + api_key: str + service_endpoint: str = DEFAULT_MEMORY_SERVICE_ENDPOINT + service_id: str = "memory_service" + + @classmethod + def from_env(cls) -> "MemoryServiceConfig": + """ + Create configuration from environment variables. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + + Returns: + MemoryServiceConfig: Configuration instance + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + """ + api_key = os.getenv("DASHSCOPE_API_KEY") + if not api_key: + raise ValueError( + "DASHSCOPE_API_KEY environment variable is required. " + "Please set it before using ModelStudio Memory components.", + ) + + return cls( + api_key=api_key, + service_endpoint=os.getenv( + "MEMORY_SERVICE_ENDPOINT", + DEFAULT_MEMORY_SERVICE_ENDPOINT, + ), + service_id=os.getenv("MODELSTUDIO_SERVICE_ID", "memory_service"), + ) + + def get_add_memory_url(self) -> str: + """Get URL for adding memory.""" + return f"{self.service_endpoint}/add" + + def get_search_memory_url(self) -> str: + """Get URL for searching memory.""" + return f"{self.service_endpoint}/memory_nodes/search" + + def get_list_memory_url(self) -> str: + """Get URL for listing memory.""" + return f"{self.service_endpoint}/memory_nodes" + + def get_delete_memory_url(self, memory_node_id: str) -> str: + """Get URL for deleting a specific memory node.""" + return f"{self.service_endpoint}/memory_nodes/{memory_node_id}" + + def get_create_profile_schema_url(self) -> str: + """Get URL for creating profile schema.""" + return f"{self.service_endpoint}/profile_schemas" + + def get_user_profile_url(self, schema_id: str) -> str: + """Get URL for getting user profile.""" + return ( + f"{self.service_endpoint}/profile_schemas/{schema_id}/user_profile" + ) diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/core.py b/src/agentscope_bricks/components/memory/modelstudio_memory/core.py new file mode 100644 index 0000000..3e1a1f2 --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/core.py @@ -0,0 +1,595 @@ +# -*- coding: utf-8 -*- +""" +ModelStudio Memory Components. + +This module provides components for interacting with the ModelStudio Memory +service, enabling: +- Adding conversation memories +- Searching for relevant memories +- Listing and managing memory nodes +- Creating and retrieving user profiles + +All components support async operations and follow the Component pattern. +""" +import logging +from typing import Any, Optional + +from agentscope_bricks.base.component import Component + +from .base import ModelStudioMemoryBase +from .config import MemoryServiceConfig +from .models import ( + AddMemoryInput, + AddMemoryOutput, + CreateProfileSchemaInput, + CreateProfileSchemaOutput, + DeleteMemoryInput, + DeleteMemoryOutput, + GetUserProfileInput, + GetUserProfileOutput, + ListMemoryInput, + ListMemoryOutput, + MemoryNode, + SearchMemoryInput, + SearchMemoryOutput, + UserProfile, + UserProfileAttribute, +) + +logger = logging.getLogger(__name__) + + +class AddMemory( + Component[AddMemoryInput, AddMemoryOutput], + ModelStudioMemoryBase, +): + """ + Component for storing conversation history as memory nodes. + + This component sends conversation messages to the ModelStudio Memory + to be processed and stored as searchable memory nodes. The service + automatically extracts and structures relevant information. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + (default: "memory_service") + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + (default: https://dashscope.aliyuncs.com/api/v2/apps/memory) + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + MemoryAPIError: If the API request fails + MemoryAuthenticationError: If authentication fails + MemoryNetworkError: If network communication fails + """ + + name = "add_memory" + description = "Store conversation messages as memory nodes" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the AddMemory component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: AddMemoryInput, + **kwargs: Any, + ) -> AddMemoryOutput: + """ + Add memory nodes for the given conversation. + + Args: + args: Input containing user_id, messages, timestamp, and optional + metadata + **kwargs: Additional parameters (currently unused) + + Returns: + AddMemoryOutput containing the created memory nodes and request_id + + Raises: + MemoryAPIError: If the API request fails + """ + logger.info(f"Adding memory for user {args.user_id}") + + try: + # Build request payload + payload = args.model_dump(exclude_none=True) + + # Send request + result = await self._request( + "POST", + self.config.get_add_memory_url(), + json=payload, + ) + + # Debug: print API response structure + logger.debug(f"API Response: {result}") + logger.debug( + f"memory_nodes type: {type(result.get('memory_nodes'))}", + ) + logger.debug(f"memory_nodes value: {result.get('memory_nodes')}") + + # Parse response - handle both list and dict formats + memory_nodes_raw = result.get("memory_nodes", []) + if isinstance(memory_nodes_raw, dict): + # If it's a dict (single node), wrap it in a list + memory_nodes_list = [memory_nodes_raw] + elif isinstance(memory_nodes_raw, list): + memory_nodes_list = memory_nodes_raw + else: + memory_nodes_list = [] + + output = AddMemoryOutput( + memory_nodes=[ + MemoryNode(**node) for node in memory_nodes_list + ], + request_id=result.get("request_id", ""), + ) + + logger.info( + f"Successfully added {len(output.memory_nodes)} memory nodes", + ) + return output + + except Exception: + logger.exception(f"Failed to add memory for user {args.user_id}") + raise + + +class SearchMemory( + Component[SearchMemoryInput, SearchMemoryOutput], + ModelStudioMemoryBase, +): + """ + Component for searching relevant memories based on conversation context. + + This component searches the memory database for relevant past conversations + and information based on the current conversation context. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + MemoryAPIError: If the API request fails + """ + + name = "search_memory" + description = "Search for relevant memories based on conversation context" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the SearchMemory component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: SearchMemoryInput, + **kwargs: Any, + ) -> SearchMemoryOutput: + """ + Search for relevant memory nodes. + + Args: + args: Input containing user_id, messages, top_k, and min_score + **kwargs: Additional parameters (currently unused) + + Returns: + SearchMemoryOutput containing retrieved memory nodes and request_id + + Raises: + MemoryAPIError: If the API request fails + """ + logger.info( + f"Searching memory for user {args.user_id} " + f"(top_k={args.top_k}, min_score={args.min_score})", + ) + + try: + # Build request payload + payload = args.model_dump(exclude_none=True) + + # Send request + result = await self._request( + "POST", + self.config.get_search_memory_url(), + json=payload, + ) + + # Parse response + output = SearchMemoryOutput( + memory_nodes=[ + MemoryNode(**node) + for node in result.get("memory_nodes", []) + ], + request_id=result.get("request_id", ""), + ) + + logger.info( + f"Found {len(output.memory_nodes)} memory nodes for " + f"user {args.user_id}", + ) + return output + + except Exception: + logger.exception( + f"Failed to search memory for user {args.user_id}", + ) + raise + + +class ListMemory( + Component[ListMemoryInput, ListMemoryOutput], + ModelStudioMemoryBase, +): + """ + Component for listing memory nodes with pagination. + + This component retrieves a paginated list of all memory nodes for a + specific user. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + MemoryAPIError: If the API request fails + """ + + name = "list_memory" + description = "List memory nodes for a user with pagination" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the ListMemory component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: ListMemoryInput, + **kwargs: Any, + ) -> ListMemoryOutput: + """ + List memory nodes for a user with pagination. + + Args: + args: Input containing user_id, page_num, and page_size + **kwargs: Additional parameters (currently unused) + + Returns: + ListMemoryOutput containing memory nodes, pagination info, + and request_id + + Raises: + MemoryAPIError: If the API request fails + """ + logger.info( + f"Listing memory for user {args.user_id} " + f"(page {args.page_num}, size {args.page_size})", + ) + + try: + # Build request params + params = args.model_dump(exclude_none=True) + + # Send request (GET with query parameters) + result = await self._request( + "GET", + self.config.get_list_memory_url(), + params=params, + ) + + # Parse response + output = ListMemoryOutput( + memory_nodes=[ + MemoryNode(**node) + for node in result.get("memory_nodes", []) + ], + page_size=result.get("page_size", args.page_size or 10), + page_num=result.get("page_num", args.page_num or 1), + total=result.get("total", 0), + request_id=result.get("request_id", ""), + ) + + logger.info( + f"Retrieved {len(output.memory_nodes)} memory nodes " + f"(total: {output.total})", + ) + return output + + except Exception: + logger.exception(f"Failed to list memory for user {args.user_id}") + raise + + +class DeleteMemory( + Component[DeleteMemoryInput, DeleteMemoryOutput], + ModelStudioMemoryBase, +): + """ + Component for deleting a specific memory node. + + This component deletes a memory node by its ID. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + MemoryAPIError: If the API request fails + MemoryNotFoundError: If the memory node is not found + """ + + name = "delete_memory" + description = "Delete a specific memory node" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the DeleteMemory component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: DeleteMemoryInput, + **kwargs: Any, + ) -> DeleteMemoryOutput: + """ + Delete a memory node. + + Args: + args: Input containing user_id and memory_node_id + **kwargs: Additional parameters (currently unused) + + Returns: + DeleteMemoryOutput containing the request_id + + Raises: + MemoryAPIError: If the API request fails + MemoryNotFoundError: If the memory node is not found + """ + logger.info( + f"Deleting memory node {args.memory_node_id} " + f"for user {args.user_id}", + ) + + try: + # Build URL with path parameter + url = self.config.get_delete_memory_url(args.memory_node_id) + + # Send request + result = await self._request("DELETE", url) + + # Parse response + output = DeleteMemoryOutput( + request_id=result.get("request_id", ""), + ) + + logger.info( + f"Successfully deleted memory node {args.memory_node_id}", + ) + return output + + except Exception: + logger.exception( + f"Failed to delete memory node {args.memory_node_id}", + ) + raise + + +class CreateProfileSchema( + Component[CreateProfileSchemaInput, CreateProfileSchemaOutput], + ModelStudioMemoryBase, +): + """ + Component for creating a user profile schema. + + This component creates a schema that defines the structure of user profiles + including attribute definitions. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set or if attributes list + is empty + MemoryAPIError: If the API request fails + """ + + name = "create_profile_schema" + description = "Create a profile schema with attribute definitions" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the CreateProfileSchema component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: CreateProfileSchemaInput, + **kwargs: Any, + ) -> CreateProfileSchemaOutput: + """ + Create a profile schema. + + Args: + args: Input containing name, description, and attributes + **kwargs: Additional parameters (currently unused) + + Returns: + CreateProfileSchemaOutput containing profile_schema_id and + request_id + + Raises: + MemoryAPIError: If the API request fails + """ + logger.info(f"Creating profile schema: {args.name}") + + try: + # Build request payload + payload = args.model_dump(exclude_none=True) + + # Send request + result = await self._request( + "POST", + self.config.get_create_profile_schema_url(), + json=payload, + ) + + # Parse response + output = CreateProfileSchemaOutput( + profile_schema_id=result.get("profile_schema_id", ""), + request_id=result.get("request_id", ""), + ) + + logger.info( + f"Successfully created profile schema: " + f"{output.profile_schema_id}", + ) + return output + + except Exception: + logger.exception(f"Failed to create profile schema: {args.name}") + raise + + +class GetUserProfile( + Component[GetUserProfileInput, GetUserProfileOutput], + ModelStudioMemoryBase, +): + """ + Component for retrieving a user profile. + + This component retrieves a user's profile based on a schema ID and user ID. + + Environment Variables: + DASHSCOPE_API_KEY: Required. API key for authentication + MODELSTUDIO_SERVICE_ID: Optional. Service identifier + MEMORY_SERVICE_ENDPOINT: Optional. API endpoint URL + + Raises: + ValueError: If DASHSCOPE_API_KEY is not set + MemoryAPIError: If the API request fails + MemoryNotFoundError: If the profile is not found + """ + + name = "get_user_profile" + description = "Get user profile by schema id and user id" + + def __init__(self, config: Optional[MemoryServiceConfig] = None) -> None: + """ + Initialize the GetUserProfile component. + + Args: + config: Optional configuration. If not provided, will be loaded + from environment variables. + """ + Component.__init__(self) + ModelStudioMemoryBase.__init__(self, config) + + async def _arun( + self, + args: GetUserProfileInput, + **kwargs: Any, + ) -> GetUserProfileOutput: + """ + Get a user profile. + + Args: + args: Input containing schema_id and user_id + **kwargs: Additional parameters (currently unused) + + Returns: + GetUserProfileOutput containing the profile and request_id + + Raises: + MemoryAPIError: If the API request fails + MemoryNotFoundError: If the profile is not found + """ + logger.info( + f"Getting user profile for user {args.user_id} " + f"with schema {args.schema_id}", + ) + + try: + # Build URL with path parameter + url = self.config.get_user_profile_url(args.schema_id) + + # Send request with user_id as query parameter + result = await self._request( + "GET", + url, + params={"user_id": args.user_id}, + ) + + # Parse response - handle API's camelCase field names + profile_raw = result.get("profile", {}) + attributes = [ + UserProfileAttribute( + name=item.get("name", ""), + id=item.get("id", ""), + value=item.get("value"), + ) + for item in profile_raw.get("attributes", []) + ] + + profile = UserProfile( + schema_description=profile_raw.get("schemaDescription"), + schema_name=profile_raw.get("schemaName"), + attributes=attributes, + ) + + output = GetUserProfileOutput( + profile=profile, + request_id=result.get("requestId", ""), + ) + + logger.info( + f"Successfully retrieved profile for user {args.user_id}", + ) + return output + + except Exception: + logger.exception( + f"Failed to get profile for user {args.user_id}", + ) + raise diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/exceptions.py b/src/agentscope_bricks/components/memory/modelstudio_memory/exceptions.py new file mode 100644 index 0000000..369d2e3 --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/exceptions.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +Custom exceptions for ModelStudio Memory components. +""" +from typing import Optional + + +class MemoryAPIError(Exception): + """ + Base exception for Memory API errors. + + Attributes: + message: Error message + status_code: HTTP status code + error_code: API error code (e.g., 'InvalidApiKey', 'InvalidParameter') + request_id: Request ID for tracking + """ + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + error_code: Optional[str] = None, + request_id: Optional[str] = None, + ): + self.status_code = status_code + self.error_code = error_code + self.request_id = request_id + super().__init__(message) + + def __str__(self) -> str: + """Format error message with all available information.""" + parts = [super().__str__()] + + if self.error_code: + parts.append(f"Error Code: {self.error_code}") + + if self.status_code: + parts.append(f"Status Code: {self.status_code}") + + if self.request_id: + parts.append(f"Request ID: {self.request_id}") + + return " | ".join(parts) + + +class MemoryAuthenticationError(MemoryAPIError): + """Raised when authentication fails (401, 403).""" + + pass + + +class MemoryNotFoundError(MemoryAPIError): + """Raised when a memory node is not found (404).""" + + pass + + +class MemoryValidationError(MemoryAPIError): + """Raised when input validation fails (400).""" + + pass + + +class MemoryNetworkError(MemoryAPIError): + """Raised when network communication fails.""" + + pass diff --git a/src/agentscope_bricks/components/memory/modelstudio_memory/models.py b/src/agentscope_bricks/components/memory/modelstudio_memory/models.py new file mode 100644 index 0000000..660227a --- /dev/null +++ b/src/agentscope_bricks/components/memory/modelstudio_memory/models.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +""" +Pydantic models for ModelStudio Memory API. +""" +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, model_validator + + +# ==================== Message ==================== +class Message(BaseModel): + """Message in a conversation.""" + + role: str = Field(..., description="Role of the message sender") + content: Any = Field(..., description="Content of the message") + + +# ==================== Memory Node ==================== +class MemoryNode(BaseModel): + """A memory node stored in the system.""" + + memory_node_id: Optional[str] = Field( + None, + description="Unique identifier for the memory node", + ) + content: str = Field(..., description="Content of the memory node") + + +# ==================== Add Memory ==================== +class AddMemoryInput(BaseModel): + """Input for adding memory.""" + + user_id: str = Field(..., description="End user id") + messages: List[Message] = Field( + ..., + description="Conversation messages to be stored as memory", + ) + timestamp: int = Field(..., description="Timestamp of the memory") + meta_data: Optional[Dict[str, Any]] = Field( + None, + description="Optional metadata", + ) + + class Config: + extra = "allow" # Allow extra fields + + +class AddMemoryOutput(BaseModel): + """Output from adding memory.""" + + memory_nodes: List[MemoryNode] = Field( + ..., + description="Generated memory nodes", + ) + request_id: str = Field(..., description="Request id") + + +# ==================== Search Memory ==================== +class SearchFilters(BaseModel): + """Filters for memory search.""" + + tags: Optional[List[str]] = Field( + None, + description="Filter results by tags", + ) + + +class SearchMemoryInput(BaseModel): + """Input for searching memory.""" + + user_id: str = Field(..., description="End user id") + messages: List[Message] = Field( + ..., + description="Conversation messages for context", + ) + top_k: Optional[int] = Field( + 100, + description="Maximum number of results to return", + ) + min_score: Optional[float] = Field( + 0.0, + description="Minimum similarity score threshold", + ) + + class Config: + extra = "allow" # Allow extra fields + + +class SearchMemoryOutput(BaseModel): + """Output from searching memory.""" + + memory_nodes: List[MemoryNode] = Field( + ..., + description="Retrieved memory nodes", + ) + request_id: str = Field(..., description="Request id") + + +# ==================== List Memory ==================== +class ListMemoryInput(BaseModel): + """Input for listing memory nodes.""" + + user_id: str = Field(..., description="End user id") + page_num: Optional[int] = Field(1, description="Page number (1-based)") + page_size: Optional[int] = Field( + 10, + description="Number of items per page", + ) + + class Config: + extra = "allow" # Allow extra fields + + +class ListMemoryOutput(BaseModel): + """Output from listing memory nodes.""" + + memory_nodes: List[MemoryNode] = Field( + ..., + description="Retrieved memory nodes", + ) + page_size: int = Field(..., description="Number of items per page") + page_num: int = Field(..., description="Current page number") + total: int = Field(..., description="Total number of memory nodes") + request_id: str = Field(..., description="Request id") + + +# ==================== Delete Memory ==================== +class DeleteMemoryInput(BaseModel): + """Input for deleting a memory node.""" + + user_id: str = Field(..., description="End user id") + memory_node_id: str = Field( + ..., + description="Memory node id to delete", + ) + + class Config: + extra = "allow" # Allow extra fields + + +class DeleteMemoryOutput(BaseModel): + """Output from deleting a memory node.""" + + request_id: str = Field(..., description="Request id") + + +# ==================== Profile Schema ==================== +class ProfileAttribute(BaseModel): + """Attribute definition in a profile schema.""" + + name: str = Field(..., description="Attribute name") + description: Optional[str] = Field( + None, + description="Attribute description", + ) + immutable: Optional[bool] = Field( + False, + description="Whether the attribute is immutable", + ) + default_value: Optional[Any] = Field( + None, + description="Default value for the attribute", + ) + + +class CreateProfileSchemaInput(BaseModel): + """Input for creating a profile schema.""" + + name: str = Field(..., description="Profile schema name") + description: Optional[str] = Field( + None, + description="Profile schema description", + ) + attributes: List[ProfileAttribute] = Field( + ..., + description="List of attribute definitions (must have at least 1)", + ) + + @model_validator(mode="after") + def validate_attributes(self) -> "CreateProfileSchemaInput": + """Validate that at least one attribute is provided.""" + if not self.attributes or len(self.attributes) == 0: + raise ValueError("attributes must contain at least one item") + return self + + class Config: + extra = "allow" + + +class CreateProfileSchemaOutput(BaseModel): + """Output from creating a profile schema.""" + + profile_schema_id: str = Field( + ..., + description="Created profile schema id", + ) + request_id: str = Field(..., description="Request id") + + +# ==================== User Profile ==================== +class UserProfileAttribute(BaseModel): + """Attribute in a user profile.""" + + name: str = Field(..., description="Attribute name") + id: str = Field(..., description="Attribute id") + value: Optional[Any] = Field(None, description="Attribute value") + + +class UserProfile(BaseModel): + """User profile with attributes.""" + + schema_description: Optional[str] = Field( + None, + alias="schemaDescription", + description="Schema description", + ) + schema_name: Optional[str] = Field( + None, + alias="schemaName", + description="Schema name", + ) + attributes: List[UserProfileAttribute] = Field( + default_factory=list, + description="User attributes", + ) + + class Config: + populate_by_name = True # Allow both field names and aliases + + +class GetUserProfileInput(BaseModel): + """Input for getting a user profile.""" + + schema_id: str = Field(..., description="Profile schema id") + user_id: str = Field(..., description="End user id") + + +class GetUserProfileOutput(BaseModel): + """Output from getting a user profile.""" + + request_id: str = Field(..., description="Request id", alias="requestId") + profile: UserProfile = Field(..., description="User profile") + + class Config: + populate_by_name = True # Allow both field names and aliases