From cb4e80f1bcb7a95db21f25f3ddd557b268d3aec1 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 12:15:35 +0800 Subject: [PATCH 001/175] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=87=8F=E9=99=90=E5=88=B6=E6=95=B0=E9=87=8F=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/user_memory_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 67a6ab2c..535dcc12 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1621,6 +1621,5 @@ def _clean_neo4j_value(value: Any) -> Any: return str(value) except Exception: return None - # 返回原始值 - return value + return value \ No newline at end of file From 89500df0ac1a1576380fa053b124133d85714cd0 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 12:20:27 +0800 Subject: [PATCH 002/175] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=87=8F=E9=99=90=E5=88=B6=E6=95=B0=E9=87=8F=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/user_memory_service.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 535dcc12..59bbc211 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1416,11 +1416,10 @@ async def analytics_graph_data( elementId(n) as id, labels(n)[0] as label, properties(n) as properties - LIMIT $limit """ node_params = { "group_id": end_user_id, - "limit": limit + # "limit": limit } # 执行节点查询 @@ -1567,9 +1566,9 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ allowed_fields = field_whitelist.get(label, []) # 如果没有定义白名单,返回空字典(或者可以返回所有字段) - if not allowed_fields: - # 对于未定义的节点类型,只返回基本字段 - allowed_fields = ["name", "created_at", "caption"] + # if not allowed_fields: + # # 对于未定义的节点类型,只返回基本字段 + # allowed_fields = ["name", "created_at", "caption"] count_neo4j=f"""MATCH (n)-[r]-(m) WHERE elementId(n) ="{node_id}" RETURN count(r) AS rel_count;""" node_results = await (_neo4j_connector.execute_query(count_neo4j)) # 提取白名单中的字段 From 1a2e043ec2de5bf0ea09bd2ce80a1d2564b43dff Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 14:27:05 +0800 Subject: [PATCH 003/175] =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=87=8F=E9=99=90=E5=88=B6=E6=95=B0=E9=87=8F=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/user_memory_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 59bbc211..eb9b2c34 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1416,10 +1416,11 @@ async def analytics_graph_data( elementId(n) as id, labels(n)[0] as label, properties(n) as properties + LIMIT $limit """ node_params = { "group_id": end_user_id, - # "limit": limit + "limit": limit } # 执行节点查询 From ba2ff053f97d83638478bd0ab0bbbc34eb7afc5d Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 14:48:37 +0800 Subject: [PATCH 004/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/memory_storage_schema.py | 20 +++++++++++++++++++ .../memory_entity_relationship_service.py | 2 +- api/app/services/user_memory_service.py | 6 ++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index ecb1570f..77afe052 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -11,6 +11,26 @@ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_valida # ============================================================================ # 原 UserInput 相关 Schema (保留原有功能) # ============================================================================ +type_mapping = { + "Person": "人物实体节点", + "Organization": "组织实体节点", + "ORG": "组织实体节点", + "Location": "地点实体节点", + "LOC": "地点实体节点", + "Event": "事件实体节点", + "Concept": "概念实体节点", + "Time": "时间实体节点", + "Position": "职位实体节点", + "WorkRole": "职业实体节点", + "System": "系统实体节点", + "Policy": "政策实体节点", + "HistoricalPeriod": "历史时期实体节点", + "HistoricalState": "历史国家实体节点", + "HistoricalEvent": "历史事件实体节点", + "EconomicFactor": "经济因素实体节点", + "Condition": "条件实体节点", + "Numeric": "数值实体节点" + } class UserInput(BaseModel): message: str history: list[dict] diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index f650217d..8baf038e 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -123,7 +123,7 @@ class MemoryEntityService: extracted_entity_list = self._deduplicate_dict_list(extracted_entity_list) # 合并所有数据并处理相同text的合并 - all_timeline_data = memory_summary_list + statement_list + extracted_entity_list + all_timeline_data = memory_summary_list + statement_list all_timeline_data = self._merge_same_text_items(all_timeline_data) result = { diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 59bbc211..52bddbcb 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -15,6 +15,7 @@ from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.schemas.memory_storage_schema import type_mapping from app.services.memory_base_service import MemoryBaseService from app.services.memory_config_service import MemoryConfigService from pydantic import BaseModel, Field @@ -1332,7 +1333,7 @@ async def analytics_graph_data( db: Session, end_user_id: str, node_types: Optional[List[str]] = None, - limit: int = 100, + limit: int = 130, depth: int = 1, center_node_id: Optional[str] = None ) -> Dict[str, Any]: @@ -1576,10 +1577,11 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ for field in allowed_fields: if field in properties: value = properties[field] + if str(field) == 'entity_type': + value=type_mapping.get(value,'') # 清理 Neo4j 特殊类型 filtered_props[field] = _clean_neo4j_value(value) filtered_props['associative_memory']=[i['rel_count'] for i in node_results][0] - print(filtered_props) return filtered_props From 1cee27e8305d751711aceca25b2be9e7f223e03c Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 14:51:20 +0800 Subject: [PATCH 005/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/user_memory_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 52bddbcb..e5e09f64 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1417,10 +1417,11 @@ async def analytics_graph_data( elementId(n) as id, labels(n)[0] as label, properties(n) as properties + LIMIT $limit """ node_params = { "group_id": end_user_id, - # "limit": limit + "limit": limit } # 执行节点查询 From ceee4fe5cf1b71465733312c72fd3fb9b4569ffa Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 14:54:38 +0800 Subject: [PATCH 006/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/user_memory_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index e5e09f64..752f9c1a 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1424,6 +1424,7 @@ async def analytics_graph_data( "limit": limit } + # 执行节点查询 node_results = await _neo4j_connector.execute_query(node_query, **node_params) From 6db37d35ed79704e636955582ecd5d4443943a85 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 15:25:04 +0800 Subject: [PATCH 007/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/memory_episodic_schema.py | 36 +++++++++++++++++++ api/app/schemas/memory_storage_schema.py | 20 ----------- .../memory_entity_relationship_service.py | 8 +++-- api/app/services/user_memory_service.py | 5 ++- 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/api/app/schemas/memory_episodic_schema.py b/api/app/schemas/memory_episodic_schema.py index 7b3f3d2d..6b41c493 100644 --- a/api/app/schemas/memory_episodic_schema.py +++ b/api/app/schemas/memory_episodic_schema.py @@ -1,9 +1,45 @@ """ 情景记忆的请求和响应模型 """ +from abc import ABC from pydantic import BaseModel, Field from typing import Optional +type_mapping = { + "Person": "人物实体节点", + "Organization": "组织实体节点", + "ORG": "组织实体节点", + "Location": "地点实体节点", + "LOC": "地点实体节点", + "Event": "事件实体节点", + "Concept": "概念实体节点", + "Time": "时间实体节点", + "Position": "职位实体节点", + "WorkRole": "职业实体节点", + "System": "系统实体节点", + "Policy": "政策实体节点", + "HistoricalPeriod": "历史时期实体节点", + "HistoricalState": "历史国家实体节点", + "HistoricalEvent": "历史事件实体节点", + "EconomicFactor": "经济因素实体节点", + "Condition": "条件实体节点", + "Numeric": "数值实体节点" + } +class EmotionType(ABC): + JOY_TYPE = "joy" + SURPRISE_TYPE = "surprise" + SANDROWNESS_TYPE = "sadness" + FEAR_TYPE = "fear" + ANGET_TYPE="anger" + NEUTRAL_TYPE="neutral" + EMOTION_MAPPING={ + "joy":"愉快", + "surprise":"惊喜", + "sadness":"悲伤", + "fear":"恐惧", + "anger":"生气", + "neutral":"中性" + } class EpisodicMemoryOverviewRequest(BaseModel): """情景记忆总览查询请求""" diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index 77afe052..ecb1570f 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -11,26 +11,6 @@ from pydantic import BaseModel, Field, ConfigDict, field_validator, model_valida # ============================================================================ # 原 UserInput 相关 Schema (保留原有功能) # ============================================================================ -type_mapping = { - "Person": "人物实体节点", - "Organization": "组织实体节点", - "ORG": "组织实体节点", - "Location": "地点实体节点", - "LOC": "地点实体节点", - "Event": "事件实体节点", - "Concept": "概念实体节点", - "Time": "时间实体节点", - "Position": "职位实体节点", - "WorkRole": "职业实体节点", - "System": "系统实体节点", - "Policy": "政策实体节点", - "HistoricalPeriod": "历史时期实体节点", - "HistoricalState": "历史国家实体节点", - "HistoricalEvent": "历史事件实体节点", - "EconomicFactor": "经济因素实体节点", - "Condition": "条件实体节点", - "Numeric": "数值实体节点" - } class UserInput(BaseModel): message: str history: list[dict] diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index 8baf038e..ca97fb39 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -15,6 +15,8 @@ from neo4j.time import DateTime as Neo4jDateTime import json from datetime import datetime +from app.schemas.memory_episodic_schema import EmotionType + logger = logging.getLogger(__name__) class MemoryEntityService: @@ -496,11 +498,11 @@ class MemoryEmotion: length_data.append(emotion_intensity) if emotion_type is not None and emotion_intensity is not None and formatted_created_at is not None: # 使用(emotion_type, created_at)作为分组键 - if emotion_type in {"joy", "surprise"}: + if emotion_type in {EmotionType.JOY_TYPE, EmotionType.SURPRISE_TYPE}: emotion_type='positive' - elif emotion_type in {"sadness", "fear", "anger"}: + elif emotion_type in {EmotionType.SANDROWNESS_TYPE, EmotionType.FEAR_TYPE, EmotionType.ANGET_TYPE}: emotion_type='negative' - elif emotion_type=='neutral': + elif emotion_type==EmotionType.NEUTRAL_TYPE: emotion_type='neutral' group_key = (emotion_type, formatted_created_at) # 累加emotion_intensity diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 752f9c1a..d19726af 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -15,7 +15,8 @@ from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.schemas.memory_storage_schema import type_mapping +from app.schemas.memory_episodic_schema import type_mapping, EmotionType + from app.services.memory_base_service import MemoryBaseService from app.services.memory_config_service import MemoryConfigService from pydantic import BaseModel, Field @@ -1581,6 +1582,8 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ value = properties[field] if str(field) == 'entity_type': value=type_mapping.get(value,'') + if str(field)=="emotion_type": + value=EmotionType.EMOTION_MAPPING.get(value) # 清理 Neo4j 特殊类型 filtered_props[field] = _clean_neo4j_value(value) filtered_props['associative_memory']=[i['rel_count'] for i in node_results][0] From 2c9e5df27dde50bf63bd2fdbd42b182e5d3b9748 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 15:34:45 +0800 Subject: [PATCH 008/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/memory_episodic_schema.py | 6 ++++++ api/app/services/user_memory_service.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/app/schemas/memory_episodic_schema.py b/api/app/schemas/memory_episodic_schema.py index 6b41c493..832bf34b 100644 --- a/api/app/schemas/memory_episodic_schema.py +++ b/api/app/schemas/memory_episodic_schema.py @@ -40,6 +40,12 @@ class EmotionType(ABC): "anger":"生气", "neutral":"中性" } +class EmotionSubject(ABC): + SUBJECT_MAPPING={ + "self":"自己", + "other":"别人", + "object":"事物对象" + } class EpisodicMemoryOverviewRequest(BaseModel): """情景记忆总览查询请求""" diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index d19726af..5011e83e 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -15,7 +15,7 @@ from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.schemas.memory_episodic_schema import type_mapping, EmotionType +from app.schemas.memory_episodic_schema import type_mapping, EmotionType, EmotionSubject from app.services.memory_base_service import MemoryBaseService from app.services.memory_config_service import MemoryConfigService @@ -1584,6 +1584,8 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ value=type_mapping.get(value,'') if str(field)=="emotion_type": value=EmotionType.EMOTION_MAPPING.get(value) + if str(field)=="emotion_subject": + value=EmotionSubject.SUBJECT_MAPPING.get(value) # 清理 Neo4j 特殊类型 filtered_props[field] = _clean_neo4j_value(value) filtered_props['associative_memory']=[i['rel_count'] for i in node_results][0] From 0695c117391f88249aec7ed9c3ac0075625dff27 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 14 Jan 2026 18:25:55 +0800 Subject: [PATCH 009/175] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 2d78d796..e05daf4a 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -634,7 +634,7 @@ class MemoryAgentService: retrieved_content.append({query:statements}) if retrieved_content==[]: retrieved_content='' - if '信息不足,无法回答。' != str(final_answer) :#and retrieved_content!=[] + if '信息不足,无法回答。' != str(final_answer) and str(search_switch).strip() != "2":#and retrieved_content!=[] # 使用 upsert 方法 repo.upsert( end_user_id=end_user_id, # 确保这个变量在作用域内 From d9fb8edaa9330f0919d8a8633b1cd9ab503763ec Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 15 Jan 2026 16:47:55 +0800 Subject: [PATCH 010/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E7=9A=84=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=85=A8=E5=B1=80=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 402 +++++++++++------------ 1 file changed, 195 insertions(+), 207 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index e05daf4a..f0756764 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -9,7 +9,7 @@ import os import re import time import uuid -from threading import Lock + from typing import Any, AsyncGenerator, Dict, List, Optional import redis @@ -51,9 +51,7 @@ _neo4j_connector = Neo4jConnector() class MemoryAgentService: """Service for memory agent operations""" - def __init__(self): - self.user_locks: Dict[str, Lock] = {} - self.locks_lock = Lock() + def writer_messages_deal(self,messages,start_time,group_id,config_id,message): messages = str(messages).replace("'", '"').replace('\\n', '').replace('\n', '').replace('\\', '') @@ -83,12 +81,7 @@ class MemoryAgentService: raise ValueError(f"写入失败: {messages}") - def get_group_lock(self, group_id: str) -> Lock: - """Get lock for specific group to prevent concurrent processing""" - with self.locks_lock: - if group_id not in self.user_locks: - self.user_locks[group_id] = Lock() - return self.user_locks[group_id] + def extract_tool_call_info(self, event: Dict) -> bool: """Extract tool call information from event""" @@ -417,241 +410,236 @@ class MemoryAgentService: except ImportError: audit_logger = None - # Get group lock to prevent concurrent processing - group_lock = self.get_group_lock(group_id) + try: + config_service = MemoryConfigService(db) + memory_config = config_service.load_memory_config( + config_id=config_id, + service_name="MemoryAgentService" + ) + logger.info(f"Configuration loaded successfully: {memory_config.config_name}") + except ConfigurationError as e: + error_msg = f"Failed to load configuration for config_id: {config_id}: {e}" + logger.error(error_msg) - with group_lock: - # Step 1: Load configuration from database only - try: - config_service = MemoryConfigService(db) - memory_config = config_service.load_memory_config( + # Log failed operation + if audit_logger: + duration = time.time() - start_time + audit_logger.log_operation( + operation="READ", config_id=config_id, - service_name="MemoryAgentService" + group_id=group_id, + success=False, + duration=duration, + error=error_msg ) - logger.info(f"Configuration loaded successfully: {memory_config.config_name}") - except ConfigurationError as e: - error_msg = f"Failed to load configuration for config_id: {config_id}: {e}" - logger.error(error_msg) - # Log failed operation - if audit_logger: - duration = time.time() - start_time - audit_logger.log_operation( - operation="READ", - config_id=config_id, - group_id=group_id, - success=False, - duration=duration, - error=error_msg - ) + raise ValueError(error_msg) - raise ValueError(error_msg) + # Step 2: Prepare history + history.append({"role": "user", "content": message}) + logger.debug(f"Group ID:{group_id}, Message:{message}, History:{history}, Config ID:{config_id}") - # Step 2: Prepare history - history.append({"role": "user", "content": message}) - logger.debug(f"Group ID:{group_id}, Message:{message}, History:{history}, Config ID:{config_id}") + # Step 3: Initialize MCP client and execute read workflow + mcp_config = get_mcp_server_config() + client = MultiServerMCPClient(mcp_config) - # Step 3: Initialize MCP client and execute read workflow - mcp_config = get_mcp_server_config() - client = MultiServerMCPClient(mcp_config) + async with client.session('data_flow') as session: + session_start = time.time() + logger.debug("Connected to MCP Server: data_flow") - async with client.session('data_flow') as session: - session_start = time.time() - logger.debug("Connected to MCP Server: data_flow") - - tools_start = time.time() - tools = await load_mcp_tools(session) - tools_time = time.time() - tools_start - logger.info(f"[PERF] MCP tools loading took: {tools_time:.4f}s") - - outputs = [] - intermediate_outputs = [] - seen_intermediates = set() # Track seen intermediate outputs to avoid duplicates + tools_start = time.time() + tools = await load_mcp_tools(session) + tools_time = time.time() - tools_start + logger.info(f"[PERF] MCP tools loading took: {tools_time:.4f}s") - # Pass memory_config to the graph workflow - graph_start = time.time() - async with make_read_graph(group_id, tools, search_switch, group_id, group_id, memory_config=memory_config, storage_type=storage_type, user_rag_memory_id=user_rag_memory_id) as graph: - graph_init_time = time.time() - graph_start - logger.info(f"[PERF] Graph initialization took: {graph_init_time:.4f}s") - - start = time.time() - config = {"configurable": {"thread_id": group_id}} - workflow_errors = [] # Track errors from workflow - - event_count = 0 - async for event in graph.astream( - {"messages": history, "memory_config": memory_config, "errors": []}, - stream_mode="values", - config=config - ): - event_count += 1 - event_start = time.time() - messages = event.get('messages') - # Capture any errors from the state - if event.get('errors'): - workflow_errors.extend(event.get('errors', [])) + outputs = [] + intermediate_outputs = [] + seen_intermediates = set() # Track seen intermediate outputs to avoid duplicates - for msg in messages: - msg_content = msg.content - msg_role = msg.__class__.__name__.lower().replace("message", "") - outputs.append({ - "role": msg_role, - "content": msg_content - }) + # Pass memory_config to the graph workflow + graph_start = time.time() + async with make_read_graph(group_id, tools, search_switch, group_id, group_id, memory_config=memory_config, storage_type=storage_type, user_rag_memory_id=user_rag_memory_id) as graph: + graph_init_time = time.time() - graph_start + logger.info(f"[PERF] Graph initialization took: {graph_init_time:.4f}s") - # Extract intermediate outputs - if hasattr(msg, 'content'): - try: - # Handle MCP content format: [{'type': 'text', 'text': '...'}] - content_to_parse = msg_content - if isinstance(msg_content, list): - for block in msg_content: - if isinstance(block, dict) and block.get('type') == 'text': - content_to_parse = block.get('text', '') - break - else: - continue # No text block found + start = time.time() + config = {"configurable": {"thread_id": group_id}} + workflow_errors = [] # Track errors from workflow - # Try to parse content as JSON - if isinstance(content_to_parse, str): - try: - parsed = json.loads(content_to_parse) - if isinstance(parsed, dict): - # Check for single intermediate output - if '_intermediate' in parsed: - intermediate_data = parsed['_intermediate'] + event_count = 0 + async for event in graph.astream( + {"messages": history, "memory_config": memory_config, "errors": []}, + stream_mode="values", + config=config + ): + event_count += 1 + event_start = time.time() + messages = event.get('messages') + # Capture any errors from the state + if event.get('errors'): + workflow_errors.extend(event.get('errors', [])) + + for msg in messages: + msg_content = msg.content + msg_role = msg.__class__.__name__.lower().replace("message", "") + outputs.append({ + "role": msg_role, + "content": msg_content + }) + + # Extract intermediate outputs + if hasattr(msg, 'content'): + try: + # Handle MCP content format: [{'type': 'text', 'text': '...'}] + content_to_parse = msg_content + if isinstance(msg_content, list): + for block in msg_content: + if isinstance(block, dict) and block.get('type') == 'text': + content_to_parse = block.get('text', '') + break + else: + continue # No text block found + + # Try to parse content as JSON + if isinstance(content_to_parse, str): + try: + parsed = json.loads(content_to_parse) + if isinstance(parsed, dict): + # Check for single intermediate output + if '_intermediate' in parsed: + intermediate_data = parsed['_intermediate'] + output_key = self._create_intermediate_key(intermediate_data) + + if output_key not in seen_intermediates: + seen_intermediates.add(output_key) + intermediate_outputs.append(self._format_intermediate_output(intermediate_data)) + + # Check for multiple intermediate outputs (from Retrieve) + if '_intermediates' in parsed: + for intermediate_data in parsed['_intermediates']: output_key = self._create_intermediate_key(intermediate_data) if output_key not in seen_intermediates: seen_intermediates.add(output_key) intermediate_outputs.append(self._format_intermediate_output(intermediate_data)) + except (json.JSONDecodeError, ValueError): + pass + except Exception as e: + logger.debug(f"Failed to extract intermediate output: {e}") - # Check for multiple intermediate outputs (from Retrieve) - if '_intermediates' in parsed: - for intermediate_data in parsed['_intermediates']: - output_key = self._create_intermediate_key(intermediate_data) + event_time = time.time() - event_start + logger.info(f"[PERF] Event {event_count} processing took: {event_time:.4f}s") - if output_key not in seen_intermediates: - seen_intermediates.add(output_key) - intermediate_outputs.append(self._format_intermediate_output(intermediate_data)) - except (json.JSONDecodeError, ValueError): - pass - except Exception as e: - logger.debug(f"Failed to extract intermediate output: {e}") - - event_time = time.time() - event_start - logger.info(f"[PERF] Event {event_count} processing took: {event_time:.4f}s") + workflow_duration = time.time() - start + session_duration = time.time() - session_start + logger.info(f"[PERF] Read graph workflow completed in {workflow_duration}s") + logger.info(f"[PERF] Total session duration: {session_duration:.4f}s") + logger.info(f"[PERF] Total events processed: {event_count}") + # Extract final answer + final_answer = "" + for messages in outputs: + if messages['role'] == 'tool': + message = messages['content'] - workflow_duration = time.time() - start - session_duration = time.time() - session_start - logger.info(f"[PERF] Read graph workflow completed in {workflow_duration}s") - logger.info(f"[PERF] Total session duration: {session_duration:.4f}s") - logger.info(f"[PERF] Total events processed: {event_count}") - # Extract final answer - final_answer = "" - for messages in outputs: - if messages['role'] == 'tool': - message = messages['content'] + # Handle MCP content format: [{'type': 'text', 'text': '...'}] + if isinstance(message, list): + # Extract text from MCP content blocks + for block in message: + if isinstance(block, dict) and block.get('type') == 'text': + message = block.get('text', '') + break + else: + continue # No text block found - # Handle MCP content format: [{'type': 'text', 'text': '...'}] - if isinstance(message, list): - # Extract text from MCP content blocks - for block in message: - if isinstance(block, dict) and block.get('type') == 'text': - message = block.get('text', '') - break - else: - continue # No text block found + try: + parsed = json.loads(message) if isinstance(message, str) else message + if isinstance(parsed, dict): + if parsed.get('status') == 'success': + summary_result = parsed.get('summary_result') + if summary_result: + final_answer = summary_result + except (json.JSONDecodeError, ValueError): + pass - try: - parsed = json.loads(message) if isinstance(message, str) else message - if isinstance(parsed, dict): - if parsed.get('status') == 'success': - summary_result = parsed.get('summary_result') - if summary_result: - final_answer = summary_result - except (json.JSONDecodeError, ValueError): - pass + # 记录成功的操作 + total_duration = time.time() - start_time - # 记录成功的操作 - total_duration = time.time() - start_time + # Check for workflow errors + if workflow_errors: + error_details = "; ".join([f"{e['tool']}: {e['error']}" for e in workflow_errors]) + logger.warning(f"Read workflow completed with errors: {error_details}") - # Check for workflow errors - if workflow_errors: - error_details = "; ".join([f"{e['tool']}: {e['error']}" for e in workflow_errors]) - logger.warning(f"Read workflow completed with errors: {error_details}") - - if audit_logger: - audit_logger.log_operation( - operation="READ", - config_id=config_id, - group_id=group_id, - success=False, - duration=total_duration, - error=error_details, - details={ - "search_switch": search_switch, - "history_length": len(history), - "intermediate_outputs_count": len(intermediate_outputs), - "has_answer": bool(final_answer), - "errors": workflow_errors - } - ) - - # Raise error if no answer was produced - if not final_answer: - raise ValueError(f"Read workflow failed: {error_details}") - - if audit_logger and not workflow_errors: + if audit_logger: audit_logger.log_operation( operation="READ", config_id=config_id, group_id=group_id, - success=True, + success=False, duration=total_duration, + error=error_details, details={ "search_switch": search_switch, "history_length": len(history), "intermediate_outputs_count": len(intermediate_outputs), - "has_answer": bool(final_answer) + "has_answer": bool(final_answer), + "errors": workflow_errors } ) - retrieved_content=[] - repo = ShortTermMemoryRepository(db) - if str(search_switch)!="2": - for intermediate in intermediate_outputs: - print(intermediate) - intermediate_type=intermediate['type'] - if intermediate_type=="search_result": - query=intermediate['query'] - raw_results=intermediate['raw_results'] - reranked_results=raw_results.get('reranked_results',[]) - try: - statements=[statement['statement'] for statement in reranked_results.get('statements', [])] - except Exception: - statements=[] - statements=list(set(statements)) - retrieved_content.append({query:statements}) - if retrieved_content==[]: - retrieved_content='' - if '信息不足,无法回答。' != str(final_answer) and str(search_switch).strip() != "2":#and retrieved_content!=[] - # 使用 upsert 方法 - repo.upsert( - end_user_id=end_user_id, # 确保这个变量在作用域内 - messages=ori_message, - aimessages=final_answer, - retrieved_content=retrieved_content, - search_switch=str(search_switch) - ) - print("写入成功") + + # Raise error if no answer was produced + if not final_answer: + raise ValueError(f"Read workflow failed: {error_details}") + + if audit_logger and not workflow_errors: + audit_logger.log_operation( + operation="READ", + config_id=config_id, + group_id=group_id, + success=True, + duration=total_duration, + details={ + "search_switch": search_switch, + "history_length": len(history), + "intermediate_outputs_count": len(intermediate_outputs), + "has_answer": bool(final_answer) + } + ) + retrieved_content=[] + repo = ShortTermMemoryRepository(db) + if str(search_switch)!="2": + for intermediate in intermediate_outputs: + print(intermediate) + intermediate_type=intermediate['type'] + if intermediate_type=="search_result": + query=intermediate['query'] + raw_results=intermediate['raw_results'] + reranked_results=raw_results.get('reranked_results',[]) + try: + statements=[statement['statement'] for statement in reranked_results.get('statements', [])] + except Exception: + statements=[] + statements=list(set(statements)) + retrieved_content.append({query:statements}) + if retrieved_content==[]: + retrieved_content='' + if '信息不足,无法回答。' != str(final_answer) and str(search_switch).strip() != "2":#and retrieved_content!=[] + # 使用 upsert 方法 + repo.upsert( + end_user_id=end_user_id, # 确保这个变量在作用域内 + messages=ori_message, + aimessages=final_answer, + retrieved_content=retrieved_content, + search_switch=str(search_switch) + ) + print("写入成功") - return { - "answer": final_answer, - "intermediate_outputs": intermediate_outputs - } - + return { + "answer": final_answer, + "intermediate_outputs": intermediate_outputs + } + def _create_intermediate_key(self, output: Dict) -> str: """ Create a unique key for an intermediate output to detect duplicates. From 871304c89bb000c5a7c1ea90bde6f58d1c85e7f4 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 15 Jan 2026 21:48:08 +0800 Subject: [PATCH 011/175] =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=95=B0=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_entity_relationship_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index ca97fb39..eedb7c29 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -597,7 +597,7 @@ class MemoryInteraction: group_id = ori_data[0]['group_id'] Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) if not Space_User: - return '不存在用户' + return [] user_id=Space_User[0]['id'] results = await self.connector.execute_query(Memory_Space_Associative, id=self.id,user_id=user_id) From 5a0d3df689805be0d25c8a7356f97b855dde45a1 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 19 Jan 2026 16:28:01 +0800 Subject: [PATCH 012/175] =?UTF-8?q?=E5=8F=8D=E6=80=9D=E4=BC=98=E5=8C=961.0?= =?UTF-8?q?=EF=BC=88=E4=BC=98=E5=8C=96=E9=9A=90=E7=A7=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E3=80=81=E6=97=B6=E9=97=B4=E6=A3=80=E7=B4=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory_reflection_controller.py | 95 +++++----------- .../reflection_engine/self_reflexion.py | 36 +++---- .../utils/prompt/prompts/evaluate.jinja2 | 3 +- .../utils/prompt/prompts/reflexion.jinja2 | 28 +++-- .../repositories/data_config_repository.py | 102 ++++++++++-------- api/app/repositories/neo4j/cypher_queries.py | 4 + api/app/repositories/neo4j/neo4j_update.py | 55 +++++++--- api/app/schemas/memory_storage_schema.py | 10 +- api/app/services/memory_reflection_service.py | 10 +- 9 files changed, 173 insertions(+), 170 deletions(-) diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index b0287d80..24c143b9 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -1,10 +1,11 @@ import asyncio import time +import uuid from app.core.logging_config import get_api_logger from app.core.memory.storage_services.reflection_engine.self_reflexion import ( ReflectionConfig, - ReflectionEngine, + ReflectionEngine, ReflectionRange, ReflectionBaseline, ) from app.core.response_utils import success from app.db import get_db @@ -39,9 +40,6 @@ async def save_reflection_config( db: Session = Depends(get_db), ) -> dict: """Save reflection configuration to data_comfig table""" - - - try: config_id = request.config_id if not config_id: @@ -52,51 +50,30 @@ async def save_reflection_config( api_logger.info(f"用户 {current_user.username} 保存反思配置,config_id: {config_id}") - update_params = { - "enable_self_reflexion": request.reflection_enabled, - "iteration_period": request.reflection_period_in_hours, - "reflexion_range": request.reflexion_range, - "baseline": request.baseline, - "reflection_model_id": request.reflection_model_id, - "memory_verify": request.memory_verify, - "quality_assessment": request.quality_assessment, - } + data_config = DataConfigRepository.update_reflection_config( + db, + config_id=config_id, + enable_self_reflexion=request.reflection_enabled, + iteration_period=request.reflection_period_in_hours, + reflexion_range=request.reflexion_range, + baseline=request.baseline, + reflection_model_id=request.reflection_model_id, + memory_verify=request.memory_verify, + quality_assessment=request.quality_assessment + ) - - - query, params = DataConfigRepository.build_update_reflection(config_id, **update_params) - - result = db.execute(text(query), params) - if result.rowcount == 0: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"未找到config_id为 {config_id} 的配置" - ) - db.commit() - - # 查询更新后的配置 - select_query, select_params = DataConfigRepository.build_select_reflection(config_id) - result = db.execute(text(select_query), select_params).fetchone() - - if not result: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"更新后未找到config_id为 {config_id} 的配置" - ) - - api_logger.info(f"成功保存反思配置到数据库,config_id: {config_id}") + db.refresh(data_config) reflection_result={ - "config_id": result.config_id, - "enable_self_reflexion": result.enable_self_reflexion, - "iteration_period": result.iteration_period, - "reflexion_range": result.reflexion_range, - "baseline": result.baseline, - "reflection_model_id": result.reflection_model_id, - "memory_verify": result.memory_verify, - "quality_assessment": result.quality_assessment, - "user_id": result.user_id} + "config_id": data_config.config_id, + "enable_self_reflexion": data_config.enable_self_reflexion, + "iteration_period": data_config.iteration_period, + "reflexion_range": data_config.reflexion_range, + "baseline": data_config.baseline, + "reflection_model_id": data_config.reflection_model_id, + "memory_verify": data_config.memory_verify, + "quality_assessment": data_config.quality_assessment} return success(data=reflection_result, msg="反思配置成功") @@ -116,9 +93,8 @@ async def save_reflection_config( ) -@router.post("/reflection") +@router.get("/reflection") async def start_workspace_reflection( - config_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -178,17 +154,7 @@ async def start_reflection_configs( """通过config_id查询data_config表中的反思配置信息""" try: api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - - # 使用DataConfigRepository查询反思配置 - select_query, select_params = DataConfigRepository.build_select_reflection(config_id) - result = db.execute(text(select_query), select_params).fetchone() - - if not result: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"未找到config_id为 {config_id} 的配置" - ) - + result = DataConfigRepository.query_reflection_config_by_id(db, config_id) # 构建返回数据 reflection_config = { "config_id": result.config_id, @@ -198,8 +164,7 @@ async def start_reflection_configs( "baseline": result.baseline, "reflection_model_id": result.reflection_model_id, "memory_verify": result.memory_verify, - "quality_assessment": result.quality_assessment, - "user_id": result.user_id + "quality_assessment": result.quality_assessment } api_logger.info(f"成功查询反思配置,config_id: {config_id}") return success(data=reflection_config, msg="反思配置查询成功") @@ -227,9 +192,7 @@ async def reflection_run( api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") # 使用DataConfigRepository查询反思配置 - select_query, select_params = DataConfigRepository.build_select_reflection(config_id) - result = db.execute(text(select_query), select_params).fetchone() - + result = DataConfigRepository.query_reflection_config_by_id(db, config_id) if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -242,7 +205,7 @@ async def reflection_run( model_id = result.reflection_model_id if model_id: try: - ModelConfigService.get_model_by_id(db=db, model_id=model_id) + ModelConfigService.get_model_by_id(db=db, model_id=uuid.UUID(model_id)) api_logger.info(f"模型ID验证成功: {model_id}") except Exception as e: api_logger.warning(f"模型ID '{model_id}' 不存在,将使用默认模型: {str(e)}") @@ -252,8 +215,8 @@ async def reflection_run( config = ReflectionConfig( enabled=result.enable_self_reflexion, iteration_period=result.iteration_period, - reflexion_range=result.reflexion_range, - baseline=result.baseline, + reflexion_range=ReflectionRange(result.reflexion_range), + baseline=ReflectionBaseline(result.baseline), output_example='', memory_verify=result.memory_verify, quality_assessment=result.quality_assessment, diff --git a/api/app/core/memory/storage_services/reflection_engine/self_reflexion.py b/api/app/core/memory/storage_services/reflection_engine/self_reflexion.py index e9fb8855..bd3a9190 100644 --- a/api/app/core/memory/storage_services/reflection_engine/self_reflexion.py +++ b/api/app/core/memory/storage_services/reflection_engine/self_reflexion.py @@ -24,15 +24,9 @@ from app.core.memory.utils.config.get_data import ( get_data, get_data_statement, ) -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.utils.prompt.template_render import ( - render_evaluate_prompt, - render_reflexion_prompt, -) + from app.core.models.base import RedBearModelConfig -from app.core.response_utils import success from app.repositories.neo4j.cypher_queries import ( - UPDATE_STATEMENT_INVALID_AT, neo4j_query_all, neo4j_query_part, neo4j_statement_all, @@ -160,12 +154,11 @@ class ReflectionEngine: self.neo4j_connector = Neo4jConnector() if self.llm_client is None: - from app.core.memory.utils.config import definitions as config_defs from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context with get_db_context() as db: factory = MemoryClientFactory(db) - self.llm_client = factory.get_llm_client(config_defs.SELECTED_LLM_ID) + self.llm_client = factory.get_llm_client(self.config.model_id) elif isinstance(self.llm_client, str): # 如果 llm_client 是字符串(model_id),则用它初始化客户端 from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -263,25 +256,23 @@ class ReflectionEngine: # 2. 检测冲突(基于事实的反思) conflict_data = await self._detect_conflicts(reflexion_data, statement_databasets) - print(100 * '-') - print(conflict_data) - print(100 * '-') - # # 检查是否真的有冲突 - conflicts_found='' + conflict_list=[] + for i in conflict_data: + conflict_list.append(i['data']) - conflicts_found='' + + + conflicts_found=0 # 3. 解决冲突 - solved_data = await self._resolve_conflicts(conflict_data, statement_databasets) + solved_data = await self._resolve_conflicts(conflict_list, statement_databasets) + if not solved_data: return ReflectionResult( success=False, - message="反思失败,未解决冲突", + message=f"没有{self.config.baseline}相关的冲突数据", conflicts_found=conflicts_found, execution_time=asyncio.get_event_loop().time() - start_time ) - print(100 * '*') - print(solved_data) - print(100 * '*') conflicts_resolved = len(solved_data) logging.info(f"解决了 {conflicts_resolved} 个冲突") @@ -386,7 +377,7 @@ class ReflectionEngine: memory_verifies.append(item['memory_verify']) result_data['memory_verifies'] = memory_verifies result_data['quality_assessments'] = quality_assessments - conflicts_found='' + conflicts_found = 0 # 初始化为整数0而不是空字符串 REMOVE_KEYS = {"created_at", "expired_at","relationship","predicate","statement_id","id","statement_id","relationship_statement_id"} # Clearn conflict_data,And memory_verify和quality_assessment cleaned_conflict_data = [] @@ -414,7 +405,7 @@ class ReflectionEngine: cleaned_conflict_data_.append(cleaned_item) print(cleaned_conflict_data_) # 3. 解决冲突 - solved_data = await self._resolve_conflicts(cleaned_conflict_data, source_data) + solved_data = await self._resolve_conflicts(cleaned_conflict_data_, source_data) if not solved_data: return ReflectionResult( success=False, @@ -739,4 +730,3 @@ class ReflectionEngine: raise ValueError(f"未知的反思基线: {self.config.baseline}") - diff --git a/api/app/core/memory/utils/prompt/prompts/evaluate.jinja2 b/api/app/core/memory/utils/prompt/prompts/evaluate.jinja2 index e649897a..5da6d4b5 100644 --- a/api/app/core/memory/utils/prompt/prompts/evaluate.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/evaluate.jinja2 @@ -24,7 +24,8 @@ - **身份冲突**: 同一实体被赋予不同类型或角色 - **隐私审核**: 存在隐私信息也作为冲突输出当{{ memory_verify }}是true的时候 ### 混合冲突 -检测所有逻辑不一致或相互矛盾的记录。 +- 检测所有逻辑不一致或相互矛盾的记录。 +- **隐私审核**: 存在隐私信息也作为冲突输出当{{ memory_verify }}是true的时候 **检测原则**: - 重点检查相同实体的记录 - 分析description字段语义冲突 diff --git a/api/app/core/memory/utils/prompt/prompts/reflexion.jinja2 b/api/app/core/memory/utils/prompt/prompts/reflexion.jinja2 index ed3aad32..99660aa4 100644 --- a/api/app/core/memory/utils/prompt/prompts/reflexion.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/reflexion.jinja2 @@ -63,7 +63,7 @@ **脱敏字段**: name、entity1_name、entity2_name、description、relationship ## 4. 处理流程 - +###如果存在冲突数据执行以下步骤,不存在返回【】在data中 ### 步骤1: 类型匹配验证 **匹配规则**: - baseline="TIME": 只处理时间相关冲突(涉及时间表达式、日期、时间点) @@ -78,7 +78,7 @@ ### 步骤2: 冲突数据分组 **分组策略**: -- 时间冲突组: 涉及用户时间的记录 +- 时间冲突组: 涉及用户时间的记录比如(生日在2月17...) - 活动时间冲突组: 同一活动不同时间的记录 - 事实冲突组: 同一实体不同属性的记录 - 其他冲突组: 其他类型冲突记录 @@ -97,11 +97,12 @@ ### 处理规则 ** baseline是TIME - -保留正确记录不变修改错误记录的expired_at为当前时间(2025-12-16T12:00:00),以及name需要修改成正确的 -** baseline不是TIME + - 只处理时间相关的内容,比如时间表达式、日期、时间点 + -保留正确记录不变修改错误记录的expired_at为当前时间,比如(2025-12-16T12:00:00) +** baseline是FACT或者HYBRID + - 处理不是时间相关的内容 - 修改字段内容( name、entity1_name、entity2_name、description、relationship)字段内容是否正确,如果不正确,需要对这些字段的内容重新生成,则不需要修改expired_at字段, 如果涉及到修改entity1_name/entity2_name字段的时候,同时也需要修改description字段,输出修改前和修改后的放入change里面的field - **核心原则**: - 只输出需要修改的记录 - 优先保留策略: 时间冲突保留最可信created_at时间,事实冲突选择最新且可信度最高记录 @@ -110,22 +111,26 @@ - 脱敏变更记录: 隐私脱敏变更也必须在change字段中记录{% endif %} - 不可修改数据: 数据被判定为正确时不可修改,无数据可输出时为空 - 输出的结果reflexion字段中的reason字段和solution不允许含有(expired_at设为2024-01-01T00:00:00Z、memory_verify=true、memory_verify=false)等原数据字段以及涉及需要修改的字段以及内容, - ,如果是FACT,只记录事实冲突相关的数据;如果是TIME,只记录时间冲突相关的数据;如果是HYBRID,则记录所有冲突相关的数据 + ,如果是FACT,只记录事实冲突相关的数据;如果是TIME,只记录时间冲突相关的数据;如果是HYBRID,则记录所有冲突相关的数据,如果存在隐私审核,隐私审核是true,也需要放到reflexion的reason字段和solution **变更记录格式**: ```json "change": [ { "field": [ - {"id":修改字段对应的ID} - {"statement_id":需要修改的对象对应的statement_id} - {"字段名1": ["修改前的值1","修改后的值1"]}, - {"字段名2": ["修改前的值2","修改后的值2"]} + {"id": "修改字段对应的ID"}, + {"字段名1": ["修改前的值1", "修改后的值1"]}, + {"字段名2": ["修改前的值2", "修改后的值2"]} ] } ] ``` +**resolved_memory格式说明**: +- 对于TIME类型冲突: 只需expired_at字段即可 +- 对于FACT/HYBRID类型冲突: 需要包含完整的记录对象(包括name、entity1_name、entity2_name、description、relationship等所有相关字段) +- resolved_memory中只包含需要修改的记录,不需要修改的记录不要包含在内 + **类型不匹配处理**: - 冲突类型与baseline不匹配时,resolved设为null - reflexion.reason说明类型不匹配原因 @@ -157,7 +162,8 @@ "conflict": true }, "reflexion": { - "reason": "该冲突类型的原因分析,如果是FACT就是存在事实冲突,分析该冲突原因,如果是TIME就是存在时间冲突,分析该冲突原因,如果是HYBRID,可以输出存在时间与事实的混合冲突再添加上原因分析, + "reason": "该冲突类型的原因分析,如果是FACT就是存在事实冲突,分析该冲突原因,如果是TIME就是存在时间冲突,分析该冲突原因,如果是HYBRID,可以输出存在时间与事实的混合冲突再添加上原因分析,如果 + 隐私审核打开的时候如果存在冲突,分析该冲突的原因 不可以随意分配冲突类型以及原因,不允许输出字段比如(statement、description、entity1_name、entity2_name、name、memory_verify、expired_at、conflict)等类似这种", "solution": "该冲突类型的解决方案(不允许输出字段比如(statement、description、entity1_name、entity2_name、name、memory_verify、expired_at、conflict)等类似这种)" }, diff --git a/api/app/repositories/data_config_repository.py b/api/app/repositories/data_config_repository.py index 7843acc2..ea9fadea 100644 --- a/api/app/repositories/data_config_repository.py +++ b/api/app/repositories/data_config_repository.py @@ -10,7 +10,7 @@ Classes: import uuid from typing import Dict, List, Optional, Tuple - +from app.core.exceptions import BusinessException from app.core.logging_config import get_config_logger, get_db_logger from app.models.data_config_model import DataConfig from app.schemas.memory_storage_schema import ( @@ -20,7 +20,7 @@ from app.schemas.memory_storage_schema import ( ConfigUpdateExtracted, ConfigUpdateForget, ) -from sqlalchemy import desc +from sqlalchemy import desc, select from sqlalchemy.orm import Session # 获取数据库专用日志器 @@ -136,72 +136,88 @@ class DataConfigRepository: id: m.id } AS targetNode """ - - # ==================== SQLAlchemy ORM 数据库操作方法 ==================== @staticmethod - def build_update_reflection(config_id: int, **kwargs) -> Tuple[str, Dict]: + def update_reflection_config( + db: Session, + config_id: int, + enable_self_reflexion: bool, + iteration_period: str, + reflexion_range: str, + baseline: str, + reflection_model_id: str, + memory_verify: bool, + quality_assessment: bool + ) -> DataConfig: """构建反思配置更新语句(SQLAlchemy text() 命名参数) Args: + quality_assessment: + memory_verify: + reflection_model_id: + baseline: + reflexion_range: + iteration_period: + enable_self_reflexion: + db: database object config_id: 配置ID - **kwargs: 反思配置参数 Returns: - Tuple[str, Dict]: (SQL查询字符串, 参数字典) + Data Raises: ValueError: 没有字段需要更新时抛出 """ db_logger.debug(f"构建反思配置更新语句: config_id={config_id}") + stmt = select(DataConfig).where(DataConfig.config_id == config_id) + data_config_obj = db.scalars(stmt).first() + if not data_config_obj: + raise BusinessException + data_config_obj.enable_self_reflexion = enable_self_reflexion + data_config_obj.iteration_period = iteration_period + data_config_obj.reflexion_range = reflexion_range + data_config_obj.baseline = baseline + data_config_obj.reflection_model_id = reflection_model_id + data_config_obj.memory_verify = memory_verify + data_config_obj.quality_assessment = quality_assessment - key_where = "config_id = :config_id" - set_fields: List[str] = [] - params: Dict = { - "config_id": config_id, - } - - # 反思配置字段映射 - mapping = { - "enable_self_reflexion": "enable_self_reflexion", - "iteration_period": "iteration_period", - "reflexion_range": "reflexion_range", - "baseline": "baseline", - "reflection_model_id": "reflection_model_id", - "memory_verify": "memory_verify", - "quality_assessment": "quality_assessment", - } - - for api_field, db_col in mapping.items(): - if api_field in kwargs and kwargs[api_field] is not None: - set_fields.append(f"{db_col} = :{api_field}") - params[api_field] = kwargs[api_field] - - if not set_fields: - raise ValueError("No fields to update") - - set_fields.append("updated_at = timezone('Asia/Shanghai', now())") - query = f"UPDATE {TABLE_NAME} SET " + ", ".join(set_fields) + f" WHERE {key_where}" - return query, params + return data_config_obj @staticmethod - def build_select_reflection(config_id: int) -> Tuple[str, Dict]: + def query_reflection_config_by_id(db: Session, config_id: int) -> DataConfig: """构建反思配置查询语句,通过config_id查询反思配置(SQLAlchemy text() 命名参数) Args: + db: database object config_id: 配置ID Returns: Tuple[str, Dict]: (SQL查询字符串, 参数字典) """ db_logger.debug(f"构建反思配置查询语句: config_id={config_id}") + stmt = select(DataConfig).where(DataConfig.config_id == config_id) + data_config = db.scalars(stmt).first() + if not data_config: + raise RuntimeError("reflection config not found") + return data_config + @staticmethod + def query_reflection_config_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> DataConfig: + """构建查询所有配置的语句(SQLAlchemy text() 命名参数) + + Args: + db: database object + workspace_id: 工作空间ID + + Returns: + Tuple[str, Dict]: (SQL查询字符串, 参数字典) + """ + db_logger.debug(f"构建查询所有配置语句: workspace_id={workspace_id}") + + stmt = select(DataConfig).where(DataConfig.workspace_id == workspace_id) + data_config = db.scalars(stmt).first() + if not data_config: + raise RuntimeError("reflection config not found") + return data_config - query = ( - f"SELECT config_id, enable_self_reflexion, iteration_period, reflexion_range, baseline, " - f"reflection_model_id, memory_verify, quality_assessment, user_id " - f"FROM {TABLE_NAME} WHERE config_id = :config_id" - ) - params = {"config_id": config_id} - return query, params @staticmethod def build_select_all(workspace_id: uuid.UUID) -> Tuple[str, Dict]: diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index fb28b81e..aac591b3 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -837,12 +837,14 @@ neo4j_query_part = """ WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) RETURN + elementId(m) as id, m.name as entity1_name, m.description as description, m.statement_id as statement_id, m.created_at as created_at, m.expired_at as expired_at, CASE WHEN rel IS NULL THEN "NO_RELATIONSHIP" ELSE type(rel) END as relationship_type, + elementId(rel) as rel_id, rel.predicate as predicate, rel.statement as relationship, rel.statement_id as relationship_statement_id, @@ -855,12 +857,14 @@ neo4j_query_all = """ WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) RETURN + elementId(m) as id, m.name as entity1_name, m.description as description, m.statement_id as statement_id, m.created_at as created_at, m.expired_at as expired_at, CASE WHEN rel IS NULL THEN "NO_RELATIONSHIP" ELSE type(rel) END as relationship_type, + elementId(rel) as rel_id, rel.predicate as predicate, rel.statement as relationship, rel.statement_id as relationship_statement_id, diff --git a/api/app/repositories/neo4j/neo4j_update.py b/api/app/repositories/neo4j/neo4j_update.py index 73b44396..753ae256 100644 --- a/api/app/repositories/neo4j/neo4j_update.py +++ b/api/app/repositories/neo4j/neo4j_update.py @@ -11,22 +11,28 @@ async def update_neo4j_data(neo4j_dict_data, update_databases): update_databases: update """ try: - # 构建WHERE条件 + # 构建WHERE条件 - 只使用elementId where_conditions = [] params = {} - for key, value in neo4j_dict_data.items(): - if value is not None: - param_name = f"param_{key}" - where_conditions.append(f"e.{key} = ${param_name}") - params[param_name] = value + # 优先使用id作为elementId进行查询 + if 'id' in neo4j_dict_data and neo4j_dict_data['id'] is not None: + where_conditions.append(f"elementId(e) = $param_id") + params['param_id'] = neo4j_dict_data['id'] + else: + # 如果没有id,使用其他字段作为条件 + for key, value in neo4j_dict_data.items(): + if value is not None: + param_name = f"param_{key}" + where_conditions.append(f"e.{key} = ${param_name}") + params[param_name] = value where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - # 构建SET条件 + # 构建SET条件 - 排除id字段 set_conditions = [] for key, value in update_databases.items(): - if value is not None: + if value is not None and key != 'id': # 不更新id字段 param_name = f"update_{key}" set_conditions.append(f"e.{key} = ${param_name}") params[param_name] = value @@ -76,22 +82,28 @@ async def update_neo4j_data_edge(neo4j_dict_data, update_databases): update_databases: update """ try: - # 构建WHERE条件 + # 构建WHERE条件 - 只使用elementId where_conditions = [] params = {} - for key, value in neo4j_dict_data.items(): - if value is not None: - param_name = f"param_{key}" - where_conditions.append(f"r.{key} = ${param_name}") - params[param_name] = value + # 优先使用id作为elementId进行查询 + if 'id' in neo4j_dict_data and neo4j_dict_data['id'] is not None: + where_conditions.append(f"elementId(r) = $param_id") + params['param_id'] = neo4j_dict_data['id'] + else: + # 如果没有id,使用其他字段作为条件 + for key, value in neo4j_dict_data.items(): + if value is not None: + param_name = f"param_{key}" + where_conditions.append(f"r.{key} = ${param_name}") + params[param_name] = value where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" - # 构建SET条件 + # 构建SET条件 - 排除id字段 set_conditions = [] for key, value in update_databases.items(): - if value is not None: + if value is not None and key != 'id': # 不更新id字段 param_name = f"update_{key}" set_conditions.append(f"r.{key} = ${param_name}") params[param_name] = value @@ -242,7 +254,16 @@ async def neo4j_data(solved_data): if key=='expired_at': updat_expired_at[key] = values[1] - elif key == 'statement_id': + elif key == 'id': + ori_edge[key] = values + updata_edge[key] = values + + ori_entity[key] = values + updata_entity[key] = values + + ori_expired_at[key] = values + elif key == 'rel_id': + key='id' ori_edge[key] = values updata_edge[key] = values diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index ecb1570f..d17a9f2c 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -35,10 +35,10 @@ class BaseDataSchema(BaseModel): expired_at: Optional[str] = Field(None, description="The expiration timestamp in ISO 8601 format.") description: Optional[str] = Field(None, description="The description of the data entry.") - # 新增字段以匹配实际输入数据 - entity1_name: str = Field(..., description="The first entity name.") + # 新增字段以匹配实际输入数据 - 改为可选以支持resolved_memory场景 + entity1_name: Optional[str] = Field(None, description="The first entity name.") entity2_name: Optional[str] = Field(None, description="The second entity name.") - statement_id: str = Field(..., description="The statement identifier.") + statement_id: Optional[str] = Field(None, description="The statement identifier.") # 新增字段 - 设为可选以保持向后兼容性 predicate: Optional[str] = Field(None, description="The predicate describing the relationship between entities.") relationship_statement_id: Optional[str] = Field(None, description="The relationship statement identifier.") @@ -108,13 +108,13 @@ class ChangeRecordSchema(BaseModel): """Schema for individual change records 字段值格式说明: - - id 和 statement_id: 字符串或 None + - id: 字符串,表示修改字段对应的记录ID - 其他字段: 可以是字符串、None,数组 [修改前的值, 修改后的值],或嵌套字典结构 - entity2等嵌套对象的字段也遵循 [old_value, new_value] 格式 """ field: List[Dict[str, Any]] = Field( ..., - description="List of field changes. First item: {id: value or None}, second: {statement_id: value}, followed by changed fields as {field_name: [old_value, new_value]} or {field_name: new_value} or nested structures like {entity2: {field_name: [old, new]}}" + description="List of field changes. First item: {id: value}, followed by changed fields as {field_name: [old_value, new_value]} or {field_name: new_value} or nested structures like {entity2: {field_name: [old, new]}}" ) class ResolvedSchema(BaseModel): diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index 0f8fb569..015cc08a 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -120,10 +120,12 @@ class WorkspaceAppService: def _get_data_config(self, memory_content: str) -> Dict[str, Any]: """Retrieve data_comfig information based on memory_comtent""" try: - data_config_query, data_config_params = DataConfigRepository.build_select_reflection(memory_content) - data_config_result = self.db.execute(text(data_config_query), data_config_params).fetchone() - if data_config_result is None: - return None + data_config_result = DataConfigRepository.query_reflection_config_by_id(self.db, int(memory_content)) + + # data_config_query, data_config_params = DataConfigRepository.build_select_reflection(memory_content) + # data_config_result = self.db.execute(text(data_config_query), data_config_params).fetchone() + # if data_config_result is None: + # return None if data_config_result: return { From d047190453716def40eff1990eac31ae7443548c Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 19 Jan 2026 18:06:19 +0800 Subject: [PATCH 013/175] =?UTF-8?q?=E5=8F=8D=E6=80=9D=E4=BC=98=E5=8C=961.0?= =?UTF-8?q?=EF=BC=88=E4=BC=98=E5=8C=96=E9=9A=90=E7=A7=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E3=80=81=E6=97=B6=E9=97=B4=E6=A3=80=E7=B4=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_entity_relationship_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index eedb7c29..cc69cb82 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -597,6 +597,7 @@ class MemoryInteraction: group_id = ori_data[0]['group_id'] Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) if not Space_User: + return [] user_id=Space_User[0]['id'] From 616f6401b421df561649dab7f6ba657592cb424c Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 19 Jan 2026 18:06:56 +0800 Subject: [PATCH 014/175] =?UTF-8?q?=E5=8F=8D=E6=80=9D=E4=BC=98=E5=8C=961.0?= =?UTF-8?q?=EF=BC=88=E4=BC=98=E5=8C=96=E9=9A=90=E7=A7=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E3=80=81=E6=97=B6=E9=97=B4=E6=A3=80=E7=B4=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_entity_relationship_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index cc69cb82..eedb7c29 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -597,7 +597,6 @@ class MemoryInteraction: group_id = ori_data[0]['group_id'] Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) if not Space_User: - return [] user_id=Space_User[0]['id'] From c9dbb6426967e200f549872b66b878a36b2a47e7 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 11:10:10 +0800 Subject: [PATCH 015/175] =?UTF-8?q?=E5=8F=8D=E6=80=9D=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory_reflection_controller.py | 13 ++- api/app/services/memory_reflection_service.py | 90 ++++++++++++++++--- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index 24c143b9..9be6e035 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -119,11 +119,20 @@ async def start_workspace_reflection( end_users = data['end_users'] for base, config, user in zip(releases, data_configs, end_users): - if int(base['config']) == int(config['config_id']) and base['app_id'] == user['app_id']: + # 安全地转换为整数,处理空字符串和None的情况 + print(base['config']) + try: + base_config = int(base['config']) if base['config'] else 0 + config_id = int(config['config_id']) if config['config_id'] else 0 + except (ValueError, TypeError): + api_logger.warning(f"无效的配置ID: base['config']={base.get('config')}, config['config_id']={config.get('config_id')}") + continue + + if base_config == config_id and base['app_id'] == user['app_id']: # 调用反思服务 api_logger.info(f"为用户 {user['id']} 启动反思,config_id: {config['config_id']}") - reflection_result = await reflection_service.start_reflection_from_data( + reflection_result = await reflection_service.start_text_reflection( config_data=config, end_user_id=user['id'] ) diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index 015cc08a..08ef2bbe 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -208,6 +208,49 @@ class MemoryReflectionService: def __init__(self,db: Session = Depends(get_db)): self.db=db + async def start_text_reflection(self, config_data: Dict[str, Any], end_user_id: str) -> Dict[str, Any]: + try: + config_id = config_data.get("config_id") + api_logger.info(f"从配置数据启动反思,config_id: {config_id}, end_user_id: {end_user_id}") + + if not config_data.get("enable_self_reflexion", False): + return { + "status": "跳过", + "message": "反思引擎未启用", + "config_id": config_id, + "end_user_id": end_user_id, + "config_data": config_data + } + + config_data_id = config_data['config_id'] + reflection_config = WorkspaceAppService(self.db)._get_data_config(config_data_id) + if reflection_config is not None and reflection_config['enable_self_reflexion']: + reflection_config = self._create_reflection_config_from_data(reflection_config) + # 3. 执行反思引擎 + reflection_results = await self._execute_reflection_engine( + reflection_config, end_user_id + ) + return { + "status": "完成", + "message": "反思引擎执行完成", + "config_id": config_id, + "end_user_id": end_user_id, + "config_data": config_data, + "reflection_results": reflection_results + } + + + + except Exception as e: + config_id = config_data.get("config_id", "unknown") + api_logger.error(f"启动反思失败,config_id: {config_id}, end_user_id: {end_user_id}, 错误: {str(e)}") + return { + "status": "错误", + "message": f"启动反思失败: {str(e)}", + "config_id": config_id, + "end_user_id": end_user_id, + "config_data": config_data + } async def start_reflection_from_data(self, config_data: Dict[str, Any], end_user_id: str) -> Dict[str, Any]: """ @@ -239,16 +282,41 @@ class MemoryReflectionService: reflection_config=WorkspaceAppService(self.db)._get_data_config(config_data_id) if reflection_config is not None and reflection_config['enable_self_reflexion']: reflection_config= self._create_reflection_config_from_data(reflection_config) - iteration_period=reflection_config.iteration_period + iteration_period = int(reflection_config.iteration_period) workspace_service = WorkspaceAppService(self.db) current_reflection_time = workspace_service.get_end_user_reflection_time(end_user_id) - reflection_time = datetime.fromisoformat(str(current_reflection_time)) - - current_time = datetime.now() - time_diff = current_time - reflection_time - hours_diff = int(time_diff.total_seconds() / 3600) - if iteration_period==hours_diff or current_reflection_time is None: + # 检查是否需要执行反思 + should_execute = False + hours_diff = 0 + + if current_reflection_time is None: + # 首次执行反思 + should_execute = True + api_logger.info(f"首次执行反思,end_user_id: {end_user_id}") + else: + # 计算时间差 + try: + if isinstance(current_reflection_time, str): + reflection_time = datetime.fromisoformat(current_reflection_time) + else: + reflection_time = current_reflection_time + + current_time = datetime.now() + time_diff = current_time - reflection_time + hours_diff = int(time_diff.total_seconds() / 3600) + + # 检查是否达到反思周期 + if hours_diff >= iteration_period: + should_execute = True + api_logger.info(f"与上次的反思时间间隔为: {hours_diff} 小时,达到周期 {iteration_period} 小时") + else: + api_logger.info(f"与上次的反思时间间隔为: {hours_diff} 小时,未达到周期 {iteration_period} 小时") + except (ValueError, TypeError) as e: + api_logger.warning(f"解析反思时间失败: {e},将执行反思") + should_execute = True + + if should_execute: api_logger.info(f"与上次的反思时间间隔为: {hours_diff} 小时") # 3. 执行反思引擎 reflection_results = await self._execute_reflection_engine( @@ -271,13 +339,15 @@ class MemoryReflectionService: } else: return { - "status": "等待中..", - "message": "反思引擎未开始执行执", + "status": "等待中", + "message": f"反思引擎未开始执行,距离下次执行还需 {iteration_period - hours_diff} 小时", "config_id": config_id, "end_user_id": end_user_id, "config_data": config_data, - "reflection_results": '' + "hours_since_last_reflection": hours_diff, + "next_reflection_in_hours": iteration_period - hours_diff } + except Exception as e: config_id = config_data.get("config_id", "unknown") From 6e77f5b0686124fb01069599918cb4bdd10664e6 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 11:11:45 +0800 Subject: [PATCH 016/175] =?UTF-8?q?=E5=8F=8D=E6=80=9D=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_reflection_service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index 08ef2bbe..46e42b46 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -239,8 +239,6 @@ class MemoryReflectionService: "reflection_results": reflection_results } - - except Exception as e: config_id = config_data.get("config_id", "unknown") api_logger.error(f"启动反思失败,config_id: {config_id}, end_user_id: {end_user_id}, 错误: {str(e)}") From a5ecbec9a63bc5f1e9f62884784aaf1be3874dfd Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 16:32:52 +0800 Subject: [PATCH 017/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E5=B1=82=E5=B5=8C=E5=A5=97BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/langgraph_graph/routing/routers.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/routing/routers.py b/api/app/core/memory/agent/langgraph_graph/routing/routers.py index c0b01be1..004e03b3 100644 --- a/api/app/core/memory/agent/langgraph_graph/routing/routers.py +++ b/api/app/core/memory/agent/langgraph_graph/routing/routers.py @@ -45,18 +45,17 @@ def Retrieve_continue(state) -> Literal["Verify", "Retrieve_Summary"]: return 'Retrieve_Summary' # Default based on business logic def Verify_continue(state: ReadState) -> Literal["Summary", "Summary_fails", "content_input"]: status=state.get('verify', '')['status'] - loop_count = counter.get_total() - print(status) + # loop_count = counter.get_total() if "success" in status: - counter.reset() + # counter.reset() return "Summary" elif "failed" in status: - if loop_count < 2: # Maximum loop count is 3 - return "content_input" - else: - counter.reset() - return "Summary_fails" - # else: - # # Add default return value to avoid returning None - # counter.reset() - # return "Summary" # Default based on business requirements + # if loop_count < 2: # Maximum loop count is 3 + # return "content_input" + # else: + # counter.reset() + return "Summary_fails" + else: + # Add default return value to avoid returning None + # counter.reset() + return "Summary" # Default based on business requirements From a634565296d677fe5357ed0036d1ea9d34895e82 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 18:46:53 +0800 Subject: [PATCH 018/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E5=B1=82=E5=B5=8C=E5=A5=97BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../langgraph_graph/nodes/problem_nodes.py | 17 +- .../langgraph_graph/nodes/summary_nodes.py | 3 +- .../agent/langgraph_graph/read_graph.py | 1 + .../agent/langgraph_graph/routing/routers.py | 3 + .../agent/services/optimized_llm_service.py | 29 +- .../core/memory/llm_tools/openai_client.py | 297 ++++++++++++++++-- 6 files changed, 311 insertions(+), 39 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index 0c68a47e..e02ef62b 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -1,3 +1,4 @@ +import os import json import time from app.core.logging_config import get_agent_logger @@ -13,7 +14,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin -template_root = PROJECT_ROOT_ + '/agent/utils/prompt' +template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') db_session = next(get_db()) logger = get_agent_logger(__name__) @@ -35,11 +36,16 @@ async def Split_The_Problem(state: ReadState) -> ReadState: memory_config = state.get('memory_config', None) history = await SessionService(store).get_history(group_id, group_id, group_id) + + # 生成 JSON schema 以指导 LLM 输出正确格式 + json_schema = ProblemExtensionResponse.model_json_schema() + system_prompt = await problem_service.template_service.render_template( template_name='problem_breakdown_prompt.jinja2', operation_name='split_the_problem', history=history, - sentence=content + sentence=content, + json_schema=json_schema ) try: @@ -147,11 +153,16 @@ async def Problem_Extension(state: ReadState) -> ReadState: data = [] history = await SessionService(store).get_history(group_id, group_id, group_id) + + # 生成 JSON schema 以指导 LLM 输出正确格式 + json_schema = ProblemExtensionResponse.model_json_schema() + system_prompt = await problem_service.template_service.render_template( template_name='Problem_Extension_prompt.jinja2', operation_name='problem_extension', history=history, - questions=databasets + questions=databasets, + json_schema=json_schema ) try: diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index 7b727da5..0d0b57b0 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -1,5 +1,6 @@ +import os import time from app.core.logging_config import get_agent_logger, log_time @@ -19,7 +20,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin -template_root = PROJECT_ROOT_ + '/agent/utils/prompt' +template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') logger = get_agent_logger(__name__) db_session = next(get_db()) diff --git a/api/app/core/memory/agent/langgraph_graph/read_graph.py b/api/app/core/memory/agent/langgraph_graph/read_graph.py index 19011a5f..c01889a9 100644 --- a/api/app/core/memory/agent/langgraph_graph/read_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/read_graph.py @@ -59,6 +59,7 @@ async def make_read_graph(): workflow.add_conditional_edges("Retrieve", Retrieve_continue) workflow.add_edge("Retrieve_Summary", END) workflow.add_conditional_edges("Verify", Verify_continue) + workflow.add_edge("Summary_fails", END) workflow.add_edge("Summary", END) diff --git a/api/app/core/memory/agent/langgraph_graph/routing/routers.py b/api/app/core/memory/agent/langgraph_graph/routing/routers.py index 004e03b3..151ce1c5 100644 --- a/api/app/core/memory/agent/langgraph_graph/routing/routers.py +++ b/api/app/core/memory/agent/langgraph_graph/routing/routers.py @@ -45,6 +45,9 @@ def Retrieve_continue(state) -> Literal["Verify", "Retrieve_Summary"]: return 'Retrieve_Summary' # Default based on business logic def Verify_continue(state: ReadState) -> Literal["Summary", "Summary_fails", "content_input"]: status=state.get('verify', '')['status'] + print(100*'-') + print(status) + print(100*'-') # loop_count = counter.get_total() if "success" in status: # counter.reset() diff --git a/api/app/core/memory/agent/services/optimized_llm_service.py b/api/app/core/memory/agent/services/optimized_llm_service.py index 6942d421..fce1cd76 100644 --- a/api/app/core/memory/agent/services/optimized_llm_service.py +++ b/api/app/core/memory/agent/services/optimized_llm_service.py @@ -162,22 +162,35 @@ class OptimizedLLMService: return fallback_value elif isinstance(fallback_value, dict): return response_model(**fallback_value) + elif isinstance(fallback_value, list): + # 对于 RootModel[List[...]] 类型,直接传入列表 + if hasattr(response_model, 'model_fields') and 'root' in response_model.model_fields: + return response_model(root=fallback_value) + # 或者尝试直接传入(Pydantic v2 的 RootModel 支持) + return response_model(fallback_value) # 尝试创建空的响应模型 - if hasattr(response_model, 'root'): - # RootModel类型 + # 检查是否是 RootModel 类型(通过检查 __pydantic_root_model__ 属性) + if hasattr(response_model, '__pydantic_root_model__') and response_model.__pydantic_root_model__: + # RootModel类型 - 传入空列表 + logger.debug(f"创建 RootModel 类型的空响应: {response_model.__name__}") return response_model([]) else: - # 普通BaseModel类型 + # 普通BaseModel类型 - 尝试无参数构造 + logger.debug(f"创建普通 BaseModel 类型的空响应: {response_model.__name__}") return response_model() except Exception as e: - logger.error(f"创建降级响应失败: {e}") + logger.error(f"创建降级响应失败: {e}", exc_info=True) # 最后的降级策略 - if hasattr(response_model, 'root'): - return response_model([]) - else: - return response_model() + try: + if hasattr(response_model, '__pydantic_root_model__') and response_model.__pydantic_root_model__: + return response_model([]) + else: + return response_model() + except Exception as final_error: + logger.error(f"最终降级策略也失败: {final_error}") + raise def clear_cache(self): """清理客户端缓存""" diff --git a/api/app/core/memory/llm_tools/openai_client.py b/api/app/core/memory/llm_tools/openai_client.py index dce7b495..93c0efd6 100644 --- a/api/app/core/memory/llm_tools/openai_client.py +++ b/api/app/core/memory/llm_tools/openai_client.py @@ -100,6 +100,41 @@ class OpenAIClient(LLMClient): logger.error(f"LLM 调用失败: {e}") raise LLMClientException(f"LLM 调用失败: {e}") from e + async def response(self, messages: List[Dict[str, str]], **kwargs) -> str: + """ + 简单响应接口实现(用于fallback机制) + + Args: + messages: 消息列表 + **kwargs: 额外参数 + + Returns: + LLM 响应文本 + + Raises: + LLMClientException: LLM 调用失败 + """ + try: + template = """{messages}""" + prompt = ChatPromptTemplate.from_template(template) + chain = prompt | self.client + + # 添加 Langfuse 回调(如果可用) + config = {} + if self.langfuse_handler: + config["callbacks"] = [self.langfuse_handler] + + response = await chain.ainvoke({"messages": messages}, config=config) + + # 提取文本内容 + if hasattr(response, "content"): + return str(response.content) + return str(response) + + except Exception as e: + logger.error(f"LLM 调用失败: {e}") + raise LLMClientException(f"LLM 调用失败: {e}") from e + async def response_structured( self, messages: List[Dict[str, str]], @@ -131,44 +166,206 @@ class OpenAIClient(LLMClient): if self.langfuse_handler: config["callbacks"] = [self.langfuse_handler] - # 方法 1: 使用 PydanticOutputParser + template = """{question}""" + prompt = ChatPromptTemplate.from_template(template) + + # 对于 DashScope 等不支持 with_structured_output 的模型,优先使用手动JSON解析 + # 这样可以避免不必要的尝试和错误 + if self.provider: #.lower() == "dashscope" + logger.info("DashScope 模型,直接使用手动JSON解析方法") + try: + # 获取原始响应,添加超时保护 + chain = prompt | self.client + response = await asyncio.wait_for( + chain.ainvoke({"question": question_text}, config=config), + timeout=self.timeout + ) + + # 提取响应文本 + response_text = "" + if hasattr(response, "content"): + response_text = str(response.content) + else: + response_text = str(response) + + logger.debug(f"LLM原始响应长度: {len(response_text)}") + + # 尝试提取JSON内容 + json_text = response_text.strip() + + # 如果响应包含markdown代码块,提取其中的JSON + if "```json" in json_text: + json_text = json_text.split("```json")[1].split("```")[0].strip() + elif "```" in json_text: + json_text = json_text.split("```")[1].split("```")[0].strip() + + # 尝试修复常见的JSON格式问题 + # 1. 移除可能的BOM标记 + json_text = json_text.lstrip('\ufeff') + + # 2. 如果JSON被截断(缺少结尾的 ] 或 }),尝试修复 + if json_text.startswith('[') and not json_text.rstrip().endswith(']'): + logger.warning("检测到JSON数组被截断,尝试修复") + # 找到最后一个完整的对象 + last_complete_brace = json_text.rfind('}') + if last_complete_brace > 0: + json_text = json_text[:last_complete_brace + 1] + ']' + logger.info(f"修复后的JSON长度: {len(json_text)}") + elif json_text.startswith('{') and not json_text.rstrip().endswith('}'): + logger.warning("检测到JSON对象被截断,尝试修复") + # 找到最后一个完整的字段 + last_complete_brace = json_text.rfind('}') + if last_complete_brace > 0: + json_text = json_text[:last_complete_brace + 1] + logger.info(f"修复后的JSON长度: {len(json_text)}") + + # 解析JSON + try: + parsed_dict = json.loads(json_text) + logger.debug(f"JSON解析成功,类型: {type(parsed_dict)}") + + # 如果是列表,记录第一个元素的结构 + if isinstance(parsed_dict, list) and len(parsed_dict) > 0: + logger.debug(f"第一个元素的键: {list(parsed_dict[0].keys()) if isinstance(parsed_dict[0], dict) else 'not a dict'}") + + # 尝试字段映射转换(处理LLM返回格式不匹配的情况) + if isinstance(parsed_dict, list): + transformed_list = [] + for item in parsed_dict: + if isinstance(item, dict): + transformed_item = {} + + # 常见的字段映射规则 + field_mappings = { + 'question': ['extended_question', 'question', 'query'], + 'original_question': ['original_question', 'original', 'source_question'], + 'extended_question': ['extended_question', 'question', 'query', 'extended'], + 'type': ['type', 'category', 'question_type'], + 'reason': ['reason', 'explanation', 'rationale'], + 'query': ['query', 'question', 'text'], + 'split_result': ['split_result', 'result', 'status'], + 'expansion_issue': ['expansion_issue', 'issues', 'expansions'], + } + + # 对于每个期望的字段,尝试从多个可能的源字段中获取 + for target_field, source_fields in field_mappings.items(): + for source_field in source_fields: + if source_field in item: + transformed_item[target_field] = item[source_field] + break + + # 特殊处理:如果只有 'question' 但缺少 'original_question' 和 'extended_question' + if 'question' in item and 'original_question' not in transformed_item: + transformed_item['original_question'] = item['question'] + if 'question' in item and 'extended_question' not in transformed_item: + transformed_item['extended_question'] = item['question'] + + # 保留原始字段(如果没有被映射) + for key, value in item.items(): + if key not in transformed_item: + transformed_item[key] = value + + transformed_list.append(transformed_item) + else: + transformed_list.append(item) + + logger.info(f"字段映射完成,尝试重新验证") + logger.debug(f"转换后的数据: {transformed_list}") + + try: + return response_model.model_validate(transformed_list) + except Exception as retry_error: + logger.error(f"字段映射后仍然验证失败: {retry_error}") + logger.error(f"完整的LLM响应: {response_text}") + logger.error(f"原始解析字典: {parsed_dict}") + logger.error(f"转换后的字典: {transformed_list}") + raise + else: + # 非列表类型,记录并抛出原始错误 + logger.error(f"完整的LLM响应: {response_text}") + logger.error(f"解析后的字典: {parsed_dict}") + raise + except json.JSONDecodeError as je: + logger.error(f"JSON解析失败: {je}") + logger.error(f"问题位置附近的文本: {json_text[max(0, je.pos-50):min(len(json_text), je.pos+50)]}") + + # 尝试更激进的修复:逐行解析,找到有效的JSON部分 + logger.info("尝试逐行解析JSON") + lines = json_text.split('\n') + for i in range(len(lines), 0, -1): + try: + partial_json = '\n'.join(lines[:i]) + if partial_json.startswith('['): + partial_json = partial_json.rstrip().rstrip(',') + ']' + elif partial_json.startswith('{'): + partial_json = partial_json.rstrip().rstrip(',') + '}' + + parsed_dict = json.loads(partial_json) + logger.info(f"成功解析部分JSON(前{i}行)") + return response_model.model_validate(parsed_dict) + except: + continue + + # 如果所有尝试都失败,抛出原始错误 + raise LLMClientException(f"JSON解析失败: {je}") from je + + except asyncio.TimeoutError: + logger.error(f"LLM调用超时({self.timeout}秒)") + raise LLMClientException(f"LLM调用超时({self.timeout}秒)") + except LLMClientException: + raise + except Exception as e: + logger.error(f"手动JSON解析失败: {e}", exc_info=True) + raise LLMClientException(f"手动JSON解析失败: {e}") from e + + + + + + # 方法 1: 使用 PydanticOutputParser(适用于支持的模型) if PydanticOutputParser is not None: try: parser = PydanticOutputParser(pydantic_object=response_model) format_instructions = parser.get_format_instructions() - prompt = ChatPromptTemplate.from_template( + prompt_with_instructions = ChatPromptTemplate.from_template( "{question}\n{format_instructions}" ) - chain = prompt | self.client | parser + chain = prompt_with_instructions | self.client | parser - parsed = await chain.ainvoke( - { - "question": question_text, - "format_instructions": format_instructions, - }, - config=config + parsed = await asyncio.wait_for( + chain.ainvoke( + { + "question": question_text, + "format_instructions": format_instructions, + }, + config=config + ), + timeout=self.timeout ) logger.debug(f"使用 PydanticOutputParser 解析成功") return parsed + except asyncio.TimeoutError: + logger.error(f"PydanticOutputParser 调用超时({self.timeout}秒)") + raise LLMClientException(f"LLM调用超时({self.timeout}秒)") except Exception as e: logger.warning( f"PydanticOutputParser 解析失败,尝试其他方法: {e}" ) - # 方法 2: 使用 LangChain 的 with_structured_output - template = """{question}""" - prompt = ChatPromptTemplate.from_template(template) - - try: - with_so = getattr(self.client, "with_structured_output", None) - - if callable(with_so): + # 方法 2: 使用 LangChain 的 with_structured_output (如果支持) + with_so = getattr(self.client, "with_structured_output", None) + + if callable(with_so): + try: structured_chain = prompt | with_so(response_model, strict=True) - parsed = await structured_chain.ainvoke( - {"question": question_text}, - config=config + parsed = await asyncio.wait_for( + structured_chain.ainvoke( + {"question": question_text}, + config=config + ), + timeout=self.timeout ) # 验证并返回结果 @@ -181,14 +378,60 @@ class OpenAIClient(LLMClient): # 尝试从 JSON 解析 return response_model.model_validate_json(json.dumps(parsed)) - except Exception as e: - logger.error(f"结构化输出失败: {e}") - raise LLMClientException(f"结构化输出失败: {e}") from e + except asyncio.TimeoutError: + logger.error(f"with_structured_output 调用超时({self.timeout}秒)") + raise LLMClientException(f"LLM调用超时({self.timeout}秒)") + except NotImplementedError: + logger.warning( + f"模型 {self.model_name} 不支持 with_structured_output,使用手动JSON解析" + ) + except Exception as e: + logger.warning(f"with_structured_output 失败: {e},尝试手动解析") - # 如果所有方法都失败,抛出异常 - raise LLMClientException( - "无法生成结构化输出,所有解析方法均失败" - ) + # 方法 3: 手动JSON解析(fallback方法) + logger.info("使用手动JSON解析方法(fallback)") + try: + # 获取原始响应 + chain = prompt | self.client + response = await asyncio.wait_for( + chain.ainvoke({"question": question_text}, config=config), + timeout=self.timeout + ) + + # 提取响应文本 + response_text = "" + if hasattr(response, "content"): + response_text = str(response.content) + else: + response_text = str(response) + + logger.debug(f"LLM原始响应: {response_text[:500]}...") + + # 尝试提取JSON内容 + json_text = response_text.strip() + + # 如果响应包含markdown代码块,提取其中的JSON + if "```json" in json_text: + json_text = json_text.split("```json")[1].split("```")[0].strip() + elif "```" in json_text: + json_text = json_text.split("```")[1].split("```")[0].strip() + + # 解析JSON + parsed_dict = json.loads(json_text) + logger.debug(f"JSON解析成功: {parsed_dict}") + + # 验证并创建Pydantic模型 + return response_model.model_validate(parsed_dict) + + except asyncio.TimeoutError: + logger.error(f"手动JSON解析调用超时({self.timeout}秒)") + raise LLMClientException(f"LLM调用超时({self.timeout}秒)") + except json.JSONDecodeError as je: + logger.error(f"JSON解析失败: {je}, 原始文本: {json_text[:200]}...") + raise LLMClientException(f"JSON解析失败: {je}") from je + except Exception as e: + logger.error(f"手动JSON解析失败: {e}") + raise LLMClientException(f"手动JSON解析失败: {e}") from e except LLMClientException: raise From 398964c747e108bab38430d3bcc72f3c5ba349cd Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 18:51:18 +0800 Subject: [PATCH 019/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E5=B1=82=E5=B5=8C=E5=A5=97BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nodes/verification_nodes.py | 120 +++++++++++++----- 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index f3a39afb..fbfbe86d 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -1,4 +1,4 @@ - +import os from app.core.logging_config import get_agent_logger from app.db import get_db @@ -12,7 +12,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin -template_root = PROJECT_ROOT_ + '/agent/utils/prompt' +template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') db_session = next(get_db()) logger = get_agent_logger(__name__) @@ -48,38 +48,90 @@ async def Verify_prompt(state: ReadState,messages_deal): } return Verify_result async def Verify(state: ReadState): - content = state.get('data', '') - group_id = state.get('group_id', '') - memory_config = state.get('memory_config', None) + logger.info("=== Verify 节点开始执行 ===") + try: + content = state.get('data', '') + group_id = state.get('group_id', '') + memory_config = state.get('memory_config', None) + + logger.info(f"Verify: content={content[:50] if content else 'empty'}..., group_id={group_id}") - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(group_id, group_id, group_id) + logger.info(f"Verify: 获取历史记录完成,history length={len(history)}") - retrieve = state.get("retrieve", '') - retrieve = retrieve.get("Expansion_issue", []) - messages = { - "Query": content, - "Expansion_issue": retrieve - } - - system_prompt = await verification_service.template_service.render_template( - template_name='split_verify_prompt.jinja2', - operation_name='split_verify_prompt', - history=history, - sentence=messages - ) - - # 使用优化的LLM服务 - structured = await verification_service.call_llm_structured( - state=state, - db_session=db_session, - system_prompt=system_prompt, - response_model=VerificationResult, - fallback_value={ - "split_result": "fail", - "expansion_issue": [], - "reason": "验证失败" + retrieve = state.get("retrieve", {}) + logger.info(f"Verify: retrieve data type={type(retrieve)}, keys={retrieve.keys() if isinstance(retrieve, dict) else 'N/A'}") + + retrieve_expansion = retrieve.get("Expansion_issue", []) if isinstance(retrieve, dict) else [] + logger.info(f"Verify: Expansion_issue length={len(retrieve_expansion)}") + + messages = { + "Query": content, + "Expansion_issue": retrieve_expansion } - ) - - result = await Verify_prompt(state, structured) - return {"verify": result} \ No newline at end of file + + logger.info("Verify: 开始渲染模板") + system_prompt = await verification_service.template_service.render_template( + template_name='split_verify_prompt.jinja2', + operation_name='split_verify_prompt', + history=history, + sentence=messages + ) + logger.info(f"Verify: 模板渲染完成,prompt length={len(system_prompt)}") + + # 使用优化的LLM服务,添加超时保护 + logger.info("Verify: 开始调用 LLM") + try: + # 添加 asyncio.wait_for 超时包裹,防止无限等待 + # 超时时间设置为 150 秒(比 LLM 配置的 120 秒稍长) + import asyncio + structured = await asyncio.wait_for( + verification_service.call_llm_structured( + state=state, + db_session=db_session, + system_prompt=system_prompt, + response_model=VerificationResult, + fallback_value={ + "query": content, # 添加必填的 query 字段 + "split_result": "fail", + "expansion_issue": [], + "reason": "验证失败" + } + ), + timeout=150.0 # 150秒超时 + ) + logger.info(f"Verify: LLM 调用完成,result={structured}") + except asyncio.TimeoutError: + logger.error("Verify: LLM 调用超时(150秒),使用 fallback 值") + structured = VerificationResult( + query=content, + split_result="fail", + expansion_issue=[], + reason="LLM调用超时" + ) + + result = await Verify_prompt(state, structured) + logger.info("=== Verify 节点执行完成 ===") + return {"verify": result} + + except Exception as e: + logger.error(f"Verify 节点执行失败: {e}", exc_info=True) + # 返回失败的验证结果 + return { + "verify": { + "status": "failed", + "verified_data": [], + "storage_type": state.get('storage_type', ''), + "user_rag_memory_id": state.get('user_rag_memory_id', ''), + "_intermediate": { + "type": "verification", + "title": "Data Verification", + "result": "failed", + "reason": f"验证过程出错: {str(e)}", + "query": state.get('data', ''), + "verified_count": 0, + "storage_type": state.get('storage_type', ''), + "user_rag_memory_id": state.get('user_rag_memory_id', '') + } + } + } \ No newline at end of file From 78559d98eb1ea8a41628956f90360f24a0b7f65b Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 19:11:40 +0800 Subject: [PATCH 020/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E5=B1=82=E5=B5=8C=E5=A5=97BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nodes/verification_nodes.py | 38 ++- .../agent/models/verification_models.py | 28 +- .../agent/services/optimized_llm_service.py | 33 +- .../utils/prompt/split_verify_prompt.jinja2 | 44 ++- .../core/memory/llm_tools/openai_client.py | 297 ++---------------- 5 files changed, 117 insertions(+), 323 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index fbfbe86d..dac7ea14 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -26,22 +26,33 @@ class VerificationNodeService(LLMServiceMixin): # 创建全局服务实例 verification_service = VerificationNodeService() -async def Verify_prompt(state: ReadState,messages_deal): +async def Verify_prompt(state: ReadState, messages_deal: VerificationResult): + """处理验证结果并生成输出格式""" storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') data = state.get('data', '') + + # 将 VerificationItem 对象转换为字典列表 + verified_data = [] + if messages_deal.expansion_issue: + for item in messages_deal.expansion_issue: + if hasattr(item, 'model_dump'): + verified_data.append(item.model_dump()) + elif isinstance(item, dict): + verified_data.append(item) + Verify_result = { "status": messages_deal.split_result, - "verified_data": messages_deal.expansion_issue, + "verified_data": verified_data, "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id, "_intermediate": { "type": "verification", "title": "Data Verification", "result": messages_deal.split_result, - "reason": messages_deal.reason, - "query": data, - "verified_count": len(messages_deal.expansion_issue), + "reason": messages_deal.reason or "验证完成", + "query": messages_deal.query, + "verified_count": len(verified_data), "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id } @@ -71,11 +82,16 @@ async def Verify(state: ReadState): } logger.info("Verify: 开始渲染模板") + + # 生成 JSON schema 以指导 LLM 输出正确格式 + json_schema = VerificationResult.model_json_schema() + system_prompt = await verification_service.template_service.render_template( template_name='split_verify_prompt.jinja2', operation_name='split_verify_prompt', history=history, - sentence=messages + sentence=messages, + json_schema=json_schema ) logger.info(f"Verify: 模板渲染完成,prompt length={len(system_prompt)}") @@ -92,10 +108,11 @@ async def Verify(state: ReadState): system_prompt=system_prompt, response_model=VerificationResult, fallback_value={ - "query": content, # 添加必填的 query 字段 - "split_result": "fail", + "query": content, + "history": history if isinstance(history, list) else [], "expansion_issue": [], - "reason": "验证失败" + "split_result": "failed", + "reason": "验证失败或超时" } ), timeout=150.0 # 150秒超时 @@ -105,8 +122,9 @@ async def Verify(state: ReadState): logger.error("Verify: LLM 调用超时(150秒),使用 fallback 值") structured = VerificationResult( query=content, - split_result="fail", + history=history if isinstance(history, list) else [], expansion_issue=[], + split_result="failed", reason="LLM调用超时" ) diff --git a/api/app/core/memory/agent/models/verification_models.py b/api/app/core/memory/agent/models/verification_models.py index bd8896b3..abdce040 100644 --- a/api/app/core/memory/agent/models/verification_models.py +++ b/api/app/core/memory/agent/models/verification_models.py @@ -4,11 +4,29 @@ from typing import List, Optional, Dict, Any from pydantic import BaseModel, Field +class VerificationItem(BaseModel): + """Individual verification item for a query-answer pair.""" + + query_small: str = Field(..., description="子问题") + answer_small: str = Field(..., description="子问题的回答") + status: str = Field(..., description="验证状态:True 或 False") + query_answer: str = Field(..., description="问题的答案(与 answer_small 相同)") + + class VerificationResult(BaseModel): """Result model for verification operation.""" - query: str - expansion_issue: List[Dict[str, Any]] - split_result: str - reason: Optional[str] = None - history: List[Dict[str, Any]] = Field(default_factory=list) + query: str = Field(..., description="原始查询问题") + history: List[Dict[str, Any]] = Field(default_factory=list, description="历史对话记录") + expansion_issue: List[VerificationItem] = Field( + default_factory=list, + description="验证后的数据列表,包含所有通过验证的问答对" + ) + split_result: str = Field( + ..., + description="验证结果状态:success(expansion_issue 非空)或 failed(expansion_issue 为空)" + ) + reason: Optional[str] = Field( + None, + description="验证结果的说明和分析" + ) diff --git a/api/app/core/memory/agent/services/optimized_llm_service.py b/api/app/core/memory/agent/services/optimized_llm_service.py index fce1cd76..68919c4a 100644 --- a/api/app/core/memory/agent/services/optimized_llm_service.py +++ b/api/app/core/memory/agent/services/optimized_llm_service.py @@ -162,35 +162,22 @@ class OptimizedLLMService: return fallback_value elif isinstance(fallback_value, dict): return response_model(**fallback_value) - elif isinstance(fallback_value, list): - # 对于 RootModel[List[...]] 类型,直接传入列表 - if hasattr(response_model, 'model_fields') and 'root' in response_model.model_fields: - return response_model(root=fallback_value) - # 或者尝试直接传入(Pydantic v2 的 RootModel 支持) - return response_model(fallback_value) - + # 尝试创建空的响应模型 - # 检查是否是 RootModel 类型(通过检查 __pydantic_root_model__ 属性) - if hasattr(response_model, '__pydantic_root_model__') and response_model.__pydantic_root_model__: - # RootModel类型 - 传入空列表 - logger.debug(f"创建 RootModel 类型的空响应: {response_model.__name__}") + if hasattr(response_model, 'root'): + # RootModel类型 return response_model([]) else: - # 普通BaseModel类型 - 尝试无参数构造 - logger.debug(f"创建普通 BaseModel 类型的空响应: {response_model.__name__}") + # 普通BaseModel类型 return response_model() - + except Exception as e: - logger.error(f"创建降级响应失败: {e}", exc_info=True) + logger.error(f"创建降级响应失败: {e}") # 最后的降级策略 - try: - if hasattr(response_model, '__pydantic_root_model__') and response_model.__pydantic_root_model__: - return response_model([]) - else: - return response_model() - except Exception as final_error: - logger.error(f"最终降级策略也失败: {final_error}") - raise + if hasattr(response_model, 'root'): + return response_model([]) + else: + return response_model() def clear_cache(self): """清理客户端缓存""" diff --git a/api/app/core/memory/agent/utils/prompt/split_verify_prompt.jinja2 b/api/app/core/memory/agent/utils/prompt/split_verify_prompt.jinja2 index d6ad8cab..5d10304a 100644 --- a/api/app/core/memory/agent/utils/prompt/split_verify_prompt.jinja2 +++ b/api/app/core/memory/agent/utils/prompt/split_verify_prompt.jinja2 @@ -42,19 +42,33 @@ 如果状态是TRUE保留这条数据,否则需不需要这条数据 ### 第五步 输出格式 按照json的形式输出 -{"data":"Query":原来Query的字段,"history":原来的history字段, -"expansion_issue":以为列表的形式存储验证之后的数据比如[ -{"query_small": query_small, - "answer_small": answer_small,, - "status": 回答的结果是否符合query_small,填写状态, - "query_answer": answer_small}, +{"query":"原来Query的字段", +"history":"原来的history字段", +"expansion_issue":以列表的形式存储验证之后的数据比如[ { - "query_small": "张曼婷生日是什么时候?", - "answer_small": "张曼婷喜欢绘画。", - "status": "True", - "query_answer": "张曼 婷喜欢绘画。" - },{}......] -, - "split_result":如果expansion_issue是空的列表返回failed,不是空列表返回success, - "reason": 为以上分析完之后的结果给一个说明 - } \ No newline at end of file + "query_small": "子问题", + "answer_small": "子问题的回答", + "status": "True或False,表示回答是否符合query_small", + "query_answer": "问题的答案(与answer_small相同)" +}, +{ + "query_small": "张曼婷生日是什么时候?", + "answer_small": "张曼婷喜欢绘画。", + "status": "False", + "query_answer": "张曼婷喜欢绘画。" +} +], +"split_result":"如果expansion_issue是空的列表返回failed,不是空列表返回success", +"reason": "为以上分析完之后的结果给一个说明" +} + +**输出格式要求** +**CRITICAL JSON FORMATTING REQUIREMENTS:** +1. Use only standard ASCII double quotes (") for JSON structure - never use Chinese quotation marks ("") or other Unicode quotes +2. If the extracted statement text contains quotation marks, escape them properly using backslashes (\") +3. Ensure all JSON strings are properly closed and comma-separated +4. Do not include line breaks within JSON string values +5. The output language should always be the same as the input language + +**JSON Schema:** +{{ json_schema }} \ No newline at end of file diff --git a/api/app/core/memory/llm_tools/openai_client.py b/api/app/core/memory/llm_tools/openai_client.py index 93c0efd6..dce7b495 100644 --- a/api/app/core/memory/llm_tools/openai_client.py +++ b/api/app/core/memory/llm_tools/openai_client.py @@ -100,41 +100,6 @@ class OpenAIClient(LLMClient): logger.error(f"LLM 调用失败: {e}") raise LLMClientException(f"LLM 调用失败: {e}") from e - async def response(self, messages: List[Dict[str, str]], **kwargs) -> str: - """ - 简单响应接口实现(用于fallback机制) - - Args: - messages: 消息列表 - **kwargs: 额外参数 - - Returns: - LLM 响应文本 - - Raises: - LLMClientException: LLM 调用失败 - """ - try: - template = """{messages}""" - prompt = ChatPromptTemplate.from_template(template) - chain = prompt | self.client - - # 添加 Langfuse 回调(如果可用) - config = {} - if self.langfuse_handler: - config["callbacks"] = [self.langfuse_handler] - - response = await chain.ainvoke({"messages": messages}, config=config) - - # 提取文本内容 - if hasattr(response, "content"): - return str(response.content) - return str(response) - - except Exception as e: - logger.error(f"LLM 调用失败: {e}") - raise LLMClientException(f"LLM 调用失败: {e}") from e - async def response_structured( self, messages: List[Dict[str, str]], @@ -166,206 +131,44 @@ class OpenAIClient(LLMClient): if self.langfuse_handler: config["callbacks"] = [self.langfuse_handler] - template = """{question}""" - prompt = ChatPromptTemplate.from_template(template) - - # 对于 DashScope 等不支持 with_structured_output 的模型,优先使用手动JSON解析 - # 这样可以避免不必要的尝试和错误 - if self.provider: #.lower() == "dashscope" - logger.info("DashScope 模型,直接使用手动JSON解析方法") - try: - # 获取原始响应,添加超时保护 - chain = prompt | self.client - response = await asyncio.wait_for( - chain.ainvoke({"question": question_text}, config=config), - timeout=self.timeout - ) - - # 提取响应文本 - response_text = "" - if hasattr(response, "content"): - response_text = str(response.content) - else: - response_text = str(response) - - logger.debug(f"LLM原始响应长度: {len(response_text)}") - - # 尝试提取JSON内容 - json_text = response_text.strip() - - # 如果响应包含markdown代码块,提取其中的JSON - if "```json" in json_text: - json_text = json_text.split("```json")[1].split("```")[0].strip() - elif "```" in json_text: - json_text = json_text.split("```")[1].split("```")[0].strip() - - # 尝试修复常见的JSON格式问题 - # 1. 移除可能的BOM标记 - json_text = json_text.lstrip('\ufeff') - - # 2. 如果JSON被截断(缺少结尾的 ] 或 }),尝试修复 - if json_text.startswith('[') and not json_text.rstrip().endswith(']'): - logger.warning("检测到JSON数组被截断,尝试修复") - # 找到最后一个完整的对象 - last_complete_brace = json_text.rfind('}') - if last_complete_brace > 0: - json_text = json_text[:last_complete_brace + 1] + ']' - logger.info(f"修复后的JSON长度: {len(json_text)}") - elif json_text.startswith('{') and not json_text.rstrip().endswith('}'): - logger.warning("检测到JSON对象被截断,尝试修复") - # 找到最后一个完整的字段 - last_complete_brace = json_text.rfind('}') - if last_complete_brace > 0: - json_text = json_text[:last_complete_brace + 1] - logger.info(f"修复后的JSON长度: {len(json_text)}") - - # 解析JSON - try: - parsed_dict = json.loads(json_text) - logger.debug(f"JSON解析成功,类型: {type(parsed_dict)}") - - # 如果是列表,记录第一个元素的结构 - if isinstance(parsed_dict, list) and len(parsed_dict) > 0: - logger.debug(f"第一个元素的键: {list(parsed_dict[0].keys()) if isinstance(parsed_dict[0], dict) else 'not a dict'}") - - # 尝试字段映射转换(处理LLM返回格式不匹配的情况) - if isinstance(parsed_dict, list): - transformed_list = [] - for item in parsed_dict: - if isinstance(item, dict): - transformed_item = {} - - # 常见的字段映射规则 - field_mappings = { - 'question': ['extended_question', 'question', 'query'], - 'original_question': ['original_question', 'original', 'source_question'], - 'extended_question': ['extended_question', 'question', 'query', 'extended'], - 'type': ['type', 'category', 'question_type'], - 'reason': ['reason', 'explanation', 'rationale'], - 'query': ['query', 'question', 'text'], - 'split_result': ['split_result', 'result', 'status'], - 'expansion_issue': ['expansion_issue', 'issues', 'expansions'], - } - - # 对于每个期望的字段,尝试从多个可能的源字段中获取 - for target_field, source_fields in field_mappings.items(): - for source_field in source_fields: - if source_field in item: - transformed_item[target_field] = item[source_field] - break - - # 特殊处理:如果只有 'question' 但缺少 'original_question' 和 'extended_question' - if 'question' in item and 'original_question' not in transformed_item: - transformed_item['original_question'] = item['question'] - if 'question' in item and 'extended_question' not in transformed_item: - transformed_item['extended_question'] = item['question'] - - # 保留原始字段(如果没有被映射) - for key, value in item.items(): - if key not in transformed_item: - transformed_item[key] = value - - transformed_list.append(transformed_item) - else: - transformed_list.append(item) - - logger.info(f"字段映射完成,尝试重新验证") - logger.debug(f"转换后的数据: {transformed_list}") - - try: - return response_model.model_validate(transformed_list) - except Exception as retry_error: - logger.error(f"字段映射后仍然验证失败: {retry_error}") - logger.error(f"完整的LLM响应: {response_text}") - logger.error(f"原始解析字典: {parsed_dict}") - logger.error(f"转换后的字典: {transformed_list}") - raise - else: - # 非列表类型,记录并抛出原始错误 - logger.error(f"完整的LLM响应: {response_text}") - logger.error(f"解析后的字典: {parsed_dict}") - raise - except json.JSONDecodeError as je: - logger.error(f"JSON解析失败: {je}") - logger.error(f"问题位置附近的文本: {json_text[max(0, je.pos-50):min(len(json_text), je.pos+50)]}") - - # 尝试更激进的修复:逐行解析,找到有效的JSON部分 - logger.info("尝试逐行解析JSON") - lines = json_text.split('\n') - for i in range(len(lines), 0, -1): - try: - partial_json = '\n'.join(lines[:i]) - if partial_json.startswith('['): - partial_json = partial_json.rstrip().rstrip(',') + ']' - elif partial_json.startswith('{'): - partial_json = partial_json.rstrip().rstrip(',') + '}' - - parsed_dict = json.loads(partial_json) - logger.info(f"成功解析部分JSON(前{i}行)") - return response_model.model_validate(parsed_dict) - except: - continue - - # 如果所有尝试都失败,抛出原始错误 - raise LLMClientException(f"JSON解析失败: {je}") from je - - except asyncio.TimeoutError: - logger.error(f"LLM调用超时({self.timeout}秒)") - raise LLMClientException(f"LLM调用超时({self.timeout}秒)") - except LLMClientException: - raise - except Exception as e: - logger.error(f"手动JSON解析失败: {e}", exc_info=True) - raise LLMClientException(f"手动JSON解析失败: {e}") from e - - - - - - # 方法 1: 使用 PydanticOutputParser(适用于支持的模型) + # 方法 1: 使用 PydanticOutputParser if PydanticOutputParser is not None: try: parser = PydanticOutputParser(pydantic_object=response_model) format_instructions = parser.get_format_instructions() - prompt_with_instructions = ChatPromptTemplate.from_template( + prompt = ChatPromptTemplate.from_template( "{question}\n{format_instructions}" ) - chain = prompt_with_instructions | self.client | parser + chain = prompt | self.client | parser - parsed = await asyncio.wait_for( - chain.ainvoke( - { - "question": question_text, - "format_instructions": format_instructions, - }, - config=config - ), - timeout=self.timeout + parsed = await chain.ainvoke( + { + "question": question_text, + "format_instructions": format_instructions, + }, + config=config ) logger.debug(f"使用 PydanticOutputParser 解析成功") return parsed - except asyncio.TimeoutError: - logger.error(f"PydanticOutputParser 调用超时({self.timeout}秒)") - raise LLMClientException(f"LLM调用超时({self.timeout}秒)") except Exception as e: logger.warning( f"PydanticOutputParser 解析失败,尝试其他方法: {e}" ) - # 方法 2: 使用 LangChain 的 with_structured_output (如果支持) - with_so = getattr(self.client, "with_structured_output", None) - - if callable(with_so): - try: + # 方法 2: 使用 LangChain 的 with_structured_output + template = """{question}""" + prompt = ChatPromptTemplate.from_template(template) + + try: + with_so = getattr(self.client, "with_structured_output", None) + + if callable(with_so): structured_chain = prompt | with_so(response_model, strict=True) - parsed = await asyncio.wait_for( - structured_chain.ainvoke( - {"question": question_text}, - config=config - ), - timeout=self.timeout + parsed = await structured_chain.ainvoke( + {"question": question_text}, + config=config ) # 验证并返回结果 @@ -378,60 +181,14 @@ class OpenAIClient(LLMClient): # 尝试从 JSON 解析 return response_model.model_validate_json(json.dumps(parsed)) - except asyncio.TimeoutError: - logger.error(f"with_structured_output 调用超时({self.timeout}秒)") - raise LLMClientException(f"LLM调用超时({self.timeout}秒)") - except NotImplementedError: - logger.warning( - f"模型 {self.model_name} 不支持 with_structured_output,使用手动JSON解析" - ) - except Exception as e: - logger.warning(f"with_structured_output 失败: {e},尝试手动解析") - - # 方法 3: 手动JSON解析(fallback方法) - logger.info("使用手动JSON解析方法(fallback)") - try: - # 获取原始响应 - chain = prompt | self.client - response = await asyncio.wait_for( - chain.ainvoke({"question": question_text}, config=config), - timeout=self.timeout - ) - - # 提取响应文本 - response_text = "" - if hasattr(response, "content"): - response_text = str(response.content) - else: - response_text = str(response) - - logger.debug(f"LLM原始响应: {response_text[:500]}...") - - # 尝试提取JSON内容 - json_text = response_text.strip() - - # 如果响应包含markdown代码块,提取其中的JSON - if "```json" in json_text: - json_text = json_text.split("```json")[1].split("```")[0].strip() - elif "```" in json_text: - json_text = json_text.split("```")[1].split("```")[0].strip() - - # 解析JSON - parsed_dict = json.loads(json_text) - logger.debug(f"JSON解析成功: {parsed_dict}") - - # 验证并创建Pydantic模型 - return response_model.model_validate(parsed_dict) - - except asyncio.TimeoutError: - logger.error(f"手动JSON解析调用超时({self.timeout}秒)") - raise LLMClientException(f"LLM调用超时({self.timeout}秒)") - except json.JSONDecodeError as je: - logger.error(f"JSON解析失败: {je}, 原始文本: {json_text[:200]}...") - raise LLMClientException(f"JSON解析失败: {je}") from je except Exception as e: - logger.error(f"手动JSON解析失败: {e}") - raise LLMClientException(f"手动JSON解析失败: {e}") from e + logger.error(f"结构化输出失败: {e}") + raise LLMClientException(f"结构化输出失败: {e}") from e + + # 如果所有方法都失败,抛出异常 + raise LLMClientException( + "无法生成结构化输出,所有解析方法均失败" + ) except LLMClientException: raise From 575190a96d8855503a31d6b576606e9a29fdc4c5 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 19:14:32 +0800 Subject: [PATCH 021/175] =?UTF-8?q?=E8=AF=BB=E5=8F=96=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E5=B1=82=E5=B5=8C=E5=A5=97BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/core/memory/agent/langgraph_graph/routing/routers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/routing/routers.py b/api/app/core/memory/agent/langgraph_graph/routing/routers.py index 151ce1c5..004e03b3 100644 --- a/api/app/core/memory/agent/langgraph_graph/routing/routers.py +++ b/api/app/core/memory/agent/langgraph_graph/routing/routers.py @@ -45,9 +45,6 @@ def Retrieve_continue(state) -> Literal["Verify", "Retrieve_Summary"]: return 'Retrieve_Summary' # Default based on business logic def Verify_continue(state: ReadState) -> Literal["Summary", "Summary_fails", "content_input"]: status=state.get('verify', '')['status'] - print(100*'-') - print(status) - print(100*'-') # loop_count = counter.get_total() if "success" in status: # counter.reset() From 29852ff0a5228fadc45ef7a4f088fc7bc11d8056 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 20 Jan 2026 20:12:14 +0800 Subject: [PATCH 022/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_agent_controller.py | 18 +- .../controllers/user_memory_controllers.py | 59 ++++++- api/app/schemas/end_user_schema.py | 1 + api/app/services/memory_agent_service.py | 14 +- api/app/services/memory_base_service.py | 161 +++++++++++++++++- .../memory_entity_relationship_service.py | 94 ++++++++-- api/app/services/user_memory_service.py | 63 ++++--- 7 files changed, 352 insertions(+), 58 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index b7da943c..46fe3043 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -9,7 +9,7 @@ from app.db import get_db from app.dependencies import cur_workspace_access_guard, get_current_user from app.models import ModelApiKey from app.models.user_model import User -from app.repositories import knowledge_repository +from app.repositories import knowledge_repository, WorkspaceRepository from app.schemas.memory_agent_schema import UserInput, Write_UserInput from app.schemas.response_schema import ApiResponse from app.services import task_service, workspace_service @@ -616,8 +616,10 @@ async def get_knowledge_type_stats_api( @router.get("/analytics/hot_memory_tags/by_user", response_model=ApiResponse) async def get_hot_memory_tags_by_user_api( end_user_id: Optional[str] = Query(None, description="用户ID(可选)"), + language_type: Optional[str] ="zh", limit: int = Query(20, description="返回标签数量限制"), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), + db: Session=Depends(get_db), ): """ 获取指定用户的热门记忆标签 @@ -628,10 +630,22 @@ async def get_hot_memory_tags_by_user_api( ... ] """ + + workspace_id=current_user.current_workspace_id + workspace_repo = WorkspaceRepository(db) + workspace_models = workspace_repo.get_workspace_models_configs(workspace_id) + + if workspace_models: + model_id = workspace_models.get("llm", None) + else: + model_id = None + api_logger.info(f"Hot memory tags by user requested: end_user_id={end_user_id}") try: result = await memory_agent_service.get_hot_memory_tags_by_user( end_user_id=end_user_id, + language_type=language_type, + model_id=model_id, limit=limit ) return success(data=result, msg="获取热门记忆标签成功") diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index a96c7a52..560d6c0d 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -12,6 +12,7 @@ from app.core.logging_config import get_api_logger from app.core.response_utils import success, fail from app.core.error_codes import BizCode from app.core.api_key_utils import timestamp_to_datetime +from app.services.memory_base_service import Translation_English from app.services.user_memory_service import ( UserMemoryService, analytics_memory_types, @@ -20,7 +21,7 @@ from app.services.user_memory_service import ( from app.services.memory_entity_relationship_service import MemoryEntityService,MemoryEmotion,MemoryInteraction from app.schemas.response_schema import ApiResponse from app.schemas.memory_storage_schema import GenerateCacheRequest - +from app.repositories.workspace_repository import WorkspaceRepository from app.schemas.end_user_schema import ( EndUserProfileResponse, EndUserProfileUpdate, @@ -44,6 +45,7 @@ router = APIRouter( @router.get("/analytics/memory_insight/report", response_model=ApiResponse) async def get_memory_insight_report_api( end_user_id: str, + language_type: str = "zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -53,10 +55,18 @@ async def get_memory_insight_report_api( 此接口仅查询数据库中已缓存的记忆洞察数据,不执行生成操作。 如需生成新的洞察报告,请使用专门的生成接口。 """ + workspace_id = current_user.current_workspace_id + workspace_repo = WorkspaceRepository(db) + workspace_models = workspace_repo.get_workspace_models_configs(workspace_id) + + if workspace_models: + model_id = workspace_models.get("llm", None) + else: + model_id = None api_logger.info(f"记忆洞察报告查询请求: end_user_id={end_user_id}, user={current_user.username}") try: # 调用服务层获取缓存数据 - result = await user_memory_service.get_cached_memory_insight(db, end_user_id) + result = await user_memory_service.get_cached_memory_insight(db, end_user_id,model_id,language_type) if result["is_cached"]: api_logger.info(f"成功返回缓存的记忆洞察报告: end_user_id={end_user_id}") @@ -72,6 +82,7 @@ async def get_memory_insight_report_api( @router.get("/analytics/user_summary", response_model=ApiResponse) async def get_user_summary_api( end_user_id: str, + language_type: str="zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -81,10 +92,18 @@ async def get_user_summary_api( 此接口仅查询数据库中已缓存的用户摘要数据,不执行生成操作。 如需生成新的用户摘要,请使用专门的生成接口。 """ + workspace_id = current_user.current_workspace_id + workspace_repo = WorkspaceRepository(db) + workspace_models = workspace_repo.get_workspace_models_configs(workspace_id) + + if workspace_models: + model_id = workspace_models.get("llm", None) + else: + model_id = None api_logger.info(f"用户摘要查询请求: end_user_id={end_user_id}, user={current_user.username}") try: # 调用服务层获取缓存数据 - result = await user_memory_service.get_cached_user_summary(db, end_user_id) + result = await user_memory_service.get_cached_user_summary(db, end_user_id,model_id,language_type) if result["is_cached"]: api_logger.info(f"成功返回缓存的用户摘要: end_user_id={end_user_id}") @@ -253,7 +272,6 @@ async def get_graph_data_api( depth=depth, center_node_id=center_node_id ) - # 检查是否有错误消息 if "message" in result and result["statistics"]["total_nodes"] == 0: api_logger.warning(f"图数据查询返回空结果: {result.get('message')}") @@ -274,11 +292,18 @@ async def get_graph_data_api( @router.get("/read_end_user/profile", response_model=ApiResponse) async def get_end_user_profile( end_user_id: str, + language_type: str = "zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: workspace_id = current_user.current_workspace_id + workspace_repo = WorkspaceRepository(db) + workspace_models = workspace_repo.get_workspace_models_configs(workspace_id) + if workspace_models: + model_id = workspace_models.get("llm", None) + else: + model_id = None # 检查用户是否已选择工作空间 if workspace_id is None: api_logger.warning(f"用户 {current_user.username} 尝试查询用户信息但未选择工作空间") @@ -297,12 +322,19 @@ async def get_end_user_profile( api_logger.warning(f"终端用户不存在: end_user_id={end_user_id}") return fail(BizCode.INVALID_PARAMETER, "终端用户不存在", f"end_user_id={end_user_id}") + other_name=end_user.other_name + position=end_user.position + department=end_user.department + if language_type!="zh": + other_name=await Translation_English(model_id,other_name) + position = await Translation_English(model_id, position) + department = await Translation_English(model_id, department) # 构建响应数据 profile_data = EndUserProfileResponse( id=end_user.id, - other_name=end_user.other_name, - position=end_user.position, - department=end_user.department, + other_name=other_name, + position=position, + department=department, contact=end_user.contact, phone=end_user.phone, hire_date=end_user.hire_date, @@ -396,12 +428,21 @@ async def update_end_user_profile( return fail(BizCode.INTERNAL_ERROR, "用户信息更新失败", str(e)) @router.get("/memory_space/timeline_memories", response_model=ApiResponse) -async def memory_space_timeline_of_shared_memories(id: str, label: str, +async def memory_space_timeline_of_shared_memories(id: str, label: str,language_type: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): + workspace_id=current_user.current_workspace_id + workspace_repo = WorkspaceRepository(db) + workspace_models = workspace_repo.get_workspace_models_configs(workspace_id) + + if workspace_models: + model_id = workspace_models.get("llm", None) + else: + model_id = None MemoryEntity = MemoryEntityService(id, label) - timeline_memories_result = await MemoryEntity.get_timeline_memories_server() + timeline_memories_result = await MemoryEntity.get_timeline_memories_server(model_id, language_type) + return success(data=timeline_memories_result, msg="共同记忆时间线") @router.get("/memory_space/relationship_evolution", response_model=ApiResponse) async def memory_space_relationship_evolution(id: str, label: str, diff --git a/api/app/schemas/end_user_schema.py b/api/app/schemas/end_user_schema.py index c9f9146d..6f7498a0 100644 --- a/api/app/schemas/end_user_schema.py +++ b/api/app/schemas/end_user_schema.py @@ -44,6 +44,7 @@ class EndUserProfileResponse(BaseModel): updatetime_profile: Optional[datetime.datetime] = Field(description="核心档案信息最后更新时间", default=None) + class EndUserProfileUpdate(BaseModel): """终端用户基本信息更新请求模型""" end_user_id: str = Field(description="终端用户ID") diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index c9230a26..ddf04216 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -26,6 +26,7 @@ from app.db import get_db_context from app.models.knowledge_model import Knowledge, KnowledgeType from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_config_schema import ConfigurationError +from app.services.memory_base_service import Translation_English from app.services.memory_config_service import MemoryConfigService from app.services.memory_konwledges_server import ( write_rag, @@ -692,7 +693,9 @@ class MemoryAgentService: async def get_hot_memory_tags_by_user( self, end_user_id: Optional[str] = None, - limit: int = 20 + limit: int = 20, + model_id: Optional[str] = None, + language_type: Optional[str] = "zh" ) -> List[Dict[str, Any]]: """ 获取指定用户的热门记忆标签 @@ -710,7 +713,14 @@ class MemoryAgentService: try: # by_user=False 表示按 group_id 查询(在Neo4j中,group_id就是用户维度) tags = await get_hot_memory_tags(end_user_id, limit=limit, by_user=False) - payload = [{"name": t, "frequency": f} for t, f in tags] + payload=[] + for tag, freq in tags: + print(tag, freq) + if language_type!="zh": + tag=await Translation_English(model_id, tag) + payload.append({"name": tag, "frequency": freq}) + else: + payload.append({"name": tag, "frequency": freq}) return payload except Exception as e: logger.error(f"热门记忆标签查询失败: {e}") diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index 6f844ae9..784dec7d 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -3,17 +3,164 @@ Memory Base Service 提供记忆服务的基础功能和共享辅助方法。 """ - +import asyncio +import re from datetime import datetime from typing import Optional - +from pydantic import BaseModel from app.core.logging_config import get_logger from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.services.emotion_analytics_service import EmotionAnalyticsService - +from app.core.memory.llm_tools.openai_client import OpenAIClient +from app.core.models.base import RedBearModelConfig +from app.services.memory_config_service import MemoryConfigService +from app.db import get_db_context logger = get_logger(__name__) +class TranslationResponse(BaseModel): + """翻译响应模型""" + data: str + +class MemoryTransService: + """记忆翻译服务,提供中英文翻译功能""" + + def __init__(self, llm_client=None, model_id: Optional[str] = None): + """ + 初始化翻译服务 + + Args: + llm_client: LLM客户端实例或模型ID字符串(可选) + model_id: 模型ID,用于初始化LLM客户端(可选) + + Note: + - 如果llm_client是字符串,会被当作model_id使用 + - 如果同时提供llm_client和model_id,优先使用llm_client + """ + # 处理llm_client参数:如果是字符串,当作model_id + if isinstance(llm_client, str): + self.model_id = llm_client + self.llm_client = None + else: + self.llm_client = llm_client + self.model_id = model_id + + self._initialized = False + + def _ensure_llm_client(self): + """确保LLM客户端已初始化""" + if self._initialized: + return + + if self.llm_client is None: + if self.model_id: + with get_db_context() as db: + config_service = MemoryConfigService(db) + model_config = config_service.get_model_config(self.model_id) + + extra_params = { + "temperature": 0.2, + "max_tokens": 400, + "top_p": 0.8, + "stream": False, + } + + self.llm_client = OpenAIClient( + RedBearModelConfig( + model_name=model_config.get("model_name"), + provider=model_config.get("provider"), + api_key=model_config.get("api_key"), + base_url=model_config.get("base_url"), + timeout=model_config.get("timeout", 30), + max_retries=model_config.get("max_retries", 3), + extra_params=extra_params + ), + type_=model_config.get("type") + ) + else: + raise ValueError("必须提供 llm_client 或 model_id 之一") + + self._initialized = True + + async def translate_to_english(self, text: str) -> str: + """ + 将中文翻译为英文 + + Args: + text: 要翻译的中文文本 + + Returns: + 翻译后的英文文本 + """ + self._ensure_llm_client() + + translation_messages = [ + { + "role": "user", + "content": f"{text}\n\n中文翻译为英文,输出格式为{{\"data\":\"翻译后的内容\"}}" + } + ] + + try: + response = await self.llm_client.response_structured( + messages=translation_messages, + response_model=TranslationResponse + ) + return response.data + except Exception as e: + logger.error(f"翻译失败: {str(e)}") + return text # 翻译失败时返回原文 + + async def is_english(self,text: str) -> bool: + return bool(re.fullmatch(r"[A-Za-z\s]+", text)) + async def Translate(self, text: str, target_language: str = "en") -> str: + """ + 通用翻译方法(保持向后兼容) + + Args: + text: 要翻译的文本 + target_language: 目标语言,"en"表示英文,"zh"表示中文 + + Returns: + 翻译后的文本 + """ + if target_language == "en": + return await self.translate_to_english(text) + else: + logger.warning(f"不支持的目标语言: {target_language},返回原文") + return text + + # 测试翻译服务 +async def Translation_English(modid, text, fields=None): + """ + 将数据翻译为英文(支持字段级翻译) + + Args: + modid: 模型ID + text: 要翻译的数据(可以是字符串、字典或列表) + fields: 需要翻译的字段列表(可选) + 如果为None,默认翻译: ['content', 'summary', 'statement', 'description', + 'name', 'aliases', 'caption', 'emotion_keywords'] + + Returns: + 翻译后的数据,保持原有结构 + """ + trans_service = MemoryTransService(modid) + # 执行翻译 + if isinstance(text, list): + english_result=[] + for i in text: + is_eng=await trans_service.is_english(i) + if not is_eng: + english = await trans_service.Translate(i) + english_result.append(english) + return english_result + if isinstance(text, str): + is_eng = await trans_service.is_english(text) + if not is_eng: + english_result = await trans_service.Translate(text) + return english_result + return text class MemoryBaseService: """记忆服务基类,提供共享的辅助方法""" @@ -295,3 +442,11 @@ class MemoryBaseService: except Exception as e: logger.error(f"获取遗忘记忆数量时出错: {str(e)}", exc_info=True) return 0 + +if __name__ == '__main__': + import asyncio + a=[{"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:33925", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u4f60\u597d", "created_at": "2026-01-06T14:50:08.381230", "associative_memory": 0}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:33926", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u53d1\u8d77\u4e86\u5bf9\u8bdd\uff0c\u53d1\u9001\u4e86\u95ee\u5019\u8bed\"\u4f60\u597d\"\u3002", "created_at": "2026-01-06T14:50:11.363879", "associative_memory": 0}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76903", "label": "ExtractedEntity", "properties": {"description": "\u5728\u673a\u5668\u5b66\u4e60\u4e2d\u901a\u8fc7\u4e0d\u540c\u6570\u636e\u6837\u672c\u6765\u8861\u91cf\u6a21\u578b\u9884\u6d4b\u8bef\u5dee\u7684\u65b9\u6cd5", "name": "\u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:24:55.805367", "aliases": ["\u505a\u7279\u5f81\u63d0\u53d6", "\u56de\u6eaf\u5386\u53f2\u6570\u636e", "\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861", "\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570", "\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a", "\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c", "\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7", "\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c", "\u7b54\u6848", "\u4eba\u6027", "\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb"], "connect_strength": "strong", "associative_memory": 11}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76904", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u6d89\u53ca\u591a\u56e0\u7d20\u5206\u6790\u548c\u672a\u6765\u63a8\u65ad\u7684\u590d\u6742\u4efb\u52a1\u7c7b\u578b", "name": "\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1", "entity_type": "", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76905", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5728\u91cd\u8981\u4eba\u751f\u9009\u62e9\u521d\u671f\u51fa\u73b0\u7684\u8f7b\u5fae\u4e0d\u5b89\u60c5\u7eea", "name": "\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89", "entity_type": "", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76906", "label": "ExtractedEntity", "properties": {"description": "\u5bf9\u4ee5\u5f80\u7ecf\u9a8c\u8fdb\u884c\u53cd\u601d\u548c\u5206\u6790\u7684\u884c\u4e3a", "name": "\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76907", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5c06\u91cd\u8981\u4eba\u751f\u9009\u62e9\u7c7b\u6bd4\u4e3a\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\uff1a\u521d\u59cb\u4e0d\u5b89\u5982\u540c\u6a21\u578b\u68c0\u6d4b\u5230\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u5f80\u7ecf\u5386\u662f\u8fdb\u884c\u5386\u53f2\u6570\u636e\u56de\u6eaf\u4e0e\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u4ee4\u4eba\u5b89\u5fc3\u6216\u540e\u6094\u7684\u51b3\u7b56\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u957f\u671f\u601d\u8003\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u662f\u5728\u6743\u8861\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u7684\u77ed\u671f\u4e0e\u957f\u671f\u6536\u76ca\u3002\u6700\u7ec8\u7684\u201c\u7b54\u6848\u201d\u5e76\u975e\u76f4\u63a5\u8ba1\u7b97\u5f97\u51fa\uff0c\u800c\u662f\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u8fc7\u7a0b\u4e2d\uff0c\u7531\u6a21\u578b\u9010\u6b65\u6536\u655b\u5f62\u6210\u3002", "created_at": "2026-01-06T19:25:18.822414", "associative_memory": 6}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76908", "label": "Dialogue", "properties": {"content": "\u7528\u6237: 1778 \u97f3\u4e50 ## \u4e8b\u4ef6 - 1 \u6708 1 \u65e5 \u2013 \u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002[1] - 1 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002[1] - 1 \u6708 27 \u65e5 \u2013 \u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002[1] - 2 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002[1] - 2 \u6708 17 \u65e5 \u2013 \u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002[1] - 3 \u6708 1 \u65e5 \u2013 \u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002[1] - 3 \u6708 2 \u65e5 \u2013 \u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa", "created_at": "2026-01-06T19:31:26.129718", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76909", "label": "Chunk", "properties": {"content": "\u7528\u6237: 1778 \u97f3\u4e50 ## \u4e8b\u4ef6 - 1 \u6708 1 \u65e5 \u2013 \u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002[1] - 1 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002[1] - 1 \u6708 27 \u65e5 \u2013 \u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002[1] - 2 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002[1] - 2 \u6708 17 \u65e5 \u2013 \u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002[1] - 3 \u6708 1 \u65e5 \u2013 \u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002[1] - 3 \u6708 2 \u65e5 \u2013 \u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa", "created_at": "2026-01-06T19:31:26.129718", "associative_memory": 7}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76910", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u67081\u65e5\uff0c\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002", "valid_at": "1778-01-01T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76911", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u670814\u65e5\uff0c\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002", "valid_at": "1778-01-14T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76912", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u670827\u65e5\uff0c\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002", "valid_at": "1778-01-27T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76913", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e742\u670814\u65e5\uff0c\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002", "valid_at": "1778-02-14T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76914", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e742\u670817\u65e5\uff0c\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002", "valid_at": "1778-02-17T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 6}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76915", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e743\u67081\u65e5\uff0c\u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002", "valid_at": "1778-03-01T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76916", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e743\u67082\u65e5\uff0c\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa\u3002", "valid_at": "1778-03-02T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76917", "label": "ExtractedEntity", "properties": {"description": "\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u7684\u7236\u4eb2\uff0c\u97f3\u4e50\u5bb6\u548c\u4f5c\u66f2\u5bb6", "name": "\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b", "\u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b", "\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279", "\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c", "\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2", "\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279", "\u83ab\u624e\u7279"], "connect_strength": "strong", "associative_memory": 11}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76918", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u90e8\u7531\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u521b\u4f5c\u7684\u6b4c\u5531\u5267", "name": "\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["Die Bergknappen"], "connect_strength": "strong", "associative_memory": 6}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76919", "label": "ExtractedEntity", "properties": {"description": "\u4f4d\u4e8e\u4f26\u6566\u7684\u7687\u5bb6\u5bab\u6bbf\uff0c\u66fe\u7528\u4e8e\u4e3e\u529e\u97f3\u4e50\u9996\u6f14", "name": "\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u5723\u8a79\u59c6\u65af\u5bab", "\u7f57\u5170"], "connect_strength": "strong", "associative_memory": 5}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76920", "label": "ExtractedEntity", "properties": {"description": "\u4f4d\u4e8e\u5df4\u9ece\u7684\u8457\u540d\u6b4c\u5267\u9662\uff0c\u9996\u6f14\u591a\u90e8\u91cd\u8981\u6b4c\u5267\u4f5c\u54c1", "name": "\u5df4\u9ece\u6b4c\u5267\u9662", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u5df4\u9ece\u56fd\u5bb6\u6b4c\u5267\u9662", "\u7ef4\u4e5f\u7eb3", "\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8", "\u7ef4\u4e5f\u7eb3\u5e02"], "connect_strength": "strong", "associative_memory": 8}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76921", "label": "ExtractedEntity", "properties": {"description": "\u7ec4\u7ec7\u4e3e\u529e\u7684\u6700\u7ec8\u573a\u6b21\u7684\u8868\u6f14\u6d3b\u52a8", "name": "\u4e3a\u957f\u7b1b\u4f5c\u66f2", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u6700\u540e\u4e00\u6b21\u6f14\u51fa"], "connect_strength": "strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76922", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u7ed3\u5408\u97f3\u4e50\u4e0e\u620f\u5267\u7684\u821e\u53f0\u827a\u672f\u5f62\u5f0f", "name": "\u6b4c\u5531\u5267", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["Singspiel", "\u5fb7\u8bed\u6b4c\u5531\u5267"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76923", "label": "MemorySummary", "properties": {"content": "1778\u5e74\u97f3\u4e50\u4e8b\u4ef6\uff1a1\u67081\u65e5\uff0c\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u300a\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u300b\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6f14\uff1b1\u670814\u65e5\uff0c\u83ab\u624e\u7279\u5728\u66fc\u6d77\u59c6\u4f1a\u89c1\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\uff1b1\u670827\u65e5\uff0c\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u6b4c\u5267\u300a\u7f57\u5170\u300b\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\uff1b2\u670814\u65e5\uff0c\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u7236\u4eb2\uff0c\u8868\u8fbe\u5bf9\u4e3a\u957f\u7b1b\u4f5c\u66f2\u7684\u538c\u6076\uff1b2\u670817\u65e5\uff0c\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u300aDie Bergknappen\u300b\u6210\u4e3a\u9996\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u672c\u5730\u521b\u4f5c\u6b4c\u5531\u5267\uff1b3\u67081\u65e5\uff0c\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\uff1b3\u67082\u65e5\uff0c\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u573a\u6f14\u51fa\u3002", "created_at": "2026-01-06T19:32:01.901346", "associative_memory": 7}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76998", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002\u4ee5\u4eba\u6027\uff0c\u795e\u6027\u7b49\u65b9\u9762\u6765\u63cf\u8ff0\u4e86\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb", "created_at": "2026-01-07T13:40:33.679530", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77001", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002\u4ee5\u4eba\u6027\uff0c\u795e\u6027\u7b49\u65b9\u9762\u6765\u63cf\u8ff0\u4e86\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb", "created_at": "2026-01-07T13:40:33.679530", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77002", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002", "created_at": "2026-01-07T13:40:33.679530", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77003", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "FACT", "statement": "\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb\u4ece\u4eba\u6027\u3001\u795e\u6027\u7b49\u65b9\u9762\u88ab\u63cf\u8ff0\u3002", "created_at": "2026-01-07T13:40:33.679530", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u4e8b\u7269\u5bf9\u8c61", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77015", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:44.913286", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77016", "label": "ExtractedEntity", "properties": {"description": "\u4e2d\u56fd\u8457\u540d\u5973\u6f14\u5458", "name": "\u5f20\u66fc\u7389", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T13:40:33.679530", "aliases": ["\u5f90\u514b"], "connect_strength": "strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77017", "label": "ExtractedEntity", "properties": {"description": "\u7531\u5f90\u514b\u5bfc\u6f14\u7684\u7535\u5f71\u4f5c\u54c1", "name": "\u9752\u86c7", "entity_type": "", "created_at": "2026-01-07T13:40:33.679530", "aliases": ["\u9752\u86c7\u7535\u5f71"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77018", "label": "ExtractedEntity", "properties": {"description": "\u795e\u7684\u7279\u8d28\uff0c\u5982\u8d85\u51e1\u80fd\u529b\u3001\u4e0d\u673d\u3001\u795e\u5723\u6027", "name": "\u795e\u6027", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T13:40:33.679530", "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77019", "label": "MemorySummary", "properties": {"content": "\u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u7535\u5f71\u300a\u9752\u86c7\u300b\uff0c\u5f71\u7247\u901a\u8fc7\u4eba\u6027\u4e0e\u795e\u6027\u7684\u5bf9\u6bd4\u63a2\u8ba8\u4e86\u4eba\u7269\u5173\u7cfb\uff0c\u5c55\u73b0\u4e86\u89d2\u8272\u4e4b\u95f4\u590d\u6742\u7684\u60c5\u611f\u4e0e\u8eab\u4efd\u51b2\u7a81\u3002", "created_at": "2026-01-07T13:40:50.650486", "associative_memory": 2}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77020", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u4eca\u5929\u5468\u4e00\uff0c\u6211\u60f3\u53bb\u722c\u5c71", "created_at": "2026-01-07T16:44:09.602315", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77021", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u4eca\u5929\u5468\u4e00\uff0c\u6211\u60f3\u53bb\u722c\u5c71", "created_at": "2026-01-07T16:44:09.602315", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77022", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u4eca\u5929\u662f\u5468\u4e00\u3002", "valid_at": "2026-01-05T00:00:00+00:00", "created_at": "2026-01-07T16:44:09.602315", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77023", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u60f3\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T16:44:09.602315", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77024", "label": "ExtractedEntity", "properties": {"description": "\u5f53\u524d\u65e5\u671f\uff0c\u6307\u8bf4\u8bdd\u65f6\u7684\u5f53\u5929", "name": "\u4eca\u5929", "entity_type": "\u65f6\u95f4\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u5468\u4e00", "\u661f\u671f\u4e00"], "connect_strength": "strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77025", "label": "ExtractedEntity", "properties": {"description": "\u8bf4\u8bdd\u7684\u672c\u4eba\uff0c\u8ba1\u5212\u53c2\u4e0e\u722c\u5c71\u6d3b\u52a8", "name": "\u7528\u6237", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u5c0f\u660e"], "connect_strength": "Strong", "associative_memory": 6}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77026", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u6237\u5916\u767b\u5c71\u8fd0\u52a8", "name": "\u722c\u5c71", "entity_type": "", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u767b\u5c71"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77027", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5728\u5468\u4e00\u8868\u793a\u60f3\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-07T16:44:54.146672", "associative_memory": 2}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77028", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:44.913286", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77029", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "OPINION", "statement": "\u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247\u3002", "created_at": "2026-01-12T10:38:44.913286", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77030", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u4e2a\u7528\u6237\u63d0\u5230\u7684\u4eba\u7269", "name": "\u5c0f\u7eff", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T10:38:44.913286", "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77031", "label": "ExtractedEntity", "properties": {"description": "\u89c2\u770b\u6050\u6016\u7535\u5f71\u7684\u6d3b\u52a8", "name": "\u770b\u6050\u6016\u7247", "entity_type": "", "created_at": "2026-01-12T10:38:44.913286", "aliases": ["\u770b\u6050\u6016\u7535\u5f71", "\u89c2\u770b\u6050\u6016\u7247"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77032", "label": "MemorySummary", "properties": {"content": "\u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:54.849079", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77033", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71", "created_at": "2026-01-12T10:40:16.459309", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77034", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71", "created_at": "2026-01-12T10:40:16.459309", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77035", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T10:40:16.459309", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77036", "label": "ExtractedEntity", "properties": {"description": "\u6237\u5916\u81ea\u7136\u5730\u5f62\uff0c\u7528\u4e8e\u722c\u5c71\u6d3b\u52a8", "name": "\u5c71", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T10:40:16.459309", "aliases": ["\u5c71\u8109", "\u9ad8\u5c71"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77037", "label": "MemorySummary", "properties": {"content": "\u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-12T10:40:36.429460", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77038", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71", "created_at": "2026-01-12T11:21:39.219607", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77039", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71", "created_at": "2026-01-12T11:21:39.219607", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77040", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-08-12T11:21:39.219607+00:00", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77041", "label": "ExtractedEntity", "properties": {"description": "\u88ab\u63d0\u53ca\u7684\u53e6\u4e00\u4e2a\u4eba\u7269\uff0c\u53ef\u80fd\u662f\u670b\u53cb\u6216\u540c\u4f34", "name": "\u5c0f\u7ea2", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:21:39.219607", "connect_strength": "Strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77042", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u8ba1\u5212\u4e0e\u5c0f\u660e\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-12T11:21:56.346023", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77043", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71", "created_at": "2026-01-12T11:23:35.894508", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77044", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71", "created_at": "2026-01-12T11:23:35.894508", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77045", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T11:23:35.894508", "emotion_keywords": ["\u60f3"], "emotion_type": "\u6109\u5feb", "emotion_subject": "\u81ea\u5df1", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77046", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5a31\u4e50\u6d3b\u52a8\uff0c\u6307\u89c2\u770b\u7535\u5f71\u7684\u884c\u4e3a", "name": "\u7535\u5f71", "entity_type": "\u4e8b\u4ef6\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:23:35.894508", "aliases": ["\u5f71\u7247", "\u7535\u5f71\u653e\u6620"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77047", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u60f3\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u770b\u7535\u5f71\u3002", "created_at": "2026-01-12T11:23:47.907049", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77048", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56", "created_at": "2026-01-12T11:43:22.323255", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77049", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56", "created_at": "2026-01-12T11:43:22.323255", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77050", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T11:43:22.323255", "emotion_keywords": ["\u60f3"], "emotion_type": "\u6109\u5feb", "emotion_subject": "\u81ea\u5df1", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77051", "label": "ExtractedEntity", "properties": {"description": "\u81ea\u7136\u6c34\u4f53\uff0c\u7528\u4e8e\u6e38\u61a9\u6d3b\u52a8\u7684\u6e56\u6cca", "name": "\u6e56", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:43:22.323255", "aliases": ["\u6e56\u6cca"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77052", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u60f3\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u6e38\u6e56\u3002", "created_at": "2026-01-12T11:43:37.367749", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77054", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u60a8\u597d\u6211\u53eb\u5c0f\u84dd\uff0c\u5c0f\u660e\u4eca\u5929\u7ea6\u6211\u51fa\u53bb\u91ce\u9910\uff0c\u4f46\u662f\u5c0f\u7eff\u7ea6\u6211\u51fa\u53bb\u770b\u7535\u5f71\uff0c\u6211\u5f88\u72b9\u8c6b\uff0c\u6240\u4ee5\u6211\u548c\u6211\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f", "created_at": "2026-01-07T19:14:34.489524", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77055", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u60a8\u597d\u6211\u53eb\u5c0f\u84dd\uff0c\u5c0f\u660e\u4eca\u5929\u7ea6\u6211\u51fa\u53bb\u91ce\u9910\uff0c\u4f46\u662f\u5c0f\u7eff\u7ea6\u6211\u51fa\u53bb\u770b\u7535\u5f71\uff0c\u6211\u5f88\u72b9\u8c6b\uff0c\u6240\u4ee5\u6211\u548c\u6211\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f", "created_at": "2026-01-07T19:14:34.489524", "associative_memory": 5}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77056", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "FACT", "statement": "\u5c0f\u84dd\u53eb\u5c0f\u84dd\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77057", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u4eca\u5929\u7ea6\u5c0f\u84dd\u51fa\u53bb\u91ce\u9910\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77058", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u7eff\u7ea6\u5c0f\u84dd\u51fa\u53bb\u770b\u7535\u5f71\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77059", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "OPINION", "statement": "\u5c0f\u84dd\u5bf9\u662f\u5426\u53bb\u91ce\u9910\u6216\u770b\u7535\u5f71\u611f\u5230\u72b9\u8c6b\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77060", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u84dd\u548c\u5979\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77061", "label": "ExtractedEntity", "properties": {"description": "\u5bf9\u8bdd\u4e2d\u7684\u7528\u6237\uff0c\u6536\u5230\u91ce\u9910\u548c\u770b\u7535\u5f71\u9080\u7ea6\u7684\u4eba", "name": "\u5c0f\u84dd", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u5c0f\u7eff"], "connect_strength": "strong", "associative_memory": 9}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77062", "label": "ExtractedEntity", "properties": {"description": "\u5728\u5f71\u9662\u6216\u5bb6\u4e2d\u89c2\u770b\u7535\u5f71\u7684\u5a31\u4e50\u6d3b\u52a8", "name": "\u91ce\u9910", "entity_type": "\u4e8b\u4ef6\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u53bb\u91ce\u9910", "\u770b\u620f", "\u770b\u7535\u5f71"], "connect_strength": "strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77063", "label": "ExtractedEntity", "properties": {"description": "\u7528\u4e8e\u89c2\u770b\u7535\u5f71\u7684\u573a\u6240", "name": "\u7535\u5f71\u9662", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u5f71\u9662"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77064", "label": "MemorySummary", "properties": {"content": "\u5c0f\u84dd\u4eca\u5929\u539f\u8ba1\u5212\u4e0e\u5c0f\u660e\u91ce\u9910\u3001\u4e0e\u5c0f\u7eff\u770b\u7535\u5f71\uff0c\u4f46\u6700\u7ec8\u9009\u62e9\u4e0e\u59d0\u59d0\u5c0f\u7ea2\u4e00\u8d77\u770b\u620f\u3002", "created_at": "2026-01-07T19:14:58.086704", "associative_memory": 5}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77133", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\uff0c\u5176\u5b9e\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\uff0c\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\uff0c\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u3001\u505a\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\uff0c\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u800c\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u5219\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002\u7b49\u8fd9\u4e9b\u6b65\u9aa4\u90fd\u8d70\u8fc7\u4e4b\u540e\uff0c\u6240\u8c13\u201c\u7b54\u6848\u201d\uff0c\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77134", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\uff0c\u5176\u5b9e\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\uff0c\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\uff0c\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u3001\u505a\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\uff0c\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u800c\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u5219\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002\u7b49\u8fd9\u4e9b\u6b65\u9aa4\u90fd\u8d70\u8fc7\u4e4b\u540e\uff0c\u6240\u8c13\u201c\u7b54\u6848\u201d\uff0c\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "associative_memory": 6}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77135", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77136", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77137", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u5e76\u505a\u7279\u5f81\u63d0\u53d6\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77138", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77139", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77140", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u6240\u8c13\u201c\u7b54\u6848\u201d\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77141", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:41:05.181477", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77142", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:41:05.181477", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77143", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "created_at": "2026-01-06T14:41:05.181477", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77144", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:41:05.181477", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77145", "label": "ExtractedEntity", "properties": {"description": "\u53c2\u4e0e\u5bf9\u8bdd\u7684\u4e2a\u4eba\uff0c\u53d1\u51fa\u91ce\u9910\u9080\u7ea6\u7684\u4e00\u65b9", "name": "\u5c0f\u660e", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u5c0f\u7ea2"], "connect_strength": "strong", "associative_memory": 13}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77146", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5e38\u89c1\u7684\u542b\u5496\u5561\u56e0\u996e\u54c1", "name": "\u5496\u5561", "entity_type": "", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u5496\u5561\u996e\u6599"], "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77147", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5496\u5561\u996e\u54c1\uff0c\u7531\u6d53\u7f29\u5496\u5561\u548c\u725b\u5976\u5236\u6210", "name": "\u62ff\u94c1", "entity_type": "", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u62ff\u94c1\u5496\u5561", "Latte"], "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77148", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:44:22.921668", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77149", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:44:22.921668", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77150", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:44:22.921668", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77151", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:44:22.921668", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77152", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77153", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77154", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:46:02.387455", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77155", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77156", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:16.548556", "associative_memory": 2}, + "caption": "MemorySummary"}] + + result=asyncio.run(Translation_English("2699984d-23be-4817-b81c-c38682a08306",a)) + print(result) \ No newline at end of file diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index eedb7c29..f544cb75 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -16,6 +16,7 @@ import json from datetime import datetime from app.schemas.memory_episodic_schema import EmotionType +from app.services.memory_base_service import Translation_English logger = logging.getLogger(__name__) @@ -24,7 +25,7 @@ class MemoryEntityService: self.id = id self.table = table self.connector = Neo4jConnector() - async def get_timeline_memories_server(self): + async def get_timeline_memories_server(self,model_id, language_type): """ 获取时间线记忆数据 @@ -48,10 +49,10 @@ class MemoryEntityService: logger.info(f"获取时间线记忆数据 - ID: {self.id}, Table: {self.table}") # 根据表类型选择查询 - if self.table == 'Statement': + if self.table == 'Statement': # Statement只需要输入ID,使用简化查询 results = await self.connector.execute_query(Memory_Timeline_Statement, id=self.id) - elif self.table == 'ExtractedEntity': + elif self.table == 'ExtractedEntity': # ExtractedEntity类型查询 results = await self.connector.execute_query(Memory_Timeline_ExtractedEntity, id=self.id) else: @@ -62,7 +63,7 @@ class MemoryEntityService: logger.info(f"时间线查询结果类型: {type(results)}, 长度: {len(results) if isinstance(results, list) else 'N/A'}") # 处理查询结果 - timeline_data = self._process_timeline_results(results) + timeline_data =await self._process_timeline_results(results, model_id, language_type) logger.info(f"成功获取时间线记忆数据: 总计 {len(timeline_data.get('timelines_memory', []))} 条") @@ -71,12 +72,14 @@ class MemoryEntityService: except Exception as e: logger.error(f"获取时间线记忆数据失败: {str(e)}", exc_info=True) return str(e) - def _process_timeline_results(self, results: List[Dict[str, Any]]) -> Dict[str, Any]: + async def _process_timeline_results(self, results: List[Dict[str, Any]], model_id: str, language_type: str) -> Dict[str, Any]: """ 处理时间线查询结果 Args: results: Neo4j查询结果 + model_id: 模型ID用于翻译 + language_type: 语言类型 ('zh' 或其他) Returns: 处理后的时间线数据字典 @@ -104,19 +107,19 @@ class MemoryEntityService: # 处理MemorySummary summary = data.get('MemorySummary') if summary is not None: - processed_summary = self._process_field_value(summary, "MemorySummary") + processed_summary = await self._process_field_value(summary, "MemorySummary") memory_summary_list.extend(processed_summary) # 处理Statement statement = data.get('statement') if statement is not None: - processed_statement = self._process_field_value(statement, "Statement") + processed_statement = await self._process_field_value(statement, "Statement") statement_list.extend(processed_statement) # 处理ExtractedEntity extracted_entity = data.get('ExtractedEntity') if extracted_entity is not None: - processed_entity = self._process_field_value(extracted_entity, "ExtractedEntity") + processed_entity = await self._process_field_value(extracted_entity, "ExtractedEntity") extracted_entity_list.extend(processed_entity) # 去重 - 现在处理的是字典列表,需要更智能的去重 @@ -128,6 +131,21 @@ class MemoryEntityService: all_timeline_data = memory_summary_list + statement_list all_timeline_data = self._merge_same_text_items(all_timeline_data) + # 如果需要翻译(非中文),对整个结果进行翻译 + if language_type != 'zh': + # 定义需要翻译的字段 + fields_to_translate = ['text', 'type'] + + # 翻译各个列表 + if memory_summary_list: + memory_summary_list = await self._translate_list(memory_summary_list, model_id, fields_to_translate) + if statement_list: + statement_list = await self._translate_list(statement_list, model_id, fields_to_translate) + if extracted_entity_list: + extracted_entity_list = await self._translate_list(extracted_entity_list, model_id, fields_to_translate) + if all_timeline_data: + all_timeline_data = await self._translate_list(all_timeline_data, model_id, fields_to_translate) + result = { "MemorySummary": memory_summary_list, "Statement": statement_list, @@ -233,7 +251,7 @@ class MemoryEntityService: except Exception: return False - def _process_field_value(self, value: Any, field_name: str) -> List[Dict[str, Any]]: + async def _process_field_value(self, value: Any, field_name: str) -> List[Dict[str, Any]]: """ 处理字段值,支持字符串、列表等类型 @@ -251,13 +269,13 @@ class MemoryEntityService: # 如果是列表,处理每个元素 for item in value: if self._is_valid_item(item): - processed_item = self._process_single_item(item) + processed_item = await self._process_single_item(item) if processed_item: processed_values.append(processed_item) elif isinstance(value, dict): # 如果是字典,直接处理 if self._is_valid_item(value): - processed_item = self._process_single_item(value) + processed_item = await self._process_single_item(value) if processed_item: processed_values.append(processed_item) elif isinstance(value, str): @@ -304,7 +322,7 @@ class MemoryEntityService: return (str(item).strip() != '' and "MemorySummaryChunk" not in str(item)) - def _process_single_item(self, item: Dict[str, Any]) -> Optional[Dict[str, Any]]: + async def _process_single_item(self, item: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ 处理单个项目 @@ -369,6 +387,42 @@ class MemoryEntityService: logger.warning(f"转换时间格式失败: {e}, 原始值: {dt}") return str(dt) if dt is not None else None + async def _translate_list(self, data_list: List[Dict[str, Any]], model_id: str, fields: List[str]) -> List[Dict[str, Any]]: + """ + 翻译列表中每个字典的指定字段 + + Args: + data_list: 要翻译的字典列表 + model_id: 模型ID + fields: 需要翻译的字段列表 + + Returns: + 翻译后的字典列表 + """ + if not data_list: + return data_list + + translated_list = [] + for item in data_list: + if not isinstance(item, dict): + translated_list.append(item) + continue + + translated_item = item.copy() + for field in fields: + if field in translated_item and translated_item[field]: + try: + # 调用Translation_English翻译单个字段 + translated_value = await Translation_English(model_id, translated_item[field]) + if translated_value: + translated_item[field] = translated_value + except Exception as e: + logger.warning(f"翻译字段 {field} 失败: {e}") + + translated_list.append(translated_item) + + return translated_list + @@ -426,15 +480,19 @@ class MemoryEmotion: # 如果解析失败,返回原始字符串 return iso_string - async def get_emotion(self) -> Dict[str, Any]: + async def get_emotion(self, model_id: str = None, language_type: str = 'zh') -> Dict[str, Any]: """ 获取情绪随时间变化数据 + Args: + model_id: 模型ID用于翻译 + language_type: 语言类型 ('zh' 或其他) + Returns: 包含情绪数据的字典 """ try: - logger.info(f"获取情绪数据 - ID: {self.id}, Table: {self.table}") + logger.info(f"获取情绪数据 - ID: {self.id}, Table: {self.table}, language_type={language_type}") if self.table == 'Statement': results = await self.connector.execute_query(Memory_Space_Emotion_Statement, id=self.id) @@ -450,6 +508,10 @@ class MemoryEmotion: # 转换Neo4j类型 final_data = self._convert_neo4j_types(emotion_data) + # 如果需要翻译(非中文) + if language_type != 'zh' and model_id and final_data: + final_data = await self._translate_emotion_data(final_data, model_id) + logger.info(f"成功获取 {len(final_data)} 条情绪数据") return final_data @@ -590,16 +652,14 @@ class MemoryInteraction: """ try: logger.info(f"获取交互数据 - ID: {self.id}, Table: {self.table}") - ori_data= await self.connector.execute_query(Memory_Space_Entity, id=self.id) if ori_data!=[]: # name = ori_data[0]['name'] - group_id = ori_data[0]['group_id'] + group_id = [i['group_id'] for i in ori_data][0] Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) if not Space_User: return [] user_id=Space_User[0]['id'] - results = await self.connector.execute_query(Memory_Space_Associative, id=self.id,user_id=user_id) diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 9221ab06..ae07256a 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -18,7 +18,7 @@ from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping from app.services.implicit_memory_service import ImplicitMemoryService -from app.services.memory_base_service import MemoryBaseService +from app.services.memory_base_service import MemoryBaseService, MemoryTransService, Translation_English from app.services.memory_config_service import MemoryConfigService from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_short_service import ShortService @@ -360,7 +360,9 @@ class UserMemoryService: async def get_cached_memory_insight( self, db: Session, - end_user_id: str + end_user_id: str, + model_id: str, + language_type: str ) -> Dict[str, Any]: """ 从数据库获取缓存的记忆洞察(四个维度) @@ -419,11 +421,18 @@ class UserMemoryService: key_findings_array = [] logger.info(f"成功获取 end_user_id {end_user_id} 的缓存记忆洞察(四维度)") + memory_insight=end_user.memory_insight + behavior_pattern=end_user.behavior_pattern + growth_trajectory=end_user.growth_trajectory + if language_type!='zh': + memory_insight=await Translation_English(model_id,memory_insight) + behavior_pattern=await Translation_English(model_id,behavior_pattern) + growth_trajectory=await Translation_English(model_id,growth_trajectory) return { - "memory_insight": end_user.memory_insight, # 总体概述存储在 memory_insight - "behavior_pattern": end_user.behavior_pattern, + "memory_insight":memory_insight, # 总体概述存储在 memory_insight + "behavior_pattern":behavior_pattern, "key_findings": key_findings_array, # 返回数组 - "growth_trajectory": end_user.growth_trajectory, + "growth_trajectory": growth_trajectory, "updated_at": self._datetime_to_timestamp(end_user.memory_insight_updated_at), "is_cached": True } @@ -457,7 +466,9 @@ class UserMemoryService: async def get_cached_user_summary( self, db: Session, - end_user_id: str + end_user_id: str, + model_id:str, + language_type:str="zh" ) -> Dict[str, Any]: """ 从数据库获取缓存的用户摘要(四个部分) @@ -481,7 +492,6 @@ class UserMemoryService: user_uuid = uuid.UUID(end_user_id) repo = EndUserRepository(db) end_user = repo.get_by_id(user_uuid) - if not end_user: logger.warning(f"未找到 end_user_id 为 {end_user_id} 的用户") return { @@ -495,20 +505,29 @@ class UserMemoryService: } # 检查是否有缓存数据(至少有一个字段不为空) + user_summary=end_user.user_summary + personality_traits=end_user.personality_traits + core_values=end_user.core_values + one_sentence_summary=end_user.one_sentence_summary + if language_type!='zh': + user_summary=await Translation_English(model_id, user_summary) + personality_traits = await Translation_English(model_id, personality_traits) + core_values = await Translation_English(model_id, core_values) + one_sentence_summary = await Translation_English(model_id, one_sentence_summary) has_cache = any([ - end_user.user_summary, - end_user.personality_traits, - end_user.core_values, - end_user.one_sentence_summary + user_summary, + personality_traits, + core_values, + one_sentence_summary ]) if has_cache: logger.info(f"成功获取 end_user_id {end_user_id} 的缓存用户摘要") return { - "user_summary": end_user.user_summary, - "personality": end_user.personality_traits, - "core_values": end_user.core_values, - "one_sentence": end_user.one_sentence_summary, + "user_summary": user_summary, + "personality": personality_traits, + "core_values":core_values, + "one_sentence": one_sentence_summary, "updated_at": self._datetime_to_timestamp(end_user.user_summary_updated_at), "is_cached": True } @@ -1367,7 +1386,6 @@ async def analytics_memory_types( return memory_types - async def analytics_graph_data( db: Session, end_user_id: str, @@ -1557,7 +1575,7 @@ async def analytics_graph_data( f"成功获取图数据: end_user_id={end_user_id}, " f"nodes={len(nodes)}, edges={len(edges)}" ) - + return { "nodes": nodes, "edges": edges, @@ -1606,11 +1624,7 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ # 获取该节点类型的白名单字段 allowed_fields = field_whitelist.get(label, []) - - # 如果没有定义白名单,返回空字典(或者可以返回所有字段) - # if not allowed_fields: - # # 对于未定义的节点类型,只返回基本字段 - # allowed_fields = ["name", "created_at", "caption"] + count_neo4j=f"""MATCH (n)-[r]-(m) WHERE elementId(n) ="{node_id}" RETURN count(r) AS rel_count;""" node_results = await (_neo4j_connector.execute_query(count_neo4j)) # 提取白名单中的字段 @@ -1618,13 +1632,12 @@ async def _extract_node_properties(label: str, properties: Dict[str, Any],node_ for field in allowed_fields: if field in properties: value = properties[field] - if str(field) == 'entity_type': + if str(field) == 'entity_type': value=type_mapping.get(value,'') if str(field)=="emotion_type": value=EmotionType.EMOTION_MAPPING.get(value) - if str(field)=="emotion_subject": + if str(field)=="emotion_subject": value=EmotionSubject.SUBJECT_MAPPING.get(value) - # 清理 Neo4j 特殊类型 filtered_props[field] = _clean_neo4j_value(value) filtered_props['associative_memory']=[i['rel_count'] for i in node_results][0] return filtered_props From 84c6c7e2a63811488458c4cd16d4018e5a5ce5bc Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 10:36:04 +0800 Subject: [PATCH 023/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_short_term_controller.py | 1 + api/app/controllers/user_memory_controllers.py | 15 +++------------ api/app/schemas/emotion_schema.py | 5 +++++ api/app/schemas/memory_episodic_schema.py | 2 ++ api/app/schemas/memory_explicit_schema.py | 4 ++++ api/app/services/memory_base_service.py | 10 +--------- 6 files changed, 16 insertions(+), 21 deletions(-) diff --git a/api/app/controllers/memory_short_term_controller.py b/api/app/controllers/memory_short_term_controller.py index 64991f4d..9cf66749 100644 --- a/api/app/controllers/memory_short_term_controller.py +++ b/api/app/controllers/memory_short_term_controller.py @@ -20,6 +20,7 @@ router = APIRouter( @router.get("/short_term") async def short_term_configs( end_user_id: str, + language_type:Optional[str] = "zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 560d6c0d..8ff9c41d 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -292,7 +292,6 @@ async def get_graph_data_api( @router.get("/read_end_user/profile", response_model=ApiResponse) async def get_end_user_profile( end_user_id: str, - language_type: str = "zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -321,20 +320,12 @@ async def get_end_user_profile( if not end_user: api_logger.warning(f"终端用户不存在: end_user_id={end_user_id}") return fail(BizCode.INVALID_PARAMETER, "终端用户不存在", f"end_user_id={end_user_id}") - - other_name=end_user.other_name - position=end_user.position - department=end_user.department - if language_type!="zh": - other_name=await Translation_English(model_id,other_name) - position = await Translation_English(model_id, position) - department = await Translation_English(model_id, department) # 构建响应数据 profile_data = EndUserProfileResponse( id=end_user.id, - other_name=other_name, - position=position, - department=department, + other_name=end_user.other_name, + position=end_user.position, + department=end_user.department, contact=end_user.contact, phone=end_user.phone, hire_date=end_user.hire_date, diff --git a/api/app/schemas/emotion_schema.py b/api/app/schemas/emotion_schema.py index 5175fed1..cfa65b0f 100644 --- a/api/app/schemas/emotion_schema.py +++ b/api/app/schemas/emotion_schema.py @@ -11,6 +11,7 @@ class EmotionTagsRequest(BaseModel): start_date: Optional[str] = Field(None, description="开始日期(ISO格式,如:2024-01-01)") end_date: Optional[str] = Field(None, description="结束日期(ISO格式,如:2024-12-31)") limit: int = Field(10, ge=1, le=100, description="返回数量限制") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") class EmotionWordcloudRequest(BaseModel): @@ -18,20 +19,24 @@ class EmotionWordcloudRequest(BaseModel): group_id: str = Field(..., description="组ID") emotion_type: Optional[str] = Field(None, description="情绪类型过滤(joy/sadness/anger/fear/surprise/neutral)") limit: int = Field(50, ge=1, le=200, description="返回词语数量") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") class EmotionHealthRequest(BaseModel): """获取情绪健康指数请求""" group_id: str = Field(..., description="组ID") time_range: str = Field("30d", description="时间范围(7d/30d/90d)") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") class EmotionSuggestionsRequest(BaseModel): """获取个性化情绪建议请求""" group_id: str = Field(..., description="组ID") config_id: Optional[int] = Field(None, description="配置ID(用于指定LLM模型)") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") class EmotionGenerateSuggestionsRequest(BaseModel): """生成个性化情绪建议请求""" end_user_id: str = Field(..., description="终端用户ID") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") diff --git a/api/app/schemas/memory_episodic_schema.py b/api/app/schemas/memory_episodic_schema.py index 832bf34b..74e68837 100644 --- a/api/app/schemas/memory_episodic_schema.py +++ b/api/app/schemas/memory_episodic_schema.py @@ -51,6 +51,7 @@ class EpisodicMemoryOverviewRequest(BaseModel): """情景记忆总览查询请求""" end_user_id: str = Field(..., description="终端用户ID") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") time_range: str = Field( default="all", description="时间范围筛选,可选值:all, today, this_week, this_month" @@ -70,3 +71,4 @@ class EpisodicMemoryDetailsRequest(BaseModel): end_user_id: str = Field(..., description="终端用户ID") summary_id: str = Field(..., description="情景记忆摘要ID") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") diff --git a/api/app/schemas/memory_explicit_schema.py b/api/app/schemas/memory_explicit_schema.py index c2b51a81..823a3116 100644 --- a/api/app/schemas/memory_explicit_schema.py +++ b/api/app/schemas/memory_explicit_schema.py @@ -1,15 +1,19 @@ """ 显性记忆的请求和响应模型 """ +from typing import Optional + from pydantic import BaseModel, Field class ExplicitMemoryOverviewRequest(BaseModel): """显性记忆总览查询请求""" end_user_id: str = Field(..., description="终端用户ID") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") class ExplicitMemoryDetailsRequest(BaseModel): """显性记忆详情查询请求""" end_user_id: str = Field(..., description="终端用户ID") memory_id: str = Field(..., description="记忆ID(情景记忆或语义记忆的ID)") + language_type: Optional[str] = Field("zh", description="语言类型(zh/en)") diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index 784dec7d..ce4940d0 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -441,12 +441,4 @@ class MemoryBaseService: except Exception as e: logger.error(f"获取遗忘记忆数量时出错: {str(e)}", exc_info=True) - return 0 - -if __name__ == '__main__': - import asyncio - a=[{"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:33925", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u4f60\u597d", "created_at": "2026-01-06T14:50:08.381230", "associative_memory": 0}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:33926", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u53d1\u8d77\u4e86\u5bf9\u8bdd\uff0c\u53d1\u9001\u4e86\u95ee\u5019\u8bed\"\u4f60\u597d\"\u3002", "created_at": "2026-01-06T14:50:11.363879", "associative_memory": 0}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76903", "label": "ExtractedEntity", "properties": {"description": "\u5728\u673a\u5668\u5b66\u4e60\u4e2d\u901a\u8fc7\u4e0d\u540c\u6570\u636e\u6837\u672c\u6765\u8861\u91cf\u6a21\u578b\u9884\u6d4b\u8bef\u5dee\u7684\u65b9\u6cd5", "name": "\u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:24:55.805367", "aliases": ["\u505a\u7279\u5f81\u63d0\u53d6", "\u56de\u6eaf\u5386\u53f2\u6570\u636e", "\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861", "\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570", "\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a", "\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c", "\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7", "\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c", "\u7b54\u6848", "\u4eba\u6027", "\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb"], "connect_strength": "strong", "associative_memory": 11}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76904", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u6d89\u53ca\u591a\u56e0\u7d20\u5206\u6790\u548c\u672a\u6765\u63a8\u65ad\u7684\u590d\u6742\u4efb\u52a1\u7c7b\u578b", "name": "\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1", "entity_type": "", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76905", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5728\u91cd\u8981\u4eba\u751f\u9009\u62e9\u521d\u671f\u51fa\u73b0\u7684\u8f7b\u5fae\u4e0d\u5b89\u60c5\u7eea", "name": "\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89", "entity_type": "", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76906", "label": "ExtractedEntity", "properties": {"description": "\u5bf9\u4ee5\u5f80\u7ecf\u9a8c\u8fdb\u884c\u53cd\u601d\u548c\u5206\u6790\u7684\u884c\u4e3a", "name": "\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:24:55.805367", "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76907", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5c06\u91cd\u8981\u4eba\u751f\u9009\u62e9\u7c7b\u6bd4\u4e3a\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\uff1a\u521d\u59cb\u4e0d\u5b89\u5982\u540c\u6a21\u578b\u68c0\u6d4b\u5230\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u5f80\u7ecf\u5386\u662f\u8fdb\u884c\u5386\u53f2\u6570\u636e\u56de\u6eaf\u4e0e\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u4ee4\u4eba\u5b89\u5fc3\u6216\u540e\u6094\u7684\u51b3\u7b56\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u957f\u671f\u601d\u8003\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u662f\u5728\u6743\u8861\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u7684\u77ed\u671f\u4e0e\u957f\u671f\u6536\u76ca\u3002\u6700\u7ec8\u7684\u201c\u7b54\u6848\u201d\u5e76\u975e\u76f4\u63a5\u8ba1\u7b97\u5f97\u51fa\uff0c\u800c\u662f\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u8fc7\u7a0b\u4e2d\uff0c\u7531\u6a21\u578b\u9010\u6b65\u6536\u655b\u5f62\u6210\u3002", "created_at": "2026-01-06T19:25:18.822414", "associative_memory": 6}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76908", "label": "Dialogue", "properties": {"content": "\u7528\u6237: 1778 \u97f3\u4e50 ## \u4e8b\u4ef6 - 1 \u6708 1 \u65e5 \u2013 \u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002[1] - 1 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002[1] - 1 \u6708 27 \u65e5 \u2013 \u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002[1] - 2 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002[1] - 2 \u6708 17 \u65e5 \u2013 \u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002[1] - 3 \u6708 1 \u65e5 \u2013 \u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002[1] - 3 \u6708 2 \u65e5 \u2013 \u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa", "created_at": "2026-01-06T19:31:26.129718", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76909", "label": "Chunk", "properties": {"content": "\u7528\u6237: 1778 \u97f3\u4e50 ## \u4e8b\u4ef6 - 1 \u6708 1 \u65e5 \u2013 \u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002[1] - 1 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002[1] - 1 \u6708 27 \u65e5 \u2013 \u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002[1] - 2 \u6708 14 \u65e5 \u2013 \u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002[1] - 2 \u6708 17 \u65e5 \u2013 \u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002[1] - 3 \u6708 1 \u65e5 \u2013 \u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002[1] - 3 \u6708 2 \u65e5 \u2013 \u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa", "created_at": "2026-01-06T19:31:26.129718", "associative_memory": 7}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76910", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u67081\u65e5\uff0c\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u201c\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u201d\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6620\u3002", "valid_at": "1778-01-01T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76911", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u670814\u65e5\uff0c\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5728\u8bbf\u95ee\u66fc\u6d77\u59c6\u65f6\u4f1a\u89c1\u4e86\u5f53\u5730\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\u3002", "valid_at": "1778-01-14T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76912", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e741\u670827\u65e5\uff0c\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u7b2c\u4e00\u90e8\u6cd5\u56fd\u6b4c\u5267\u201c\u7f57\u5170\u201d\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\u3002", "valid_at": "1778-01-27T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76913", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e742\u670814\u65e5\uff0c\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u4ed6\u7684\u7236\u4eb2\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279\uff0c\u544a\u8bc9\u4ed6\u4ed6\u591a\u4e48\u8ba8\u538c\u4e3a\u957f\u7b1b\u4f5c\u66f2\u3002", "valid_at": "1778-02-14T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76914", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e742\u670817\u65e5\uff0c\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u201cDie Bergknappen\u201d\u6210\u4e3a\u7b2c\u4e00\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u5f53\u5730\u4f5c\u66f2\u5bb6\u521b\u4f5c\u7684\u6b4c\u5531\u5267\u3002", "valid_at": "1778-02-17T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 6}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76915", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e743\u67081\u65e5\uff0c\u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\u3002", "valid_at": "1778-03-01T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76916", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "1778\u5e743\u67082\u65e5\uff0c\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u6b21\u6f14\u51fa\u3002", "valid_at": "1778-03-02T00:00:00+00:00", "created_at": "2026-01-06T19:31:26.129718", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76917", "label": "ExtractedEntity", "properties": {"description": "\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279\u7684\u7236\u4eb2\uff0c\u97f3\u4e50\u5bb6\u548c\u4f5c\u66f2\u5bb6", "name": "\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b", "\u514b\u91cc\u65af\u6258\u592b\u00b7\u5a01\u5229\u5df4\u5c14\u5fb7\u00b7\u683c\u9c81\u514b", "\u5229\u5965\u6ce2\u5fb7\u00b7\u83ab\u624e\u7279", "\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c", "\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2", "\u6c83\u5c14\u592b\u5188\u00b7\u963f\u9a6c\u5fb7\u4e4c\u65af\u00b7\u83ab\u624e\u7279", "\u83ab\u624e\u7279"], "connect_strength": "strong", "associative_memory": 11}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76918", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u90e8\u7531\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u521b\u4f5c\u7684\u6b4c\u5531\u5267", "name": "\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["Die Bergknappen"], "connect_strength": "strong", "associative_memory": 6}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76919", "label": "ExtractedEntity", "properties": {"description": "\u4f4d\u4e8e\u4f26\u6566\u7684\u7687\u5bb6\u5bab\u6bbf\uff0c\u66fe\u7528\u4e8e\u4e3e\u529e\u97f3\u4e50\u9996\u6f14", "name": "\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u5723\u8a79\u59c6\u65af\u5bab", "\u7f57\u5170"], "connect_strength": "strong", "associative_memory": 5}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76920", "label": "ExtractedEntity", "properties": {"description": "\u4f4d\u4e8e\u5df4\u9ece\u7684\u8457\u540d\u6b4c\u5267\u9662\uff0c\u9996\u6f14\u591a\u90e8\u91cd\u8981\u6b4c\u5267\u4f5c\u54c1", "name": "\u5df4\u9ece\u6b4c\u5267\u9662", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u5df4\u9ece\u56fd\u5bb6\u6b4c\u5267\u9662", "\u7ef4\u4e5f\u7eb3", "\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8", "\u7ef4\u4e5f\u7eb3\u5e02"], "connect_strength": "strong", "associative_memory": 8}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76921", "label": "ExtractedEntity", "properties": {"description": "\u7ec4\u7ec7\u4e3e\u529e\u7684\u6700\u7ec8\u573a\u6b21\u7684\u8868\u6f14\u6d3b\u52a8", "name": "\u4e3a\u957f\u7b1b\u4f5c\u66f2", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["\u6700\u540e\u4e00\u6b21\u6f14\u51fa"], "connect_strength": "strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76922", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u7ed3\u5408\u97f3\u4e50\u4e0e\u620f\u5267\u7684\u821e\u53f0\u827a\u672f\u5f62\u5f0f", "name": "\u6b4c\u5531\u5267", "entity_type": "", "created_at": "2026-01-06T19:31:26.129718", "aliases": ["Singspiel", "\u5fb7\u8bed\u6b4c\u5531\u5267"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76923", "label": "MemorySummary", "properties": {"content": "1778\u5e74\u97f3\u4e50\u4e8b\u4ef6\uff1a1\u67081\u65e5\uff0c\u5a01\u5ec9\u00b7\u535a\u4f0a\u65af\u7684\u300a\u5f53\u654c\u5bf9\u56fd\u5bb6\u6b66\u88c5\u8d77\u6765\u65f6\u300b\u5728\u4f26\u6566\u5723\u8a79\u59c6\u65af\u5bab\u9996\u6f14\uff1b1\u670814\u65e5\uff0c\u83ab\u624e\u7279\u5728\u66fc\u6d77\u59c6\u4f1a\u89c1\u4f5c\u66f2\u5bb6\u683c\u5965\u5c14\u683c\u00b7\u7ea6\u745f\u592b\u00b7\u6c83\u683c\u52d2\uff1b1\u670827\u65e5\uff0c\u5c3c\u79d1\u6d1b\u00b7\u76ae\u94a6\u5c3c\u7684\u6b4c\u5267\u300a\u7f57\u5170\u300b\u5728\u5df4\u9ece\u6b4c\u5267\u9662\u9996\u6f14\uff1b2\u670814\u65e5\uff0c\u83ab\u624e\u7279\u5199\u4fe1\u7ed9\u7236\u4eb2\uff0c\u8868\u8fbe\u5bf9\u4e3a\u957f\u7b1b\u4f5c\u66f2\u7684\u538c\u6076\uff1b2\u670817\u65e5\uff0c\u4f0a\u683c\u7eb3\u5179\u00b7\u4e4c\u59c6\u52b3\u592b\u7684\u300aDie Bergknappen\u300b\u6210\u4e3a\u9996\u90e8\u5728\u7ef4\u4e5f\u7eb3\u4e0a\u6f14\u7684\u672c\u5730\u521b\u4f5c\u6b4c\u5531\u5267\uff1b3\u67081\u65e5\uff0c\u683c\u9c81\u514b\u5728\u5df4\u9ece\u5c45\u4f4f\u5341\u5e74\u540e\u8fd4\u56de\u7ef4\u4e5f\u7eb3\uff1b3\u67082\u65e5\uff0c\u7ef4\u4e5f\u7eb3\u56fd\u5bb6\u5267\u9662\u559c\u6b4c\u5267\u516c\u53f8\u4e3e\u884c\u6700\u540e\u4e00\u573a\u6f14\u51fa\u3002", "created_at": "2026-01-06T19:32:01.901346", "associative_memory": 7}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:76998", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002\u4ee5\u4eba\u6027\uff0c\u795e\u6027\u7b49\u65b9\u9762\u6765\u63cf\u8ff0\u4e86\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb", "created_at": "2026-01-07T13:40:33.679530", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77001", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002\u4ee5\u4eba\u6027\uff0c\u795e\u6027\u7b49\u65b9\u9762\u6765\u63cf\u8ff0\u4e86\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb", "created_at": "2026-01-07T13:40:33.679530", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77002", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u9752\u86c7\u3002", "created_at": "2026-01-07T13:40:33.679530", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77003", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "FACT", "statement": "\u6545\u4e8b\u4e2d\u7684\u4eba\u7269\u5173\u7cfb\u4ece\u4eba\u6027\u3001\u795e\u6027\u7b49\u65b9\u9762\u88ab\u63cf\u8ff0\u3002", "created_at": "2026-01-07T13:40:33.679530", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u4e8b\u7269\u5bf9\u8c61", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77015", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:44.913286", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77016", "label": "ExtractedEntity", "properties": {"description": "\u4e2d\u56fd\u8457\u540d\u5973\u6f14\u5458", "name": "\u5f20\u66fc\u7389", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T13:40:33.679530", "aliases": ["\u5f90\u514b"], "connect_strength": "strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77017", "label": "ExtractedEntity", "properties": {"description": "\u7531\u5f90\u514b\u5bfc\u6f14\u7684\u7535\u5f71\u4f5c\u54c1", "name": "\u9752\u86c7", "entity_type": "", "created_at": "2026-01-07T13:40:33.679530", "aliases": ["\u9752\u86c7\u7535\u5f71"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77018", "label": "ExtractedEntity", "properties": {"description": "\u795e\u7684\u7279\u8d28\uff0c\u5982\u8d85\u51e1\u80fd\u529b\u3001\u4e0d\u673d\u3001\u795e\u5723\u6027", "name": "\u795e\u6027", "entity_type": "\u6982\u5ff5\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T13:40:33.679530", "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77019", "label": "MemorySummary", "properties": {"content": "\u5f20\u66fc\u7389\u51fa\u6f14\u4e86\u5f90\u514b\u5bfc\u6f14\u7684\u7535\u5f71\u300a\u9752\u86c7\u300b\uff0c\u5f71\u7247\u901a\u8fc7\u4eba\u6027\u4e0e\u795e\u6027\u7684\u5bf9\u6bd4\u63a2\u8ba8\u4e86\u4eba\u7269\u5173\u7cfb\uff0c\u5c55\u73b0\u4e86\u89d2\u8272\u4e4b\u95f4\u590d\u6742\u7684\u60c5\u611f\u4e0e\u8eab\u4efd\u51b2\u7a81\u3002", "created_at": "2026-01-07T13:40:50.650486", "associative_memory": 2}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77020", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u4eca\u5929\u5468\u4e00\uff0c\u6211\u60f3\u53bb\u722c\u5c71", "created_at": "2026-01-07T16:44:09.602315", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77021", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u4eca\u5929\u5468\u4e00\uff0c\u6211\u60f3\u53bb\u722c\u5c71", "created_at": "2026-01-07T16:44:09.602315", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77022", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u4eca\u5929\u662f\u5468\u4e00\u3002", "valid_at": "2026-01-05T00:00:00+00:00", "created_at": "2026-01-07T16:44:09.602315", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77023", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u60f3\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T16:44:09.602315", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77024", "label": "ExtractedEntity", "properties": {"description": "\u5f53\u524d\u65e5\u671f\uff0c\u6307\u8bf4\u8bdd\u65f6\u7684\u5f53\u5929", "name": "\u4eca\u5929", "entity_type": "\u65f6\u95f4\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u5468\u4e00", "\u661f\u671f\u4e00"], "connect_strength": "strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77025", "label": "ExtractedEntity", "properties": {"description": "\u8bf4\u8bdd\u7684\u672c\u4eba\uff0c\u8ba1\u5212\u53c2\u4e0e\u722c\u5c71\u6d3b\u52a8", "name": "\u7528\u6237", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u5c0f\u660e"], "connect_strength": "Strong", "associative_memory": 6}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77026", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u6237\u5916\u767b\u5c71\u8fd0\u52a8", "name": "\u722c\u5c71", "entity_type": "", "created_at": "2026-01-07T16:44:09.602315", "aliases": ["\u767b\u5c71"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77027", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5728\u5468\u4e00\u8868\u793a\u60f3\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-07T16:44:54.146672", "associative_memory": 2}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77028", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:44.913286", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77029", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "OPINION", "statement": "\u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247\u3002", "created_at": "2026-01-12T10:38:44.913286", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77030", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u4e2a\u7528\u6237\u63d0\u5230\u7684\u4eba\u7269", "name": "\u5c0f\u7eff", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T10:38:44.913286", "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77031", "label": "ExtractedEntity", "properties": {"description": "\u89c2\u770b\u6050\u6016\u7535\u5f71\u7684\u6d3b\u52a8", "name": "\u770b\u6050\u6016\u7247", "entity_type": "", "created_at": "2026-01-12T10:38:44.913286", "aliases": ["\u770b\u6050\u6016\u7535\u5f71", "\u89c2\u770b\u6050\u6016\u7247"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77032", "label": "MemorySummary", "properties": {"content": "\u5c0f\u7eff\u4e0d\u559c\u6b22\u770b\u6050\u6016\u7247", "created_at": "2026-01-12T10:38:54.849079", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77033", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71", "created_at": "2026-01-12T10:40:16.459309", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77034", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71", "created_at": "2026-01-12T10:40:16.459309", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77035", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T10:40:16.459309", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77036", "label": "ExtractedEntity", "properties": {"description": "\u6237\u5916\u81ea\u7136\u5730\u5f62\uff0c\u7528\u4e8e\u722c\u5c71\u6d3b\u52a8", "name": "\u5c71", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T10:40:16.459309", "aliases": ["\u5c71\u8109", "\u9ad8\u5c71"], "connect_strength": "Strong", "associative_memory": 2}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77037", "label": "MemorySummary", "properties": {"content": "\u5c0f\u7eff\u6253\u7b97\u548c\u5c0f\u660e\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-12T10:40:36.429460", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77038", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71", "created_at": "2026-01-12T11:21:39.219607", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77039", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71", "created_at": "2026-01-12T11:21:39.219607", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77040", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u6253\u7b97\u548c\u5c0f\u660e\u4ee5\u53ca\u5c0f\u7ea2\u53bb\u722c\u5c71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-08-12T11:21:39.219607+00:00", "emotion_keywords": [], "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77041", "label": "ExtractedEntity", "properties": {"description": "\u88ab\u63d0\u53ca\u7684\u53e6\u4e00\u4e2a\u4eba\u7269\uff0c\u53ef\u80fd\u662f\u670b\u53cb\u6216\u540c\u4f34", "name": "\u5c0f\u7ea2", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:21:39.219607", "connect_strength": "Strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77042", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u8ba1\u5212\u4e0e\u5c0f\u660e\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u722c\u5c71\u3002", "created_at": "2026-01-12T11:21:56.346023", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77043", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71", "created_at": "2026-01-12T11:23:35.894508", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77044", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71", "created_at": "2026-01-12T11:23:35.894508", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77045", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u770b\u7535\u5f71\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T11:23:35.894508", "emotion_keywords": ["\u60f3"], "emotion_type": "\u6109\u5feb", "emotion_subject": "\u81ea\u5df1", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77046", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5a31\u4e50\u6d3b\u52a8\uff0c\u6307\u89c2\u770b\u7535\u5f71\u7684\u884c\u4e3a", "name": "\u7535\u5f71", "entity_type": "\u4e8b\u4ef6\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:23:35.894508", "aliases": ["\u5f71\u7247", "\u7535\u5f71\u653e\u6620"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77047", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u60f3\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u770b\u7535\u5f71\u3002", "created_at": "2026-01-12T11:23:47.907049", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77048", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56", "created_at": "2026-01-12T11:43:22.323255", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77049", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u6211\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56", "created_at": "2026-01-12T11:43:22.323255", "associative_memory": 1}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77050", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "PREDICTION", "statement": "\u7528\u6237\u8fd8\u60f3\u548c\u5c0f\u7ea2\u53bb\u6e38\u6e56\u3002", "valid_at": "2026-01-12T00:00:00+00:00", "created_at": "2026-01-12T11:43:22.323255", "emotion_keywords": ["\u60f3"], "emotion_type": "\u6109\u5feb", "emotion_subject": "\u81ea\u5df1", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77051", "label": "ExtractedEntity", "properties": {"description": "\u81ea\u7136\u6c34\u4f53\uff0c\u7528\u4e8e\u6e38\u61a9\u6d3b\u52a8\u7684\u6e56\u6cca", "name": "\u6e56", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-12T11:43:22.323255", "aliases": ["\u6e56\u6cca"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77052", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u60f3\u548c\u5c0f\u7ea2\u4e00\u8d77\u53bb\u6e38\u6e56\u3002", "created_at": "2026-01-12T11:43:37.367749", "associative_memory": 1}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77054", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u60a8\u597d\u6211\u53eb\u5c0f\u84dd\uff0c\u5c0f\u660e\u4eca\u5929\u7ea6\u6211\u51fa\u53bb\u91ce\u9910\uff0c\u4f46\u662f\u5c0f\u7eff\u7ea6\u6211\u51fa\u53bb\u770b\u7535\u5f71\uff0c\u6211\u5f88\u72b9\u8c6b\uff0c\u6240\u4ee5\u6211\u548c\u6211\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f", "created_at": "2026-01-07T19:14:34.489524", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77055", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u60a8\u597d\u6211\u53eb\u5c0f\u84dd\uff0c\u5c0f\u660e\u4eca\u5929\u7ea6\u6211\u51fa\u53bb\u91ce\u9910\uff0c\u4f46\u662f\u5c0f\u7eff\u7ea6\u6211\u51fa\u53bb\u770b\u7535\u5f71\uff0c\u6211\u5f88\u72b9\u8c6b\uff0c\u6240\u4ee5\u6211\u548c\u6211\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f", "created_at": "2026-01-07T19:14:34.489524", "associative_memory": 5}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77056", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "FACT", "statement": "\u5c0f\u84dd\u53eb\u5c0f\u84dd\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u81ea\u5df1", "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77057", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u4eca\u5929\u7ea6\u5c0f\u84dd\u51fa\u53bb\u91ce\u9910\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77058", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u7eff\u7ea6\u5c0f\u84dd\u51fa\u53bb\u770b\u7535\u5f71\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77059", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "OPINION", "statement": "\u5c0f\u84dd\u5bf9\u662f\u5426\u53bb\u91ce\u9910\u6216\u770b\u7535\u5f71\u611f\u5230\u72b9\u8c6b\u3002", "valid_at": "2026-01-07T00:00:00+00:00", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77060", "label": "Statement", "properties": {"temporal_info": "STATIC", "stmt_type": "FACT", "statement": "\u5c0f\u84dd\u548c\u5979\u59d0\u59d0\u5c0f\u7ea2\u51fa\u53bb\u770b\u620f\u3002", "created_at": "2026-01-07T19:14:34.489524", "emotion_keywords": [], "emotion_type": "\u4e2d\u6027", "emotion_subject": "\u522b\u4eba", "associative_memory": 5}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77061", "label": "ExtractedEntity", "properties": {"description": "\u5bf9\u8bdd\u4e2d\u7684\u7528\u6237\uff0c\u6536\u5230\u91ce\u9910\u548c\u770b\u7535\u5f71\u9080\u7ea6\u7684\u4eba", "name": "\u5c0f\u84dd", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u5c0f\u7eff"], "connect_strength": "strong", "associative_memory": 9}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77062", "label": "ExtractedEntity", "properties": {"description": "\u5728\u5f71\u9662\u6216\u5bb6\u4e2d\u89c2\u770b\u7535\u5f71\u7684\u5a31\u4e50\u6d3b\u52a8", "name": "\u91ce\u9910", "entity_type": "\u4e8b\u4ef6\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u53bb\u91ce\u9910", "\u770b\u620f", "\u770b\u7535\u5f71"], "connect_strength": "strong", "associative_memory": 4}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77063", "label": "ExtractedEntity", "properties": {"description": "\u7528\u4e8e\u89c2\u770b\u7535\u5f71\u7684\u573a\u6240", "name": "\u7535\u5f71\u9662", "entity_type": "\u5730\u70b9\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-07T19:14:34.489524", "aliases": ["\u5f71\u9662"], "connect_strength": "Strong", "associative_memory": 1}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77064", "label": "MemorySummary", "properties": {"content": "\u5c0f\u84dd\u4eca\u5929\u539f\u8ba1\u5212\u4e0e\u5c0f\u660e\u91ce\u9910\u3001\u4e0e\u5c0f\u7eff\u770b\u7535\u5f71\uff0c\u4f46\u6700\u7ec8\u9009\u62e9\u4e0e\u59d0\u59d0\u5c0f\u7ea2\u4e00\u8d77\u770b\u620f\u3002", "created_at": "2026-01-07T19:14:58.086704", "associative_memory": 5}, "caption": "MemorySummary"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77133", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\uff0c\u5176\u5b9e\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\uff0c\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\uff0c\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u3001\u505a\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\uff0c\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u800c\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u5219\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002\u7b49\u8fd9\u4e9b\u6b65\u9aa4\u90fd\u8d70\u8fc7\u4e4b\u540e\uff0c\u6240\u8c13\u201c\u7b54\u6848\u201d\uff0c\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77134", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\uff0c\u5176\u5b9e\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\uff0c\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\uff1b\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\uff0c\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u3001\u505a\u7279\u5f81\u63d0\u53d6\uff1b\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\uff0c\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\uff1b\u800c\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\uff0c\u5219\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002\u7b49\u8fd9\u4e9b\u6b65\u9aa4\u90fd\u8d70\u8fc7\u4e4b\u540e\uff0c\u6240\u8c13\u201c\u7b54\u6848\u201d\uff0c\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "associative_memory": 6}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77135", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u5f88\u591a\u91cd\u8981\u7684\u4eba\u751f\u9009\u62e9\u5c31\u50cf\u4e00\u6b21\u590d\u6742\u7684\u9884\u6d4b\u4efb\u52a1\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77136", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u6700\u5f00\u59cb\u51fa\u73b0\u7684\u90a3\u70b9\u4e0d\u5b89\u7c7b\u4f3c\u4e8e\u6a21\u578b\u53d1\u73b0\u4e86\u5f02\u5e38\u4fe1\u53f7\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77137", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u56de\u987e\u8fc7\u53bb\u7684\u7ecf\u5386\u662f\u5728\u56de\u6eaf\u5386\u53f2\u6570\u636e\u5e76\u505a\u7279\u5f81\u63d0\u53d6\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77138", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u5bf9\u6bd4\u90a3\u4e9b\u8ba9\u4eba\u8e0f\u5b9e\u6216\u540e\u6094\u7684\u51b3\u5b9a\u76f8\u5f53\u4e8e\u5728\u4e0d\u540c\u6837\u672c\u4e0a\u8bc4\u4f30\u635f\u5931\u51fd\u6570\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77139", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u628a\u65f6\u95f4\u62c9\u957f\u53bb\u60f3\u4e00\u5e74\u3001\u4e09\u5e74\u3001\u4e94\u5e74\u540e\u7684\u7ed3\u679c\u662f\u5728\u4e0d\u540c\u65f6\u95f4\u7a97\u53e3\u4e0b\u505a\u957f\u671f\u4e0e\u77ed\u671f\u6536\u76ca\u7684\u6743\u8861\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77140", "label": "Statement", "properties": {"temporal_info": "ATEMPORAL", "stmt_type": "OPINION", "statement": "\u6240\u8c13\u201c\u7b54\u6848\u201d\u5e76\u4e0d\u662f\u88ab\u76f4\u63a5\u7b97\u51fa\u6765\u7684\uff0c\u800c\u662f\u6a21\u578b\u5728\u591a\u6b21\u62c6\u89e3\u4e0e\u8fed\u4ee3\u4e2d\u9010\u6e10\u6536\u655b\u5230\u7684\u7ed3\u679c\u3002", "created_at": "2026-01-06T19:24:55.805367", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77141", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:41:05.181477", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77142", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:41:05.181477", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77143", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "created_at": "2026-01-06T14:41:05.181477", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77144", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:41:05.181477", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77145", "label": "ExtractedEntity", "properties": {"description": "\u53c2\u4e0e\u5bf9\u8bdd\u7684\u4e2a\u4eba\uff0c\u53d1\u51fa\u91ce\u9910\u9080\u7ea6\u7684\u4e00\u65b9", "name": "\u5c0f\u660e", "entity_type": "\u4eba\u7269\u5b9e\u4f53\u8282\u70b9", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u5c0f\u7ea2"], "connect_strength": "strong", "associative_memory": 13}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77146", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5e38\u89c1\u7684\u542b\u5496\u5561\u56e0\u996e\u54c1", "name": "\u5496\u5561", "entity_type": "", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u5496\u5561\u996e\u6599"], "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77147", "label": "ExtractedEntity", "properties": {"description": "\u4e00\u79cd\u5496\u5561\u996e\u54c1\uff0c\u7531\u6d53\u7f29\u5496\u5561\u548c\u725b\u5976\u5236\u6210", "name": "\u62ff\u94c1", "entity_type": "", "created_at": "2026-01-06T14:41:05.181477", "aliases": ["\u62ff\u94c1\u5496\u5561", "Latte"], "connect_strength": "Strong", "associative_memory": 3}, "caption": "ExtractedEntity"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77148", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:44:22.921668", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77149", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:44:22.921668", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77150", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:44:22.921668", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77151", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:44:22.921668", "emotion_keywords": [], "associative_memory": 3}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77152", "label": "Dialogue", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "associative_memory": 0}, "caption": "Dialogue"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77153", "label": "Chunk", "properties": {"content": "\u7528\u6237: \u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "associative_memory": 2}, "caption": "Chunk"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77154", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\u3002", "valid_at": "2026-01-06T00:00:00+00:00", "created_at": "2026-01-06T14:46:02.387455", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77155", "label": "Statement", "properties": {"temporal_info": "DYNAMIC", "stmt_type": "FACT", "statement": "\u5c0f\u660e\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:02.387455", "emotion_keywords": [], "associative_memory": 4}, "caption": "Statement"}, {"id": "4:f6039a9b-d553-4ba2-9b1c-d9a18917801f:77156", "label": "MemorySummary", "properties": {"content": "\u7528\u6237\u5c0f\u660e\u559c\u6b22\u559d\u5496\u5561\uff0c\u6bcf\u5929\u90fd\u8981\u559d\u62ff\u94c1\u3002", "created_at": "2026-01-06T14:46:16.548556", "associative_memory": 2}, - "caption": "MemorySummary"}] - - result=asyncio.run(Translation_English("2699984d-23be-4817-b81c-c38682a08306",a)) - print(result) \ No newline at end of file + return 0 \ No newline at end of file From 0e2e495d090557d2e68fdc9ed015a23d3b8aee6a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 11:03:37 +0800 Subject: [PATCH 024/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory_entity_relationship_service.py | 107 +++++++++++++++--- 1 file changed, 91 insertions(+), 16 deletions(-) diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index f544cb75..06c1616d 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -387,9 +387,14 @@ class MemoryEntityService: logger.warning(f"转换时间格式失败: {e}, 原始值: {dt}") return str(dt) if dt is not None else None - async def _translate_list(self, data_list: List[Dict[str, Any]], model_id: str, fields: List[str]) -> List[Dict[str, Any]]: + async def _translate_list( + self, + data_list: List[Dict[str, Any]], + model_id: str, + fields: List[str] + ) -> List[Dict[str, Any]]: """ - 翻译列表中每个字典的指定字段 + 翻译列表中每个字典的指定字段(并发有限度以降低整体延迟) Args: data_list: 要翻译的字典列表 @@ -399,27 +404,97 @@ class MemoryEntityService: Returns: 翻译后的字典列表 """ - if not data_list: + # 空列表或无字段时直接返回 + if not data_list or not fields: return data_list - translated_list = [] - for item in data_list: + import asyncio + + # 并发限制,避免一次性发起过多请求 + # 可根据实际情况调整(建议 5-10) + concurrency_limit = 5 + semaphore = asyncio.Semaphore(concurrency_limit) + + async def translate_single_field( + index: int, + field: str, + value: Any, + ) -> Optional[tuple]: + """ + 翻译单个字段并返回 (索引, 字段名, 翻译结果) + + Returns: + (index, field, translated_value) 或 None(如果跳过) + """ + # 跳过空值 + if value is None or value == "": + return None + + # 统一转成字符串再翻译,防止非字符串类型导致错误 + text = str(value) + + try: + async with semaphore: + # 调用 Translation_English 进行翻译 + # 注意:Translation_English 的参数顺序是 (model_id, text) + translated = await Translation_English(model_id, text) + + # 如果翻译结果为空,保留原值 + if translated is None or translated == "": + return None + + return index, field, translated + except Exception as e: + logger.warning(f"翻译字段 {field} (索引 {index}) 失败: {e}") + return None + + # 构造所有需要翻译的任务 + tasks = [] + for idx, item in enumerate(data_list): + # 防御性检查:确保 item 是字典 if not isinstance(item, dict): - translated_list.append(item) continue - translated_item = item.copy() for field in fields: - if field in translated_item and translated_item[field]: - try: - # 调用Translation_English翻译单个字段 - translated_value = await Translation_English(model_id, translated_item[field]) - if translated_value: - translated_item[field] = translated_value - except Exception as e: - logger.warning(f"翻译字段 {field} 失败: {e}") + if field not in item: + continue + + value = item.get(field) + + # 对于 None 或空字符串的值,直接跳过,不创建任务 + if value is None or value == "": + continue + + tasks.append( + asyncio.create_task( + translate_single_field(idx, field, value) + ) + ) + + # 如果没有需要翻译的任务,直接返回原列表 + if not tasks: + return data_list + + # 使用 gather 并发执行翻译任务(受 semaphore 限制) + # return_exceptions=True 可以防止单个任务失败导致整体失败 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 创建深拷贝以避免修改原始数据 + translated_list = [item.copy() if isinstance(item, dict) else item for item in data_list] + + # 将翻译结果回填到列表 + for result in results: + # 跳过 None 结果和异常 + if result is None or isinstance(result, Exception): + if isinstance(result, Exception): + logger.warning(f"翻译任务异常: {result}") + continue - translated_list.append(translated_item) + idx, field, translated = result + + # 防御性检查索引范围 + if 0 <= idx < len(translated_list) and isinstance(translated_list[idx], dict): + translated_list[idx][field] = translated return translated_list From cd5f1a1b289985b1f6fd6ae000811fd75f156f40 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 11:05:56 +0800 Subject: [PATCH 025/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index ddf04216..fd0cb0eb 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -715,7 +715,6 @@ class MemoryAgentService: tags = await get_hot_memory_tags(end_user_id, limit=limit, by_user=False) payload=[] for tag, freq in tags: - print(tag, freq) if language_type!="zh": tag=await Translation_English(model_id, tag) payload.append({"name": tag, "frequency": freq}) From 98b2da91237a3299f2979ed6faa777563eca227a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 11:15:18 +0800 Subject: [PATCH 026/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_base_service.py | 136 +++++++++++++++++++++--- 1 file changed, 120 insertions(+), 16 deletions(-) diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index ce4940d0..25a8281d 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -109,8 +109,36 @@ class MemoryTransService: logger.error(f"翻译失败: {str(e)}") return text # 翻译失败时返回原文 - async def is_english(self,text: str) -> bool: - return bool(re.fullmatch(r"[A-Za-z\s]+", text)) + async def is_english(self, text: str) -> bool: + """ + 检查文本是否为英文 + + Args: + text: 要检查的文本(必须是字符串) + + Returns: + True 如果文本主要是英文,False 否则 + + Note: + - 只接受字符串类型 + - 检查是否主要由英文字母和常见标点组成 + - 允许数字、空格和常见标点符号 + """ + if not isinstance(text, str): + raise TypeError(f"is_english 只接受字符串类型,收到: {type(text).__name__}") + + if not text.strip(): + return True # 空字符串视为英文 + + # 更宽松的英文检查:允许字母、数字、空格和常见标点 + # 如果文本中英文字符占比超过 80%,认为是英文 + english_chars = sum(1 for c in text if c.isascii() and (c.isalnum() or c.isspace() or c in '.,!?;:\'"()-')) + total_chars = len(text) + + if total_chars == 0: + return True + + return (english_chars / total_chars) >= 0.8 async def Translate(self, text: str, target_language: str = "en") -> str: """ 通用翻译方法(保持向后兼容) @@ -144,23 +172,99 @@ async def Translation_English(modid, text, fields=None): Returns: 翻译后的数据,保持原有结构 + + Note: + - 对于字符串:直接翻译 + - 对于列表:递归处理每个元素,保持列表长度和索引不变 + - 对于字典:只翻译指定字段(fields参数) + - 对于其他类型:原样返回 """ trans_service = MemoryTransService(modid) - # 执行翻译 - if isinstance(text, list): - english_result=[] - for i in text: - is_eng=await trans_service.is_english(i) - if not is_eng: - english = await trans_service.Translate(i) - english_result.append(english) - return english_result + + # 处理字符串类型 if isinstance(text, str): - is_eng = await trans_service.is_english(text) - if not is_eng: - english_result = await trans_service.Translate(text) - return english_result - return text + # 空字符串直接返回 + if not text.strip(): + return text + + try: + is_eng = await trans_service.is_english(text) + if not is_eng: + english_result = await trans_service.Translate(text) + return english_result + return text + except Exception as e: + logger.warning(f"翻译字符串失败: {e}") + return text + + # 处理列表类型 + elif isinstance(text, list): + english_result = [] + for item in text: + # 递归处理列表中的每个元素 + if isinstance(item, str): + # 字符串元素:检查是否需要翻译 + if not item.strip(): + english_result.append(item) + continue + + try: + is_eng = await trans_service.is_english(item) + if not is_eng: + translated = await trans_service.Translate(item) + english_result.append(translated) + else: + # 保留英文项,不改变列表长度 + english_result.append(item) + except Exception as e: + logger.warning(f"翻译列表项失败: {e}") + english_result.append(item) + + elif isinstance(item, dict): + # 字典元素:递归调用自己处理字典 + translated_dict = await Translation_English(modid, item, fields) + english_result.append(translated_dict) + + elif isinstance(item, list): + # 嵌套列表:递归处理 + translated_list = await Translation_English(modid, item, fields) + english_result.append(translated_list) + + else: + # 其他类型(数字、布尔值等):原样保留 + english_result.append(item) + + return english_result + + # 处理字典类型 + elif isinstance(text, dict): + # 确定要翻译的字段 + if fields is None: + # 默认翻译字段 + fields = [ + 'content', 'summary', 'statement', 'description', + 'name', 'aliases', 'caption', 'emotion_keywords', + 'text', 'title', 'label', 'type' # 添加常用字段 + ] + + # 创建副本,避免修改原始数据 + result = text.copy() + + for field in fields: + if field in result and result[field] is not None: + # 递归翻译字段值(可能是字符串、列表或嵌套字典) + try: + result[field] = await Translation_English(modid, result[field], fields) + except Exception as e: + logger.warning(f"翻译字段 {field} 失败: {e}") + # 翻译失败时保留原值 + continue + + return result + + # 其他类型(数字、布尔值、None等):原样返回 + else: + return text class MemoryBaseService: """记忆服务基类,提供共享的辅助方法""" From c93bcb86781cef5414feb4d9b8d1f9af49083410 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 11:27:11 +0800 Subject: [PATCH 027/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/user_memory_controllers.py | 2 +- .../memory_entity_relationship_service.py | 15 +-------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 8ff9c41d..d99eb47e 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -419,7 +419,7 @@ async def update_end_user_profile( return fail(BizCode.INTERNAL_ERROR, "用户信息更新失败", str(e)) @router.get("/memory_space/timeline_memories", response_model=ApiResponse) -async def memory_space_timeline_of_shared_memories(id: str, label: str,language_type: str, +async def memory_space_timeline_of_shared_memories(id: str, label: str,language_type: str="zh", current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index 06c1616d..9b5f3c99 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -132,20 +132,7 @@ class MemoryEntityService: all_timeline_data = self._merge_same_text_items(all_timeline_data) # 如果需要翻译(非中文),对整个结果进行翻译 - if language_type != 'zh': - # 定义需要翻译的字段 - fields_to_translate = ['text', 'type'] - - # 翻译各个列表 - if memory_summary_list: - memory_summary_list = await self._translate_list(memory_summary_list, model_id, fields_to_translate) - if statement_list: - statement_list = await self._translate_list(statement_list, model_id, fields_to_translate) - if extracted_entity_list: - extracted_entity_list = await self._translate_list(extracted_entity_list, model_id, fields_to_translate) - if all_timeline_data: - all_timeline_data = await self._translate_list(all_timeline_data, model_id, fields_to_translate) - + result = { "MemorySummary": memory_summary_list, "Statement": statement_list, From 4a4931bee228a40cd139eba456ab528ac0d91634 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 19:37:03 +0800 Subject: [PATCH 028/175] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=AD=E7=BF=BB?= =?UTF-8?q?=E8=8B=B1=E5=8A=9F=E8=83=BD=EF=BC=88=E8=AE=B0=E5=BF=86=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=EF=BC=89(=E7=94=A8=E6=88=B7=E6=91=98?= =?UTF-8?q?=E8=A6=81)(=E5=85=B4=E8=B6=A3=E5=88=86=E5=B8=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3)(=E6=9F=A5=E8=AF=A2=E6=A0=B8=E5=BF=83=E6=A1=A3?= =?UTF-8?q?=E6=A1=88)(=E8=AE=B0=E5=BF=86=E6=B4=9E=E5=AF=9F)-=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0=E7=BF=BB=E8=AF=91=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/__init__.py | 0 .../controllers/memory_agent_controller.py | 51 ++-- .../service/memory_api_controller.py | 3 +- api/app/core/agent/langchain_agent.py | 69 +++-- .../langgraph_graph/nodes/problem_nodes.py | 8 +- .../langgraph_graph/nodes/retrieve_nodes.py | 18 +- .../langgraph_graph/nodes/summary_nodes.py | 16 +- .../nodes/verification_nodes.py | 6 +- .../langgraph_graph/nodes/write_nodes.py | 41 +-- .../agent/langgraph_graph/read_graph.py | 6 +- .../agent/langgraph_graph/tools/tool.py | 30 +- .../agent/langgraph_graph/write_graph.py | 19 +- .../agent/services/parameter_builder.py | 6 +- .../memory/agent/services/search_service.py | 8 +- .../memory/agent/services/session_service.py | 18 +- .../core/memory/agent/utils/get_dialogs.py | 75 +++-- api/app/core/memory/agent/utils/llm_tools.py | 10 +- api/app/core/memory/agent/utils/redis_tool.py | 26 +- .../core/memory/agent/utils/session_tools.py | 18 +- .../core/memory/agent/utils/write_tools.py | 14 +- .../core/memory/analytics/hot_memory_tags.py | 36 +-- .../analytics/implicit_memory/data_source.py | 4 +- .../memory/evaluation/dialogue_queries.py | 4 +- .../memory/evaluation/extraction_utils.py | 12 +- .../evaluation/locomo/locomo_benchmark.py | 26 +- .../memory/evaluation/locomo/locomo_test.py | 2 +- .../memory/evaluation/locomo/locomo_utils.py | 18 +- .../evaluation/locomo/qwen_search_eval.py | 24 +- .../longmemeval/qwen_search_eval.py | 52 ++-- .../evaluation/longmemeval/test_eval.py | 52 ++-- .../memory/evaluation/memsciqa/evaluate_qa.py | 14 +- .../evaluation/memsciqa/memsciqa-test.py | 14 +- api/app/core/memory/evaluation/run_eval.py | 20 +- api/app/core/memory/models/config_models.py | 4 +- api/app/core/memory/models/graph_models.py | 16 +- api/app/core/memory/models/message_models.py | 16 +- api/app/core/memory/src/search.py | 108 +++++-- .../data_preprocessing/data_preprocessor.py | 10 +- .../deduplication/deduped_and_disamb.py | 18 +- .../deduplication/entity_dedup_llm.py | 18 +- .../deduplication/second_layer_dedup.py | 8 +- .../deduplication/two_stage_dedup.py | 14 +- .../extraction_orchestrator.py | 50 ++-- .../knowledge_extraction/memory_summary.py | 6 +- .../statement_extraction.py | 16 +- .../temporal_extraction.py | 2 +- .../triplet_extraction.py | 2 +- .../access_history_manager.py | 86 +++--- .../forgetting_engine/forgetting_scheduler.py | 28 +- .../forgetting_engine/forgetting_strategy.py | 20 +- .../storage_services/search/__init__.py | 6 +- .../storage_services/search/hybrid_search.py | 14 +- .../storage_services/search/keyword_search.py | 12 +- .../search/search_strategy.py | 10 +- .../search/semantic_search.py | 12 +- api/app/core/memory/utils/config/get_data.py | 4 +- api/app/core/memory/utils/log/audit_logger.py | 12 +- api/app/core/rag/vdb/field.py | 2 +- api/app/repositories/neo4j/add_edges.py | 4 +- api/app/repositories/neo4j/add_nodes.py | 22 +- .../neo4j/base_neo4j_repository.py | 2 +- api/app/repositories/neo4j/cypher_queries.py | 165 ++++------- .../repositories/neo4j/dialog_repository.py | 32 +-- .../repositories/neo4j/emotion_repository.py | 22 +- api/app/repositories/neo4j/graph_saver.py | 12 +- api/app/repositories/neo4j/graph_search.py | 266 ++++++++---------- .../neo4j/memory_summary_repository.py | 30 +- api/app/repositories/neo4j/neo4j_connector.py | 16 +- .../neo4j/statement_repository.py | 2 +- api/app/schemas/memory_agent_schema.py | 4 +- api/app/services/draft_run_service.py | 2 +- api/app/services/emotion_analytics_service.py | 12 +- api/app/services/memory_agent_service.py | 121 ++++---- api/app/services/memory_api_service.py | 19 +- api/app/services/memory_base_service.py | 18 +- .../memory_entity_relationship_service.py | 4 +- api/app/services/memory_episodic_service.py | 30 +- api/app/services/memory_explicit_service.py | 16 +- api/app/services/memory_forget_service.py | 64 ++--- api/app/services/memory_konwledges_server.py | 14 +- api/app/services/memory_storage_service.py | 39 ++- api/app/services/pilot_run_service.py | 2 +- api/app/services/user_memory_service.py | 42 +-- api/app/tasks.py | 209 +++++++++----- 84 files changed, 1193 insertions(+), 1190 deletions(-) create mode 100644 api/app/__init__.py diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 22830890..a1337085 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -125,7 +125,7 @@ async def write_server( Write service endpoint - processes write operations synchronously Args: - user_input: Write request containing message and group_id + user_input: Write request containing message and end_user_id Returns: Response with write operation status @@ -160,14 +160,11 @@ async def write_server( api_logger.warning("workspace_id 为空,无法使用 rag 存储,将使用 neo4j 存储") storage_type = 'neo4j' - api_logger.info(f"Write service requested for group {user_input.group_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") + api_logger.info(f"Write service requested for group {user_input.end_user_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") try: - # 获取标准化的消息列表 - messages_list = memory_agent_service.get_messages_list(user_input) - result = await memory_agent_service.write_memory( - user_input.group_id, - messages_list, # 传递结构化消息列表 + user_input.end_user_id, + user_input.message, config_id, db, storage_type, @@ -196,7 +193,7 @@ async def write_server_async( Async write service endpoint - enqueues write processing to Celery Args: - user_input: Write request containing message and group_id + user_input: Write request containing message and end_user_id Returns: Task ID for tracking async operation @@ -224,12 +221,9 @@ async def write_server_async( if knowledge: user_rag_memory_id = str(knowledge.id) api_logger.info(f"Async write: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") try: - # 获取标准化的消息列表 - messages_list = memory_agent_service.get_messages_list(user_input) - task = celery_app.send_task( "app.core.memory.agent.write_message", - args=[user_input.group_id, messages_list, config_id, storage_type, user_rag_memory_id] + args=[user_input.end_user_id, user_input.message, config_id, storage_type, user_rag_memory_id] ) api_logger.info(f"Write task queued: {task.id}") @@ -255,7 +249,7 @@ async def read_server( - "2": Direct answer based on context Args: - user_input: Read request with message, history, search_switch, and group_id + user_input: Read request with message, history, search_switch, and end_user_id Returns: Response with query answer @@ -279,12 +273,13 @@ async def read_server( name="USER_RAG_MERORY", workspace_id=workspace_id ) - if knowledge: user_rag_memory_id = str(knowledge.id) + if knowledge: + user_rag_memory_id = str(knowledge.id) - api_logger.info(f"Read service: group={user_input.group_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") + api_logger.info(f"Read service: group={user_input.end_user_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") try: result = await memory_agent_service.read_memory( - user_input.group_id, + user_input.end_user_id, user_input.message, user_input.history, user_input.search_switch, @@ -297,7 +292,7 @@ async def read_server( retrieve_info = result['answer'] history = await SessionService(store).get_history(user_input.group_id, user_input.group_id, user_input.group_id) query = user_input.message - + # 调用 memory_agent_service 的方法生成最终答案 result['answer'] = await memory_agent_service.generate_summary_from_retrieve( retrieve_info=retrieve_info, @@ -403,7 +398,7 @@ async def read_server_async( try: task = celery_app.send_task( "app.core.memory.agent.read_message", - args=[user_input.group_id, user_input.message, user_input.history, user_input.search_switch, + args=[user_input.end_user_id, user_input.message, user_input.history, user_input.search_switch, config_id, storage_type, user_rag_memory_id] ) api_logger.info(f"Read task queued: {task.id}") @@ -447,7 +442,7 @@ async def get_read_task_result( return success( data={ "result": task_result.get("result"), - "group_id": task_result.get("group_id"), + "end_user_id": task_result.get("end_user_id"), "elapsed_time": task_result.get("elapsed_time"), "task_id": task_id }, @@ -524,7 +519,7 @@ async def get_write_task_result( return success( data={ "result": task_result.get("result"), - "group_id": task_result.get("group_id"), + "end_user_id": task_result.get("end_user_id"), "elapsed_time": task_result.get("elapsed_time"), "task_id": task_id }, @@ -578,16 +573,16 @@ async def status_type( Determine the type of user message (read or write) Args: - user_input: Request containing user message and group_id + user_input: Request containing user message and end_user_id Returns: Type classification result """ - api_logger.info(f"Status type check requested for group {user_input.group_id}") + api_logger.info(f"Status type check requested for group {user_input.end_user_id}") try: # 获取标准化的消息列表 messages_list = memory_agent_service.get_messages_list(user_input) - + # 将消息列表转换为字符串用于分类 # 只取最后一条用户消息进行分类 last_user_message = "" @@ -595,13 +590,13 @@ async def status_type( if msg.get('role') == 'user': last_user_message = msg.get('content', '') break - + if not last_user_message: # 如果没有用户消息,使用所有消息的内容 last_user_message = " ".join([msg.get('content', '') for msg in messages_list]) - + result = await memory_agent_service.classify_message_type( - last_user_message, + user_input.message, user_input.config_id, db ) @@ -624,7 +619,7 @@ async def get_knowledge_type_stats_api( 会对缺失类型补 0,返回字典形式。 可选按状态过滤。 - 知识库类型根据当前用户的 current_workspace_id 过滤 - - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (group_id) 过滤 + - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (end_user_id) 过滤 - 如果用户没有当前工作空间或未提供 end_user_id,对应的统计返回 0 """ api_logger.info(f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") @@ -697,7 +692,7 @@ async def get_user_profile_api( current_user: User = Depends(get_current_user) ): """ - 获取工作空间下Popular Memory Tags,包含: + 获取用户详情,包含: - name: 用户名字(直接使用 end_user_id) - tags: 3个用户特征标签(从语句和实体中LLM总结) - hot_tags: 4个热门记忆标签 diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index 30ca1306..87c1aa20 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -39,7 +39,7 @@ async def write_memory_api_service( Stores memory content for the specified end user using the Memory API Service. """ - logger.info(f"Memory write request - end_user_id: {payload.end_user_id}") + logger.info(f"Memory write request - end_user_id: {payload.end_user_id}, tenant_id: {api_key_auth.tenant_id}") memory_api_service = MemoryAPIService(db) @@ -50,6 +50,7 @@ async def write_memory_api_service( config_id=payload.config_id, storage_type=payload.storage_type, user_rag_memory_id=payload.user_rag_memory_id, + tenant_id=api_key_auth.tenant_id, ) logger.info(f"Memory write successful for end_user: {payload.end_user_id}") diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index 87b46e6f..e6c59a79 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -145,41 +145,38 @@ class LangChainAgent: messages.append(HumanMessage(content=user_content)) return messages -# TODO 乐力齐 - 累积多组对话批量写入功能已禁用 - # async def term_memory_save(self,messages,end_user_end,aimessages): - # '''短长期存储redis,为不影响正常使用6句一段话,存储用户名加一个前缀,当数据存够6条返回给neo4j''' - # end_user_end=f"Term_{end_user_end}" - # print(messages) - # print(aimessages) - # session_id = store.save_session( - # userid=end_user_end, - # messages=messages, - # apply_id=end_user_end, - # group_id=end_user_end, - # aimessages=aimessages - # ) - # store.delete_duplicate_sessions() - # # logger.info(f'Redis_Agent:{end_user_end};{session_id}') - # return session_id - -# TODO 乐力齐 - 累积多组对话批量写入功能已禁用 - # async def term_memory_redis_read(self,end_user_end): - # end_user_end = f"Term_{end_user_end}" - # history = store.find_user_apply_group(end_user_end, end_user_end, end_user_end) - # # logger.info(f'Redis_Agent:{end_user_end};{history}') - # messagss_list=[] - # retrieved_content=[] - # for messages in history: - # query = messages.get("Query") - # aimessages = messages.get("Answer") - # messagss_list.append(f'用户:{query}。AI回复:{aimessages}') - # retrieved_content.append({query: aimessages}) - # return messagss_list,retrieved_content + async def term_memory_save(self,messages,end_user_end,aimessages): + '''短长期存储redis,为不影响正常使用6句一段话,存储用户名加一个前缀,当数据存够6条返回给neo4j''' + end_user_end=f"Term_{end_user_end}" + print(messages) + print(aimessages) + session_id = store.save_session( + userid=end_user_end, + messages=messages, + apply_id=end_user_end, + end_user_id=end_user_end, + aimessages=aimessages + ) + store.delete_duplicate_sessions() + # logger.info(f'Redis_Agent:{end_user_end};{session_id}') + return session_id + async def term_memory_redis_read(self,end_user_end): + end_user_end = f"Term_{end_user_end}" + history = store.find_user_apply_group(end_user_end, end_user_end, end_user_end) + # logger.info(f'Redis_Agent:{end_user_end};{history}') + messagss_list=[] + retrieved_content=[] + for messages in history: + query = messages.get("Query") + aimessages = messages.get("Answer") + messagss_list.append(f'用户:{query}。AI回复:{aimessages}') + retrieved_content.append({query: aimessages}) + return messagss_list,retrieved_content async def write(self, storage_type, end_user_id, user_message, ai_message, user_rag_memory_id, actual_end_user_id, actual_config_id): """ 写入记忆(支持结构化消息) - + Args: storage_type: 存储类型 (neo4j/rag) end_user_id: 终端用户ID @@ -188,7 +185,7 @@ class LangChainAgent: user_rag_memory_id: RAG 记忆ID actual_end_user_id: 实际用户ID actual_config_id: 配置ID - + 逻辑说明: - RAG 模式:组合 user_message 和 ai_message 为字符串格式,保持原有逻辑不变 - Neo4j 模式:使用结构化消息列表 @@ -204,20 +201,20 @@ class LangChainAgent: else: # Neo4j 模式:使用结构化消息列表 structured_messages = [] - + # 始终添加用户消息(如果不为空) if user_message: structured_messages.append({"role": "user", "content": user_message}) - + # 只有当 AI 回复不为空时才添加 assistant 消息 if ai_message: structured_messages.append({"role": "assistant", "content": ai_message}) - + # 如果没有消息,直接返回 if not structured_messages: logger.warning(f"No messages to write for user {actual_end_user_id}") return - + # 调用 Celery 任务,传递结构化消息列表 # 数据流: # 1. structured_messages 传递给 write_message_task diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index 697a13bd..bb8f3ae5 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -35,10 +35,10 @@ async def Split_The_Problem(state: ReadState) -> ReadState: """问题分解节点""" # 从状态中获取数据 content = state.get('data', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) # 生成 JSON schema 以指导 LLM 输出正确格式 json_schema = ProblemExtensionResponse.model_json_schema() @@ -140,7 +140,7 @@ async def Problem_Extension(state: ReadState) -> ReadState: start = time.time() content = state.get('data', '') data = state.get('spit_data', '')['context'] - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') memory_config = state.get('memory_config', None) @@ -156,7 +156,7 @@ async def Problem_Extension(state: ReadState) -> ReadState: databasets = {} data = [] - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) # 生成 JSON schema 以指导 LLM 输出正确格式 json_schema = ProblemExtensionResponse.model_json_schema() diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py index 14f8fa8b..1880357c 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py @@ -52,9 +52,9 @@ async def rag_config(state): return kb_config async def rag_knowledge(state,question): kb_config = await rag_config(state) - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') user_rag_memory_id=state.get("user_rag_memory_id",'') - retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(group_id)]) + retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(end_user_id)]) try: retrieval_knowledge = [i.page_content for i in retrieve_chunks_result] clean_content = '\n\n'.join(retrieval_knowledge) @@ -159,7 +159,7 @@ async def retrieve_nodes(state: ReadState) -> ReadState: problem_extension=state.get('problem_extension', '')['context'] storage_type=state.get('storage_type', '') user_rag_memory_id=state.get('user_rag_memory_id', '') - group_id=state.get('group_id', '') + end_user_id=state.get('end_user_id', '') memory_config = state.get('memory_config', None) original=state.get('data', '') problem_list=[] @@ -172,7 +172,7 @@ async def retrieve_nodes(state: ReadState) -> ReadState: try: # Prepare search parameters based on storage type search_params = { - "group_id": group_id, + "end_user_id": end_user_id, "question": question, "return_raw_results": True } @@ -263,13 +263,13 @@ async def retrieve_nodes(state: ReadState) -> ReadState: async def retrieve(state: ReadState) -> ReadState: - # 从state中获取group_id + # 从state中获取end_user_id import time start=time.time() problem_extension = state.get('problem_extension', '')['context'] storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) original = state.get('data', '') problem_list = [] @@ -295,13 +295,13 @@ async def retrieve(state: ReadState) -> ReadState: temperature=0.2, ) - time_retrieval_tool = create_time_retrieval_tool(group_id) - search_params = { "group_id": group_id, "return_raw_results": True } + time_retrieval_tool = create_time_retrieval_tool(end_user_id) + search_params = { "end_user_id": end_user_id, "return_raw_results": True } hybrid_retrieval=create_hybrid_retrieval_tool_sync(memory_config, **search_params) agent = create_agent( llm, tools=[time_retrieval_tool,hybrid_retrieval], - system_prompt=f"我是检索专家,可以根据适合的工具进行检索。当前使用的group_id是: {group_id}" + system_prompt=f"我是检索专家,可以根据适合的工具进行检索。当前使用的end_user_id是: {end_user_id}" ) # 创建异步任务处理单个问题 diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index 44f89c6a..8ccad579 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -34,8 +34,8 @@ class SummaryNodeService(LLMServiceMixin): summary_service = SummaryNodeService() async def summary_history(state: ReadState) -> ReadState: - group_id = state.get("group_id", '') - history = await SessionService(store).get_history(group_id, group_id, group_id) + end_user_id = state.get("end_user_id", '') + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) return history async def summary_llm(state: ReadState, history, retrieve_info, template_name, operation_name, response_model,search_mode) -> str: @@ -122,12 +122,12 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o async def summary_redis_save(state: ReadState,aimessages) -> ReadState: data = state.get("data", '') - group_id = state.get("group_id", '') + end_user_id = state.get("end_user_id", '') await SessionService(store).save_session( - user_id=group_id, + user_id=end_user_id, query=data, - apply_id=group_id, - group_id=group_id, + apply_id=end_user_id, + end_user_id=end_user_id, ai_response=aimessages ) await SessionService(store).cleanup_duplicates() @@ -175,11 +175,11 @@ async def Input_Summary(state: ReadState) -> ReadState: memory_config = state.get('memory_config', None) user_rag_memory_id=state.get("user_rag_memory_id",'') data=state.get("data", '') - group_id=state.get("group_id", '') + end_user_id=state.get("end_user_id", '') logger.info(f"Input_Summary: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") history = await summary_history( state) search_params = { - "group_id": group_id, + "end_user_id": end_user_id, "question": data, "return_raw_results": True, "include": ["summaries"] # Only search summary nodes for faster performance diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index dac7ea14..ad605ec9 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -62,12 +62,12 @@ async def Verify(state: ReadState): logger.info("=== Verify 节点开始执行 ===") try: content = state.get('data', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - logger.info(f"Verify: content={content[:50] if content else 'empty'}..., group_id={group_id}") + logger.info(f"Verify: content={content[:50] if content else 'empty'}..., end_user_id={end_user_id}") - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) logger.info(f"Verify: 获取历史记录完成,history length={len(history)}") retrieve = state.get("retrieve", {}) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py index 6af313c3..e2a61045 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py @@ -9,47 +9,36 @@ async def write_node(state: WriteState) -> WriteState: Write data to the database/file system. Args: - state: WriteState containing messages, group_id, and memory_config + content: Data content to write + end_user_id: End user identifier + memory_config: MemoryConfig object containing all configuration Returns: - dict: Contains 'write_result' with status and data fields + dict: Contains 'status', 'saved_to', and 'data' fields """ - messages = state.get('messages', []) - group_id = state.get('group_id', '') - memory_config = state.get('memory_config', '') - - # Convert LangChain messages to structured format expected by write() - structured_messages = [] - for msg in messages: - if hasattr(msg, 'type') and hasattr(msg, 'content'): - # Map LangChain message types to role names - role = 'user' if msg.type == 'human' else 'assistant' if msg.type == 'ai' else msg.type - structured_messages.append({ - "role": role, - "content": msg.content # content is now guaranteed to be a string - }) - + content=state.get('data','') + end_user_id=state.get('end_user_id','') + memory_config=state.get('memory_config', '') try: - result = await write( - messages=structured_messages, - user_id=group_id, - apply_id=group_id, - group_id=group_id, + result=await write( + content=content, + end_user_id=end_user_id, memory_config=memory_config, ) logger.info(f"Write completed successfully! Config: {memory_config.config_name}") - write_result = { + write_result= { "status": "success", - "data": structured_messages, + "data": content, "config_id": memory_config.config_id, "config_name": memory_config.config_name, } - return {"write_result": write_result} + return {"write_result":write_result} + except Exception as e: logger.error(f"Data_write failed: {e}", exc_info=True) - write_result = { + write_result= { "status": "error", "message": str(e), } diff --git a/api/app/core/memory/agent/langgraph_graph/read_graph.py b/api/app/core/memory/agent/langgraph_graph/read_graph.py index 19011a5f..3476d0ec 100644 --- a/api/app/core/memory/agent/langgraph_graph/read_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/read_graph.py @@ -79,7 +79,7 @@ async def make_read_graph(): async def main(): """主函数 - 运行工作流""" message = "昨天有什么好看的电影" - group_id = '88a459f5_text09' # 组ID + end_user_id = '88a459f5_text09' # 组ID storage_type = 'neo4j' # 存储类型 search_switch = '1' # 搜索开关 user_rag_memory_id = 'wwwwwwww' # 用户RAG记忆ID @@ -95,9 +95,9 @@ async def main(): start=time.time() try: async with make_read_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 - initial_state = {"messages": [HumanMessage(content=message)] ,"search_switch":search_switch,"group_id":group_id + initial_state = {"messages": [HumanMessage(content=message)] ,"search_switch":search_switch,"end_user_id":end_user_id ,"storage_type":storage_type,"user_rag_memory_id":user_rag_memory_id,"memory_config":memory_config} # 获取节点更新信息 _intermediate_outputs = [] diff --git a/api/app/core/memory/agent/langgraph_graph/tools/tool.py b/api/app/core/memory/agent/langgraph_graph/tools/tool.py index ce6d5dd4..c4814de1 100644 --- a/api/app/core/memory/agent/langgraph_graph/tools/tool.py +++ b/api/app/core/memory/agent/langgraph_graph/tools/tool.py @@ -48,11 +48,11 @@ def extract_tool_message_content(response): class TimeRetrievalInput(BaseModel): """时间检索工具的输入模式""" context: str = Field(description="用户输入的查询内容") - group_id: str = Field(default="88a459f5_text09", description="组ID,用于过滤搜索结果") + end_user_id: str = Field(default="88a459f5_text09", description="组ID,用于过滤搜索结果") -def create_time_retrieval_tool(group_id: str): +def create_time_retrieval_tool(end_user_id: str): """ - 创建一个带有特定group_id的TimeRetrieval工具(同步版本),用于按时间范围搜索语句(Statements) + 创建一个带有特定end_user_id的TimeRetrieval工具(同步版本),用于按时间范围搜索语句(Statements) """ def clean_temporal_result_fields(data): @@ -93,26 +93,26 @@ def create_time_retrieval_tool(group_id: str): return data @tool - def TimeRetrievalWithGroupId(context: str, start_date: str = None, end_date: str = None, group_id_param: str = None, clean_output: bool = True) -> str: + def TimeRetrievalWithGroupId(context: str, start_date: str = None, end_date: str = None, end_user_id_param: str = None, clean_output: bool = True) -> str: """ 优化的时间检索工具,只结合时间范围搜索(同步版本),自动过滤不需要的元数据字段 显式接收参数: - context: 查询上下文内容 - start_date: 开始时间(可选,格式:YYYY-MM-DD) - end_date: 结束时间(可选,格式:YYYY-MM-DD) - - group_id_param: 组ID(可选,用于覆盖默认组ID) + - end_user_id_param: 组ID(可选,用于覆盖默认组ID) - clean_output: 是否清理输出中的元数据字段 -end_date 需要根据用户的描述获取结束的时间,输出格式用strftime("%Y-%m-%d") """ async def _async_search(): # 使用传入的参数或默认值 - actual_group_id = group_id_param or group_id + actual_end_user_id = end_user_id_param or end_user_id actual_end_date = end_date or datetime.now().strftime("%Y-%m-%d") actual_start_date = start_date or (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") # 基本时间搜索 results = await search_by_temporal( - group_id=actual_group_id, + end_user_id=actual_end_user_id, start_date=actual_start_date, end_date=actual_end_date, limit=10 @@ -147,7 +147,7 @@ def create_time_retrieval_tool(group_id: str): # 关键词时间搜索 results = await search_by_keyword_temporal( query_text=context, - group_id=group_id, + end_user_id=end_user_id, start_date=actual_start_date, end_date=actual_end_date, limit=15 @@ -172,7 +172,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): Args: memory_config: 内存配置对象 - **search_params: 搜索参数,包含group_id, limit, include等 + **search_params: 搜索参数,包含end_user_id, limit, include等 """ def clean_result_fields(data): @@ -211,7 +211,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): context: str, search_type: str = "hybrid", limit: int = 10, - group_id: str = None, + end_user_id: str = None, rerank_alpha: float = 0.6, use_forgetting_rerank: bool = False, use_llm_rerank: bool = False, @@ -224,7 +224,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): context: 查询内容 search_type: 搜索类型 ('keyword', 'embedding', 'hybrid') limit: 结果数量限制 - group_id: 组ID,用于过滤搜索结果 + end_user_id: 组ID,用于过滤搜索结果 rerank_alpha: 重排序权重参数 use_forgetting_rerank: 是否使用遗忘重排序 use_llm_rerank: 是否使用LLM重排序 @@ -238,7 +238,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): final_params = { "query_text": context, "search_type": search_type, - "group_id": group_id or search_params.get("group_id"), + "end_user_id": end_user_id or search_params.get("end_user_id"), "limit": limit or search_params.get("limit", 10), "include": search_params.get("include", ["summaries", "statements", "chunks", "entities"]), "output_path": None, # 不保存到文件 @@ -291,7 +291,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): context: str, search_type: str = "hybrid", limit: int = 10, - group_id: str = None, + end_user_id: str = None, clean_output: bool = True ) -> str: """ @@ -301,7 +301,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): context: 查询内容 search_type: 搜索类型 ('keyword', 'embedding', 'hybrid') limit: 结果数量限制 - group_id: 组ID,用于过滤搜索结果 + end_user_id: 组ID,用于过滤搜索结果 clean_output: 是否清理输出中的元数据字段 """ async def _async_search(): @@ -311,7 +311,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): "context": context, "search_type": search_type, "limit": limit, - "group_id": group_id, + "end_user_id": end_user_id, "clean_output": clean_output }) diff --git a/api/app/core/memory/agent/langgraph_graph/write_graph.py b/api/app/core/memory/agent/langgraph_graph/write_graph.py index fe281a23..d8fcf210 100644 --- a/api/app/core/memory/agent/langgraph_graph/write_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/write_graph.py @@ -14,6 +14,7 @@ from app.db import get_db from app.core.logging_config import get_agent_logger from app.core.memory.agent.utils.llm_tools import WriteState from app.core.memory.agent.langgraph_graph.nodes.write_nodes import write_node +from app.core.memory.agent.langgraph_graph.nodes.data_nodes import content_input_write from app.services.memory_config_service import MemoryConfigService warnings.filterwarnings("ignore", category=RuntimeWarning) @@ -26,12 +27,18 @@ async def make_write_graph(): """ Create a write graph workflow for memory operations. - The workflow directly processes messages from the initial state - and saves them to Neo4j storage. + Args: + user_id: User identifier + tools: MCP tools loaded from session + apply_id: Application identifier + end_user_id: Group identifier + memory_config: MemoryConfig object containing all configuration """ workflow = StateGraph(WriteState) + workflow.add_node("content_input", content_input_write) workflow.add_node("save_neo4j", write_node) - workflow.add_edge(START, "save_neo4j") + workflow.add_edge(START, "content_input") + workflow.add_edge("content_input", "save_neo4j") workflow.add_edge("save_neo4j", END) graph = workflow.compile() @@ -42,7 +49,7 @@ async def make_write_graph(): async def main(): """主函数 - 运行工作流""" message = "今天周一" - group_id = 'new_2025test1103' # 组ID + end_user_id = 'new_2025test1103' # 组ID # 获取数据库会话 @@ -54,9 +61,9 @@ async def main(): ) try: async with make_write_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 - initial_state = {"messages": [HumanMessage(content=message)], "group_id": group_id, "memory_config": memory_config} + initial_state = {"messages": [HumanMessage(content=message)], "end_user_id": end_user_id, "memory_config": memory_config} # 获取节点更新信息 async for update_event in graph.astream( diff --git a/api/app/core/memory/agent/services/parameter_builder.py b/api/app/core/memory/agent/services/parameter_builder.py index a58fcf1a..74382ade 100644 --- a/api/app/core/memory/agent/services/parameter_builder.py +++ b/api/app/core/memory/agent/services/parameter_builder.py @@ -24,7 +24,7 @@ class ParameterBuilder: tool_call_id: str, search_switch: str, apply_id: str, - group_id: str, + end_user_id: str, storage_type: Optional[str] = None, user_rag_memory_id: Optional[str] = None ) -> Dict[str, Any]: @@ -44,7 +44,7 @@ class ParameterBuilder: tool_call_id: Extracted tool call identifier search_switch: Search routing parameter apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier storage_type: Storage type for the workspace (optional) user_rag_memory_id: User RAG memory ID for knowledge base retrieval (optional) @@ -55,7 +55,7 @@ class ParameterBuilder: base_args = { "usermessages": tool_call_id, "apply_id": apply_id, - "group_id": group_id + "end_user_id": end_user_id } # Always add storage_type and user_rag_memory_id (with defaults if None) diff --git a/api/app/core/memory/agent/services/search_service.py b/api/app/core/memory/agent/services/search_service.py index 8a2e7cfe..4fc4256e 100644 --- a/api/app/core/memory/agent/services/search_service.py +++ b/api/app/core/memory/agent/services/search_service.py @@ -91,7 +91,7 @@ class SearchService: async def execute_hybrid_search( self, - group_id: str, + end_user_id: str, question: str, limit: int = 5, search_type: str = "hybrid", @@ -105,7 +105,7 @@ class SearchService: Execute hybrid search and return clean content. Args: - group_id: Group identifier for filtering results + end_user_id: Group identifier for filtering results question: Search query text limit: Maximum number of results to return (default: 5) search_type: Type of search - "hybrid", "keyword", or "embedding" (default: "hybrid") @@ -130,7 +130,7 @@ class SearchService: answer = await run_hybrid_search( query_text=cleaned_query, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, output_path=output_path, @@ -186,7 +186,7 @@ class SearchService: except Exception as e: logger.error( - f"Search failed for query '{question}' in group '{group_id}': {e}", + f"Search failed for query '{question}' in group '{end_user_id}': {e}", exc_info=True ) # Return empty results on failure diff --git a/api/app/core/memory/agent/services/session_service.py b/api/app/core/memory/agent/services/session_service.py index b2d4f0ff..f7389984 100644 --- a/api/app/core/memory/agent/services/session_service.py +++ b/api/app/core/memory/agent/services/session_service.py @@ -59,7 +59,7 @@ class SessionService: self, user_id: str, apply_id: str, - group_id: str + end_user_id: str ) -> List[dict]: """ Retrieve conversation history from Redis. @@ -67,20 +67,20 @@ class SessionService: Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier Returns: List of conversation history items with Query and Answer keys Returns empty list if no history found or on error """ try: - history = self.store.find_user_apply_group(user_id, apply_id, group_id) + history = self.store.find_user_apply_group(user_id, apply_id, end_user_id) # Validate history structure if not isinstance(history, list): logger.warning( f"Invalid history format for user {user_id}, " - f"apply {apply_id}, group {group_id}: expected list, got {type(history)}" + f"apply {apply_id}, group {end_user_id}: expected list, got {type(history)}" ) return [] @@ -89,7 +89,7 @@ class SessionService: except Exception as e: logger.error( f"Failed to retrieve history for user {user_id}, " - f"apply {apply_id}, group {group_id}: {e}", + f"apply {apply_id}, group {end_user_id}: {e}", exc_info=True ) # Return empty list on error to allow execution to continue @@ -100,7 +100,7 @@ class SessionService: user_id: str, query: str, apply_id: str, - group_id: str, + end_user_id: str, ai_response: str ) -> Optional[str]: """ @@ -110,7 +110,7 @@ class SessionService: user_id: User identifier query: User query/message apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier ai_response: AI response/answer Returns: @@ -131,7 +131,7 @@ class SessionService: userid=user_id, messages=query, apply_id=apply_id, - group_id=group_id, + end_user_id=end_user_id, aimessages=ai_response ) @@ -152,7 +152,7 @@ class SessionService: Duplicates are identified by matching: - sessionid - user_id (id field) - - group_id + - end_user_id - messages - aimessages diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index 82a41773..4751f18c 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -9,65 +9,56 @@ from app.core.memory.models.message_models import DialogData, ConversationContex async def get_chunked_dialogs( chunker_strategy: str = "RecursiveChunker", - group_id: str = "group_1", - user_id: str = "user1", - apply_id: str = "applyid", - messages: list = None, + end_user_id: str = "group_1", + content: str = "这是用户的输入", ref_id: str = "wyl_20251027", config_id: str = None ) -> List[DialogData]: - """Generate chunks from structured messages using the specified chunker strategy. + """Generate chunks from all test data entries using the specified chunker strategy. Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - group_id: Group identifier - user_id: User identifier - apply_id: Application identifier - messages: Structured message list [{"role": "user", "content": "..."}, ...] + end_user_id: End user identifier + content: Dialog content ref_id: Reference identifier config_id: Configuration ID for processing Returns: - List of DialogData objects with generated chunks + List of DialogData objects with generated chunks for each test entry """ - from app.core.logging_config import get_agent_logger - logger = get_agent_logger(__name__) - - if not messages or not isinstance(messages, list) or len(messages) == 0: - raise ValueError("messages parameter must be a non-empty list") - - conversation_messages = [] - - for idx, msg in enumerate(messages): - if not isinstance(msg, dict) or 'role' not in msg or 'content' not in msg: - raise ValueError(f"Message {idx} format error: must contain 'role' and 'content' fields") - - role = msg['role'] - content = msg['content'] - - if role not in ['user', 'assistant']: - raise ValueError(f"Message {idx} role must be 'user' or 'assistant', got: {role}") - - if content.strip(): - conversation_messages.append(ConversationMessage(role=role, msg=content.strip())) - - if not conversation_messages: - raise ValueError("Message list cannot be empty after filtering") - - conversation_context = ConversationContext(msgs=conversation_messages) + dialog_data_list = [] + messages = [] + + messages.append(ConversationMessage(role="用户", msg=content)) + + # Create DialogData + conversation_context = ConversationContext(msgs=messages) + # Create DialogData with end_user_id dialog_data = DialogData( context=conversation_context, ref_id=ref_id, - group_id=group_id, - user_id=user_id, - apply_id=apply_id, + end_user_id=end_user_id, config_id=config_id ) - + # Create DialogueChunker and process the dialogue chunker = DialogueChunker(chunker_strategy) extracted_chunks = await chunker.process_dialogue(dialog_data) dialog_data.chunks = extracted_chunks - - logger.info(f"DialogData created with {len(extracted_chunks)} chunks") - return [dialog_data] + dialog_data_list.append(dialog_data) + + # Convert to dict with datetime serialized + def serialize_datetime(obj): + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") + + combined_output = [dd.model_dump() for dd in dialog_data_list] + + print(dialog_data_list) + + # with open(os.path.join(os.path.dirname(__file__), "chunker_test_output.txt"), "w", encoding="utf-8") as f: + # json.dump(combined_output, f, ensure_ascii=False, indent=4, default=serialize_datetime) + + + return dialog_data_list diff --git a/api/app/core/memory/agent/utils/llm_tools.py b/api/app/core/memory/agent/utils/llm_tools.py index 8dd2f1d3..aca7fdd7 100644 --- a/api/app/core/memory/agent/utils/llm_tools.py +++ b/api/app/core/memory/agent/utils/llm_tools.py @@ -12,13 +12,11 @@ class WriteState(TypedDict): Langgrapg Writing TypedDict ''' messages: Annotated[list[AnyMessage], add_messages] - user_id:str - apply_id:str - group_id:str + end_user_id: str errors: list[dict] # Track errors: [{"tool": "tool_name", "error": "message"}] memory_config: object write_result: dict - data:str + data: str class ReadState(TypedDict): """ @@ -28,7 +26,7 @@ class ReadState(TypedDict): messages: 消息列表,支持自动追加 loop_count: 遍历次数 search_switch: 搜索类型开关 - group_id: 组标识 + end_user_id: 组标识 config_id: 配置ID,用于过滤结果 data: 从content_input_node传递的内容数据 spit_data: 从Split_The_Problem传递的分解结果 @@ -39,7 +37,7 @@ class ReadState(TypedDict): messages: Annotated[list[AnyMessage], add_messages] # 消息追加模式 loop_count: int search_switch: str - group_id: str + end_user_id: str config_id: str data: str # 新增字段用于传递内容 spit_data: dict # 新增字段用于传递问题分解结果 diff --git a/api/app/core/memory/agent/utils/redis_tool.py b/api/app/core/memory/agent/utils/redis_tool.py index 31a76a11..505545b3 100644 --- a/api/app/core/memory/agent/utils/redis_tool.py +++ b/api/app/core/memory/agent/utils/redis_tool.py @@ -28,7 +28,7 @@ class RedisSessionStore: return text # 修改后的 save_session 方法 - def save_session(self, userid, messages, aimessages, apply_id, group_id): + def save_session(self, userid, messages, aimessages, apply_id, end_user_id): """ 写入一条会话数据,返回 session_id 优化版本:确保写入时间不超过1秒 @@ -46,7 +46,7 @@ class RedisSessionStore: "id": self.uudi, "sessionid": userid, "apply_id": apply_id, - "group_id": group_id, + "end_user_id": end_user_id, "messages": messages, "aimessages": aimessages, "starttime": starttime @@ -67,7 +67,7 @@ class RedisSessionStore: def save_sessions_batch(self, sessions_data): """ 批量写入多条会话数据,返回 session_id 列表 - sessions_data: list of dict, 每个 dict 包含 userid, messages, aimessages, apply_id, group_id + sessions_data: list of dict, 每个 dict 包含 userid, messages, aimessages, apply_id, end_user_id 优化版本:批量操作,大幅提升性能 """ try: @@ -83,7 +83,7 @@ class RedisSessionStore: "id": self.uudi, "sessionid": session.get('userid'), "apply_id": session.get('apply_id'), - "group_id": session.get('group_id'), + "end_user_id": session.get('end_user_id'), "messages": session.get('messages'), "aimessages": session.get('aimessages'), "starttime": starttime @@ -108,9 +108,9 @@ class RedisSessionStore: data = self.r.hgetall(key) return data if data else None - def get_session_apply_group(self, sessionid, apply_id, group_id): + def get_session_apply_group(self, sessionid, apply_id, end_user_id): """ - 根据 sessionid、apply_id 和 group_id 三个条件查询会话数据 + 根据 sessionid、apply_id 和 end_user_id 三个条件查询会话数据 """ result_items = [] @@ -124,7 +124,7 @@ class RedisSessionStore: # 检查三个条件是否都匹配 if (data.get('sessionid') == sessionid and data.get('apply_id') == apply_id and - data.get('group_id') == group_id): + data.get('end_user_id') == end_user_id): result_items.append(data) return result_items @@ -172,7 +172,7 @@ class RedisSessionStore: def delete_duplicate_sessions(self): """ 删除重复会话数据,条件: - "sessionid"、"user_id"、"group_id"、"messages"、"aimessages" 五个字段都相同的只保留一个,其他删除 + "sessionid"、"user_id"、"end_user_id"、"messages"、"aimessages" 五个字段都相同的只保留一个,其他删除 优化版本:使用 pipeline 批量操作,确保在1秒内完成 """ import time @@ -202,12 +202,12 @@ class RedisSessionStore: # 获取五个字段的值 sessionid = data.get('sessionid', '') user_id = data.get('id', '') - group_id = data.get('group_id', '') + end_user_id = data.get('end_user_id', '') messages = data.get('messages', '') aimessages = data.get('aimessages', '') # 用五元组作为唯一标识 - identifier = (sessionid, user_id, group_id, messages, aimessages) + identifier = (sessionid, user_id, end_user_id, messages, aimessages) if identifier in seen: # 重复,标记为待删除 @@ -248,9 +248,9 @@ class RedisSessionStore: result_items = [] return (result_items) - def find_user_apply_group(self, sessionid, apply_id, group_id): + def find_user_apply_group(self, sessionid, apply_id, end_user_id): """ - 根据 sessionid、apply_id 和 group_id 三个条件查询会话数据,返回最新的6条 + 根据 sessionid、apply_id 和 end_user_id 三个条件查询会话数据,返回最新的6条 """ import time start_time = time.time() @@ -276,7 +276,7 @@ class RedisSessionStore: # 检查是否符合三个条件 if (data.get('apply_id') == apply_id and - data.get('group_id') == group_id): + data.get('end_user_id') == end_user_id): # 支持模糊匹配 sessionid 或者完全匹配 if sessionid in data.get('sessionid', '') or data.get('sessionid') == sessionid: matched_items.append({ diff --git a/api/app/core/memory/agent/utils/session_tools.py b/api/app/core/memory/agent/utils/session_tools.py index b2d4f0ff..f7389984 100644 --- a/api/app/core/memory/agent/utils/session_tools.py +++ b/api/app/core/memory/agent/utils/session_tools.py @@ -59,7 +59,7 @@ class SessionService: self, user_id: str, apply_id: str, - group_id: str + end_user_id: str ) -> List[dict]: """ Retrieve conversation history from Redis. @@ -67,20 +67,20 @@ class SessionService: Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier Returns: List of conversation history items with Query and Answer keys Returns empty list if no history found or on error """ try: - history = self.store.find_user_apply_group(user_id, apply_id, group_id) + history = self.store.find_user_apply_group(user_id, apply_id, end_user_id) # Validate history structure if not isinstance(history, list): logger.warning( f"Invalid history format for user {user_id}, " - f"apply {apply_id}, group {group_id}: expected list, got {type(history)}" + f"apply {apply_id}, group {end_user_id}: expected list, got {type(history)}" ) return [] @@ -89,7 +89,7 @@ class SessionService: except Exception as e: logger.error( f"Failed to retrieve history for user {user_id}, " - f"apply {apply_id}, group {group_id}: {e}", + f"apply {apply_id}, group {end_user_id}: {e}", exc_info=True ) # Return empty list on error to allow execution to continue @@ -100,7 +100,7 @@ class SessionService: user_id: str, query: str, apply_id: str, - group_id: str, + end_user_id: str, ai_response: str ) -> Optional[str]: """ @@ -110,7 +110,7 @@ class SessionService: user_id: User identifier query: User query/message apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier ai_response: AI response/answer Returns: @@ -131,7 +131,7 @@ class SessionService: userid=user_id, messages=query, apply_id=apply_id, - group_id=group_id, + end_user_id=end_user_id, aimessages=ai_response ) @@ -152,7 +152,7 @@ class SessionService: Duplicates are identified by matching: - sessionid - user_id (id field) - - group_id + - end_user_id - messages - aimessages diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 1df0b336..ce55286e 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -29,9 +29,7 @@ logger = get_agent_logger(__name__) async def write( - user_id: str, - apply_id: str, - group_id: str, + end_user_id: str, memory_config: MemoryConfig, messages: list, ref_id: str = "wyl20251027", @@ -40,9 +38,7 @@ async def write( Execute the complete knowledge extraction pipeline. Args: - user_id: User identifier - apply_id: Application identifier - group_id: Group identifier + end_user_id: End user identifier memory_config: MemoryConfig object containing all configuration messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference ID, defaults to "wyl20251027" @@ -58,7 +54,7 @@ async def write( logger.info(f"LLM model: {memory_config.llm_model_name}") logger.info(f"Embedding model: {memory_config.embedding_model_name}") logger.info(f"Chunker strategy: {chunker_strategy}") - logger.info(f"Group ID: {group_id}") + logger.info(f"End User ID: {end_user_id}") # Construct clients from memory_config using factory pattern with db session with get_db_context() as db: @@ -83,9 +79,7 @@ async def write( step_start = time.time() chunked_dialogs = await get_chunked_dialogs( chunker_strategy=chunker_strategy, - group_id=group_id, - user_id=user_id, - apply_id=apply_id, + end_user_id=end_user_id, messages=messages, ref_id=ref_id, config_id=config_id, diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index cab6cacd..95302726 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -16,13 +16,13 @@ class FilteredTags(BaseModel): """用于接收LLM筛选后的核心标签列表的模型。""" meaningful_tags: List[str] = Field(..., description="从原始列表中筛选出的具有核心代表意义的名词列表。") -async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: +async def filter_tags_with_llm(tags: List[str], end_user_id: str) -> List[str]: """ 使用LLM筛选标签列表,仅保留具有代表性的核心名词。 Args: tags: 原始标签列表 - group_id: 用户组ID,用于获取配置 + end_user_id: 用户组ID,用于获取配置 Returns: 筛选后的标签列表 @@ -37,12 +37,12 @@ async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: get_end_user_connected_config, ) - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if not config_id: raise ValueError( - f"No memory_config_id found for group_id: {group_id}. " + f"No memory_config_id found for end_user_id: {end_user_id}. " "Please ensure the user has a valid memory configuration." ) @@ -87,7 +87,7 @@ async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: async def get_raw_tags_from_db( connector: Neo4jConnector, - group_id: str, + end_user_id: str, limit: int, by_user: bool = False ) -> List[Tuple[str, int]]: @@ -99,9 +99,9 @@ async def get_raw_tags_from_db( Args: connector: Neo4j连接器实例 - group_id: 如果by_user=False,则为group_id;如果by_user=True,则为user_id + end_user_id: 如果by_user=False,则为end_user_id;如果by_user=True,则为user_id limit: 返回的标签数量限制 - by_user: 是否按user_id查询(默认False,按group_id查询) + by_user: 是否按user_id查询(默认False,按end_user_id查询) Returns: List[Tuple[str, int]]: 标签名称和频率的元组列表 @@ -119,7 +119,7 @@ async def get_raw_tags_from_db( else: query = ( "MATCH (e:ExtractedEntity) " - "WHERE e.group_id = $id AND e.entity_type <> '人物' AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " + "WHERE e.end_user_id = $id AND e.entity_type <> '人物' AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC " "LIMIT $limit" @@ -128,44 +128,44 @@ async def get_raw_tags_from_db( # 使用项目的Neo4jConnector执行查询 results = await connector.execute_query( query, - id=group_id, + id=end_user_id, limit=limit, names_to_exclude=names_to_exclude ) return [(record["name"], record["frequency"]) for record in results] -async def get_hot_memory_tags(group_id: str, limit: int = 40, by_user: bool = False) -> List[Tuple[str, int]]: +async def get_hot_memory_tags(end_user_id: str, limit: int = 40, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 查询更多的标签(limit=40)给LLM提供更丰富的上下文进行筛选。 Args: - group_id: 必需参数。如果by_user=False,则为group_id;如果by_user=True,则为user_id + end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id limit: 返回的标签数量限制 - by_user: 是否按user_id查询(默认False,按group_id查询) + by_user: 是否按user_id查询(默认False,按end_user_id查询) Raises: - ValueError: 如果group_id未提供或为空 + ValueError: 如果end_user_id未提供或为空 """ - # 验证group_id必须提供且不为空 - if not group_id or not group_id.strip(): + # 验证end_user_id必须提供且不为空 + if not end_user_id or not end_user_id.strip(): raise ValueError( - "group_id is required. Please provide a valid group_id or user_id." + "end_user_id is required. Please provide a valid end_user_id or user_id." ) # 使用项目的Neo4jConnector connector = Neo4jConnector() try: # 1. 从数据库获取原始排名靠前的标签 - raw_tags_with_freq = await get_raw_tags_from_db(connector, group_id, limit, by_user=by_user) + raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, limit, by_user=by_user) if not raw_tags_with_freq: return [] raw_tag_names = [tag for tag, freq in raw_tags_with_freq] # 2. 初始化LLM客户端并使用LLM筛选出有意义的标签 - meaningful_tag_names = await filter_tags_with_llm(raw_tag_names, group_id) + meaningful_tag_names = await filter_tags_with_llm(raw_tag_names, end_user_id) # 3. 根据LLM的筛选结果,构建最终的标签列表(保留原始频率和顺序) final_tags = [] diff --git a/api/app/core/memory/analytics/implicit_memory/data_source.py b/api/app/core/memory/analytics/implicit_memory/data_source.py index d277a05e..18678a55 100644 --- a/api/app/core/memory/analytics/implicit_memory/data_source.py +++ b/api/app/core/memory/analytics/implicit_memory/data_source.py @@ -75,8 +75,8 @@ class MemoryDataSource: start_date = time_range.start_date if time_range else None end_date = time_range.end_date if time_range else None - summary_dicts = await self.memory_summary_repo.find_by_group_id( - group_id=user_id, + summary_dicts = await self.memory_summary_repo.find_by_end_user_id( + end_user_id=user_id, limit=limit, start_date=start_date, end_date=end_date diff --git a/api/app/core/memory/evaluation/dialogue_queries.py b/api/app/core/memory/evaluation/dialogue_queries.py index fd7fa671..25abe64e 100644 --- a/api/app/core/memory/evaluation/dialogue_queries.py +++ b/api/app/core/memory/evaluation/dialogue_queries.py @@ -41,7 +41,7 @@ DIALOGUE_EMBEDDING_SEARCH = """ WITH $embedding AS q MATCH (d:Dialogue) WHERE d.dialog_embedding IS NOT NULL - AND ($group_id IS NULL OR d.group_id = $group_id) + AND ($end_user_id IS NULL OR d.end_user_id = $end_user_id) WITH d, q, d.dialog_embedding AS v WITH d, reduce(dot = 0.0, i IN range(0, size(q)-1) | dot + toFloat(q[i]) * toFloat(v[i])) AS dot, @@ -50,7 +50,7 @@ WITH d, WITH d, CASE WHEN qnorm = 0 OR vnorm = 0 THEN 0.0 ELSE dot / (qnorm * vnorm) END AS score WHERE score > $threshold RETURN d.id AS dialog_id, - d.group_id AS group_id, + d.end_user_id AS end_user_id, d.content AS content, d.created_at AS created_at, d.expired_at AS expired_at, diff --git a/api/app/core/memory/evaluation/extraction_utils.py b/api/app/core/memory/evaluation/extraction_utils.py index 9afa228c..9e70bc28 100644 --- a/api/app/core/memory/evaluation/extraction_utils.py +++ b/api/app/core/memory/evaluation/extraction_utils.py @@ -36,7 +36,7 @@ from app.repositories.neo4j.neo4j_connector import Neo4jConnector async def ingest_contexts_via_full_pipeline( contexts: List[str], - group_id: str, + end_user_id: str, chunker_strategy: str | None = None, embedding_name: str | None = None, save_chunk_output: bool = False, @@ -48,7 +48,7 @@ async def ingest_contexts_via_full_pipeline( This function mirrors the steps in main(), but starts from raw text contexts. Args: contexts: List of dialogue texts, each containing lines like "role: message". - group_id: Group ID to assign to generated DialogData and graph nodes. + end_user_id: Group ID to assign to generated DialogData and graph nodes. chunker_strategy: Optional chunker strategy; defaults to SELECTED_CHUNKER_STRATEGY. embedding_name: Optional embedding model ID; defaults to SELECTED_EMBEDDING_ID. save_chunk_output: If True, write chunked DialogData list to a JSON file for debugging. @@ -109,7 +109,7 @@ async def ingest_contexts_via_full_pipeline( dialog = DialogData( context=context_model, ref_id=f"pipeline_item_{idx}", - group_id=group_id, + end_user_id=end_user_id, user_id="default_user", apply_id="default_application", ) @@ -318,16 +318,16 @@ async def handle_context_processing(args): print("No contexts provided for processing.") return False - return await main_from_contexts(contexts, args.context_group_id) + return await main_from_contexts(contexts, args.context_end_user_id) -async def main_from_contexts(contexts: List[str], group_id: str): +async def main_from_contexts(contexts: List[str], end_user_id: str): """Run the pipeline from provided dialogue contexts instead of test data.""" print("=== Running pipeline from provided contexts ===") success = await ingest_contexts_via_full_pipeline( contexts=contexts, - group_id=group_id, + end_user_id=end_user_id, chunker_strategy=SELECTED_CHUNKER_STRATEGY, embedding_name=SELECTED_EMBEDDING_ID, save_chunk_output=True diff --git a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py index b7d988c5..1c70c28e 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py +++ b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py @@ -47,7 +47,7 @@ from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient from app.core.memory.utils.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, + SELECTED_end_user_id, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -59,7 +59,7 @@ from app.services.memory_config_service import MemoryConfigService async def run_locomo_benchmark( sample_size: int = 20, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, search_type: str = "hybrid", search_limit: int = 12, context_char_budget: int = 8000, @@ -85,7 +85,7 @@ async def run_locomo_benchmark( Args: sample_size: Number of QA pairs to evaluate (from first conversation) - group_id: Database group ID for retrieval (uses default if None) + end_user_id: Database group ID for retrieval (uses default if None) search_type: "keyword", "embedding", or "hybrid" search_limit: Max documents to retrieve per query context_char_budget: Max characters for context @@ -96,8 +96,8 @@ async def run_locomo_benchmark( Returns: Dictionary with evaluation results including metrics, timing, and samples """ - # Use default group_id if not provided - group_id = group_id or SELECTED_GROUP_ID + # Use default end_user_id if not provided + end_user_id = end_user_id or SELECTED_end_user_id # Determine data path data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") @@ -110,7 +110,7 @@ async def run_locomo_benchmark( print(f"{'='*60}") print("📊 Configuration:") print(f" Sample size: {sample_size}") - print(f" Group ID: {group_id}") + print(f" Group ID: {end_user_id}") print(f" Search type: {search_type}") print(f" Search limit: {search_limit}") print(f" Context budget: {context_char_budget} chars") @@ -134,7 +134,7 @@ async def run_locomo_benchmark( # Step 2: Extract conversations and ingest if needed if skip_ingest: print("⏭️ Skipping data ingestion (using existing data in Neo4j)") - print(f" Group ID: {group_id}\n") + print(f" Group ID: {end_user_id}\n") else: print("💾 Checking database ingestion...") try: @@ -142,10 +142,10 @@ async def run_locomo_benchmark( print(f"📝 Extracted {len(conversations)} conversations") # Always ingest for now (ingestion check not implemented) - print(f"🔄 Ingesting conversations into group '{group_id}'...") + print(f"🔄 Ingesting conversations into group '{end_user_id}'...") success = await ingest_conversations_if_needed( conversations=conversations, - group_id=group_id, + end_user_id=end_user_id, reset=reset_group ) @@ -224,7 +224,7 @@ async def run_locomo_benchmark( try: retrieved_info = await retrieve_relevant_information( question=question, - group_id=group_id, + end_user_id=end_user_id, search_type=search_type, search_limit=search_limit, connector=connector, @@ -409,7 +409,7 @@ async def run_locomo_benchmark( "sample_size": len(qa_items), "timestamp": datetime.now().isoformat(), "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_type": search_type, "search_limit": search_limit, "context_char_budget": context_char_budget, @@ -467,7 +467,7 @@ def main(): help="Number of QA pairs to evaluate" ) parser.add_argument( - "--group_id", + "--end_user_id", type=str, default=None, help="Database group ID for retrieval (uses default if not specified)" @@ -516,7 +516,7 @@ def main(): # Run benchmark result = asyncio.run(run_locomo_benchmark( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_type=args.search_type, search_limit=args.search_limit, context_char_budget=args.context_char_budget, diff --git a/api/app/core/memory/evaluation/locomo/locomo_test.py b/api/app/core/memory/evaluation/locomo/locomo_test.py index b5ad5820..b871fb9c 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_test.py +++ b/api/app/core/memory/evaluation/locomo/locomo_test.py @@ -555,7 +555,7 @@ async def run_enhanced_evaluation(): search_results = await run_hybrid_search( query_text=q, search_type="hybrid", - group_id="locomo_sk", + end_user_id="locomo_sk", limit=20, include=["statements", "chunks", "entities", "summaries"], alpha=0.6, # BM25权重 diff --git a/api/app/core/memory/evaluation/locomo/locomo_utils.py b/api/app/core/memory/evaluation/locomo/locomo_utils.py index 69be5da9..d3b74947 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_utils.py +++ b/api/app/core/memory/evaluation/locomo/locomo_utils.py @@ -348,7 +348,7 @@ def select_and_format_information( async def retrieve_relevant_information( question: str, - group_id: str, + end_user_id: str, search_type: str, search_limit: int, connector: Any, @@ -368,7 +368,7 @@ async def retrieve_relevant_information( Args: question: Question to search for - group_id: Database group ID (identifies which conversation memory to search) + end_user_id: Database group ID (identifies which conversation memory to search) search_type: "keyword", "embedding", or "hybrid" search_limit: Max memory pieces to retrieve connector: Neo4j connector instance @@ -396,7 +396,7 @@ async def retrieve_relevant_information( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -455,7 +455,7 @@ async def retrieve_relevant_information( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit ) @@ -491,7 +491,7 @@ async def retrieve_relevant_information( search_results = await run_hybrid_search( query_text=question, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], output_path=None, @@ -524,7 +524,7 @@ async def retrieve_relevant_information( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -584,7 +584,7 @@ async def retrieve_relevant_information( async def ingest_conversations_if_needed( conversations: List[str], - group_id: str, + end_user_id: str, reset: bool = False ) -> bool: """ @@ -603,7 +603,7 @@ async def ingest_conversations_if_needed( Args: conversations: List of raw conversation texts from LoCoMo dataset Example: ["User: I went to Paris. AI: When was that?", ...] - group_id: Target group ID for database storage + end_user_id: Target group ID for database storage reset: Whether to clear existing data first (not implemented in wrapper) Returns: @@ -617,7 +617,7 @@ async def ingest_conversations_if_needed( try: success = await ingest_contexts_via_full_pipeline( contexts=conversations, - group_id=group_id, + end_user_id=end_user_id, save_chunk_output=True ) return success diff --git a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py index 87a70a29..3147e880 100644 --- a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py @@ -30,7 +30,7 @@ from app.core.memory.storage_services.search import run_hybrid_search from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, + SELECTED_end_user_id, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -249,7 +249,7 @@ def get_search_params_by_category(category: str): async def run_locomo_eval( sample_size: int = 1, - group_id: str | None = None, + end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, # 保持默认值不变 llm_temperature: float = 0.0, @@ -262,7 +262,7 @@ async def run_locomo_eval( ) -> Dict[str, Any]: # 函数内部使用三路检索逻辑,但保持参数签名不变 - group_id = group_id or SELECTED_GROUP_ID + end_user_id = end_user_id or SELECTED_end_user_id data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") if not os.path.exists(data_path): data_path = os.path.join(os.getcwd(), "data", "locomo10.json") @@ -340,7 +340,7 @@ async def run_locomo_eval( # 关键修复:强制重新摄入纯净的对话数据 print("🔄 强制重新摄入纯净的对话数据...") - await ingest_contexts_via_full_pipeline(contents, group_id, save_chunk_output=True) + await ingest_contexts_via_full_pipeline(contents, end_user_id, save_chunk_output=True) # 使用异步LLM客户端 with get_db_context() as db: @@ -405,7 +405,7 @@ async def run_locomo_eval( connector=connector, embedder_client=embedder, query_text=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], # 修复:使用正确的类型 ) @@ -456,7 +456,7 @@ async def run_locomo_eval( search_results = await search_graph( connector=connector, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit ) dialogs = search_results.get("dialogues", []) @@ -486,7 +486,7 @@ async def run_locomo_eval( search_results = await run_hybrid_search( query_text=q, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], output_path=None, @@ -524,7 +524,7 @@ async def run_locomo_eval( connector=connector, embedder_client=embedder, query_text=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -597,7 +597,7 @@ async def run_locomo_eval( "dialogues": [ { "uuid": d.get("uuid", ""), - "group_id": d.get("group_id", ""), + "end_user_id": d.get("end_user_id", ""), "content": d.get("content", "")[:200] + "..." if len(d.get("content", "")) > 200 else d.get("content", ""), "score": d.get("score", 0.0) } @@ -795,7 +795,7 @@ async def run_locomo_eval( }, "samples": samples, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, @@ -825,7 +825,7 @@ async def run_locomo_eval( def main(): parser = argparse.ArgumentParser(description="Run LoCoMo evaluation with Qwen search") parser.add_argument("--sample_size", type=int, default=1, help="Number of samples to evaluate") - parser.add_argument("--group_id", type=str, default=None, help="Group ID for retrieval") + parser.add_argument("--end_user_id", type=str, default=None, help="Group ID for retrieval") parser.add_argument("--search_limit", type=int, default=8, help="Search limit per query") parser.add_argument("--context_char_budget", type=int, default=12000, help="Max characters for context") parser.add_argument("--llm_temperature", type=float, default=0.0, help="LLM temperature") @@ -841,7 +841,7 @@ def main(): result = asyncio.run(run_locomo_eval( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py index 53c5ce19..320f9de7 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py @@ -523,11 +523,11 @@ def generate_query_keywords_cn(question: str) -> List[str]: # 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], group_id: str | None, limit: int) -> List[Dict[str, Any]]: +async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] try: for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, group_id=group_id, limit=limit) + rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) if rows: results.extend(rows) except Exception: @@ -547,15 +547,15 @@ async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[st # 通过对话/陈述中的entity_ids反查实体名称 _FETCH_ENTITIES_BY_IDS = """ MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type """ -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], group_id: str | None) -> List[Dict[str, Any]]: +async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: if not ids: return [] try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), group_id=group_id) + rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) return rows or [] except Exception: return [] @@ -565,18 +565,18 @@ async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], grou _TIME_ENTITY_SEARCH = """ MATCH (e:ExtractedEntity) WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type LIMIT $limit """ -async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: +async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: """专门搜索时间相关的实体""" try: date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" rows = await connector.execute_query(_TIME_ENTITY_SEARCH, date_pattern=date_pattern, - group_id=group_id, + end_user_id=end_user_id, limit=limit) return rows or [] except Exception: @@ -623,7 +623,7 @@ def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: async def run_longmemeval_test( sample_size: int = 3, - group_id: str = "longmemeval_zh_bak_3", + end_user_id: str = "longmemeval_zh_bak_3", search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -677,13 +677,13 @@ async def run_longmemeval_test( contexts.extend(selected) print(f"📥 摄入 {len(contexts)} 个上下文到数据库") - if reset_group_before_ingest and group_id: + if reset_group_before_ingest and end_user_id: try: _tmp_conn = Neo4jConnector() - await _tmp_conn.delete_group(group_id) - print(f"🧹 已清空组 {group_id} 的历史图数据") + await _tmp_conn.delete_group(end_user_id) + print(f"🧹 已清空组 {end_user_id} 的历史图数据") except Exception as _e: - print(f"⚠️ 清空组数据失败(忽略继续): {group_id} - {_e}") + print(f"⚠️ 清空组数据失败(忽略继续): {end_user_id} - {_e}") finally: try: await _tmp_conn.close() @@ -695,7 +695,7 @@ async def run_longmemeval_test( else: await _ingest_fn( contexts, - group_id, + end_user_id, save_chunk_output=save_chunk_output, save_chunk_output_path=save_chunk_output_path, ) @@ -750,7 +750,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -795,7 +795,7 @@ async def run_longmemeval_test( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) chunks = search_results.get("chunks", []) @@ -830,7 +830,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -848,7 +848,7 @@ async def run_longmemeval_test( kw_res = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) if isinstance(kw_res, dict): @@ -859,7 +859,7 @@ async def run_longmemeval_test( # 时间推理问题的特殊处理 if is_temporal: # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, group_id, search_limit//2) + time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) if time_entities: kw_entities.extend(time_entities) # 添加时间相关关键词检索 @@ -869,7 +869,7 @@ async def run_longmemeval_test( time_res = await search_graph( connector=connector, q=tk, - group_id=group_id, + end_user_id=end_user_id, limit=2, ) if isinstance(time_res, dict): @@ -880,7 +880,7 @@ async def run_longmemeval_test( # 中文关键词拆分后做别名匹配 cn_tokens = _extract_cn_tokens(question) - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, group_id, search_limit) + alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) if alias_entities: kw_entities.extend(alias_entities) @@ -894,7 +894,7 @@ async def run_longmemeval_test( except Exception: pass if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, group_id) + id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) if id_entities: kw_entities.extend(id_entities) @@ -908,7 +908,7 @@ async def run_longmemeval_test( sub_res = await search_graph( connector=connector, q=str(kw), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(sub_res, dict): @@ -927,7 +927,7 @@ async def run_longmemeval_test( opt_res = await search_graph( connector=connector, q=str(opt), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(opt_res, dict): diff --git a/api/app/core/memory/evaluation/longmemeval/test_eval.py b/api/app/core/memory/evaluation/longmemeval/test_eval.py index 08a763e3..a49d48d0 100644 --- a/api/app/core/memory/evaluation/longmemeval/test_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/test_eval.py @@ -498,11 +498,11 @@ def smart_context_selection(contexts: List[str], question: str, max_chars: int = # 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], group_id: str | None, limit: int) -> List[Dict[str, Any]]: +async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] try: for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, group_id=group_id, limit=limit) + rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) if rows: results.extend(rows) except Exception: @@ -522,15 +522,15 @@ async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[st # 通过对话/陈述中的entity_ids反查实体名称 _FETCH_ENTITIES_BY_IDS = """ MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type """ -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], group_id: str | None) -> List[Dict[str, Any]]: +async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: if not ids: return [] try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), group_id=group_id) + rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) return rows or [] except Exception: return [] @@ -540,18 +540,18 @@ async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], grou _TIME_ENTITY_SEARCH = """ MATCH (e:ExtractedEntity) WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type LIMIT $limit """ -async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: +async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: """专门搜索时间相关的实体""" try: date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" rows = await connector.execute_query(_TIME_ENTITY_SEARCH, date_pattern=date_pattern, - group_id=group_id, + end_user_id=end_user_id, limit=limit) return rows or [] except Exception: @@ -559,25 +559,25 @@ async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, # 技术术语专门检索 -async def _search_tech_terms(connector: Neo4jConnector, question: str, group_id: str | None, limit: int = 3) -> List[Dict[str, Any]]: +async def _search_tech_terms(connector: Neo4jConnector, question: str, end_user_id: str | None, limit: int = 3) -> List[Dict[str, Any]]: """专门搜索技术术语相关的实体""" tech_entities = [] try: # GPS相关 if any(term in question for term in ["GPS", "导航", "定位系统"]): - gps_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="GPS", group_id=group_id, limit=limit) + gps_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="GPS", end_user_id=end_user_id, limit=limit) if gps_rows: tech_entities.extend(gps_rows) # 活动相关 if any(term in question for term in ["工作坊", "研讨会", "网络研讨会"]): - workshop_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="工作坊", group_id=group_id, limit=limit) + workshop_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="工作坊", end_user_id=end_user_id, limit=limit) if workshop_rows: tech_entities.extend(workshop_rows) # 时间顺序相关 if any(term in question for term in ["先", "后", "第一个"]): - time_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="第一次", group_id=group_id, limit=limit) + time_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="第一次", end_user_id=end_user_id, limit=limit) if time_rows: tech_entities.extend(time_rows) @@ -627,7 +627,7 @@ def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: async def run_longmemeval_test( sample_size: int = 3, - group_id: str = "longmemeval_zh_bak_2", + end_user_id: str = "longmemeval_zh_bak_2", search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -707,7 +707,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], ) @@ -746,7 +746,7 @@ async def run_longmemeval_test( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) dialogs = search_results.get("dialogues", []) @@ -776,7 +776,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], ) @@ -792,7 +792,7 @@ async def run_longmemeval_test( kw_res = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) if isinstance(kw_res, dict): @@ -801,14 +801,14 @@ async def run_longmemeval_test( kw_entities = kw_res.get("entities", []) or [] # 技术术语专门检索 - tech_entities = await _search_tech_terms(connector, question, group_id, search_limit//2) + tech_entities = await _search_tech_terms(connector, question, end_user_id, search_limit//2) if tech_entities: kw_entities.extend(tech_entities) # 时间推理问题的特殊处理 if is_temporal: # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, group_id, search_limit//2) + time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) if time_entities: kw_entities.extend(time_entities) # 添加时间相关关键词检索 @@ -818,7 +818,7 @@ async def run_longmemeval_test( time_res = await search_graph( connector=connector, q=tk, - group_id=group_id, + end_user_id=end_user_id, limit=2, ) if isinstance(time_res, dict): @@ -829,7 +829,7 @@ async def run_longmemeval_test( # 中文关键词拆分后做别名匹配 cn_tokens = generate_query_keywords_cn(question) # 使用增强版关键词提取 - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, group_id, search_limit) + alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) if alias_entities: kw_entities.extend(alias_entities) @@ -843,7 +843,7 @@ async def run_longmemeval_test( except Exception: pass if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, group_id) + id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) if id_entities: kw_entities.extend(id_entities) @@ -857,7 +857,7 @@ async def run_longmemeval_test( sub_res = await search_graph( connector=connector, q=str(kw), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(sub_res, dict): @@ -876,7 +876,7 @@ async def run_longmemeval_test( opt_res = await search_graph( connector=connector, q=str(opt), - group_id=group_id, + end_user_id=group_id, limit=max(3, search_limit // 2), ) if isinstance(opt_res, dict): diff --git a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py index 6efb66ff..ec147f3c 100644 --- a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py +++ b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py @@ -27,7 +27,7 @@ from app.core.memory.storage_services.search import run_hybrid_search from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, + SELECTED_end_user_id, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -135,8 +135,8 @@ def _combine_dialogues_for_hybrid(results: Dict[str, Any]) -> List[Dict[str, Any return merged -async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: - group_id = group_id or SELECTED_GROUP_ID +async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: + end_user_id = end_user_id or SELECTED_end_user_id # Load data data_path = os.path.join(PROJECT_ROOT, "data", "msc_self_instruct.jsonl") if not os.path.exists(data_path): @@ -147,7 +147,7 @@ async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, s # 改为:每条样本仅摄入一个上下文(完整对话转录),避免多上下文摄入 # 说明:memsciqa 数据集的每个样本天然只有一个对话,保持按样本一上下文的策略 contexts: List[str] = [build_context_from_dialog(item) for item in items] - await ingest_contexts_via_full_pipeline(contexts, group_id) + await ingest_contexts_via_full_pipeline(contexts, end_user_id) # LLM client (使用异步调用) with get_db_context() as db: @@ -173,7 +173,7 @@ async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, s results = await run_hybrid_search( query_text=question, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], output_path=None, @@ -298,7 +298,7 @@ def main(): load_dotenv() parser = argparse.ArgumentParser(description="Evaluate DMR (memsciqa) with graph search and Qwen") parser.add_argument("--sample-size", type=int, default=1, help="评测样本数量") - parser.add_argument("--group-id", type=str, default=None, help="可选 group_id,默认取 runtime.json") + parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") parser.add_argument("--search-limit", type=int, default=8, help="每类检索最大返回数") parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") @@ -309,7 +309,7 @@ def main(): result = asyncio.run( run_memsciqa_eval( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py index 279f4042..631035aa 100644 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py @@ -33,7 +33,7 @@ from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, + SELECTED_end_user_id, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -198,7 +198,7 @@ def load_dataset_memsciqa(data_path: str) -> List[Dict[str, Any]]: async def run_memsciqa_test( sample_size: int = 3, - group_id: str | None = None, + end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -216,7 +216,7 @@ async def run_memsciqa_test( """ # 默认使用指定的 memsci 组 ID - group_id = group_id or "group_memsci" + end_user_id = end_user_id or "group_memsci" # 数据路径解析(项目根与当前工作目录兜底) if not data_path: @@ -282,7 +282,7 @@ async def run_memsciqa_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues ) @@ -291,7 +291,7 @@ async def run_memsciqa_test( results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues ) @@ -499,7 +499,7 @@ async def run_memsciqa_test( }, "samples": samples, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "llm_temperature": llm_temperature, @@ -542,7 +542,7 @@ def main(): result = asyncio.run( run_memsciqa_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/run_eval.py b/api/app/core/memory/evaluation/run_eval.py index 1de3de89..f665bdb8 100644 --- a/api/app/core/memory/evaluation/run_eval.py +++ b/api/app/core/memory/evaluation/run_eval.py @@ -15,7 +15,7 @@ except Exception: return None from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.utils.config.definitions import SELECTED_GROUP_ID, PROJECT_ROOT +from app.core.memory.utils.config.definitions import SELECTED_end_user_id, PROJECT_ROOT from app.core.memory.evaluation.memsciqa.evaluate_qa import run_memsciqa_eval from app.core.memory.evaluation.longmemeval.qwen_search_eval import run_longmemeval_test @@ -26,7 +26,7 @@ async def run( dataset: str, sample_size: int, reset_group: bool, - group_id: str | None, + end_user_id: str | None, judge_model: str | None = None, search_limit: int | None = None, context_char_budget: int | None = None, @@ -37,17 +37,17 @@ async def run( max_contexts_per_item: int | None = None, ) -> Dict[str, Any]: # 恢复原始风格:统一入口做路由,并沿用各数据集既有默认 - group_id = group_id or SELECTED_GROUP_ID + end_user_id = end_user_id or SELECTED_end_user_id if reset_group: connector = Neo4jConnector() try: - await connector.delete_group(group_id) + await connector.delete_group(end_user_id) finally: await connector.close() if dataset == "locomo": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -61,7 +61,7 @@ async def run( return await run_locomo_eval(**kwargs) if dataset == "memsciqa": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -75,7 +75,7 @@ async def run( return await run_memsciqa_eval(**kwargs) if dataset == "longmemeval": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -99,8 +99,8 @@ def main(): parser = argparse.ArgumentParser(description="统一评估入口:memsciqa / longmemeval / locomo") parser.add_argument("--dataset", choices=["memsciqa", "longmemeval", "locomo"], required=True) parser.add_argument("--sample-size", type=int, default=1, help="先用一条数据跑通") - parser.add_argument("--reset-group", action="store_true", help="运行前清空当前 group_id 的图数据") - parser.add_argument("--group-id", type=str, default=None, help="可选 group_id,默认取 runtime.json") + parser.add_argument("--reset-group", action="store_true", help="运行前清空当前 end_user_id 的图数据") + parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") parser.add_argument("--judge-model", type=str, default=None, help="可选:longmemeval 判别式评测模型名") parser.add_argument("--search-limit", type=int, default=None, help="检索返回的对话节点数量上限(不提供则使用各脚本默认)") parser.add_argument("--context-char-budget", type=int, default=None, help="上下文字符预算(不提供则使用各脚本默认)") @@ -117,7 +117,7 @@ def main(): args.dataset, args.sample_size, args.reset_group, - args.group_id, + args.end_user_id, args.judge_model, args.search_limit, args.context_char_budget, diff --git a/api/app/core/memory/models/config_models.py b/api/app/core/memory/models/config_models.py index f3341cc5..ca1780aa 100644 --- a/api/app/core/memory/models/config_models.py +++ b/api/app/core/memory/models/config_models.py @@ -72,7 +72,7 @@ class TemporalSearchParams(BaseModel): """Parameters for temporal search queries in the knowledge graph. Attributes: - group_id: Group ID to filter search results (default: 'test') + end_user_id: Group ID to filter search results (default: 'test') apply_id: Application ID to filter search results user_id: User ID to filter search results start_date: Start date for temporal filtering (format: 'YYYY-MM-DD') @@ -81,7 +81,7 @@ class TemporalSearchParams(BaseModel): invalid_date: Date when memory should be invalid (format: 'YYYY-MM-DD') limit: Maximum number of results to return (default: 3) """ - group_id: Optional[str] = Field("test", description="The group ID to filter the search.") + end_user_id: Optional[str] = Field("test", description="The group ID to filter the search.") apply_id: Optional[str] = Field(None, description="The apply ID to filter the search.") user_id: Optional[str] = Field(None, description="The user ID to filter the search.") start_date: Optional[str] = Field(None, description="The start date for the search.") diff --git a/api/app/core/memory/models/graph_models.py b/api/app/core/memory/models/graph_models.py index 7a48d6cb..79b88fdc 100644 --- a/api/app/core/memory/models/graph_models.py +++ b/api/app/core/memory/models/graph_models.py @@ -103,9 +103,7 @@ class Edge(BaseModel): id: Unique identifier for the edge source: ID of the source node target: ID of the target node - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy run_id: Unique identifier for the pipeline run that created this edge created_at: Timestamp when the edge was created (system perspective) expired_at: Optional timestamp when the edge expires (system perspective) @@ -113,9 +111,7 @@ class Edge(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex, description="A unique identifier for the edge.") source: str = Field(..., description="The ID of the source node.") target: str = Field(..., description="The ID of the target node.") - group_id: str = Field(..., description="The group ID of the edge.") - user_id: str = Field(..., description="The user ID of the edge.") - apply_id: str = Field(..., description="The apply ID of the edge.") + end_user_id: str = Field(..., description="The end user ID of the edge.") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(..., description="The valid time of the edge from system perspective.") expired_at: Optional[datetime] = Field(None, description="The expired time of the edge from system perspective.") @@ -185,18 +181,14 @@ class Node(BaseModel): Attributes: id: Unique identifier for the node name: Name of the node - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy run_id: Unique identifier for the pipeline run that created this node created_at: Timestamp when the node was created (system perspective) expired_at: Optional timestamp when the node expires (system perspective) """ id: str = Field(..., description="The unique identifier for the node.") name: str = Field(..., description="The name of the node.") - group_id: str = Field(..., description="The group ID of the node.") - user_id: str = Field(..., description="The user ID of the edge.") - apply_id: str = Field(..., description="The apply ID of the edge.") + end_user_id: str = Field(..., description="The end user ID of the node.") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(..., description="The valid time of the node from system perspective.") expired_at: Optional[datetime] = Field(None, description="The expired time of the node from system perspective.") diff --git a/api/app/core/memory/models/message_models.py b/api/app/core/memory/models/message_models.py index bcf08999..c660d841 100644 --- a/api/app/core/memory/models/message_models.py +++ b/api/app/core/memory/models/message_models.py @@ -55,7 +55,7 @@ class Statement(BaseModel): Attributes: id: Unique identifier for the statement chunk_id: ID of the parent chunk this statement belongs to - group_id: Optional group ID for multi-tenancy + end_user_id: Optional group ID for multi-tenancy statement: The actual statement text content speaker: Optional speaker identifier ('用户' for user, 'AI' for AI responses) statement_embedding: Optional embedding vector for the statement @@ -73,7 +73,7 @@ class Statement(BaseModel): """ id: str = Field(default_factory=lambda: uuid4().hex, description="A unique identifier for the statement.") chunk_id: str = Field(..., description="ID of the parent chunk this statement belongs to.") - group_id: Optional[str] = Field(None, description="ID of the group this statement belongs to.") + end_user_id: Optional[str] = Field(None, description="ID of the group this statement belongs to.") statement: str = Field(..., description="The text content of the statement.") speaker: Optional[str] = Field(None, description="Speaker identifier: 'user' for user messages, 'assistant' for AI responses") statement_embedding: Optional[List[float]] = Field(None, description="The embedding vector of the statement.") @@ -159,9 +159,7 @@ class DialogData(BaseModel): context: Full conversation context dialog_embedding: Optional embedding vector for the entire dialog ref_id: Reference ID linking to external dialog system - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy created_at: Timestamp when the dialog was created expired_at: Timestamp when the dialog expires (default: far future) metadata: Additional metadata as key-value pairs @@ -175,9 +173,7 @@ class DialogData(BaseModel): context: ConversationContext = Field(..., description="The full conversation context as a single string.") dialog_embedding: Optional[List[float]] = Field(None, description="The embedding vector of the dialog.") ref_id: str = Field(..., description="Refer to external dialog id. This is used to link to the original dialog.") - group_id: str = Field(default=..., description="Group ID of dialogue data") - user_id: str = Field(..., description="USER ID of dialogue data") - apply_id: str = Field(..., description="APPLY ID of dialogue data") + end_user_id: str = Field(default=..., description="End user ID of dialogue data") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(default_factory=datetime.now, description="The timestamp when the dialog was created.") expired_at: datetime = Field(default_factory=lambda: datetime(9999, 12, 31), description="The timestamp when the dialog expires.") @@ -256,5 +252,5 @@ class DialogData(BaseModel): """ for chunk in self.chunks: for statement in chunk.statements: - if statement.group_id is None: - statement.group_id = self.group_id + if statement.end_user_id is None: + statement.end_user_id = self.end_user_id diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 91e47eae..345cd69b 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -6,6 +6,7 @@ import os import time from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional +from uuid import UUID if TYPE_CHECKING: from app.schemas.memory_config_schema import MemoryConfig @@ -396,13 +397,13 @@ def rerank_with_activation( return reranked -def log_search_query(query_text: str, search_type: str, group_id: str | None, limit: int, include: List[str], log_file: str = None): +def log_search_query(query_text: str, search_type: str, end_user_id: str | None, limit: int, include: List[str], log_file: str = None): """Log search query information using the logger. Args: query_text: The search query text search_type: Type of search (keyword, embedding, hybrid) - group_id: Group identifier for filtering + end_user_id: Group identifier for filtering limit: Maximum number of results include: List of result types to include log_file: Deprecated parameter, kept for backward compatibility @@ -413,7 +414,7 @@ def log_search_query(query_text: str, search_type: str, group_id: str | None, li # Log using the standard logger logger.info( f"Search query: query='{cleaned_query}', type={search_type}, " - f"group_id={group_id}, limit={limit}, include={include}" + f"end_user_id={end_user_id}, limit={limit}, include={include}" ) @@ -672,7 +673,7 @@ def apply_reranker_placeholder( async def run_hybrid_search( query_text: str, search_type: str, - group_id: str | None, + end_user_id: str | None, limit: int, include: List[str], output_path: str | None, @@ -692,6 +693,9 @@ async def run_hybrid_search( # Start overall timing search_start_time = time.time() latency_metrics = {} + print(100*'-') + print(memory_config) + print(100 * '-') logger.info(f"using embedding_id:{memory_config.embedding_model_id}...") # Clean and normalize the incoming query before use/logging @@ -715,7 +719,7 @@ async def run_hybrid_search( } # Log the search query - log_search_query(query_text, search_type, group_id, limit, include) + log_search_query(query_text, search_type, end_user_id, limit, include) connector = Neo4jConnector() results = {} @@ -732,7 +736,7 @@ async def run_hybrid_search( search_graph( connector=connector, q=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include ) @@ -769,7 +773,7 @@ async def run_hybrid_search( connector=connector, embedder_client=embedder, query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, ) @@ -916,9 +920,7 @@ async def run_hybrid_search( async def search_by_temporal( - group_id: Optional[str] = "test", - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = "test", start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -929,7 +931,7 @@ async def search_by_temporal( Temporal search across Statements. - Matches statements created between start_date and end_date - - Optionally filters by group_id + - Optionally filters by end_user_id - Returns up to 'limit' statements """ connector = Neo4jConnector() @@ -939,9 +941,7 @@ async def search_by_temporal( end_date = normalize_date_safe(end_date) params = TemporalSearchParams.model_validate({ - "group_id": group_id, - "apply_id": apply_id, - "user_id": user_id, + "end_user_id": end_user_id, "start_date": start_date, "end_date": end_date, "valid_date": valid_date, @@ -950,9 +950,7 @@ async def search_by_temporal( }) statements = await search_graph_by_temporal( connector=connector, - group_id=params.group_id, - apply_id=params.apply_id, - user_id=params.user_id, + end_user_id=params.end_user_id, start_date=params.start_date, end_date=params.end_date, valid_date=params.valid_date, @@ -964,9 +962,7 @@ async def search_by_temporal( async def search_by_keyword_temporal( query_text: str, - group_id: Optional[str] = "test", - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = "test", start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -987,9 +983,7 @@ async def search_by_keyword_temporal( invalid_date = normalize_date_safe(invalid_date) params = TemporalSearchParams.model_validate({ - "group_id": group_id, - "apply_id": apply_id, - "user_id": user_id, + "end_user_id": end_user_id, "start_date": start_date, "end_date": end_date, "valid_date": valid_date, @@ -999,9 +993,7 @@ async def search_by_keyword_temporal( statements = await search_graph_by_keyword_temporal( connector=connector, query_text=query_text, - group_id=params.group_id, - apply_id=params.apply_id, - user_id=params.user_id, + end_user_id=params.end_user_id, start_date=params.start_date, end_date=params.end_date, valid_date=params.valid_date, @@ -1013,7 +1005,7 @@ async def search_by_keyword_temporal( async def search_chunk_by_chunk_id( chunk_id: str, - group_id: Optional[str] = "test", + end_user_id: Optional[str] = "test", limit: int = 1, ): """ @@ -1023,8 +1015,68 @@ async def search_chunk_by_chunk_id( chunks = await search_graph_by_chunk_id( connector=connector, chunk_id=chunk_id, - group_id=group_id, + end_user_id=end_user_id, limit=limit ) return {"chunks": chunks} +if __name__ == '__main__': + # 测试混合检索功能 + from app.schemas.memory_config_schema import MemoryConfig + from app.db import get_db + from app.services.memory_config_service import MemoryConfigService + + # 从数据库获取真实配置 + db = next(get_db()) + try: + config_service = MemoryConfigService(db) + + # 使用 config_id=17 获取配置 + memory_config = config_service.load_memory_config(config_id=17) + + if not memory_config: + print("错误:找不到 config_id=17 的配置") + print("请先在数据库中创建配置,或修改 config_id") + exit(1) + + print(f"✓ 成功加载配置: {memory_config.config_name}") + print(f" - Workspace: {memory_config.workspace_name}") + print(f" - LLM Model: {memory_config.llm_model_name}") + print(f" - Embedding Model: {memory_config.embedding_model_name}") + print(f" - Storage Type: {memory_config.storage_type}") + print() + + # 修改这里的参数进行测试 + test_end_user_id = "021886bc-fab9-4fd5-b607-497b262e0381" # 修改为你的 end_user_id + test_query = "小明擅长什么?" # 修改为你的查询 + + print(f"开始测试检索...") + print(f" - Query: {test_query}") + print(f" - End User ID: {test_end_user_id}") + print(f" - Search Type: hybrid") + print() + + results = asyncio.run(run_hybrid_search( + query_text=test_query, + search_type="hybrid", # 可选: "keyword", "embedding", "hybrid" + end_user_id=test_end_user_id, + limit=10, + include=["statements", "entities", "chunks", "summaries"], + output_path=None, + memory_config=memory_config, + rerank_alpha=0.6, + use_forgetting_rerank=False, + use_llm_rerank=False + )) + + print("=" * 80) + print("检索结果:") + print("=" * 80) + print(results) + + except Exception as e: + print(f"错误: {e}") + import traceback + traceback.print_exc() + finally: + db.close() diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py index f5e72517..4dafd3ed 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py @@ -555,8 +555,8 @@ class DataPreprocessor: dialog_id = item.get('dialog_id', item.get('ref_id', item.get('id', f'dialog_{i}'))) - # 获取group_id,如果不存在则生成默认值 - group_id = item.get('group_id', f'group_default_{i}') + # 获取end_user_id,如果不存在则生成默认值 + end_user_id = item.get('end_user_id', f'group_default_{i}') user_id = item.get('user_id', f'user_default_{i}') apply_id = item.get('apply_id', f'apply_default_{i}') @@ -574,7 +574,7 @@ class DataPreprocessor: dialog_data = DialogData( context=context, ref_id=dialog_id, - group_id=group_id, + end_user_id=end_user_id, user_id=user_id, apply_id=apply_id, metadata=metadata @@ -644,7 +644,7 @@ class DataPreprocessor: context = ConversationContext(msgs=messages) dialog_id = item.get('dialog_id', item.get('ref_id', item.get('id', f'dialog_{i}'))) - group_id = item.get('group_id', f'group_default_{i}') + end_user_id = item.get('end_user_id', f'group_default_{i}') user_id = item.get('user_id', f'user_default_{i}') apply_id = item.get('apply_id', f'apply_default_{i}') @@ -657,7 +657,7 @@ class DataPreprocessor: dialog_data = DialogData( context=context, ref_id=dialog_id, - group_id=group_id, + end_user_id=end_user_id, user_id=user_id, apply_id=apply_id, metadata=metadata diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 62b656b0..a425e0ed 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -199,7 +199,7 @@ def accurate_match( entity_nodes: List[ExtractedEntityNode] ) -> Tuple[List[ExtractedEntityNode], Dict[str, str], Dict[str, Dict]]: """ - 精确匹配:按 (group_id, name, entity_type) 合并实体并建立重定向与合并记录。 + 精确匹配:按 (end_user_id, name, entity_type) 合并实体并建立重定向与合并记录。 返回: (deduped_entities, id_redirect, exact_merge_map) """ exact_merge_map: Dict[str, Dict] = {} @@ -210,8 +210,8 @@ def accurate_match( for ent in entity_nodes: name_norm = (getattr(ent, "name", "") or "").strip() type_norm = (getattr(ent, "entity_type", "") or "").strip() - key = f"{getattr(ent, 'group_id', None)}|{name_norm}|{type_norm}" - # 为避免跨业务组误并,明确以 group_id 为范围边界 + key = f"{getattr(ent, 'end_user_id', None)}|{name_norm}|{type_norm}" + # 为避免跨业务组误并,明确以 end_user_id 为范围边界 if key not in canonical_map: canonical_map[key] = ent id_redirect[ent.id] = ent.id @@ -223,11 +223,11 @@ def accurate_match( id_redirect[ent.id] = canonical.id # 记录精确匹配的合并项(使用规范化键,避免外层变量误用) try: - k = f"{canonical.group_id}|{(canonical.name or '').strip()}|{(canonical.entity_type or '').strip()}" + k = f"{canonical.end_user_id}|{(canonical.name or '').strip()}|{(canonical.entity_type or '').strip()}" if k not in exact_merge_map: exact_merge_map[k] = { "canonical_id": canonical.id, - "group_id": canonical.group_id, + "end_user_id": canonical.end_user_id, "name": canonical.name, "entity_type": canonical.entity_type, "merged_ids": set(), @@ -596,7 +596,7 @@ def fuzzy_match( b = deduped_entities[j] # 跳过不同业务组的实体 - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): j += 1 continue @@ -671,7 +671,7 @@ def fuzzy_match( merge_reason = "[别名匹配]" if alias_match_merge else "[模糊]" merge_reason = "[别名匹配]" if alias_match_merge else "[模糊]" fuzzy_merge_records.append( - f"{merge_reason} 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type}) | " + f"{merge_reason} 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type}) | " f"s_name={s_name:.3f}, s_type={s_type:.3f}, overall={overall:.3f}, exact_alias={has_exact_match}" ) except Exception: @@ -779,7 +779,7 @@ async def LLM_decision( # 决策中包含去重和消歧的功能 # 记录 LLM 融合日志 try: llm_records.append( - f"[LLM融合] 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type})" + f"[LLM融合] 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type})" ) # 详细的“同类名称相似”记录改由 LLM 去重模块统一生成以携带 conf/reason except Exception: @@ -847,7 +847,7 @@ async def LLM_disamb_decision( id_redirect[k] = a.id try: disamb_records.append( - f"[DISAMB合并应用] 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type})" + f"[DISAMB合并应用] 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type})" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py index 734f7b69..0249ac1f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py @@ -174,7 +174,7 @@ async def _judge_pair( pass # 3. 构建LLM判断的“上下文信息”(规则层计算的所有特征) 判断上下文特征有助于实体消歧首先判断的类型关系 ctx = { - "same_group": getattr(a, "group_id", None) == getattr(b, "group_id", None), + "same_group": getattr(a, "end_user_id", None) == getattr(b, "end_user_id", None), "type_ok": _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "type_similarity": _type_similarity(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "name_text_sim": name_text_sim, @@ -235,7 +235,7 @@ async def _judge_pair_disamb( except Exception: pass ctx = { - "same_group": getattr(a, "group_id", None) == getattr(b, "group_id", None), + "same_group": getattr(a, "end_user_id", None) == getattr(b, "end_user_id", None), "type_ok": _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "name_text_sim": name_text_sim, "name_embed_sim": name_embed_sim, @@ -317,8 +317,8 @@ async def llm_dedup_entities( # 保留对偶判断作为子流程,是为了 a = entity_nodes[i] for j in range(i + 1, len(entity_nodes)): b = entity_nodes[j] - # 规则1:必须属于同一组(group_id相同,不同组的实体不重复) - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + # 规则1:必须属于同一组(end_user_id相同,不同组的实体不重复) + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): continue # 规则2:类型必须兼容(调用_simple_type_ok判断) if not _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)): @@ -474,7 +474,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 - max_rounds: upper bound for iterative passes (default 3) - auto_merge_threshold: decision confidence for auto-merge when no co-occurrence (default 0.90) - co_ctx_threshold: lower threshold when co-occurrence is detected (default 0.83) - - shuffle_each_round: whether to shuffle entities within group_id each round to vary block composition + - shuffle_each_round: whether to shuffle entities within end_user_id each round to vary block composition Returns: - global_redirect: dict losing_id -> canonical_id accumulated across rounds @@ -509,7 +509,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 def _partition_blocks(nodes: List[ExtractedEntityNode]) -> List[List[ExtractedEntityNode]]: """ - 按 group_id 分块,避免跨组实体在同一块,减少无效候选对 + 按 end_user_id 分块,避免跨组实体在同一块,减少无效候选对 Args: nodes: 实体节点列表 @@ -519,7 +519,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 """ groups: Dict[str, List[ExtractedEntityNode]] = {} for e in nodes: - gid = getattr(e, "group_id", None) + gid = getattr(e, "end_user_id", None) groups.setdefault(str(gid), []).append(e) blocks: List[List[ExtractedEntityNode]] = [] for gid, arr in groups.items(): @@ -559,7 +559,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 # Collapse nodes to canonical reps before each round to avoid redundant comparisons # 步骤1:折叠实体(合并已确定的重复实体,减少后续计算量) current_nodes = _collapse_nodes(current_nodes) - # 步骤2:分块(按group_id分块,避免跨组处理) + # 步骤2:分块(按end_user_id分块,避免跨组处理) blocks = _partition_blocks(current_nodes) if not blocks: # 无块可处理(实体已全部折叠),退出循环 break @@ -645,7 +645,7 @@ async def llm_disambiguate_pairs_iterative( a = entity_nodes[i] b = entity_nodes[j] # 必须同组 - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): continue ta = getattr(a, "entity_type", None) tb = getattr(b, "entity_type", None) diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py index b41f35a4..dbc697d9 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py @@ -61,7 +61,7 @@ def _row_to_entity(row: Dict[str, Any]) -> ExtractedEntityNode: return ExtractedEntityNode( id=row.get("id"), name=row.get("name") or "", - group_id=row.get("group_id") or "", + end_user_id=row.get("end_user_id") or "", user_id=row.get("user_id") or "", apply_id=row.get("apply_id") or "", created_at=_parse_dt(row.get("created_at")), @@ -79,7 +79,7 @@ def _row_to_entity(row: Dict[str, Any]) -> ExtractedEntityNode: async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑,与 Neo4j 中同组实体联合去重 connector: Neo4jConnector, - group_id: str, # 用于定位neo4j中同一组的实体,确保只在同组内去重 + end_user_id: str, # 用于定位neo4j中同一组的实体,确保只在同组内去重 entity_nodes: List[ExtractedEntityNode], # 输入的实体节点列表,包含待去重的实体 statement_entity_edges: List[StatementEntityEdge], # 输入的语句实体边列表,用于处理实体之间的关系 entity_entity_edges: List[EntityEntityEdge], # 输入的实体实体边列表,用于处理实体之间的关系 @@ -88,7 +88,7 @@ async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑 ) -> Tuple[List[ExtractedEntityNode], List[StatementEntityEdge], List[EntityEntityEdge]]: """ 第二层去重消歧: - - 以第一层结果为索引,检索相同 group_id 下的 DB 候选实体 + - 以第一层结果为索引,检索相同 end_user_id 下的 DB 候选实体 - 将 DB 候选与当前实体集合联合,按既有精确/模糊/LLM 决策进行融合 - 返回融合后的实体与重定向后的边(边已指向规范 ID,优先 DB ID) """ @@ -102,7 +102,7 @@ async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑 ] candidates_map = await get_dedup_candidates_for_entities( # 从 Neo4j 中查询候选实体,并将结果赋值给candidates_map(等待异步操作完成)。 - connector=connector, group_id=group_id, + connector=connector, end_user_id=end_user_id, entities=incoming_rows, # 传入参数:第一层实体的核心信息(作为查询索引) use_contains_fallback=True # 传入参数:启用 “包含关系” 作为匹配失败的降级策略(若精确匹配无结果,用包含关系召回候选),与src\database\cypher_queries.py的307产生联动 ) diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py index 11845d7d..f28b8a5f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py @@ -57,11 +57,11 @@ async def dedup_layers_and_merge_and_return( if pipeline_config is None: raise ValueError("pipeline_config is required for dedup_layers_and_merge_and_return") - # 先探测 group_id,决定报告写入策略 - group_id: Optional[str] = None + # 先探测 end_user_id,决定报告写入策略 + end_user_id: Optional[str] = None for dd in dialog_data_list: - group_id = getattr(dd, "group_id", None) - if group_id: + end_user_id = getattr(dd, "end_user_id", None) + if end_user_id: break # 第一层去重消歧 @@ -82,11 +82,11 @@ async def dedup_layers_and_merge_and_return( # 第二层去重消歧:与 Neo4j 中同组实体联合融合 try: - if group_id: + if end_user_id: if connector: fused_entity_nodes, fused_statement_entity_edges, fused_entity_entity_edges = await second_layer_dedup_and_merge_with_neo4j( connector=connector, - group_id=group_id, + end_user_id=end_user_id, entity_nodes=dedup_entity_nodes, statement_entity_edges=dedup_statement_entity_edges, entity_entity_edges=dedup_entity_entity_edges, @@ -96,7 +96,7 @@ async def dedup_layers_and_merge_and_return( else: print("Skip second-layer dedup: missing connector") else: - print("Skip second-layer dedup: missing group_id") + print("Skip second-layer dedup: missing end_user_id") except Exception as e: print(f"Second-layer dedup failed: {e}") diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 46ba1dde..c2c5d54e 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -287,7 +287,7 @@ class ExtractionOrchestrator: for d_idx, dialog in enumerate(dialog_data_list): dialogue_content = dialog.content if self.config.statement_extraction.include_dialogue_context else None for c_idx, chunk in enumerate(dialog.chunks): - all_chunks.append((chunk, dialog.group_id, dialogue_content)) + all_chunks.append((chunk, dialog.end_user_id, dialogue_content)) chunk_metadata.append((d_idx, c_idx)) logger.info(f"收集到 {len(all_chunks)} 个分块,开始全局并行提取") @@ -299,9 +299,9 @@ class ExtractionOrchestrator: # 全局并行处理所有分块 async def extract_for_chunk(chunk_data, chunk_index): nonlocal completed_chunks - chunk, group_id, dialogue_content = chunk_data + chunk, end_user_id, dialogue_content = chunk_data try: - statements = await self.statement_extractor._extract_statements(chunk, group_id, dialogue_content) + statements = await self.statement_extractor._extract_statements(chunk, end_user_id, dialogue_content) # 流式输出:每提取完一个分块的陈述句,立即发送进度 # 注意:只在试运行模式下发送陈述句详情,正式模式不发送 @@ -992,9 +992,7 @@ class ExtractionOrchestrator: id=dialog_data.id, name=f"Dialog_{dialog_data.id}", # 添加必需的 name 字段 ref_id=dialog_data.ref_id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id content=dialog_data.context.content if dialog_data.context else "", dialog_embedding=dialog_data.dialog_embedding if hasattr(dialog_data, 'dialog_embedding') else None, @@ -1012,9 +1010,7 @@ class ExtractionOrchestrator: id=chunk.id, name=f"Chunk_{chunk.id}", # 添加必需的 name 字段 dialog_id=dialog_data.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id content=chunk.content, chunk_embedding=chunk.chunk_embedding, @@ -1035,9 +1031,7 @@ class ExtractionOrchestrator: stmt_type=getattr(statement, 'stmt_type', 'general'), # 添加必需的 stmt_type 字段 temporal_info=getattr(statement, 'temporal_info', TemporalInfo.ATEMPORAL), # 添加必需的 temporal_info 字段 connect_strength=statement.connect_strength if statement.connect_strength is not None else 'Strong', # 添加必需的 connect_strength 字段 - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id statement=statement.statement, speaker=getattr(statement, 'speaker', None), # 添加 speaker 字段 @@ -1060,9 +1054,7 @@ class ExtractionOrchestrator: statement_chunk_edge = StatementChunkEdge( source=statement.id, target=chunk.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, ) @@ -1095,9 +1087,7 @@ class ExtractionOrchestrator: aliases=getattr(entity, 'aliases', []) or [], # 传递从三元组提取阶段获取的aliases name_embedding=getattr(entity, 'name_embedding', None), is_explicit_memory=getattr(entity, 'is_explicit_memory', False), # 新增:传递语义记忆标记 - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, expired_at=dialog_data.expired_at, @@ -1112,9 +1102,7 @@ class ExtractionOrchestrator: source=statement.id, target=entity.id, connect_strength=entity_connect_strength if entity_connect_strength is not None else 'Strong', - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, ) @@ -1134,9 +1122,7 @@ class ExtractionOrchestrator: relation_type=triplet.predicate, statement=statement.statement, source_statement_id=statement.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, expired_at=dialog_data.expired_at, @@ -1763,14 +1749,14 @@ class ExtractionOrchestrator: async def get_chunked_dialogs( chunker_strategy: str = "RecursiveChunker", - group_id: str = "group_1", + end_user_id: str = "group_1", indices: Optional[List[int]] = None, ) -> List[DialogData]: """从测试数据生成分块对话 Args: chunker_strategy: 分块策略(默认: RecursiveChunker) - group_id: 组ID + end_user_id: 组ID indices: 要处理的数据索引列表(可选) Returns: @@ -1834,7 +1820,7 @@ async def get_chunked_dialogs( dialog_data = DialogData( context=conversation_context, ref_id=data['id'], - group_id=group_id, + end_user_id=end_user_id, metadata=dialog_metadata, ) @@ -1936,7 +1922,7 @@ async def get_chunked_dialogs_from_preprocessed( async def get_chunked_dialogs_with_preprocessing( chunker_strategy: str = "RecursiveChunker", - group_id: str = "default", + end_user_id: str = "default", user_id: str = "default", apply_id: str = "default", indices: Optional[List[int]] = None, @@ -1948,7 +1934,7 @@ async def get_chunked_dialogs_with_preprocessing( Args: chunker_strategy: 分块策略 - group_id: 组ID + end_user_id: 组ID user_id: 用户ID apply_id: 应用ID indices: 要处理的数据索引列表 @@ -1976,11 +1962,9 @@ async def get_chunked_dialogs_with_preprocessing( indices=indices, ) - # 设置 group_id, user_id, apply_id + # 设置 end_user_id for dd in preprocessed_data: - dd.group_id = group_id - dd.user_id = user_id - dd.apply_id = apply_id + dd.end_user_id = end_user_id # 步骤2: 语义剪枝 try: diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py index 7e75fd2d..f39313a8 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py @@ -193,9 +193,9 @@ async def _process_chunk_summary( node = MemorySummaryNode( id=uuid4().hex, name=title if title else f"MemorySummaryChunk_{chunk.id}", - group_id=dialog.group_id, - user_id=dialog.user_id, - apply_id=dialog.apply_id, + end_user_id=dialog.end_user_id, + user_id=dialog.end_user_id, + apply_id=dialog.end_user_id, run_id=dialog.run_id, # 使用 dialog 的 run_id created_at=datetime.now(), expired_at=datetime(9999, 12, 31), diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py index fb1b539a..b06bd70f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py @@ -82,12 +82,12 @@ class StatementExtractor: logger.warning(f"Chunk {getattr(chunk, 'id', 'unknown')} has no speaker field or is empty") return None - async def _extract_statements(self, chunk, group_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]: + async def _extract_statements(self, chunk, end_user_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]: """Process a single chunk and return extracted statements Args: chunk: Chunk object to process - group_id: Group ID to assign to all statements in this chunk + end_user_id: Group ID to assign to all statements in this chunk dialogue_content: Full dialogue content to provide as context Returns: @@ -158,7 +158,7 @@ class StatementExtractor: temporal_info=temporal_type, relevence_info=relevence_info, chunk_id=chunk.id, - group_id=group_id, + end_user_id=end_user_id, speaker=chunk_speaker, ) @@ -184,10 +184,10 @@ class StatementExtractor: logger.info(f"Processing {len(chunks_to_process)} chunks for statement extraction") - # Process all chunks concurrently, passing the group_id and dialogue content from dialog_data + # Process all chunks concurrently, passing the end_user_id and dialogue content from dialog_data dialogue_content = dialog_data.content if self.config.include_dialogue_context else None results = await asyncio.gather( - *[self._extract_statements(chunk, dialog_data.group_id, dialogue_content) for chunk in chunks_to_process], + *[self._extract_statements(chunk, dialog_data.end_user_id, dialogue_content) for chunk in chunks_to_process], return_exceptions=True ) @@ -225,7 +225,7 @@ class StatementExtractor: for i, statement in enumerate(statements, 1): f.write(f"Statement {i}:\n") f.write(f"Id: {statement.id}\n") - f.write(f"Group Id: {statement.group_id}\n") + f.write(f"Group Id: {statement.end_user_id}\n") f.write(f"Content: {statement.statement}\n") f.write(f"Type: {statement.stmt_type.value}\n") f.write(f"Temporal Info: {statement.temporal_info.value}\n") @@ -298,7 +298,7 @@ class StatementExtractor: dialog_sections.append({ "dialog_id": dialog.ref_id, - "group_id": dialog.group_id, + "end_user_id": dialog.end_user_id, "content": dialog.content if getattr(dialog, "content", None) else "", "strong": strong_relations, "weak": weak_relations, @@ -312,7 +312,7 @@ class StatementExtractor: for idx, section in enumerate(dialog_sections, 1): f.write(f"Dialog {idx}:\n") f.write(f"Dialog ID: {section.get('dialog_id', '')}\n") - f.write(f"Group ID: {section.get('group_id', '')}\n") + f.write(f"Group ID: {section.get('end_user_id', '')}\n") f.write("Content:\n") f.write(f"{section.get('content', '')}\n") f.write("-" * 40 + "\n\n") diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py index 9528e638..499027a4 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py @@ -132,7 +132,7 @@ class TemporalExtractor: prompt_logger.info("") prompt_logger.info("=== TEMPORAL EXTRACTION RESULTS ===") prompt_logger.info( - f"[Temporal] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, group_id={getattr(dialog_data, 'group_id', None)}" + f"[Temporal] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, end_user_id={getattr(dialog_data, 'end_user_id', None)}" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py index d3d059b0..bfc0bc88 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py @@ -116,7 +116,7 @@ class TripletExtractor: logger.info(f"Processing {len(all_statements)} statements for triplet extraction...") try: prompt_logger.info( - f"[Triplet] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, group_id={getattr(dialog_data, 'group_id', None)}, statements_to_process={len(all_statements)}" + f"[Triplet] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, end_user_id={getattr(dialog_data, 'end_user_id', None)}, statements_to_process={len(all_statements)}" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py index 5722769a..a71c0957 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py +++ b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py @@ -75,7 +75,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_time: Optional[datetime] = None ) -> Dict[str, Any]: """ @@ -91,7 +91,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签(Statement, ExtractedEntity, MemorySummary) - group_id: 组ID(可选,用于过滤) + end_user_id: 组ID(可选,用于过滤) current_time: 当前时间(可选,默认使用系统时间) Returns: @@ -123,7 +123,7 @@ class AccessHistoryManager: for attempt in range(self.max_retries): try: # 步骤1:读取当前节点状态 - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: raise ValueError( @@ -142,7 +142,7 @@ class AccessHistoryManager: node_id=node_id, node_label=node_label, update_data=update_data, - group_id=group_id + end_user_id=end_user_id ) logger.info( @@ -172,7 +172,7 @@ class AccessHistoryManager: self, node_ids: List[str], node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_time: Optional[datetime] = None ) -> List[Dict[str, Any]]: """ @@ -184,7 +184,7 @@ class AccessHistoryManager: Args: node_ids: 节点ID列表 node_label: 节点标签(所有节点必须是同一类型) - group_id: 组ID(可选) + end_user_id: 组ID(可选) current_time: 当前时间(可选) Returns: @@ -202,7 +202,7 @@ class AccessHistoryManager: task = self.record_access( node_id=node_id, node_label=node_label, - group_id=group_id, + end_user_id=end_user_id, current_time=current_time ) tasks.append(task) @@ -235,7 +235,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Tuple[ConsistencyCheckResult, Optional[str]]: """ 检查节点数据的一致性 @@ -249,14 +249,14 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Tuple[ConsistencyCheckResult, Optional[str]]: - 一致性检查结果枚举 - 错误描述(如果不一致) """ - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: return ConsistencyCheckResult.CONSISTENT, None @@ -305,7 +305,7 @@ class AccessHistoryManager: async def check_batch_consistency( self, node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1000 ) -> Dict[str, Any]: """ @@ -313,7 +313,7 @@ class AccessHistoryManager: Args: node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) limit: 检查的最大节点数 Returns: @@ -329,16 +329,16 @@ class AccessHistoryManager: MATCH (n:{node_label}) WHERE n.access_history IS NOT NULL """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ RETURN n.id as id LIMIT $limit """ params = {"limit": limit} - if group_id: - params["group_id"] = group_id + if end_user_id: + params["end_user_id"] = end_user_id results = await self.connector.execute_query(query, **params) node_ids = [r['id'] for r in results] @@ -351,7 +351,7 @@ class AccessHistoryManager: result, message = await self.check_consistency( node_id=node_id, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) if result == ConsistencyCheckResult.CONSISTENT: @@ -387,7 +387,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> bool: """ 自动修复节点的数据不一致问题 @@ -401,7 +401,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: bool: 修复成功返回True,否则返回False @@ -411,7 +411,7 @@ class AccessHistoryManager: result, message = await self.check_consistency( node_id=node_id, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) if result == ConsistencyCheckResult.CONSISTENT: @@ -419,7 +419,7 @@ class AccessHistoryManager: return True # 获取节点数据 - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: logger.error(f"节点不存在,无法修复: {node_label}[{node_id}]") return False @@ -457,8 +457,8 @@ class AccessHistoryManager: query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - query += " WHERE n.group_id = $group_id" + if end_user_id: + query += " WHERE n.end_user_id = $end_user_id" query += """ SET n += $repair_data RETURN n @@ -468,8 +468,8 @@ class AccessHistoryManager: 'node_id': node_id, 'repair_data': repair_data } - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id await self.connector.execute_query(query, **params) @@ -491,7 +491,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ 获取节点数据 @@ -499,7 +499,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Optional[Dict[str, Any]]: 节点数据,如果不存在返回None @@ -507,8 +507,8 @@ class AccessHistoryManager: query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - query += " WHERE n.group_id = $group_id" + if end_user_id: + query += " WHERE n.end_user_id = $end_user_id" query += """ RETURN n.id as id, n.importance_score as importance_score, @@ -519,8 +519,8 @@ class AccessHistoryManager: """ params = {'node_id': node_id} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) @@ -585,7 +585,7 @@ class AccessHistoryManager: node_id: str, node_label: str, update_data: Dict[str, Any], - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Dict[str, Any]: """ 原子性更新节点(使用乐观锁) @@ -597,7 +597,7 @@ class AccessHistoryManager: node_id: 节点ID node_label: 节点标签 update_data: 更新数据 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Dict[str, Any]: 更新后的节点数据 @@ -606,13 +606,13 @@ class AccessHistoryManager: RuntimeError: 如果更新失败或发生版本冲突 """ # 定义事务函数 - async def update_transaction(tx, node_id, node_label, update_data, group_id): + async def update_transaction(tx, node_id, node_label, update_data, end_user_id): # 步骤1:读取当前节点并获取版本号 read_query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - read_query += " WHERE n.group_id = $group_id" + if end_user_id: + read_query += " WHERE n.end_user_id = $end_user_id" read_query += """ RETURN n.id as id, n.version as version, @@ -624,8 +624,8 @@ class AccessHistoryManager: """ read_params = {'node_id': node_id} - if group_id: - read_params['group_id'] = group_id + if end_user_id: + read_params['end_user_id'] = end_user_id read_result = await tx.run(read_query, **read_params) current_node = await read_result.single() @@ -656,8 +656,8 @@ class AccessHistoryManager: # 构建 WHERE 子句 where_conditions = [] - if group_id: - where_conditions.append("n.group_id = $group_id") + if end_user_id: + where_conditions.append("n.end_user_id = $end_user_id") # 添加版本检查 if current_version > 0: @@ -695,8 +695,8 @@ class AccessHistoryManager: 'last_access_time': update_data['last_access_time'], 'access_count': update_data['access_count'] } - if group_id: - update_params['group_id'] = group_id + if end_user_id: + update_params['end_user_id'] = end_user_id update_result = await tx.run(update_query, **update_params) updated_node = await update_result.single() @@ -720,7 +720,7 @@ class AccessHistoryManager: node_id=node_id, node_label=node_label, update_data=update_data, - group_id=group_id + end_user_id=end_user_id ) return result except Exception as e: diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py index 6d42af53..e9d4c144 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py @@ -66,7 +66,7 @@ class ForgettingScheduler: async def run_forgetting_cycle( self, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, max_merge_batch_size: int = 100, min_days_since_access: int = 30, config_id: Optional[int] = None, @@ -77,7 +77,7 @@ class ForgettingScheduler: Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) max_merge_batch_size: 单次最大融合节点对数(默认 100) min_days_since_access: 最小未访问天数(默认 30 天) config_id: 配置ID(可选,用于获取 llm_id) @@ -107,19 +107,19 @@ class ForgettingScheduler: start_time_iso = start_time.isoformat() logger.info( - f"开始遗忘周期: group_id={group_id}, " + f"开始遗忘周期: end_user_id={end_user_id}, " f"max_batch={max_merge_batch_size}, " f"min_days={min_days_since_access}" ) try: # 步骤1:统计遗忘前的节点数量 - nodes_before = await self._count_knowledge_nodes(group_id) + nodes_before = await self._count_knowledge_nodes(end_user_id) logger.info(f"遗忘前节点总数: {nodes_before}") # 步骤2:识别可遗忘的节点对 forgettable_pairs = await self.forgetting_strategy.find_forgettable_nodes( - group_id=group_id, + end_user_id=end_user_id, min_days_since_access=min_days_since_access ) @@ -213,7 +213,7 @@ class ForgettingScheduler: 'statement_text': pair['statement_text'], 'statement_activation': pair['statement_activation'], 'statement_importance': pair['statement_importance'], - 'group_id': group_id + 'end_user_id': end_user_id } entity_node = { @@ -222,7 +222,7 @@ class ForgettingScheduler: 'entity_type': pair['entity_type'], 'entity_activation': pair['entity_activation'], 'entity_importance': pair['entity_importance'], - 'group_id': group_id + 'end_user_id': end_user_id } # 融合节点 @@ -262,7 +262,7 @@ class ForgettingScheduler: continue # 步骤6:统计遗忘后的节点数量 - nodes_after = await self._count_knowledge_nodes(group_id) + nodes_after = await self._count_knowledge_nodes(end_user_id) logger.info(f"遗忘后节点总数: {nodes_after}") # 步骤7:生成遗忘报告 @@ -315,7 +315,7 @@ class ForgettingScheduler: async def _count_knowledge_nodes( self, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> int: """ 统计知识层节点总数 @@ -323,7 +323,7 @@ class ForgettingScheduler: 统计 Statement、ExtractedEntity 和 MemorySummary 节点的总数。 Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) Returns: int: 知识层节点总数 @@ -333,16 +333,16 @@ class ForgettingScheduler: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ RETURN count(n) as total """ params = {} - if group_id: - params['group_id'] = group_id + if end_user_id: + end_user_id['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py index ccd8d2ca..6b2d9e99 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py @@ -90,7 +90,7 @@ class ForgettingStrategy: async def find_forgettable_nodes( self, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, min_days_since_access: int = 30 ) -> List[Dict[str, Any]]: """ @@ -102,7 +102,7 @@ class ForgettingStrategy: 3. Statement 和 Entity 之间存在关系边 Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) min_days_since_access: 最小未访问天数(默认 30 天) Returns: @@ -136,8 +136,8 @@ class ForgettingStrategy: AND (e.entity_type IS NULL OR e.entity_type <> 'Person') """ - if group_id: - query += " AND s.group_id = $group_id AND e.group_id = $group_id" + if end_user_id: + query += " AND s.end_user_id = $end_user_id AND e.end_user_id = $end_user_id" query += """ RETURN s.id as statement_id, @@ -159,8 +159,8 @@ class ForgettingStrategy: 'threshold': self.forgetting_threshold, 'cutoff_time': cutoff_time_iso } - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) @@ -247,8 +247,8 @@ class ForgettingStrategy: entity_activation = entity_node['entity_activation'] entity_importance = entity_node['entity_importance'] - # 获取 group_id(从 statement 或 entity 节点) - group_id = statement_node.get('group_id') or entity_node.get('group_id') + # 获取 end_user_id(从 statement 或 entity 节点) + end_user_id = statement_node.get('end_user_id') or entity_node.get('end_user_id') # 生成摘要内容 summary_text = await self._generate_summary( @@ -325,7 +325,7 @@ class ForgettingStrategy: last_access_time: $current_time, access_count: 1, version: 1, - group_id: $group_id, + end_user_id: $end_user_id, created_at: datetime($current_time), merged_at: datetime($current_time) }) @@ -423,7 +423,7 @@ class ForgettingStrategy: 'inherited_activation': inherited_activation, 'inherited_importance': inherited_importance, 'current_time': current_time_iso, - 'group_id': group_id + 'end_user_id': end_user_id } try: diff --git a/api/app/core/memory/storage_services/search/__init__.py b/api/app/core/memory/storage_services/search/__init__.py index 2bec5bf1..c12c39b0 100644 --- a/api/app/core/memory/storage_services/search/__init__.py +++ b/api/app/core/memory/storage_services/search/__init__.py @@ -37,7 +37,7 @@ __all__ = [ async def run_hybrid_search( query_text: str, search_type: str = "hybrid", - group_id: str | None = None, + end_user_id: str | None = None, apply_id: str | None = None, user_id: str | None = None, limit: int = 50, @@ -54,7 +54,7 @@ async def run_hybrid_search( Args: query_text: 查询文本 search_type: 搜索类型("hybrid", "keyword", "semantic") - group_id: 组ID过滤 + end_user_id: 组ID过滤 apply_id: 应用ID过滤 user_id: 用户ID过滤 limit: 每个类别的最大结果数 @@ -104,7 +104,7 @@ async def run_hybrid_search( # 执行搜索 result = await strategy.search( query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, alpha=alpha, diff --git a/api/app/core/memory/storage_services/search/hybrid_search.py b/api/app/core/memory/storage_services/search/hybrid_search.py index 43215df5..4111b09c 100644 --- a/api/app/core/memory/storage_services/search/hybrid_search.py +++ b/api/app/core/memory/storage_services/search/hybrid_search.py @@ -77,7 +77,7 @@ # async def search( # self, # query_text: str, -# group_id: Optional[str] = None, +# end_user_id: Optional[str] = None, # limit: int = 50, # include: Optional[List[str]] = None, # **kwargs @@ -86,7 +86,7 @@ # Args: # query_text: 查询文本 -# group_id: 可选的组ID过滤 +# end_user_id: 可选的组ID过滤 # limit: 每个类别的最大结果数 # include: 要包含的搜索类别列表 # **kwargs: 其他搜索参数(如alpha, use_forgetting_curve) @@ -94,7 +94,7 @@ # Returns: # SearchResult: 搜索结果对象 # """ -# logger.info(f"执行混合搜索: query='{query_text}', group_id={group_id}, limit={limit}") +# logger.info(f"执行混合搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # # 从kwargs中获取参数 # alpha = kwargs.get("alpha", self.alpha) @@ -107,14 +107,14 @@ # # 并行执行关键词搜索和语义搜索 # keyword_result = await self.keyword_strategy.search( # query_text=query_text, -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list # ) # semantic_result = await self.semantic_strategy.search( # query_text=query_text, -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list # ) @@ -139,7 +139,7 @@ # metadata = self._create_metadata( # query_text=query_text, # search_type="hybrid", -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list, # alpha=alpha, @@ -165,7 +165,7 @@ # metadata=self._create_metadata( # query_text=query_text, # search_type="hybrid", -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # error=str(e) # ) diff --git a/api/app/core/memory/storage_services/search/keyword_search.py b/api/app/core/memory/storage_services/search/keyword_search.py index 95dd0581..d2591945 100644 --- a/api/app/core/memory/storage_services/search/keyword_search.py +++ b/api/app/core/memory/storage_services/search/keyword_search.py @@ -44,7 +44,7 @@ class KeywordSearchStrategy(SearchStrategy): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -53,7 +53,7 @@ class KeywordSearchStrategy(SearchStrategy): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表 **kwargs: 其他搜索参数 @@ -61,7 +61,7 @@ class KeywordSearchStrategy(SearchStrategy): Returns: SearchResult: 搜索结果对象 """ - logger.info(f"执行关键词搜索: query='{query_text}', group_id={group_id}, limit={limit}") + logger.info(f"执行关键词搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # 获取有效的搜索类别 include_list = self._get_include_list(include) @@ -75,7 +75,7 @@ class KeywordSearchStrategy(SearchStrategy): results_dict = await search_graph( connector=self.connector, q=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -84,7 +84,7 @@ class KeywordSearchStrategy(SearchStrategy): metadata = self._create_metadata( query_text=query_text, search_type="keyword", - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -115,7 +115,7 @@ class KeywordSearchStrategy(SearchStrategy): metadata=self._create_metadata( query_text=query_text, search_type="keyword", - group_id=group_id, + end_user_id=end_user_id, limit=limit, error=str(e) ) diff --git a/api/app/core/memory/storage_services/search/search_strategy.py b/api/app/core/memory/storage_services/search/search_strategy.py index 27c02c89..3a670dd6 100644 --- a/api/app/core/memory/storage_services/search/search_strategy.py +++ b/api/app/core/memory/storage_services/search/search_strategy.py @@ -58,7 +58,7 @@ class SearchStrategy(ABC): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -67,7 +67,7 @@ class SearchStrategy(ABC): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表(statements, chunks, entities, summaries) **kwargs: 其他搜索参数 @@ -81,7 +81,7 @@ class SearchStrategy(ABC): self, query_text: str, search_type: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, **kwargs ) -> Dict[str, Any]: @@ -90,7 +90,7 @@ class SearchStrategy(ABC): Args: query_text: 查询文本 search_type: 搜索类型 - group_id: 组ID + end_user_id: 组ID limit: 结果限制 **kwargs: 其他元数据 @@ -100,7 +100,7 @@ class SearchStrategy(ABC): metadata = { "query": query_text, "search_type": search_type, - "group_id": group_id, + "end_user_id": end_user_id, "limit": limit, "timestamp": datetime.now().isoformat() } diff --git a/api/app/core/memory/storage_services/search/semantic_search.py b/api/app/core/memory/storage_services/search/semantic_search.py index b20f90a5..8d4eb05f 100644 --- a/api/app/core/memory/storage_services/search/semantic_search.py +++ b/api/app/core/memory/storage_services/search/semantic_search.py @@ -85,7 +85,7 @@ class SemanticSearchStrategy(SearchStrategy): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -94,7 +94,7 @@ class SemanticSearchStrategy(SearchStrategy): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表 **kwargs: 其他搜索参数 @@ -102,7 +102,7 @@ class SemanticSearchStrategy(SearchStrategy): Returns: SearchResult: 搜索结果对象 """ - logger.info(f"执行语义搜索: query='{query_text}', group_id={group_id}, limit={limit}") + logger.info(f"执行语义搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # 获取有效的搜索类别 include_list = self._get_include_list(include) @@ -119,7 +119,7 @@ class SemanticSearchStrategy(SearchStrategy): connector=self.connector, embedder_client=self.embedder_client, query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -128,7 +128,7 @@ class SemanticSearchStrategy(SearchStrategy): metadata = self._create_metadata( query_text=query_text, search_type="semantic", - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -159,7 +159,7 @@ class SemanticSearchStrategy(SearchStrategy): metadata=self._create_metadata( query_text=query_text, search_type="semantic", - group_id=group_id, + end_user_id=end_user_id, limit=limit, error=str(e) ) diff --git a/api/app/core/memory/utils/config/get_data.py b/api/app/core/memory/utils/config/get_data.py index 1de6f6aa..e37ad723 100644 --- a/api/app/core/memory/utils/config/get_data.py +++ b/api/app/core/memory/utils/config/get_data.py @@ -23,7 +23,7 @@ async def _load_(data: List[Any]) -> List[Dict]: target_keys = [ "id", "statement", - "group_id", + "end_user_id", "chunk_id", "created_at", "expired_at", @@ -75,7 +75,7 @@ async def get_data(result): """ EXCLUDE_FIELDS = { "user_id", - "group_id", + "end_user_id", "entity_type", "connect_strength", "relationship_type", diff --git a/api/app/core/memory/utils/log/audit_logger.py b/api/app/core/memory/utils/log/audit_logger.py index 9010aad5..f80ad4d5 100644 --- a/api/app/core/memory/utils/log/audit_logger.py +++ b/api/app/core/memory/utils/log/audit_logger.py @@ -62,7 +62,7 @@ class ConfigAuditLogger: self, config_id: str, user_id: Optional[str] = None, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, success: bool = True, details: Optional[Dict[str, Any]] = None ): @@ -72,14 +72,14 @@ class ConfigAuditLogger: Args: config_id: 配置 ID user_id: 用户 ID(可选) - group_id: 组 ID(可选) + end_user_id: 组 ID(可选) success: 是否成功 details: 详细信息(可选) """ result = "SUCCESS" if success else "FAILED" msg = ( f"CONFIG_LOAD config_id={config_id} " - f"user={user_id or 'N/A'} group={group_id or 'N/A'} " + f"user={user_id or 'N/A'} group={end_user_id or 'N/A'} " f"result={result}" ) if details: @@ -121,7 +121,7 @@ class ConfigAuditLogger: self, operation: str, config_id: str, - group_id: str, + end_user_id: str, success: bool = True, duration: Optional[float] = None, error: Optional[str] = None, @@ -133,7 +133,7 @@ class ConfigAuditLogger: Args: operation: 操作类型(WRITE, READ 等) config_id: 配置 ID - group_id: 组 ID + end_user_id: 组 ID success: 是否成功 duration: 操作耗时(秒) error: 错误信息(可选) @@ -142,7 +142,7 @@ class ConfigAuditLogger: result = "SUCCESS" if success else "FAILED" msg = ( f"{operation.upper()} config_id={config_id} " - f"group={group_id} result={result}" + f"group={end_user_id} result={result}" ) if duration is not None: msg += f" duration={duration:.2f}s" diff --git a/api/app/core/rag/vdb/field.py b/api/app/core/rag/vdb/field.py index 86d39060..99d872c2 100644 --- a/api/app/core/rag/vdb/field.py +++ b/api/app/core/rag/vdb/field.py @@ -4,7 +4,7 @@ from enum import StrEnum, auto class Field(StrEnum): CONTENT_KEY = "page_content" METADATA_KEY = "metadata" - GROUP_KEY = "group_id" + GROUP_KEY = "end_user_id" VECTOR = auto() # Sparse Vector aims to support full text search SPARSE_VECTOR = auto() diff --git a/api/app/repositories/neo4j/add_edges.py b/api/app/repositories/neo4j/add_edges.py index 3b45867e..162bf411 100644 --- a/api/app/repositories/neo4j/add_edges.py +++ b/api/app/repositories/neo4j/add_edges.py @@ -32,7 +32,7 @@ async def add_chunk_statement_edges(chunks: List[Chunk], connector: Neo4jConnect "id": stable_edge_id, "source": chunk.id, "target": stmt.id, - "group_id": getattr(stmt, 'group_id', None), + "end_user_id": getattr(stmt, 'end_user_id', None), "user_id":getattr(stmt, 'user_id', None), "apply_id": getattr(stmt, 'apply_id', None), "run_id": getattr(stmt, 'run_id', None) or getattr(chunk, 'run_id', None), @@ -83,7 +83,7 @@ async def add_memory_summary_statement_edges(summaries: List[MemorySummaryNode], edges.append({ "summary_id": s.id, "chunk_id": chunk_id, - "group_id": s.group_id, + "end_user_id": s.end_user_id, "run_id": s.run_id, "created_at": s.created_at.isoformat() if s.created_at else None, "expired_at": s.expired_at.isoformat() if s.expired_at else None, diff --git a/api/app/repositories/neo4j/add_nodes.py b/api/app/repositories/neo4j/add_nodes.py index cf60a773..fcf700b5 100644 --- a/api/app/repositories/neo4j/add_nodes.py +++ b/api/app/repositories/neo4j/add_nodes.py @@ -6,10 +6,10 @@ from app.core.memory.models.graph_models import DialogueNode, StatementNode, Chu from app.repositories.neo4j.neo4j_connector import Neo4jConnector -async def delete_all_nodes(group_id: str, connector: Neo4jConnector): +async def delete_all_nodes(end_user_id: str, connector: Neo4jConnector): """Delete all nodes in the database.""" - result = await connector.execute_query(f"MATCH (n {{group_id: '{group_id}'}}) DETACH DELETE n") - print(f"All group_id: {group_id} node and edge deleted successfully") + result = await connector.execute_query(f"MATCH (n {{end_user_id: '{end_user_id}'}}) DETACH DELETE n") + print(f"All end_user_id: {end_user_id} node and edge deleted successfully") return result async def add_dialogue_nodes(dialogues: List[DialogueNode], connector: Neo4jConnector) -> Optional[List[str]]: @@ -32,9 +32,7 @@ async def add_dialogue_nodes(dialogues: List[DialogueNode], connector: Neo4jConn for dialogue in dialogues: flattened_dialogues.append({ "id": dialogue.id, - "group_id": dialogue.group_id, - "user_id": dialogue.user_id, - "apply_id": dialogue.apply_id, + "end_user_id": dialogue.end_user_id, "run_id": dialogue.run_id, "ref_id": dialogue.ref_id, "name": dialogue.name, @@ -79,9 +77,7 @@ async def add_statement_nodes(statements: List[StatementNode], connector: Neo4jC flattened_statement = { "id": statement.id, "name": statement.name, - "group_id": statement.group_id, - "user_id": statement.user_id, - "apply_id": statement.apply_id, + "end_user_id": statement.end_user_id, "run_id": statement.run_id, "chunk_id": statement.chunk_id, # "created_at": statement.created_at.isoformat(), @@ -154,9 +150,7 @@ async def add_chunk_nodes(chunks: List[ChunkNode], connector: Neo4jConnector) -> flattened_chunk = { "id": chunk.id, "name": chunk.name, - "group_id": chunk.group_id, - "user_id": chunk.user_id, - "apply_id": chunk.apply_id, + "end_user_id": chunk.end_user_id, "run_id": chunk.run_id, "created_at": chunk.created_at.isoformat() if chunk.created_at else None, "expired_at": chunk.expired_at.isoformat() if chunk.expired_at else None, @@ -206,9 +200,7 @@ async def add_memory_summary_nodes(summaries: List[MemorySummaryNode], connector flattened.append({ "id": s.id, "name": s.name, - "group_id": s.group_id, - "user_id": s.user_id, - "apply_id": s.apply_id, + "end_user_id": s.end_user_id, "run_id": s.run_id, "created_at": s.created_at.isoformat() if s.created_at else None, "expired_at": s.expired_at.isoformat() if s.expired_at else None, diff --git a/api/app/repositories/neo4j/base_neo4j_repository.py b/api/app/repositories/neo4j/base_neo4j_repository.py index 959a1e68..df953eb9 100644 --- a/api/app/repositories/neo4j/base_neo4j_repository.py +++ b/api/app/repositories/neo4j/base_neo4j_repository.py @@ -152,7 +152,7 @@ class BaseNeo4jRepository(BaseRepository[T]): Example: >>> results = await repository.find( - ... {"group_id": "group_123", "user_id": "user_456"}, + ... {"end_user_id": "group_123", "user_id": "user_456"}, ... limit=50 ... ) """ diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index cd3cbed7..eaef1e7a 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -3,9 +3,7 @@ DIALOGUE_NODE_SAVE = """ UNWIND $dialogues AS dialogue MERGE (n:Dialogue {id: dialogue.id}) SET n.uuid = coalesce(n.uuid, dialogue.id), - n.group_id = dialogue.group_id, - n.user_id = dialogue.user_id, - n.apply_id = dialogue.apply_id, + n.end_user_id = dialogue.end_user_id, n.run_id = dialogue.run_id, n.ref_id = dialogue.ref_id, n.created_at = dialogue.created_at, @@ -22,9 +20,7 @@ SET s += { id: statement.id, run_id: statement.run_id, chunk_id: statement.chunk_id, - group_id: statement.group_id, - user_id: statement.user_id, - apply_id: statement.apply_id, + end_user_id: statement.end_user_id, stmt_type: statement.stmt_type, statement: statement.statement, emotion_intensity: statement.emotion_intensity, @@ -54,9 +50,7 @@ MERGE (c:Chunk {id: chunk.id}) SET c += { id: chunk.id, name: chunk.name, - group_id: chunk.group_id, - user_id: chunk.user_id, - apply_id: chunk.apply_id, + end_user_id: chunk.end_user_id, run_id: chunk.run_id, created_at: chunk.created_at, expired_at: chunk.expired_at, @@ -76,9 +70,7 @@ EXTRACTED_ENTITY_NODE_SAVE = """ UNWIND $entities AS entity MERGE (e:ExtractedEntity {id: entity.id}) SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity.name ELSE e.name END, - e.group_id = CASE WHEN entity.group_id IS NOT NULL AND entity.group_id <> '' THEN entity.group_id ELSE e.group_id END, - e.user_id = CASE WHEN entity.user_id IS NOT NULL AND entity.user_id <> '' THEN entity.user_id ELSE e.user_id END, - e.apply_id = CASE WHEN entity.apply_id IS NOT NULL AND entity.apply_id <> '' THEN entity.apply_id ELSE e.apply_id END, + e.end_user_id = CASE WHEN entity.end_user_id IS NOT NULL AND entity.end_user_id <> '' THEN entity.end_user_id ELSE e.end_user_id END, e.run_id = CASE WHEN entity.run_id IS NOT NULL AND entity.run_id <> '' THEN entity.run_id ELSE e.run_id END, e.created_at = CASE WHEN entity.created_at IS NOT NULL AND (e.created_at IS NULL OR entity.created_at < e.created_at) @@ -134,9 +126,9 @@ RETURN e.id AS uuid # Add back ENTITY_RELATIONSHIP_SAVE to be used by graph_saver.save_entities_and_relationships ENTITY_RELATIONSHIP_SAVE = """ UNWIND $relationships AS rel -// Match entities by stable id within group, do not constrain by run_id -MATCH (subject:ExtractedEntity {id: rel.source_id, group_id: rel.group_id}) -MATCH (object:ExtractedEntity {id: rel.target_id, group_id: rel.group_id}) +// Match entities by stable id within end_user_id, do not constrain by run_id +MATCH (subject:ExtractedEntity {id: rel.source_id, end_user_id: rel.end_user_id}) +MATCH (object:ExtractedEntity {id: rel.target_id, end_user_id: rel.end_user_id}) // Avoid duplicate edges across runs for the same endpoints MERGE (subject)-[r:EXTRACTED_RELATIONSHIP]->(object) SET r.predicate = rel.predicate, @@ -148,7 +140,7 @@ SET r.predicate = rel.predicate, r.created_at = rel.created_at, r.expired_at = rel.expired_at, r.run_id = rel.run_id, - r.group_id = rel.group_id + r.end_user_id = rel.end_user_id RETURN elementId(r) AS uuid """ @@ -160,7 +152,7 @@ UNWIND $weak_entities AS entity MERGE (e:ExtractedEntity {id: entity.id, run_id: entity.run_id}) SET e += { name: entity.name, - group_id: entity.group_id, + end_user_id: entity.end_user_id, run_id: entity.run_id, description: entity.description, chunk_id: entity.chunk_id, @@ -175,11 +167,11 @@ RETURN e.id AS id SAVE_STRONG_TRIPLE_ENTITIES = """ UNWIND $items AS item MERGE (s:ExtractedEntity {id: item.source_id, run_id: item.run_id}) -SET s += {name: item.subject, group_id: item.group_id, run_id: item.run_id} +SET s += {name: item.subject, end_user_id: item.end_user_id, run_id: item.run_id} // Independent strong flag SET s.is_strong = true MERGE (o:ExtractedEntity {id: item.target_id, run_id: item.run_id}) -SET o += {name: item.object, group_id: item.group_id, run_id: item.run_id} +SET o += {name: item.object, end_user_id: item.end_user_id, run_id: item.run_id} // Independent strong flag SET o.is_strong = true """ @@ -194,7 +186,7 @@ DIALOGUE_STATEMENT_EDGE_SAVE = """ // 仅按端点去重,关系属性可更新 MERGE (dialogue)-[e:MENTIONS]->(statement) SET e.uuid = edge.id, - e.group_id = edge.group_id, + e.end_user_id = edge.end_user_id, e.created_at = edge.created_at, e.expired_at = edge.expired_at RETURN e.uuid AS uuid @@ -208,7 +200,7 @@ CHUNK_STATEMENT_EDGE_SAVE = """ MATCH (statement:Statement {id: edge.source, run_id: edge.run_id}) MATCH (chunk:Chunk {id: edge.target, run_id: edge.run_id}) MERGE (chunk)-[e:CONTAINS {id: edge.id}]->(statement) - SET e.group_id = edge.group_id, + SET e.end_user_id = edge.end_user_id, e.run_id = edge.run_id, e.created_at = edge.created_at, e.expired_at = edge.expired_at @@ -218,13 +210,12 @@ CHUNK_STATEMENT_EDGE_SAVE = """ STATEMENT_ENTITY_EDGE_SAVE = """ UNWIND $relationships AS rel // Statement nodes are per-run; keep run_id constraint on statements -// Statement nodes are per-run; keep run_id constraint on statements MATCH (statement:Statement {id: rel.source, run_id: rel.run_id}) -// Entities are shared across runs within a group; do not constrain by run_id -MATCH (entity:ExtractedEntity {id: rel.target, group_id: rel.group_id}) +// Entities are shared across runs within end_user_id; do not constrain by run_id +MATCH (entity:ExtractedEntity {id: rel.target, end_user_id: rel.end_user_id}) // Avoid duplicate edges across runs for same endpoints MERGE (statement)-[r:REFERENCES_ENTITY]->(entity) -SET r.group_id = rel.group_id, +SET r.end_user_id = rel.end_user_id, r.run_id = rel.run_id, r.created_at = rel.created_at, r.expired_at = rel.expired_at, @@ -236,10 +227,10 @@ ENTITY_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('entity_embedding_index', $limit * 100, $embedding) YIELD node AS e, score WHERE e.name_embedding IS NOT NULL - AND ($group_id IS NULL OR e.group_id = $group_id) + AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) RETURN e.id AS id, e.name AS name, - e.group_id AS group_id, + e.end_user_id AS end_user_id, e.entity_type AS entity_type, COALESCE(e.activation_value, e.importance_score, 0.5) AS activation_value, COALESCE(e.importance_score, 0.5) AS importance_score, @@ -254,10 +245,10 @@ STATEMENT_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('statement_embedding_index', $limit * 100, $embedding) YIELD node AS s, score WHERE s.statement_embedding IS NOT NULL - AND ($group_id IS NULL OR s.group_id = $group_id) + AND ($end_user_id IS NULL OR s.end_user_id = $end_user_id) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.expired_at AS expired_at, @@ -277,9 +268,9 @@ CHUNK_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('chunk_embedding_index', $limit * 100, $embedding) YIELD node AS c, score WHERE c.chunk_embedding IS NOT NULL - AND ($group_id IS NULL OR c.group_id = $group_id) + AND ($end_user_id IS NULL OR c.end_user_id = $end_user_id) RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, COALESCE(c.activation_value, 0.5) AS activation_value, @@ -292,12 +283,12 @@ LIMIT $limit SEARCH_STATEMENTS_BY_KEYWORD = """ CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score -WHERE ($group_id IS NULL OR s.group_id = $group_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.expired_at AS expired_at, @@ -316,15 +307,13 @@ LIMIT $limit # 查询实体名称包含指定字符串的实体 SEARCH_ENTITIES_BY_NAME = """ CALL db.index.fulltext.queryNodes("entitiesFulltext", $q) YIELD node AS e, score -WHERE ($group_id IS NULL OR e.group_id = $group_id) +WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) RETURN e.id AS id, e.name AS name, - e.group_id AS group_id, + e.end_user_id AS end_user_id, e.entity_type AS entity_type, - e.apply_id AS apply_id, - e.user_id AS user_id, e.created_at AS created_at, e.expired_at AS expired_at, e.entity_idx AS entity_idx, @@ -347,11 +336,11 @@ LIMIT $limit SEARCH_CHUNKS_BY_CONTENT = """ CALL db.index.fulltext.queryNodes("chunksFulltext", $q) YIELD node AS c, score -WHERE ($group_id IS NULL OR c.group_id = $group_id) +WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) OPTIONAL MATCH (c)-[:CONTAINS]->(s:Statement) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, c.sequence_number AS sequence_number, @@ -413,10 +402,10 @@ LIMIT $limit SEARCH_DIALOGUE_BY_DIALOG_ID = """ MATCH (d:Dialogue) -WHERE ($group_id IS NULL OR d.group_id = $group_id) +WHERE ($end_user_id IS NULL OR d.end_user_id = $end_user_id) AND d.id = $dialog_id RETURN d.id AS dialog_id, - d.group_id AS group_id, + d.end_user_id AS end_user_id, d.content AS content, d.created_at AS created_at, d.expired_at AS expired_at @@ -426,10 +415,10 @@ LIMIT $limit SEARCH_CHUNK_BY_CHUNK_ID = """ MATCH (c:Chunk) -WHERE ($group_id IS NULL OR c.group_id = $group_id) +WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) AND c.id = $chunk_id RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, c.created_at AS created_at, @@ -441,18 +430,14 @@ LIMIT $limit SEARCH_STATEMENTS_BY_TEMPORAL = """ MATCH (s:Statement) -WHERE ($group_id IS NULL OR s.group_id = $group_id) - AND ($apply_id IS NULL OR s.apply_id = $apply_id) - AND ($user_id IS NULL OR s.user_id = $user_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND ((($start_date IS NULL OR datetime(s.created_at) >= datetime($start_date)) AND ($end_date IS NULL OR datetime(s.created_at) <= datetime($end_date))) OR (($valid_date IS NULL OR (s.valid_at IS NOT NULL AND datetime(s.valid_at) >= datetime($valid_date))) AND ($invalid_date IS NULL OR (s.invalid_at IS NOT NULL AND datetime(s.invalid_at) <= datetime($invalid_date))))) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, - s.apply_id AS apply_id, - s.user_id AS user_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.valid_at AS valid_at, @@ -468,9 +453,7 @@ LIMIT $limit SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL = """ CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score -WHERE ($group_id IS NULL OR s.group_id = $group_id) - AND ($apply_id IS NULL OR s.apply_id = $apply_id) - AND ($user_id IS NULL OR s.user_id = $user_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND ((($start_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) >= datetime($start_date))) AND ($end_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) <= datetime($end_date)))) OR (($valid_date IS NULL OR (s.valid_at IS NOT NULL AND datetime(s.valid_at) >= datetime($valid_date))) @@ -479,9 +462,7 @@ OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, - s.apply_id AS apply_id, - s.user_id AS user_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.valid_at AS valid_at, @@ -499,15 +480,11 @@ LIMIT $limit SEARCH_STATEMENTS_BY_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 10)) = date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -519,15 +496,11 @@ LIMIT $limit SEARCH_STATEMENTS_BY_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) = date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -539,15 +512,11 @@ LIMIT $limit SEARCH_STATEMENTS_G_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 19)) = date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -559,15 +528,11 @@ LIMIT $limit SEARCH_STATEMENTS_L_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 19)) < date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -579,15 +544,11 @@ LIMIT $limit SEARCH_STATEMENTS_G_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) > date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -599,15 +560,11 @@ LIMIT $limit SEARCH_STATEMENTS_L_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) < date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -665,18 +622,18 @@ LIMIT $limit # 根据id修改句子的invalid_at的值 UPDATE_STATEMENT_INVALID_AT = """ -MATCH (n:Statement {group_id: $group_id, id: $id}) +MATCH (n:Statement {end_user_id: $end_user_id, id: $id}) SET n.invalid_at = $new_invalid_at """ # MemorySummary keyword search using fulltext index SEARCH_MEMORY_SUMMARIES_BY_KEYWORD = """ CALL db.index.fulltext.queryNodes("summariesFulltext", $q) YIELD node AS m, score -WHERE ($group_id IS NULL OR m.group_id = $group_id) +WHERE ($end_user_id IS NULL OR m.end_user_id = $end_user_id) OPTIONAL MATCH (m)-[:DERIVED_FROM_STATEMENT]->(s:Statement) RETURN m.id AS id, m.name AS name, - m.group_id AS group_id, + m.end_user_id AS end_user_id, m.dialog_id AS dialog_id, m.chunk_ids AS chunk_ids, m.content AS content, @@ -695,10 +652,10 @@ MEMORY_SUMMARY_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('summary_embedding_index', $limit * 100, $embedding) YIELD node AS m, score WHERE m.summary_embedding IS NOT NULL - AND ($group_id IS NULL OR m.group_id = $group_id) + AND ($end_user_id IS NULL OR m.end_user_id = $end_user_id) RETURN m.id AS id, m.name AS name, - m.group_id AS group_id, + m.end_user_id AS end_user_id, m.dialog_id AS dialog_id, m.chunk_ids AS chunk_ids, m.content AS content, @@ -718,9 +675,7 @@ MERGE (m:MemorySummary {id: summary.id}) SET m += { id: summary.id, name: summary.name, - group_id: summary.group_id, - user_id: summary.user_id, - apply_id: summary.apply_id, + end_user_id: summary.end_user_id, run_id: summary.run_id, created_at: summary.created_at, expired_at: summary.expired_at, @@ -814,7 +769,7 @@ RETURN count(losing) as deleted neo4j_statement_part = ''' MATCH (n:Statement) -WHERE n.group_id = "{}" +WHERE n.end_user_id = "{}" AND datetime(n.created_at) >= datetime() - duration('P3D') RETURN n.statement as statement_name, @@ -824,7 +779,7 @@ RETURN ''' neo4j_statement_all = ''' MATCH (n:Statement) -WHERE n.group_id = "{}" +WHERE n.end_user_id = "{}" RETURN n.statement as statement_name, n.id as statement_id @@ -832,7 +787,7 @@ RETURN ''' neo4j_query_part = """ MATCH (n)-[r]-(m:ExtractedEntity) - WHERE n.group_id = "{}" + WHERE n.end_user_id = "{}" AND datetime(n.created_at) >= datetime() - duration('P3D') WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) @@ -853,7 +808,7 @@ neo4j_query_part = """ """ neo4j_query_all = """ MATCH (n)-[r]-(m:ExtractedEntity) - WHERE n.group_id = "{}" + WHERE n.end_user_id = "{}" WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) RETURN @@ -1027,14 +982,14 @@ RETURN DISTINCT Memory_Space_User=""" MATCH (n)-[r]->(m) -WHERE n.group_id = $group_id AND m.name="用户" +WHERE n.end_user_id = $end_user_id AND m.name="用户" return DISTINCT elementId(m) as id """ Memory_Space_Entity=""" MATCH (n)-[]-(m) WHERE elementId(m) = $id AND m.entity_type = "Person" RETURN -DISTINCT m.name as name,m.group_id as group_id +DISTINCT m.name as name,m.end_user_id as end_user_id """ Memory_Space_Associative=""" MATCH (u)-[]-(x)-[]-(h) diff --git a/api/app/repositories/neo4j/dialog_repository.py b/api/app/repositories/neo4j/dialog_repository.py index ccb3d94c..48376c2a 100644 --- a/api/app/repositories/neo4j/dialog_repository.py +++ b/api/app/repositories/neo4j/dialog_repository.py @@ -19,7 +19,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """对话仓储 管理对话节点的创建、查询、更新和删除操作。 - 提供按group_id、user_id、ref_id等条件查询对话的方法。 + 提供按end_user_id、user_id、ref_id等条件查询对话的方法。 Attributes: connector: Neo4j连接器实例 @@ -54,17 +54,17 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): return DialogueNode(**n) - async def find_by_group_id(self, group_id: str, limit: int = 100) -> List[DialogueNode]: - """根据group_id查询对话 + async def find_by_end_user_id(self, end_user_id: str, limit: int = 100) -> List[DialogueNode]: + """根据end_user_id查询对话 Args: - group_id: 组ID + end_user_id: 组ID limit: 返回结果的最大数量 Returns: List[DialogueNode]: 对话列表 """ - return await self.find({"group_id": group_id}, limit=limit) + return await self.find({"end_user_id": end_user_id}, limit=limit) async def find_by_user_id(self, user_id: str, limit: int = 100) -> List[DialogueNode]: """根据user_id查询对话 @@ -94,14 +94,14 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): async def find_by_group_and_user( self, - group_id: str, + end_user_id: str, user_id: str, limit: int = 100 ) -> List[DialogueNode]: - """根据group_id和user_id查询对话 + """根据end_user_id和user_id查询对话 Args: - group_id: 组ID + end_user_id: 组ID user_id: 用户ID limit: 返回结果的最大数量 @@ -109,20 +109,20 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): List[DialogueNode]: 对话列表 """ return await self.find( - {"group_id": group_id, "user_id": user_id}, + {"end_user_id": end_user_id, "user_id": user_id}, limit=limit ) async def find_recent_dialogs( self, - group_id: str, + end_user_id: str, days: int = 7, limit: int = 100 ) -> List[DialogueNode]: """查询最近的对话 Args: - group_id: 组ID + end_user_id: 组ID days: 查询最近多少天的对话 limit: 返回结果的最大数量 @@ -131,7 +131,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND n.created_at >= datetime() - duration({{days: $days}}) RETURN n ORDER BY n.created_at DESC @@ -139,7 +139,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """ results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, days=days, limit=limit ) @@ -164,16 +164,16 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): async def find_by_config_and_group( self, config_id: str, - group_id: str, + end_user_id: str, limit: int = 100 ) -> List[DialogueNode]: - """根据config_id和group_id查询对话 + """根据config_id和end_user_id查询对话 支持按配置ID和组ID同时过滤,确保只返回使用特定配置处理的对话。 Args: config_id: 配置ID - group_id: 组ID + end_user_id: 组ID limit: 返回结果的最大数量 Returns: diff --git a/api/app/repositories/neo4j/emotion_repository.py b/api/app/repositories/neo4j/emotion_repository.py index d445c8d4..7a8ebcf9 100644 --- a/api/app/repositories/neo4j/emotion_repository.py +++ b/api/app/repositories/neo4j/emotion_repository.py @@ -40,7 +40,7 @@ class EmotionRepository: async def get_emotion_tags( self, - group_id: str, + end_user_id: str, emotion_type: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, @@ -51,7 +51,7 @@ class EmotionRepository: 查询指定用户的情绪类型分布,包括计数、百分比和平均强度。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) emotion_type: 可选的情绪类型过滤(joy/sadness/anger/fear/surprise/neutral) start_date: 可选的开始日期(ISO格式字符串) end_date: 可选的结束日期(ISO格式字符串) @@ -65,8 +65,8 @@ class EmotionRepository: - avg_intensity: 平均强度 """ # 构建查询条件 - where_clauses = ["s.group_id = $group_id", "s.emotion_type IS NOT NULL"] - params = {"group_id": group_id, "limit": limit} + where_clauses = ["s.end_user_id = $end_user_id", "s.emotion_type IS NOT NULL"] + params = {"end_user_id": end_user_id, "limit": limit} if emotion_type: where_clauses.append("s.emotion_type = $emotion_type") @@ -119,7 +119,7 @@ class EmotionRepository: async def get_emotion_wordcloud( self, - group_id: str, + end_user_id: str, emotion_type: Optional[str] = None, limit: int = 50 ) -> List[Dict[str, Any]]: @@ -128,7 +128,7 @@ class EmotionRepository: 查询情绪关键词及其频率,用于生成词云可视化。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) emotion_type: 可选的情绪类型过滤 limit: 返回关键词的最大数量 @@ -140,8 +140,8 @@ class EmotionRepository: - avg_intensity: 平均强度 """ # 构建查询条件 - where_clauses = ["s.group_id = $group_id", "s.emotion_keywords IS NOT NULL"] - params = {"group_id": group_id, "limit": limit} + where_clauses = ["s.end_user_id = $end_user_id", "s.emotion_keywords IS NOT NULL"] + params = {"end_user_id": end_user_id, "limit": limit} if emotion_type: where_clauses.append("s.emotion_type = $emotion_type") @@ -186,7 +186,7 @@ class EmotionRepository: async def get_emotions_in_range( self, - group_id: str, + end_user_id: str, time_range: str = "30d" ) -> List[Dict[str, Any]]: """获取时间范围内的情绪数据 @@ -194,7 +194,7 @@ class EmotionRepository: 查询指定时间范围内的所有情绪数据,用于健康指数计算。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) time_range: 时间范围(7d/30d/90d) Returns: @@ -214,7 +214,7 @@ class EmotionRepository: # 优化的 Cypher 查询:使用字符串比较避免时区问题 query = """ MATCH (s:Statement) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id AND s.emotion_type IS NOT NULL AND s.created_at >= $start_date RETURN s.id as statement_id, diff --git a/api/app/repositories/neo4j/graph_saver.py b/api/app/repositories/neo4j/graph_saver.py index 13215e0f..1575315f 100644 --- a/api/app/repositories/neo4j/graph_saver.py +++ b/api/app/repositories/neo4j/graph_saver.py @@ -44,9 +44,7 @@ async def save_entities_and_relationships( 'created_at': edge.created_at.isoformat(), 'expired_at': edge.expired_at.isoformat(), 'run_id': edge.run_id, - 'group_id': edge.group_id, - 'user_id': edge.user_id, - 'apply_id': edge.apply_id, + 'end_user_id': edge.end_user_id, } all_relationships.append(relationship) @@ -101,9 +99,7 @@ async def save_statement_chunk_edges( "id": edge.id, "source": edge.source, "target": edge.target, - "group_id": edge.group_id, - "user_id": edge.user_id, - "apply_id": edge.apply_id, + "end_user_id": edge.end_user_id, "run_id": edge.run_id, "created_at": edge.created_at.isoformat() if edge.created_at else None, "expired_at": edge.expired_at.isoformat() if edge.expired_at else None, @@ -132,9 +128,7 @@ async def save_statement_entity_edges( edge_data = { "source": edge.source, "target": edge.target, - "group_id": edge.group_id, - "user_id": edge.user_id, - "apply_id": edge.apply_id, + "end_user_id": edge.end_user_id, "run_id": edge.run_id, "connect_strength": edge.connect_strength, "created_at": edge.created_at.isoformat() if edge.created_at else None, diff --git a/api/app/repositories/neo4j/graph_search.py b/api/app/repositories/neo4j/graph_search.py index 6f5764b4..9660f6cb 100644 --- a/api/app/repositories/neo4j/graph_search.py +++ b/api/app/repositories/neo4j/graph_search.py @@ -33,7 +33,7 @@ async def _update_activation_values_batch( connector: Neo4jConnector, nodes: List[Dict[str, Any]], node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, max_retries: int = 3 ) -> List[Dict[str, Any]]: """ @@ -46,7 +46,7 @@ async def _update_activation_values_batch( connector: Neo4j连接器 nodes: 节点列表,每个节点必须包含 'id' 字段 node_label: 节点标签(Statement, ExtractedEntity, MemorySummary) - group_id: 组ID(可选) + end_user_id: 组ID(可选) max_retries: 最大重试次数 Returns: @@ -97,7 +97,7 @@ async def _update_activation_values_batch( updated_nodes = await access_manager.record_batch_access( node_ids=unique_node_ids, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) logger.info( @@ -118,7 +118,7 @@ async def _update_activation_values_batch( async def _update_search_results_activation( connector: Neo4jConnector, results: Dict[str, List[Dict[str, Any]]], - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Dict[str, List[Dict[str, Any]]]: """ 更新搜索结果中所有知识节点的激活值 @@ -129,7 +129,7 @@ async def _update_search_results_activation( Args: connector: Neo4j连接器 results: 搜索结果字典,包含不同类型节点的列表 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Dict[str, List[Dict[str, Any]]]: 更新后的搜索结果 @@ -152,7 +152,7 @@ async def _update_search_results_activation( connector=connector, nodes=results[key], node_label=label, - group_id=group_id + end_user_id=end_user_id ) ) update_keys.append(key) @@ -218,7 +218,7 @@ async def _update_search_results_activation( async def search_graph( connector: Neo4jConnector, q: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: List[str] = None, ) -> Dict[str, List[Dict[str, Any]]]: @@ -236,7 +236,7 @@ async def search_graph( Args: connector: Neo4j connector q: Query text - group_id: Optional group filter + end_user_id: Optional group filter limit: Max results per category include: List of categories to search (default: all) @@ -254,7 +254,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_STATEMENTS_BY_KEYWORD, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("statements") @@ -263,7 +263,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("entities") @@ -272,7 +272,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_CHUNKS_BY_CONTENT, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("chunks") @@ -281,7 +281,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_MEMORY_SUMMARIES_BY_KEYWORD, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("summaries") @@ -305,19 +305,12 @@ async def search_graph( results[key] = _deduplicate_results(results[key]) # 更新知识节点的激活值(Statement, ExtractedEntity, MemorySummary) - # Skip activation updates if only searching summaries (optimization) - needs_activation_update = any( - key in include and key in results and results[key] - for key in ['statements', 'entities', 'chunks'] + results = await _update_search_results_activation( + connector=connector, + results=results, + end_user_id=end_user_id ) - if needs_activation_update: - results = await _update_search_results_activation( - connector=connector, - results=results, - group_id=group_id - ) - return results @@ -325,7 +318,7 @@ async def search_graph_by_embedding( connector: Neo4jConnector, embedder_client, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: List[str] = ["statements", "chunks", "entities","summaries"], ) -> Dict[str, List[Dict[str, Any]]]: @@ -337,7 +330,7 @@ async def search_graph_by_embedding( - Computes query embedding with the provided embedder_client - Ranks by cosine similarity in Cypher - - Filters by group_id if provided + - Filters by end_user_id if provided - Returns up to 'limit' per included type """ import time @@ -346,7 +339,7 @@ async def search_graph_by_embedding( embed_start = time.time() embeddings = await embedder_client.response([query_text]) embed_time = time.time() - embed_start - logger.info(f"[PERF] Embedding generation took: {embed_time:.4f}s") + print(f"[PERF] Embedding generation took: {embed_time:.4f}s") if not embeddings or not embeddings[0]: return {"statements": [], "chunks": [], "entities": [], "summaries": []} @@ -361,7 +354,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( STATEMENT_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("statements") @@ -371,7 +364,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( CHUNK_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("chunks") @@ -381,7 +374,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( ENTITY_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("entities") @@ -391,7 +384,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( MEMORY_SUMMARY_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("summaries") @@ -400,7 +393,7 @@ async def search_graph_by_embedding( query_start = time.time() task_results = await asyncio.gather(*tasks, return_exceptions=True) query_time = time.time() - query_start - logger.info(f"[PERF] Neo4j queries (parallel) took: {query_time:.4f}s") + print(f"[PERF] Neo4j queries (parallel) took: {query_time:.4f}s") # Build results dictionary results: Dict[str, List[Dict[str, Any]]] = { @@ -424,28 +417,19 @@ async def search_graph_by_embedding( results[key] = _deduplicate_results(results[key]) # 更新知识节点的激活值(Statement, ExtractedEntity, MemorySummary) - # Skip activation updates if only searching summaries (optimization) - needs_activation_update = any( - key in include and key in results and results[key] - for key in ['statements', 'entities', 'chunks'] + update_start = time.time() + results = await _update_search_results_activation( + connector=connector, + results=results, + end_user_id=end_user_id ) - - if needs_activation_update: - update_start = time.time() - results = await _update_search_results_activation( - connector=connector, - results=results, - group_id=group_id - ) - update_time = time.time() - update_start - logger.info(f"[PERF] Activation value updates took: {update_time:.4f}s") - else: - logger.info(f"[PERF] Skipping activation updates (only summaries)") + update_time = time.time() - update_start + print(f"[PERF] Activation value updates took: {update_time:.4f}s") return results async def get_dedup_candidates_for_entities( # 适配新版查询:使用全文索引按名称检索候选实体 connector: Neo4jConnector, - group_id: str, + end_user_id: str, entities: List[Dict[str, Any]], use_contains_fallback: bool = True, batch_size: int = 500, @@ -453,7 +437,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 ) -> Dict[str, List[Dict[str, Any]]]: """ 为第二层去重消歧批量检索候选实体(适配新版 cypher_queries): - - 使用全文索引查询 `SEARCH_ENTITIES_BY_NAME` 按 (group_id, name) 检索候选; + - 使用全文索引查询 `SEARCH_ENTITIES_BY_NAME` 按 (end_user_id, name) 检索候选; - 保留并发控制与返回结构(incoming_id -> [db_entity_props...]); - 若提供 `entity_type`,在本地对返回结果做类型过滤; - `use_contains_fallback` 保留形参以兼容,必要时可扩展二次查询策略。 @@ -477,7 +461,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 rows = await connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=name, - group_id=group_id, + end_user_id=end_user_id, limit=100, ) except Exception: @@ -501,7 +485,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 rows = await connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=name.lower(), - group_id=group_id, + end_user_id=end_user_id, limit=100, ) for r in rows: @@ -532,9 +516,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 async def search_graph_by_keyword_temporal( connector: Neo4jConnector, query_text: str, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -547,32 +529,30 @@ async def search_graph_by_keyword_temporal( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements containing query_text created between start_date and end_date - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ if not query_text: - logger.warning(f"query_text cannot be empty") + print(f"query_text不能为空") return {"statements": []} statements = await connector.execute_query( SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL, q=query_text, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, start_date=start_date, end_date=end_date, valid_date=valid_date, invalid_date=invalid_date, limit=limit, ) - logger.debug(f"Temporal keyword search results: {len(statements)} statements found") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results @@ -580,9 +560,7 @@ async def search_graph_by_keyword_temporal( async def search_graph_by_temporal( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -595,14 +573,12 @@ async def search_graph_by_temporal( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created between start_date and end_date - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_TEMPORAL, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, start_date=start_date, end_date=end_date, valid_date=valid_date, @@ -610,16 +586,16 @@ async def search_graph_by_temporal( limit=limit, ) - logger.debug(f"Temporal search query: {SEARCH_STATEMENTS_BY_TEMPORAL}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, start_date={start_date}, end_date={end_date}, valid_date={valid_date}, invalid_date={invalid_date}, limit={limit}") - logger.debug(f"Temporal search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_TEMPORAL}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, start_date: {start_date}, end_date: {end_date}, valid_date: {valid_date}, invalid_date: {invalid_date}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results @@ -628,23 +604,23 @@ async def search_graph_by_temporal( async def search_graph_by_dialog_id( connector: Neo4jConnector, dialog_id: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: """ Temporal search across Dialogues. - Matches dialogues with dialog_id - - Optionally filters by group_id + - Optionally filters by end_user_id - Returns up to 'limit' dialogues """ if not dialog_id: - logger.warning(f"dialog_id cannot be empty") + print(f"dialog_id不能为空") return {"dialogues": []} dialogues = await connector.execute_query( SEARCH_DIALOGUE_BY_DIALOG_ID, - group_id=group_id, + end_user_id=end_user_id, dialog_id=dialog_id, limit=limit, ) @@ -654,15 +630,15 @@ async def search_graph_by_dialog_id( async def search_graph_by_chunk_id( connector: Neo4jConnector, chunk_id : str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: if not chunk_id: - logger.warning(f"chunk_id cannot be empty") + print(f"chunk_id不能为空") return {"chunks": []} chunks = await connector.execute_query( SEARCH_CHUNK_BY_CHUNK_ID, - group_id=group_id, + end_user_id=end_user_id, chunk_id=chunk_id, limit=limit, ) @@ -671,9 +647,9 @@ async def search_graph_by_chunk_id( async def search_graph_by_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -683,37 +659,37 @@ async def search_graph_by_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search by created_at query: {SEARCH_STATEMENTS_BY_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id} created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_by_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -723,37 +699,37 @@ async def search_graph_by_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search by valid_at query: {SEARCH_STATEMENTS_BY_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_g_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -763,37 +739,37 @@ async def search_graph_g_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_G_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search greater than created_at query: {SEARCH_STATEMENTS_G_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_G_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_g_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -803,37 +779,37 @@ async def search_graph_g_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_G_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search greater than valid_at query: {SEARCH_STATEMENTS_G_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_G_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_l_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -843,37 +819,37 @@ async def search_graph_l_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_L_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search less than created_at query: {SEARCH_STATEMENTS_L_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_L_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_l_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -883,28 +859,28 @@ async def search_graph_l_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_L_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search less than valid_at query: {SEARCH_STATEMENTS_L_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_L_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results diff --git a/api/app/repositories/neo4j/memory_summary_repository.py b/api/app/repositories/neo4j/memory_summary_repository.py index fc743f33..2564aeab 100644 --- a/api/app/repositories/neo4j/memory_summary_repository.py +++ b/api/app/repositories/neo4j/memory_summary_repository.py @@ -18,7 +18,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """Memory Summary Repository Manages CRUD operations for MemorySummary nodes. - Provides methods to query summaries by group_id, user_id, and time ranges. + Provides methods to query summaries by end_user_id, user_id, and time ranges. Attributes: connector: Neo4j connector instance @@ -51,17 +51,17 @@ class MemorySummaryRepository(BaseNeo4jRepository): return dict(n) - async def find_by_group_id( + async def find_by_end_user_id( self, - group_id: str, + end_user_id: str, limit: int = 1000, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: - """Query memory summaries by group_id + """Query memory summaries by end_user_id Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by limit: Maximum number of results to return start_date: Optional start date filter end_date: Optional end date filter @@ -71,10 +71,10 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id """ - params = {"group_id": group_id, "limit": limit} + params = {"end_user_id": end_user_id, "limit": limit} # Add date range filters if provided if start_date: @@ -139,16 +139,16 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_by_group_and_user( self, - group_id: str, + end_user_id: str, user_id: str, limit: int = 1000, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: - """Query memory summaries by both group_id and user_id + """Query memory summaries by both end_user_id and user_id Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by user_id: User ID to filter by limit: Maximum number of results to return start_date: Optional start date filter @@ -159,10 +159,10 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id AND n.user_id = $user_id + WHERE n.end_user_id = $end_user_id AND n.user_id = $user_id """ - params = {"group_id": group_id, "user_id": user_id, "limit": limit} + params = {"end_user_id": end_user_id, "user_id": user_id, "limit": limit} # Add date range filters if provided if start_date: @@ -184,14 +184,14 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_recent_summaries( self, - group_id: str, + end_user_id: str, days: int = 7, limit: int = 1000 ) -> List[Dict[str, Any]]: """Query recent memory summaries Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by days: Number of recent days to query limit: Maximum number of results to return @@ -200,7 +200,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND n.created_at >= datetime() - duration({{days: $days}}) RETURN n ORDER BY n.created_at DESC diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index 7c4b43b5..456c4e08 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -141,14 +141,14 @@ class Neo4jConnector: async with self.driver.session(database="neo4j") as session: return await session.execute_read(transaction_func, **kwargs) - async def delete_group(self, group_id: str): + async def delete_group(self, end_user_id: str): """删除指定组的所有数据 - 删除所有属于指定group_id的节点和边。 + 删除所有属于指定end_user_id的节点和边。 这是一个危险操作,会永久删除数据。 Args: - group_id: 要删除的组ID + end_user_id: 要删除的组ID Example: >>> connector = Neo4jConnector() @@ -157,14 +157,14 @@ class Neo4jConnector: """ # 删除节点(DETACH DELETE会同时删除相关的边) await self.driver.execute_query( - "MATCH (n) WHERE n.group_id = $group_id DETACH DELETE n", + "MATCH (n) WHERE n.end_user_id = $end_user_id DETACH DELETE n", database="neo4j", - group_id=group_id + end_user_id=end_user_id ) # 删除独立的边(如果有的话) await self.driver.execute_query( - "MATCH ()-[r]->() WHERE r.group_id = $group_id DELETE r", + "MATCH ()-[r]->() WHERE r.end_user_id = $end_user_id DELETE r", database="neo4j", - group_id=group_id + end_user_id=end_user_id ) - print(f"Group {group_id} deleted.") + print(f"Group {end_user_id} deleted.") diff --git a/api/app/repositories/neo4j/statement_repository.py b/api/app/repositories/neo4j/statement_repository.py index cd9f2fac..4f12af83 100644 --- a/api/app/repositories/neo4j/statement_repository.py +++ b/api/app/repositories/neo4j/statement_repository.py @@ -20,7 +20,7 @@ class StatementRepository(BaseNeo4jRepository[StatementNode]): """陈述句仓储 管理陈述句节点的创建、查询、更新和删除操作。 - 提供按chunk_id、group_id、向量相似度等条件查询陈述句的方法。 + 提供按chunk_id、end_user_id、向量相似度等条件查询陈述句的方法。 Attributes: connector: Neo4j连接器实例 diff --git a/api/app/schemas/memory_agent_schema.py b/api/app/schemas/memory_agent_schema.py index d4354c40..e7b1fe65 100644 --- a/api/app/schemas/memory_agent_schema.py +++ b/api/app/schemas/memory_agent_schema.py @@ -7,11 +7,11 @@ class UserInput(BaseModel): message: str history: list[dict] search_switch: str - group_id: str + end_user_id: str config_id: Optional[str] = None class Write_UserInput(BaseModel): messages: list[dict] - group_id: str + end_user_id: str config_id: Optional[str] = None diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 46bda5f6..2dd06e89 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -92,7 +92,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str try: memory_content = asyncio.run( MemoryAgentService().read_memory( - group_id=end_user_id, + end_user_id=end_user_id, message=question, history=[], search_switch="2", diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index 601d2921..19c6cef1 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -75,7 +75,7 @@ class EmotionAnalyticsService: # 调用仓储层查询 tags = await self.emotion_repo.get_emotion_tags( - group_id=end_user_id, + end_user_id=end_user_id, emotion_type=emotion_type, start_date=start_date, end_date=end_date, @@ -157,7 +157,7 @@ class EmotionAnalyticsService: # 调用仓储层查询 keywords = await self.emotion_repo.get_emotion_wordcloud( - group_id=end_user_id, + end_user_id=end_user_id, emotion_type=emotion_type, limit=limit ) @@ -339,7 +339,7 @@ class EmotionAnalyticsService: # 获取时间范围内的情绪数据 emotions = await self.emotion_repo.get_emotions_in_range( - group_id=end_user_id, + end_user_id=end_user_id, time_range=time_range ) @@ -519,7 +519,7 @@ class EmotionAnalyticsService: # 3. 获取情绪数据用于模式分析 emotions = await self.emotion_repo.get_emotions_in_range( - group_id=end_user_id, + end_user_id=end_user_id, time_range="30d" ) @@ -598,13 +598,13 @@ class EmotionAnalyticsService: # 查询用户的实体和标签 query = """ MATCH (e:Entity) - WHERE e.group_id = $group_id + WHERE e.end_user_id = $end_user_id RETURN e.name as name, e.type as type ORDER BY e.created_at DESC LIMIT 20 """ - entities = await connector.execute_query(query, group_id=end_user_id) + entities = await connector.execute_query(query, end_user_id=end_user_id) # 提取兴趣标签 interests = [e["name"] for e in entities if e.get("type") in ["INTEREST", "HOBBY"]][:5] diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 8170bdd8..e475bef0 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -27,6 +27,7 @@ from app.core.memory.analytics.hot_memory_tags import get_hot_memory_tags from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.models.knowledge_model import Knowledge, KnowledgeType +from app.repositories.memory_short_repository import ShortTermMemoryRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_agent_schema import Write_UserInput from app.schemas.memory_config_schema import ConfigurationError @@ -54,25 +55,25 @@ _neo4j_connector = Neo4jConnector() class MemoryAgentService: """Service for memory agent operations""" - def writer_messages_deal(self, messages, start_time, group_id, config_id, message, context): + def writer_messages_deal(self, messages, start_time, end_user_id, config_id, message, context): duration = time.time() - start_time if str(messages) == 'success': - logger.info(f"Write operation successful for group {group_id} with config_id {config_id}") + logger.info(f"Write operation successful for group {end_user_id} with config_id {config_id}") # 记录成功的操作 if audit_logger: - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=True, + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=True, duration=duration, details={"message_length": len(message)}) return context else: - logger.warning(f"Write operation failed for group {group_id}") + logger.warning(f"Write operation failed for group {end_user_id}") # 记录失败的操作 if audit_logger: audit_logger.log_operation( operation="WRITE", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=f"写入失败: {messages[:100]}" @@ -265,13 +266,13 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, group_id: str, messages: list[dict], config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, message: str, config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id Args: - group_id: Group identifier (also used as end_user_id) - messages: Structured message list [{"role": "user", "content": "..."}, ...] + end_user_id: Group identifier (also used as end_user_id) + message: Message to write config_id: Configuration ID from database db: SQLAlchemy database session storage_type: Storage type (neo4j or rag) @@ -286,15 +287,15 @@ class MemoryAgentService: # Resolve config_id if None using end_user's connected config if config_id is None: try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if config_id is None: - raise ValueError(f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): - raise - logger.error(f"Failed to get connected config for end_user {group_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + raise # Re-raise our specific error + logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") import time start_time = time.time() @@ -314,7 +315,7 @@ class MemoryAgentService: # Log failed operation if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) raise ValueError(error_msg) @@ -322,11 +323,11 @@ class MemoryAgentService: if storage_type == "rag": # For RAG storage, convert messages to single string message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - result = await write_rag(group_id, message_text, user_rag_memory_id) + result = await write_rag(end_user_id, message_text, user_rag_memory_id) return result else: async with make_write_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # Convert structured messages to LangChain messages langchain_messages = [] for msg in messages: @@ -339,7 +340,7 @@ class MemoryAgentService: # 初始状态 - 包含所有必要字段 initial_state = { "messages": langchain_messages, - "group_id": group_id, + "end_user_id": end_user_id, "memory_config": memory_config } @@ -356,14 +357,14 @@ class MemoryAgentService: contents = massages.get('write_result') # Convert messages back to string for logging message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - return self.writer_messages_deal(massagesstatus, start_time, group_id, config_id, message_text, contents) + return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) except Exception as e: # Ensure proper error handling and logging error_msg = f"Write operation failed: {str(e)}" logger.error(error_msg) if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) raise ValueError(error_msg) @@ -371,15 +372,14 @@ class MemoryAgentService: async def read_memory( self, - group_id: str, + end_user_id: str, message: str, history: List[Dict], search_switch: str, config_id: Optional[str], db: Session, storage_type: str, - user_rag_memory_id: str - ) -> Dict: + user_rag_memory_id: str) -> Dict: """ Process read operation with config_id @@ -389,7 +389,7 @@ class MemoryAgentService: - "2": Direct answer based on context Args: - group_id: Group identifier (also used as end_user_id) + end_user_id: Group identifier (also used as end_user_id) message: User message history: Conversation history search_switch: Search mode switch @@ -407,22 +407,22 @@ class MemoryAgentService: import time start_time = time.time() - logger.info(f"[PERF] read_memory started for group_id={group_id}, search_switch={search_switch}") + ori_message= message # Resolve config_id if None using end_user's connected config if config_id is None: try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if config_id is None: - raise ValueError(f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): raise # Re-raise our specific error - logger.error(f"Failed to get connected config for end_user {group_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") - logger.info(f"Read operation for group {group_id} with config_id {config_id}") + logger.info(f"Read operation for group {end_user_id} with config_id {config_id}") # 导入审计日志记录器 try: @@ -431,15 +431,13 @@ class MemoryAgentService: audit_logger = None - config_load_start = time.time() try: config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config( config_id=config_id, service_name="MemoryAgentService" ) - config_load_time = time.time() - config_load_start - logger.info(f"[PERF] Configuration loaded in {config_load_time:.4f}s: {memory_config.config_name}") + logger.info(f"Configuration loaded successfully: {memory_config.config_name}") except ConfigurationError as e: error_msg = f"Failed to load configuration for config_id: {config_id}: {e}" logger.error(error_msg) @@ -450,7 +448,7 @@ class MemoryAgentService: audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=error_msg @@ -460,16 +458,16 @@ class MemoryAgentService: # Step 2: Prepare history history.append({"role": "user", "content": message}) - logger.debug(f"Group ID:{group_id}, Message:{message}, History:{history}, Config ID:{config_id}") + logger.debug(f"Group ID:{end_user_id}, Message:{message}, History:{history}, Config ID:{config_id}") # Step 3: Initialize MCP client and execute read workflow graph_exec_start = time.time() try: async with make_read_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 initial_state = {"messages": [HumanMessage(content=message)], "search_switch": search_switch, - "group_id": group_id + "end_user_id": end_user_id , "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id, "memory_config": memory_config} # 获取节点更新信息 @@ -565,13 +563,13 @@ class MemoryAgentService: if '信息不足,无法回答。' != str(summary) and str(search_switch).strip() != "2": # 使用 upsert 方法 repo.upsert( - end_user_id=group_id, + end_user_id=end_user_id, messages=message, aimessages=summary, retrieved_content=retrieved_content, search_switch=str(search_switch) ) - logger.info(f"成功保存短期记忆: group_id={group_id}, search_switch={search_switch}") + logger.info(f"成功保存短期记忆: end_user_id={end_user_id}, search_switch={search_switch}") else: logger.debug(f"跳过保存短期记忆: summary={summary[:50] if summary else 'None'}, search_switch={search_switch}") @@ -580,14 +578,12 @@ class MemoryAgentService: logger.error(f"保存短期记忆失败: {str(save_error)}", exc_info=True) # Log successful operation - total_time = time.time() - start_time - logger.info(f"[PERF] read_memory completed successfully in {total_time:.4f}s (config: {config_load_time:.4f}s, graph: {graph_exec_time:.4f}s)") if audit_logger: duration = time.time() - start_time audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=True, duration=duration ) @@ -599,14 +595,13 @@ class MemoryAgentService: except Exception as e: # Ensure proper error handling and logging error_msg = f"Read operation failed: {str(e)}" - total_time = time.time() - start_time - logger.error(f"[PERF] read_memory failed after {total_time:.4f}s: {error_msg}") + logger.error(error_msg) if audit_logger: duration = time.time() - start_time audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=error_msg @@ -755,7 +750,7 @@ class MemoryAgentService: """ 统计知识库类型分布,包含: 1. PostgreSQL 中的知识库类型:General, Web, Third-party, Folder(根据 workspace_id 过滤) - 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/group_id 过滤) + 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) 3. total: 所有类型的总和 参数: @@ -841,11 +836,11 @@ class MemoryAgentService: for end_user in end_users: end_user_id_str = str(end_user.id) memory_query = """ - MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN count(n) AS Count + MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN count(n) AS Count """ neo4j_result = await _neo4j_connector.execute_query( memory_query, - group_id=end_user_id_str, + end_user_id=end_user_id_str, ) chunk_count = neo4j_result[0]["Count"] if neo4j_result else 0 total_chunks += chunk_count @@ -885,7 +880,7 @@ class MemoryAgentService: 获取指定用户的热门记忆标签 参数: - - end_user_id: 用户ID(可选),对应Neo4j中的group_id字段 + - end_user_id: 用户ID(可选),对应Neo4j中的end_user_id字段 - limit: 返回标签数量限制 返回格式: @@ -895,7 +890,7 @@ class MemoryAgentService: ] """ try: - # by_user=False 表示按 group_id 查询(在Neo4j中,group_id就是用户维度) + # by_user=False 表示按 end_user_id 查询(在Neo4j中,end_user_id就是用户维度) tags = await get_hot_memory_tags(end_user_id, limit=limit, by_user=False) payload=[] for tag, freq in tags: @@ -970,21 +965,21 @@ class MemoryAgentService: # 查询该用户的语句 query = ( "MATCH (s:Statement) " - "WHERE ($group_id IS NULL OR s.group_id = $group_id) AND s.statement IS NOT NULL " + "WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND s.statement IS NOT NULL " "RETURN s.statement AS statement " "ORDER BY s.created_at DESC LIMIT 100" ) - rows = await connector.execute_query(query, group_id=end_user_id) + rows = await connector.execute_query(query, end_user_id=end_user_id) statements = [r.get("statement", "") for r in rows if r.get("statement")] # 查询该用户的热门实体 entity_query = ( "MATCH (e:ExtractedEntity) " - "WHERE ($group_id IS NULL OR e.group_id = $group_id) AND e.entity_type <> '人物' AND e.name IS NOT NULL " + "WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) AND e.entity_type <> '人物' AND e.name IS NOT NULL " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC LIMIT 20" ) - entity_rows = await connector.execute_query(entity_query, group_id=end_user_id) + entity_rows = await connector.execute_query(entity_query, end_user_id=end_user_id) entities = [f"{r['name']} ({r['frequency']})" for r in entity_rows] await connector.close() @@ -1037,14 +1032,14 @@ class MemoryAgentService: names_to_exclude = ['AI', 'Caroline', 'Melanie', 'Jon', 'Gina', '用户', 'AI助手', 'John', 'Maria'] hot_tag_query = ( "MATCH (e:ExtractedEntity) " - "WHERE ($group_id IS NULL OR e.group_id = $group_id) AND e.entity_type <> '人物' " + "WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) AND e.entity_type <> '人物' " "AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC LIMIT 4" ) hot_tag_rows = await connector.execute_query( hot_tag_query, - group_id=end_user_id, + end_user_id=end_user_id, names_to_exclude=names_to_exclude ) await connector.close() @@ -1190,6 +1185,10 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An "memory_config_id": memory_config_id } + print(188*'*') + print(result) + print(188 * '*') + logger.info(f"Successfully retrieved connected config: memory_config_id={memory_config_id}") return result @@ -1230,10 +1229,10 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 1. 批量查询所有 end_user 及其 app_id end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all() - + # 创建 end_user_id -> app_id 的映射 user_to_app = {str(eu.id): eu.app_id for eu in end_users} - + # 记录未找到的用户 found_user_ids = set(user_to_app.keys()) missing_user_ids = set(end_user_ids) - found_user_ids @@ -1275,13 +1274,13 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 批量查询 memory_config_name config_id_to_name = {} if memory_config_ids: - memory_configs = db.query(DataConfig).filter(DataConfig.config_id.in_(memory_config_ids)).all() - config_id_to_name = {str(mc.config_id): mc.config_name for mc in memory_configs} + memory_configs = db.query(MemoryConfig).filter(MemoryConfig.id.in_(memory_config_ids)).all() + config_id_to_name = {str(mc.id): mc.config_name for mc in memory_configs} # 4. 构建最终结果 for end_user_id, app_id in user_to_app.items(): release = app_to_release.get(app_id) - + if not release: logger.warning(f"No active release found for app: {app_id} (end_user: {end_user_id})") result[end_user_id] = {"memory_config_id": None, "memory_config_name": None} @@ -1293,7 +1292,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None # 获取配置名称 - memory_config_name = config_id_to_name.get(str(memory_config_id)) if memory_config_id else None + memory_config_name = config_id_to_name.get(memory_config_id) if memory_config_id else None result[end_user_id] = { "memory_config_id": memory_config_id, diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index 0ae2b965..c33c9c6b 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -25,7 +25,7 @@ class MemoryAPIService: This service provides a thin layer that: 1. Validates end_user exists and belongs to the authorized workspace - 2. Maps end_user_id to group_id for memory operations + 2. Maps end_user_id to end_user_id for memory operations 3. Delegates to MemoryAgentService for actual memory read/write operations """ @@ -68,7 +68,7 @@ class MemoryAPIService: ) end_user = self.db.query(EndUser).filter(EndUser.id == end_user_uuid).first() - + if not end_user: logger.warning(f"End user not found: {end_user_id}") raise ResourceNotFoundException( @@ -115,7 +115,7 @@ class MemoryAPIService: Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as group_id) + end_user_id: End user identifier (used as end_user_id) message: Message content to store config_id: Optional memory configuration ID storage_type: Storage backend (neo4j or rag) @@ -133,13 +133,12 @@ class MemoryAPIService: # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) - # Use end_user_id as group_id for memory operations - group_id = end_user_id + # Use end_user_id as end_user_id for memory operations try: # Delegate to MemoryAgentService result = await MemoryAgentService().write_memory( - group_id=group_id, + end_user_id=end_user_id, message=message, config_id=config_id, db=self.db, @@ -186,7 +185,7 @@ class MemoryAPIService: Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as group_id) + end_user_id: End user identifier (used as end_user_id) message: Query message search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search) config_id: Optional memory configuration ID @@ -205,13 +204,13 @@ class MemoryAPIService: # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) - # Use end_user_id as group_id for memory operations - group_id = end_user_id + # Use end_user_id as end_user_id for memory operations + try: # Delegate to MemoryAgentService result = await MemoryAgentService().read_memory( - group_id=group_id, + end_user_id=end_user_id, message=message, history=[], search_switch=search_switch, diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index 25a8281d..bc647752 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -326,7 +326,7 @@ class MemoryBaseService: Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 最大emotion_intensity对应的emotion_type,如果没有则返回None @@ -334,7 +334,7 @@ class MemoryBaseService: try: query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) WHERE stmt.emotion_type IS NOT NULL AND stmt.emotion_intensity IS NOT NULL @@ -347,7 +347,7 @@ class MemoryBaseService: result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) if result and len(result) > 0: @@ -381,10 +381,10 @@ class MemoryBaseService: if end_user_id: query = """ MATCH (n:MemorySummary) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await self.neo4j_connector.execute_query(query, group_id=end_user_id) + result = await self.neo4j_connector.execute_query(query, end_user_id=end_user_id) else: query = """ MATCH (n:MemorySummary) @@ -423,12 +423,12 @@ class MemoryBaseService: if end_user_id: semantic_query = """ MATCH (e:ExtractedEntity) - WHERE e.group_id = $group_id AND e.is_explicit_memory = true + WHERE e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN count(e) as count """ semantic_result = await self.neo4j_connector.execute_query( semantic_query, - group_id=end_user_id + end_user_id=end_user_id ) else: semantic_query = """ @@ -519,7 +519,7 @@ class MemoryBaseService: """ if end_user_id: - query += " AND n.group_id = $group_id" + query += " AND n.end_user_id = $end_user_id" query += """ RETURN sum(CASE WHEN n.activation_value IS NOT NULL AND n.activation_value < $threshold THEN 1 ELSE 0 END) as low_activation_nodes @@ -528,7 +528,7 @@ class MemoryBaseService: # 设置查询参数 params = {'threshold': forgetting_threshold} if end_user_id: - params['group_id'] = end_user_id + params['end_user_id'] = end_user_id # 执行查询 result = await self.neo4j_connector.execute_query(query, **params) diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index 9b5f3c99..7081d28b 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -717,8 +717,8 @@ class MemoryInteraction: ori_data= await self.connector.execute_query(Memory_Space_Entity, id=self.id) if ori_data!=[]: # name = ori_data[0]['name'] - group_id = [i['group_id'] for i in ori_data][0] - Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) + end_user_id = [i['end_user_id'] for i in ori_data][0] + Space_User = await self.connector.execute_query(Memory_Space_User, end_user_id=end_user_id) if not Space_User: return [] user_id=Space_User[0]['id'] diff --git a/api/app/services/memory_episodic_service.py b/api/app/services/memory_episodic_service.py index 12eeff6e..08751fd1 100644 --- a/api/app/services/memory_episodic_service.py +++ b/api/app/services/memory_episodic_service.py @@ -34,7 +34,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: (标题, 类型)元组,如果不存在则返回默认值 @@ -43,14 +43,14 @@ class MemoryEpisodicService(MemoryBaseService): # 查询Summary节点的name(作为title)和memory_type(作为type) query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id RETURN s.name AS title, s.memory_type AS type """ result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) if not result or len(result) == 0: @@ -77,7 +77,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 前3个实体的name属性列表 @@ -87,7 +87,7 @@ class MemoryEpisodicService(MemoryBaseService): # 按activation_value降序排序,返回前3个 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity) WHERE entity.activation_value IS NOT NULL @@ -99,7 +99,7 @@ class MemoryEpisodicService(MemoryBaseService): result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 提取实体名称 @@ -123,7 +123,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 所有Statement节点的statement属性内容列表 @@ -132,7 +132,7 @@ class MemoryEpisodicService(MemoryBaseService): # 查询Summary节点指向的所有Statement节点 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) WHERE stmt.statement IS NOT NULL AND stmt.statement <> '' RETURN stmt.statement AS statement @@ -141,7 +141,7 @@ class MemoryEpisodicService(MemoryBaseService): result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 提取statement内容 @@ -214,12 +214,12 @@ class MemoryEpisodicService(MemoryBaseService): # 1. 先查询所有情景记忆的总数(不受筛选条件限制) total_all_query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id RETURN count(s) AS total_all """ total_all_result = await self.neo4j_connector.execute_query( total_all_query, - group_id=end_user_id + end_user_id=end_user_id ) total_all = total_all_result[0]["total_all"] if total_all_result else 0 @@ -229,7 +229,7 @@ class MemoryEpisodicService(MemoryBaseService): # 3. 构建Cypher查询 query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id """ # 添加时间范围过滤 @@ -248,7 +248,7 @@ class MemoryEpisodicService(MemoryBaseService): ORDER BY s.created_at DESC """ - params = {"group_id": end_user_id} + params = {"end_user_id": end_user_id} if time_filter: params["time_filter"] = time_filter if title_keyword: @@ -333,14 +333,14 @@ class MemoryEpisodicService(MemoryBaseService): # 1. 查询指定的MemorySummary节点 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id RETURN elementId(s) AS id, s.created_at AS created_at """ result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 2. 如果节点不存在,返回错误 diff --git a/api/app/services/memory_explicit_service.py b/api/app/services/memory_explicit_service.py index 713215c3..f8d39ae8 100644 --- a/api/app/services/memory_explicit_service.py +++ b/api/app/services/memory_explicit_service.py @@ -60,7 +60,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 1. 查询情景记忆(MemorySummary节点) ========== episodic_query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id RETURN elementId(s) AS id, s.name AS title, s.content AS content, @@ -70,7 +70,7 @@ class MemoryExplicitService(MemoryBaseService): episodic_result = await self.neo4j_connector.execute_query( episodic_query, - group_id=end_user_id + end_user_id=end_user_id ) # 处理情景记忆数据 @@ -96,7 +96,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 2. 查询语义记忆(ExtractedEntity节点) ========== semantic_query = """ MATCH (e:ExtractedEntity) - WHERE e.group_id = $group_id + WHERE e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN elementId(e) AS id, e.name AS name, @@ -107,7 +107,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_result = await self.neo4j_connector.execute_query( semantic_query, - group_id=end_user_id + end_user_id=end_user_id ) # 处理语义记忆数据 @@ -189,7 +189,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 1. 先尝试查询情景记忆 ========== episodic_query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $memory_id AND s.group_id = $group_id + WHERE elementId(s) = $memory_id AND s.end_user_id = $end_user_id RETURN s.name AS title, s.content AS content, s.created_at AS created_at @@ -198,7 +198,7 @@ class MemoryExplicitService(MemoryBaseService): episodic_result = await self.neo4j_connector.execute_query( episodic_query, memory_id=memory_id, - group_id=end_user_id + end_user_id=end_user_id ) if episodic_result and len(episodic_result) > 0: @@ -229,7 +229,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_query = """ MATCH (e:ExtractedEntity) WHERE elementId(e) = $memory_id - AND e.group_id = $group_id + AND e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN e.name AS name, e.description AS core_definition, @@ -240,7 +240,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_result = await self.neo4j_connector.execute_query( semantic_query, memory_id=memory_id, - group_id=end_user_id + end_user_id=end_user_id ) if semantic_result and len(semantic_result) > 0: diff --git a/api/app/services/memory_forget_service.py b/api/app/services/memory_forget_service.py index 2db4cdc7..558efe43 100644 --- a/api/app/services/memory_forget_service.py +++ b/api/app/services/memory_forget_service.py @@ -132,7 +132,7 @@ class MemoryForgetService: async def _get_knowledge_stats( self, connector: Neo4jConnector, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, forgetting_threshold: float = 0.3 ) -> Dict[str, Any]: """ @@ -140,7 +140,7 @@ class MemoryForgetService: Args: connector: Neo4j 连接器 - group_id: 组ID(可选) + end_user_id: 组ID(可选) forgetting_threshold: 遗忘阈值 Returns: @@ -152,8 +152,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ WITH n, @@ -172,8 +172,8 @@ class MemoryForgetService: """ params = {'threshold': forgetting_threshold} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await connector.execute_query(query, **params) @@ -200,7 +200,7 @@ class MemoryForgetService: async def _get_pending_forgetting_nodes( self, connector: Neo4jConnector, - group_id: str, + end_user_id: str, forgetting_threshold: float, min_days_since_access: int, limit: int = 20 @@ -212,7 +212,7 @@ class MemoryForgetService: Args: connector: Neo4j 连接器 - group_id: 组ID + end_user_id: 组ID forgetting_threshold: 遗忘阈值 min_days_since_access: 最小未访问天数 limit: 返回节点数量限制 @@ -229,7 +229,7 @@ class MemoryForgetService: query = """ MATCH (n) WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) - AND n.group_id = $group_id + AND n.end_user_id = $end_user_id AND n.activation_value IS NOT NULL AND n.activation_value < $threshold AND n.last_access_time IS NOT NULL @@ -250,7 +250,7 @@ class MemoryForgetService: """ params = { - 'group_id': group_id, + 'end_user_id': end_user_id, 'threshold': forgetting_threshold, 'min_access_time_str': min_access_time_str, 'limit': limit @@ -291,7 +291,7 @@ class MemoryForgetService: async def trigger_forgetting_cycle( self, db: Session, - group_id: str, + end_user_id: str, max_merge_batch_size: Optional[int] = None, min_days_since_access: Optional[int] = None, config_id: Optional[int] = None @@ -303,10 +303,10 @@ class MemoryForgetService: Args: db: 数据库会话 - group_id: 组ID(即终端用户ID,必填) + end_user_id: 组ID(即终端用户ID,必填) max_merge_batch_size: 最大融合批次大小(可选) min_days_since_access: 最小未访问天数(可选) - config_id: 配置ID(必填,由控制器层通过 group_id 获取) + config_id: 配置ID(必填,由控制器层通过 end_user_id 获取) Returns: dict: 遗忘报告 @@ -319,7 +319,7 @@ class MemoryForgetService: # 运行遗忘周期(LLM 客户端将在需要时由 forgetting_strategy 内部获取) report = await forgetting_scheduler.run_forgetting_cycle( - group_id=group_id, + end_user_id=end_user_id, max_merge_batch_size=max_merge_batch_size, min_days_since_access=min_days_since_access, config_id=config_id, @@ -338,7 +338,7 @@ class MemoryForgetService: stats_query = """ MATCH (n) WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) - AND n.group_id = $group_id + AND n.end_user_id = $end_user_id RETURN count(n) as total_nodes, avg(n.activation_value) as average_activation, @@ -347,7 +347,7 @@ class MemoryForgetService: stats_results = await connector.execute_query( stats_query, - group_id=group_id, + end_user_id=end_user_id, threshold=config['forgetting_threshold'] ) @@ -364,7 +364,7 @@ class MemoryForgetService: # 保存历史记录到数据库 self.history_repository.create( db=db, - end_user_id=group_id, + end_user_id=end_user_id, execution_time=execution_time, merged_count=report['merged_count'], failed_count=report['failed_count'], @@ -376,7 +376,7 @@ class MemoryForgetService: ) api_logger.info( - f"已保存遗忘周期历史记录: end_user_id={group_id}, " + f"已保存遗忘周期历史记录: end_user_id={end_user_id}, " f"merged_count={report['merged_count']}" ) @@ -465,7 +465,7 @@ class MemoryForgetService: async def get_forgetting_stats( self, db: Session, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, config_id: Optional[int] = None ) -> Dict[str, Any]: """ @@ -475,7 +475,7 @@ class MemoryForgetService: Args: db: 数据库会话 - group_id: 组ID(可选) + end_user_id: 组ID(可选) config_id: 配置ID(可选,用于获取遗忘阈值) Returns: @@ -493,8 +493,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) """ - if group_id: - activation_query += " AND n.group_id = $group_id" + if end_user_id: + activation_query += " AND n.end_user_id = $end_user_id" activation_query += """ RETURN @@ -506,8 +506,8 @@ class MemoryForgetService: """ params = {'threshold': forgetting_threshold} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id activation_results = await connector.execute_query(activation_query, **params) @@ -539,8 +539,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) """ - if group_id: - distribution_query += " AND n.group_id = $group_id" + if end_user_id: + distribution_query += " AND n.end_user_id = $end_user_id" distribution_query += """ WITH n, @@ -558,8 +558,8 @@ class MemoryForgetService: """ dist_params = {} - if group_id: - dist_params['group_id'] = group_id + if end_user_id: + dist_params['end_user_id'] = end_user_id distribution_results = await connector.execute_query(distribution_query, **dist_params) @@ -582,11 +582,11 @@ class MemoryForgetService: # 获取最近7个日期的历史趋势数据(每天取最后一次执行) recent_trends = [] try: - if group_id: + if end_user_id: # 查询所有历史记录 history_records = self.history_repository.get_recent_by_end_user( db=db, - end_user_id=group_id + end_user_id=end_user_id ) # 按日期分组(一天可能有多次执行,取最后一次) @@ -632,7 +632,7 @@ class MemoryForgetService: # 获取待遗忘节点列表(前20个满足遗忘条件的节点) pending_nodes = [] try: - if group_id: + if end_user_id: # 验证 min_days_since_access 配置值 min_days = config.get('min_days_since_access') if min_days is None or not isinstance(min_days, (int, float)) or min_days < 0: @@ -643,7 +643,7 @@ class MemoryForgetService: pending_nodes = await self._get_pending_forgetting_nodes( connector=connector, - group_id=group_id, + end_user_id=end_user_id, forgetting_threshold=forgetting_threshold, min_days_since_access=int(min_days), limit=20 diff --git a/api/app/services/memory_konwledges_server.py b/api/app/services/memory_konwledges_server.py index c6297e12..420f7ca1 100644 --- a/api/app/services/memory_konwledges_server.py +++ b/api/app/services/memory_konwledges_server.py @@ -450,12 +450,12 @@ async def create_document_chunk( return success(data=chunk, msg="文档块创建成功") -async def write_rag(group_id, message, user_rag_memory_id): +async def write_rag(end_user_id, message, user_rag_memory_id): """ 将消息写入 RAG 知识库 Args: - group_id: 组ID,用作文件标题 + end_user_id: 组ID,用作文件标题 message: 消息内容 user_rag_memory_id: 知识库ID(必须是有效的UUID) @@ -487,10 +487,10 @@ async def write_rag(group_id, message, user_rag_memory_id): db = next(db_gen) try: - create_data = CustomTextFileCreate(title=group_id, content=message) + create_data = CustomTextFileCreate(title=end_user_id, content=message) current_user = SimpleUser(user_rag_memory_id) # 检查文档是否已存在 - document = find_document_id_by_kb_and_filename(db=db, kb_id=user_rag_memory_id, file_name=f"{group_id}.txt") + document = find_document_id_by_kb_and_filename(db=db, kb_id=user_rag_memory_id, file_name=f"{end_user_id}.txt") print('======',document) api_logger.info(f"查找文档结果: document_id={document}") if document is not None: @@ -508,7 +508,7 @@ async def write_rag(group_id, message, user_rag_memory_id): return result else: # 文档不存在,创建新文档 - api_logger.info(f"文档不存在,创建新文档: group_id={group_id}") + api_logger.info(f"文档不存在,创建新文档: end_user_id={end_user_id}") result = await memory_konwledges_up( kb_id=user_rag_memory_id, parent_id=user_rag_memory_id, @@ -520,13 +520,13 @@ async def write_rag(group_id, message, user_rag_memory_id): new_document_id = find_document_id_by_kb_and_filename( db=db, kb_id=user_rag_memory_id, - file_name=f"{group_id}.txt" + file_name=f"{end_user_id}.txt" ) if new_document_id: await parse_document_by_id(new_document_id, db=db, current_user=current_user) else: - api_logger.error(f"创建文档后无法找到文档ID: group_id={group_id}") + api_logger.error(f"创建文档后无法找到文档ID: end_user_id={end_user_id}") return result finally: # 确保数据库会话被关闭 diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 83d5923d..05a84c01 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -183,7 +183,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, - "group_id": config.group_id, + "end_user_id": config.end_user_id, "user_id": config.user_id, "apply_id": config.apply_id, "llm_id": config.llm_id, @@ -391,7 +391,7 @@ _neo4j_connector = Neo4jConnector() async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_DIALOGUE, - group_id=end_user_id, + end_user_id=end_user_id, ) data = {"search_for": "dialogue", "num": result[0]["num"]} return data @@ -400,7 +400,7 @@ async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_CHUNK, - group_id=end_user_id, + end_user_id=end_user_id, ) data = {"search_for": "chunk", "num": result[0]["num"]} return data @@ -409,7 +409,7 @@ async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_STATEMENT, - group_id=end_user_id, + end_user_id=end_user_id, ) data = {"search_for": "statement", "num": result[0]["num"]} return data @@ -418,7 +418,7 @@ async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_ENTITY, - group_id=end_user_id, + end_user_id=end_user_id, ) data = {"search_for": "entity", "num": result[0]["num"]} return data @@ -427,7 +427,7 @@ async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_all(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_ALL, - group_id=end_user_id, + end_user_id=end_user_id, ) # 检查结果是否为空或长度不足 @@ -462,7 +462,7 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A """ result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_ALL, - group_id=end_user_id, + end_user_id=end_user_id, ) # 检查结果是否为空或长度不足 @@ -493,7 +493,7 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A async def search_detials(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_DETIALS, - group_id=end_user_id, + end_user_id=end_user_id, ) return result @@ -501,11 +501,32 @@ async def search_detials(end_user_id: Optional[str] = None) -> List[Dict[str, An async def search_edges(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( DataConfigRepository.SEARCH_FOR_EDGES, - group_id=end_user_id, + end_user_id=end_user_id, ) return result +async def search_entity_graph(end_user_id: Optional[str] = None) -> Dict[str, Any]: + """搜索所有实体之间的关系网络(group 维度)。""" + result = await _neo4j_connector.execute_query( + DataConfigRepository.SEARCH_FOR_ENTITY_GRAPH, + end_user_id=end_user_id, + ) + # 对source_node 和 target_node 的 fact_summary进行截取,只截取前三条的内容(需要提取前三条“来源”) + for item in result: + source_fact = item["sourceNode"]["fact_summary"] + target_fact = item["targetNode"]["fact_summary"] + # 截取前三条“来源” + item["sourceNode"]["fact_summary"] = source_fact.split("\n")[:4] if source_fact else [] + item["targetNode"]["fact_summary"] = target_fact.split("\n")[:4] if target_fact else [] + # 与现有返回风格保持一致,携带搜索类型、数量与详情 + data = { + "search_for": "entity_graph", + "num": len(result), + "detials": result, + } + return data + async def analytics_hot_memory_tags( db: Session, diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 17dfd7eb..755dda14 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -91,7 +91,7 @@ async def run_pilot_extraction( dialog = DialogData( context=context, ref_id="pilot_dialog_1", - group_id=str(memory_config.workspace_id), + end_user_id=str(memory_config.workspace_id), user_id=str(memory_config.tenant_id), apply_id=str(memory_config.config_id), metadata={"source": "pilot_run", "input_type": "frontend_text"}, diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 863bccb0..3a90a821 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -155,10 +155,10 @@ class MemoryInsightHelper: """ query = """ MATCH (d:Dialogue) - WHERE d.group_id = $group_id AND d.created_at IS NOT NULL AND d.created_at <> '' + WHERE d.end_user_id = $end_user_id AND d.created_at IS NOT NULL AND d.created_at <> '' RETURN d.created_at AS creation_time """ - records = await self.neo4j_connector.execute_query(query, group_id=self.user_id) + records = await self.neo4j_connector.execute_query(query, end_user_id=self.user_id) if not records: return [] @@ -211,17 +211,17 @@ class MemoryInsightHelper: async def get_social_connections(self) -> dict | None: """Find the user with whom the most memories are shared.""" query = """ - MATCH (c1:Chunk {group_id: $group_id}) + MATCH (c1:Chunk {end_user_id: $end_user_id}) OPTIONAL MATCH (c1)-[:CONTAINS]->(s:Statement) OPTIONAL MATCH (s)<-[:CONTAINS]-(c2:Chunk) - WHERE c1.group_id <> c2.group_id AND s IS NOT NULL AND c2 IS NOT NULL - WITH c2.group_id AS other_user_id, COUNT(DISTINCT s) AS common_statements + WHERE c1.end_user_id <> c2.end_user_id AND s IS NOT NULL AND c2 IS NOT NULL + WITH c2.end_user_id AS other_user_id, COUNT(DISTINCT s) AS common_statements WHERE common_statements > 0 RETURN other_user_id, common_statements ORDER BY common_statements DESC LIMIT 1 """ - records = await self.neo4j_connector.execute_query(query, group_id=self.user_id) + records = await self.neo4j_connector.execute_query(query, end_user_id=self.user_id) if not records or not records[0].get("other_user_id"): return None @@ -230,7 +230,7 @@ class MemoryInsightHelper: time_range_query = """ MATCH (c:Chunk) - WHERE c.group_id IN [$user_id, $other_user_id] + WHERE c.end_user_id IN [$user_id, $other_user_id] RETURN min(c.created_at) AS start_time, max(c.created_at) AS end_time """ time_records = await self.neo4j_connector.execute_query( @@ -294,11 +294,11 @@ class UserSummaryHelper: """Fetch recent statements authored by the user/group for context.""" query = ( "MATCH (s:Statement) " - "WHERE s.group_id = $group_id AND s.statement IS NOT NULL " + "WHERE s.end_user_id = $end_user_id AND s.statement IS NOT NULL " "RETURN s.statement AS statement, s.created_at AS created_at " "ORDER BY created_at DESC LIMIT $limit" ) - rows = await self.connector.execute_query(query, group_id=self.user_id, limit=limit) + rows = await self.connector.execute_query(query, end_user_id=self.user_id, limit=limit) records = [] for r in rows: try: @@ -1152,7 +1152,7 @@ async def analytics_user_summary(end_user_id: Optional[str] = None) -> Dict[str, import re # 创建 UserSummaryHelper 实例 - user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_GROUP_ID", "group_123")) + user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_end_user_id", "group_123")) try: # 1) 收集上下文数据 @@ -1273,10 +1273,10 @@ async def analytics_node_statistics( if end_user_id: query = f""" MATCH (n:{node_type}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await _neo4j_connector.execute_query(query, group_id=end_user_id) + result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) else: query = f""" MATCH (n:{node_type}) @@ -1387,10 +1387,10 @@ async def analytics_memory_types( # 查询 Statement 节点数量 query = """ MATCH (n:Statement) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await _neo4j_connector.execute_query(query, group_id=end_user_id) + result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) statement_count = result[0]["count"] if result and len(result) > 0 else 0 # 取三分之一作为隐性记忆数量 implicit_count = round(statement_count / 3) @@ -1504,7 +1504,7 @@ async def analytics_graph_data( 包含节点、边和统计信息的字典 """ try: - # 1. 获取 group_id + # 1. 获取 end_user_id user_uuid = uuid.UUID(end_user_id) repo = EndUserRepository(db) end_user = repo.get_by_id(user_uuid) @@ -1528,7 +1528,7 @@ async def analytics_graph_data( # 基于中心节点的扩展查询 node_query = f""" MATCH path = (center)-[*1..{depth}]-(connected) - WHERE center.group_id = $group_id + WHERE center.end_user_id = $end_user_id AND elementId(center) = $center_node_id WITH collect(DISTINCT center) + collect(DISTINCT connected) as all_nodes UNWIND all_nodes as n @@ -1539,7 +1539,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "center_node_id": center_node_id, "limit": limit } @@ -1547,7 +1547,7 @@ async def analytics_graph_data( # 按节点类型过滤查询 node_query = """ MATCH (n) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND labels(n)[0] IN $node_types RETURN elementId(n) as id, @@ -1556,7 +1556,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "node_types": node_types, "limit": limit } @@ -1564,7 +1564,7 @@ async def analytics_graph_data( # 查询所有节点 node_query = """ MATCH (n) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN elementId(n) as id, labels(n)[0] as label, @@ -1572,7 +1572,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "limit": limit } diff --git a/api/app/tasks.py b/api/app/tasks.py index fa9d1fdf..f4b5f78f 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -382,12 +382,12 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): @celery_app.task(name="app.core.memory.agent.read_message", bind=True) -def read_message_task(self, group_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: +def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a read message via MemoryAgentService. Args: - group_id: Group ID for the memory agent (also used as end_user_id) + end_user_id: Group ID for the memory agent (also used as end_user_id) message: User message to process history: Conversation history search_switch: Search switch parameter @@ -408,7 +408,7 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, from app.services.memory_agent_service import get_end_user_connected_config db = next(get_db()) try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) actual_config_id = connected_config.get("memory_config_id") finally: db.close() @@ -420,24 +420,42 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, db = next(get_db()) try: service = MemoryAgentService() - return await service.read_memory(group_id, message, history, search_switch, actual_config_id, db, storage_type, user_rag_memory_id) + return await service.read_memory(end_user_id, message, history, search_switch, actual_config_id, db, storage_type, user_rag_memory_id) finally: db.close() try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time return { "status": "SUCCESS", "result": result, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id } except BaseException as e: elapsed_time = time.time() - start_time + # Handle ExceptionGroup from TaskGroup if hasattr(e, 'exceptions'): error_messages = [f"{type(sub_e).__name__}: {str(sub_e)}" for sub_e in e.exceptions] detailed_error = "; ".join(error_messages) @@ -446,7 +464,7 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, return { "status": "FAILURE", "error": detailed_error, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id @@ -454,19 +472,13 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, @celery_app.task(name="app.core.memory.agent.write_message", bind=True) -def write_message_task(self, group_id: str, message, config_id: str, storage_type: str, user_rag_memory_id: str) -> Dict[str, Any]: +def write_message_task(self, end_user_id: str, message: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a write message via MemoryAgentService. - 支持两种消息格式: - 1. 字符串格式(向后兼容):message="user: xxx\nassistant: yyy" - 2. 结构化消息列表(推荐):message=[{"role": "user", "content": "xxx"}, {"role": "assistant", "content": "yyy"}] - Args: - group_id: Group ID for the memory agent (also used as end_user_id) - message: Message to write (str or list[dict]) + end_user_id: Group ID for the memory agent (also used as end_user_id) + message: Message to write config_id: Optional configuration ID - storage_type: Storage type (neo4j/rag) - user_rag_memory_id: RAG memory ID Returns: Dict containing the result and metadata @@ -477,7 +489,7 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ from app.core.logging_config import get_logger logger = get_logger(__name__) - logger.info(f"[CELERY WRITE] Starting write task - group_id={group_id}, config_id={config_id}, storage_type={storage_type}") + logger.info(f"[CELERY WRITE] Starting write task - end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") start_time = time.time() # Resolve config_id if None @@ -487,7 +499,7 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ from app.services.memory_agent_service import get_end_user_connected_config db = next(get_db()) try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) actual_config_id = connected_config.get("memory_config_id") finally: db.close() @@ -500,7 +512,7 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ try: logger.info(f"[CELERY WRITE] Executing MemoryAgentService.write_memory") service = MemoryAgentService() - result = await service.write_memory(group_id, message, actual_config_id, db, storage_type, user_rag_memory_id) + result = await service.write_memory(end_user_id, message, actual_config_id, db, storage_type, user_rag_memory_id) logger.info(f"[CELERY WRITE] Write completed successfully: {result}") return result except Exception as e: @@ -510,7 +522,24 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ db.close() try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time logger.info(f"[CELERY WRITE] Task completed successfully - elapsed_time={elapsed_time:.2f}s, task_id={self.request.id}") @@ -518,13 +547,14 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ return { "status": "SUCCESS", "result": result, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id } except BaseException as e: elapsed_time = time.time() - start_time + # Handle ExceptionGroup from TaskGroup if hasattr(e, 'exceptions'): error_messages = [f"{type(sub_e).__name__}: {str(sub_e)}" for sub_e in e.exceptions] detailed_error = "; ".join(error_messages) @@ -536,7 +566,7 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ return { "status": "FAILURE", "error": detailed_error, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id @@ -564,53 +594,53 @@ def reflection_timer_task() -> None: """ reflection_engine() -# unused task -# @celery_app.task(name="app.core.memory.agent.health.check_read_service") -# def check_read_service_task() -> Dict[str, str]: -# """Call read_service and write latest status to Redis. + +@celery_app.task(name="app.core.memory.agent.health.check_read_service") +def check_read_service_task() -> Dict[str, str]: + """Call read_service and write latest status to Redis. -# Returns status data dict that gets written to Redis. -# """ -# client = redis.Redis( -# host=settings.REDIS_HOST, -# port=settings.REDIS_PORT, -# db=settings.REDIS_DB, -# password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None -# ) -# try: -# api_url = f"http://{settings.SERVER_IP}:8000/api/memory/read_service" -# payload = { -# "user_id": "健康检查", -# "apply_id": "健康检查", -# "group_id": "健康检查", -# "message": "你好", -# "history": [], -# "search_switch": "2", -# } -# resp = requests.post(api_url, json=payload, timeout=15) -# ok = resp.status_code == 200 -# status = "Success" if ok else "Fail" -# msg = "接口请求成功" if ok else f"接口请求失败: {resp.status_code}" -# error = "" if ok else resp.text -# code = 0 if ok else 500 -# except Exception as e: -# status = "Fail" -# msg = "接口请求失败" -# error = str(e) -# code = 500 + Returns status data dict that gets written to Redis. + """ + client = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB, + password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None + ) + try: + api_url = f"http://{settings.SERVER_IP}:8000/api/memory/read_service" + payload = { + "user_id": "健康检查", + "apply_id": "健康检查", + "end_user_id": "健康检查", + "message": "你好", + "history": [], + "search_switch": "2", + } + resp = requests.post(api_url, json=payload, timeout=15) + ok = resp.status_code == 200 + status = "Success" if ok else "Fail" + msg = "接口请求成功" if ok else f"接口请求失败: {resp.status_code}" + error = "" if ok else resp.text + code = 0 if ok else 500 + except Exception as e: + status = "Fail" + msg = "接口请求失败" + error = str(e) + code = 500 -# data = { -# "status": status, -# "msg": msg, -# "error": error, -# "code": str(code), -# "time": str(int(time.time())), -# } + data = { + "status": status, + "msg": msg, + "error": error, + "code": str(code), + "time": str(int(time.time())), + } -# client.hset("memsci:health:read_service", mapping=data) -# client.expire("memsci:health:read_service", int(settings.HEALTH_CHECK_SECONDS)) + client.hset("memsci:health:read_service", mapping=data) + client.expire("memsci:health:read_service", int(settings.HEALTH_CHECK_SECONDS)) -# return data + return data @celery_app.task(name="app.controllers.memory_storage_controller.search_all") @@ -875,7 +905,24 @@ def regenerate_memory_cache(self) -> Dict[str, Any]: } try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time result["elapsed_time"] = elapsed_time result["task_id"] = self.request.id @@ -1002,7 +1049,24 @@ def workspace_reflection_task(self) -> Dict[str, Any]: } try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time result["elapsed_time"] = elapsed_time result["task_id"] = self.request.id @@ -1048,7 +1112,7 @@ def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str # 运行遗忘周期 report = await forget_service.trigger_forgetting( db=db, - group_id=None, # 处理所有组 + end_user_id=None, # 处理所有组 config_id=config_id ) @@ -1078,4 +1142,11 @@ def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str "duration_seconds": duration } - return asyncio.run(_run()) + # 运行异步函数 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(_run()) + return result + finally: + loop.close() From f0efed8aa1b5ec135f61201823bc643c9853c4e5 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 20:33:22 +0800 Subject: [PATCH 029/175] =?UTF-8?q?=E6=8A=8Agroup=5Fid=E6=9B=BF=E6=8D=A2en?= =?UTF-8?q?d=5Fuser=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_agent_controller.py | 6 +- .../langgraph_graph/nodes/write_nodes.py | 2 +- .../core/memory/agent/utils/write_tools.py | 17 ++- api/app/services/memory_agent_service.py | 128 +++++++++++------- 4 files changed, 100 insertions(+), 53 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index a1337085..ad5e3048 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -164,7 +164,7 @@ async def write_server( try: result = await memory_agent_service.write_memory( user_input.end_user_id, - user_input.message, + user_input.messages, config_id, db, storage_type, @@ -290,7 +290,7 @@ async def read_server( ) if str(user_input.search_switch) == "2": retrieve_info = result['answer'] - history = await SessionService(store).get_history(user_input.group_id, user_input.group_id, user_input.group_id) + history = await SessionService(store).get_history(user_input.end_user_id, user_input.end_user_id, user_input.end_user_id) query = user_input.message # 调用 memory_agent_service 的方法生成最终答案 @@ -596,7 +596,7 @@ async def status_type( last_user_message = " ".join([msg.get('content', '') for msg in messages_list]) result = await memory_agent_service.classify_message_type( - user_input.message, + user_input.messages, user_input.config_id, db ) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py index e2a61045..1dab1b0a 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py @@ -21,9 +21,9 @@ async def write_node(state: WriteState) -> WriteState: memory_config=state.get('memory_config', '') try: result=await write( - content=content, end_user_id=end_user_id, memory_config=memory_config, + messages=content, # 修复:使用正确的参数名 messages ) logger.info(f"Write completed successfully! Config: {memory_config.config_name}") diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index ce55286e..b8bc58eb 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -77,10 +77,25 @@ async def write( # Step 1: Load and chunk data step_start = time.time() + + # Convert messages list to content string + # messages format: [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...] + if isinstance(messages, list) and len(messages) > 0: + # Extract content from the last user message or concatenate all messages + if isinstance(messages[-1], dict) and 'content' in messages[-1]: + content = messages[-1]['content'] + else: + # Fallback: concatenate all message contents + content = " ".join([msg.get('content', '') for msg in messages if isinstance(msg, dict)]) + elif isinstance(messages, str): + content = messages + else: + content = str(messages) + chunked_dialogs = await get_chunked_dialogs( chunker_strategy=chunker_strategy, end_user_id=end_user_id, - messages=messages, + content=content, # 修复:使用 content 参数而不是 messages ref_id=ref_id, config_id=config_id, ) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index e475bef0..8a0d5a39 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -36,6 +36,7 @@ from app.services.memory_config_service import MemoryConfigService from app.services.memory_konwledges_server import ( write_rag, ) +from langchain_core.messages import AIMessage from langchain_core.messages import HumanMessage from pydantic import BaseModel, Field from sqlalchemy import func @@ -57,7 +58,6 @@ class MemoryAgentService: def writer_messages_deal(self, messages, start_time, end_user_id, config_id, message, context): duration = time.time() - start_time - if str(messages) == 'success': logger.info(f"Write operation successful for group {end_user_id} with config_id {config_id}") # 记录成功的操作 @@ -266,7 +266,7 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, end_user_id: str, message: str, config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, messages: str, config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id @@ -319,53 +319,85 @@ class MemoryAgentService: raise ValueError(error_msg) - try: - if storage_type == "rag": - # For RAG storage, convert messages to single string - message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - result = await write_rag(end_user_id, message_text, user_rag_memory_id) - return result - else: - async with make_write_graph() as graph: - config = {"configurable": {"thread_id": end_user_id}} - # Convert structured messages to LangChain messages - langchain_messages = [] - for msg in messages: - if msg['role'] == 'user': - langchain_messages.append(HumanMessage(content=msg['content'])) - elif msg['role'] == 'assistant': - from langchain_core.messages import AIMessage - langchain_messages.append(AIMessage(content=msg['content'])) - - # 初始状态 - 包含所有必要字段 - initial_state = { - "messages": langchain_messages, - "end_user_id": end_user_id, - "memory_config": memory_config - } + async with make_write_graph() as graph: + config = {"configurable": {"thread_id": end_user_id}} + # Convert structured messages to LangChain messages + langchain_messages = [] + for msg in messages: + if msg['role'] == 'user': + langchain_messages.append(HumanMessage(content=msg['content'])) + elif msg['role'] == 'assistant': + langchain_messages.append(AIMessage(content=msg['content'])) - # 获取节点更新信息 - async for update_event in graph.astream( - initial_state, - stream_mode="updates", - config=config - ): - for node_name, node_data in update_event.items(): - if 'save_neo4j' == node_name: - massages = node_data - massagesstatus = massages.get('write_result')['status'] - contents = massages.get('write_result') - # Convert messages back to string for logging - message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) - except Exception as e: - # Ensure proper error handling and logging - error_msg = f"Write operation failed: {str(e)}" - logger.error(error_msg) - if audit_logger: - duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) - raise ValueError(error_msg) + # 初始状态 - 包含所有必要字段 + initial_state = { + "messages": langchain_messages, + "end_user_id": end_user_id, + "memory_config": memory_config + } + + # 获取节点更新信息 + async for update_event in graph.astream( + initial_state, + stream_mode="updates", + config=config + ): + for node_name, node_data in update_event.items(): + if 'save_neo4j' == node_name: + massages = node_data + print(massages) + massagesstatus = massages.get('write_result')['status'] + contents = massages.get('write_result') + # Convert messages back to string for logging + message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) + return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) + + # try: + # if storage_type == "rag": + # # For RAG storage, convert messages to single string + # message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) + # result = await write_rag(end_user_id, message_text, user_rag_memory_id) + # return result + # else: + # async with make_write_graph() as graph: + # config = {"configurable": {"thread_id": end_user_id}} + # # Convert structured messages to LangChain messages + # langchain_messages = [] + # for msg in messages: + # if msg['role'] == 'user': + # langchain_messages.append(HumanMessage(content=msg['content'])) + # elif msg['role'] == 'assistant': + # langchain_messages.append(AIMessage(content=msg['content'])) + # + # # 初始状态 - 包含所有必要字段 + # initial_state = { + # "messages": langchain_messages, + # "end_user_id": end_user_id, + # "memory_config": memory_config + # } + # + # # 获取节点更新信息 + # async for update_event in graph.astream( + # initial_state, + # stream_mode="updates", + # config=config + # ): + # for node_name, node_data in update_event.items(): + # if 'save_neo4j' == node_name: + # massages = node_data + # massagesstatus = massages.get('write_result')['status'] + # contents = massages.get('write_result') + # # Convert messages back to string for logging + # message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) + # return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) + # except Exception as e: + # # Ensure proper error handling and logging + # error_msg = f"Write operation failed: {str(e)}" + # logger.error(error_msg) + # if audit_logger: + # duration = time.time() - start_time + # audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) + # raise ValueError(error_msg) From 4aeec8afbf43aff6777c7c0b2ca57e13d26fb431 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 21 Jan 2026 20:37:39 +0800 Subject: [PATCH 030/175] =?UTF-8?q?=E6=8A=8Agroup=5Fid=E6=9B=BF=E6=8D=A2en?= =?UTF-8?q?d=5Fuser=5Fid=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 8a0d5a39..d08cf466 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -640,6 +640,7 @@ class MemoryAgentService: ) raise ValueError(error_msg) + def get_messages_list(self, user_input: Write_UserInput) -> list[dict]: """ Get standardized message list from user input. From c4039f52bdd8300aee367c4611f7baa859a92004 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 12:12:41 +0800 Subject: [PATCH 031/175] =?UTF-8?q?=E6=8A=8Agroup=5Fid=E6=9B=BF=E6=8D=A2en?= =?UTF-8?q?d=5Fuser=5Fid=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/core/memory/src/search.py | 61 +------------------------------ 1 file changed, 1 insertion(+), 60 deletions(-) diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 345cd69b..ef1e615c 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -1020,63 +1020,4 @@ async def search_chunk_by_chunk_id( ) return {"chunks": chunks} -if __name__ == '__main__': - # 测试混合检索功能 - from app.schemas.memory_config_schema import MemoryConfig - from app.db import get_db - from app.services.memory_config_service import MemoryConfigService - - # 从数据库获取真实配置 - db = next(get_db()) - try: - config_service = MemoryConfigService(db) - - # 使用 config_id=17 获取配置 - memory_config = config_service.load_memory_config(config_id=17) - - if not memory_config: - print("错误:找不到 config_id=17 的配置") - print("请先在数据库中创建配置,或修改 config_id") - exit(1) - - print(f"✓ 成功加载配置: {memory_config.config_name}") - print(f" - Workspace: {memory_config.workspace_name}") - print(f" - LLM Model: {memory_config.llm_model_name}") - print(f" - Embedding Model: {memory_config.embedding_model_name}") - print(f" - Storage Type: {memory_config.storage_type}") - print() - - # 修改这里的参数进行测试 - test_end_user_id = "021886bc-fab9-4fd5-b607-497b262e0381" # 修改为你的 end_user_id - test_query = "小明擅长什么?" # 修改为你的查询 - - print(f"开始测试检索...") - print(f" - Query: {test_query}") - print(f" - End User ID: {test_end_user_id}") - print(f" - Search Type: hybrid") - print() - - results = asyncio.run(run_hybrid_search( - query_text=test_query, - search_type="hybrid", # 可选: "keyword", "embedding", "hybrid" - end_user_id=test_end_user_id, - limit=10, - include=["statements", "entities", "chunks", "summaries"], - output_path=None, - memory_config=memory_config, - rerank_alpha=0.6, - use_forgetting_rerank=False, - use_llm_rerank=False - )) - - print("=" * 80) - print("检索结果:") - print("=" * 80) - print(results) - - except Exception as e: - print(f"错误: {e}") - import traceback - traceback.print_exc() - finally: - db.close() + From 1c7fe6d13487650b5803e7f12bcd9fdf844bc488 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 14:59:01 +0800 Subject: [PATCH 032/175] =?UTF-8?q?config=5Fconfig=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=88=90memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory_reflection_controller.py | 36 ++-- api/app/core/memory/src/search.py | 56 ++++++ .../extraction_orchestrator.py | 18 +- .../forgetting_engine/config_utils.py | 6 +- .../forgetting_engine/forgetting_strategy.py | 4 +- api/app/core/workflow/nodes/memory/node.py | 2 +- api/app/models/__init__.py | 4 +- api/app/models/data_config_model.py | 88 --------- api/app/models/memory_config_model.py | 119 ++++++++---- ...ository.py => memory_config_repository.py} | 172 +++++++++--------- api/app/schemas/app_schema.py | 12 ++ api/app/schemas/model_schema.py | 14 +- api/app/schemas/release_share_schema.py | 14 +- api/app/services/emotion_config_service.py | 10 +- .../services/emotion_extraction_service.py | 4 +- api/app/services/memory_agent_service.py | 2 +- api/app/services/memory_config_service.py | 4 +- api/app/services/memory_forget_service.py | 4 +- api/app/services/memory_reflection_service.py | 50 ++--- api/app/services/memory_storage_service.py | 30 +-- api/app/utils/app_config_utils.py | 23 +++ 21 files changed, 374 insertions(+), 298 deletions(-) delete mode 100644 api/app/models/data_config_model.py rename api/app/repositories/{data_config_repository.py => memory_config_repository.py} (78%) diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index abd50a33..f17fcf7f 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -11,7 +11,7 @@ from app.core.response_utils import success from app.db import get_db from app.dependencies import get_current_user from app.models.user_model import User -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_reflection_schemas import Memory_Reflection from app.services.memory_reflection_service import ( @@ -50,7 +50,7 @@ async def save_reflection_config( api_logger.info(f"用户 {current_user.username} 保存反思配置,config_id: {config_id}") - data_config = DataConfigRepository.update_reflection_config( + memory_config = MemoryConfigRepository.update_reflection_config( db, config_id=config_id, enable_self_reflexion=request.reflection_enabled, @@ -63,17 +63,17 @@ async def save_reflection_config( ) db.commit() - db.refresh(data_config) + db.refresh(memory_config) reflection_result={ - "config_id": data_config.config_id, - "enable_self_reflexion": data_config.enable_self_reflexion, - "iteration_period": data_config.iteration_period, - "reflexion_range": data_config.reflexion_range, - "baseline": data_config.baseline, - "reflection_model_id": data_config.reflection_model_id, - "memory_verify": data_config.memory_verify, - "quality_assessment": data_config.quality_assessment} + "config_id": memory_config.config_id, + "enable_self_reflexion": memory_config.enable_self_reflexion, + "iteration_period": memory_config.iteration_period, + "reflexion_range": memory_config.reflexion_range, + "baseline": memory_config.baseline, + "reflection_model_id": memory_config.reflection_model_id, + "memory_verify": memory_config.memory_verify, + "quality_assessment": memory_config.quality_assessment} return success(data=reflection_result, msg="反思配置成功") @@ -111,14 +111,14 @@ async def start_workspace_reflection( reflection_results = [] for data in result['apps_detailed_info']: - if data['data_configs'] == []: + if data['memory_configs'] == []: continue releases = data['releases'] - data_configs = data['data_configs'] + memory_configs = data['memory_configs'] end_users = data['end_users'] - for base, config, user in zip(releases, data_configs, end_users): + for base, config, user in zip(releases, memory_configs, end_users): # 安全地转换为整数,处理空字符串和None的情况 print(base['config']) try: @@ -160,10 +160,10 @@ async def start_reflection_configs( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: - """通过config_id查询data_config表中的反思配置信息""" + """通过config_id查询memory_config表中的反思配置信息""" try: api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - result = DataConfigRepository.query_reflection_config_by_id(db, config_id) + result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) # 构建返回数据 reflection_config = { "config_id": result.config_id, @@ -200,8 +200,8 @@ async def reflection_run( api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - # 使用DataConfigRepository查询反思配置 - result = DataConfigRepository.query_reflection_config_by_id(db, config_id) + # 使用MemoryConfigRepository查询反思配置 + result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index ef1e615c..87a5dd6f 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -1021,3 +1021,59 @@ async def search_chunk_by_chunk_id( return {"chunks": chunks} +if __name__ == '__main__': + # 测试混合检索功能 + from app.schemas.memory_config_schema import MemoryConfig + from app.db import get_db + from app.services.memory_config_service import MemoryConfigService + + # 从数据库获取真实配置 + db = next(get_db()) + try: + config_service = MemoryConfigService(db) + + # 使用 config_id=17 获取配置 + memory_config = config_service.load_memory_config(config_id=17) + + if not memory_config: + print("错误:找不到 config_id=17 的配置") + print("请先在数据库中创建配置,或修改 config_id") + exit(1) + + print(f"✓ 成功加载配置: {memory_config.config_name}") + print(f" - Workspace: {memory_config.workspace_name}") + print(f" - LLM Model: {memory_config.llm_model_name}") + print(f" - Embedding Model: {memory_config.embedding_model_name}") + print(f" - Storage Type: {memory_config.storage_type}") + print() + + # 修改这里的参数进行测试 + test_end_user_id = "021886bc-fab9-4fd5-b607-497b262e0381" # 修改为你的 end_user_id + test_query = "小明擅长什么?" # 修改为你的查询 + + print(f"开始测试检索...") + print(f" - Query: {test_query}") + print(f" - End User ID: {test_end_user_id}") + print(f" - Search Type: hybrid") + print() + + results = asyncio.run(run_hybrid_search( + query_text=test_query, + search_type="hybrid", # 可选: "keyword", "embedding", "hybrid" + end_user_id=test_end_user_id, + limit=10, + include=["statements", "entities", "chunks", "summaries"], + output_path=None, + memory_config=memory_config, + rerank_alpha=0.6, + use_forgetting_rerank=False, + use_llm_rerank=False + )) + + except Exception as e: + print(f"错误: {e}") + import traceback + + traceback.print_exc() + finally: + db.close() \ No newline at end of file diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index c2c5d54e..8c69c7cf 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -569,32 +569,32 @@ class ExtractionOrchestrator: if dialog_data_list and hasattr(dialog_data_list[0], 'config_id'): config_id = dialog_data_list[0].config_id - # 加载DataConfig - data_config = None + # 加载MemoryConfig + memory_config = None if config_id: try: from app.db import SessionLocal - from app.repositories.data_config_repository import DataConfigRepository + from app.repositories.memory_config_repository import MemoryConfigRepository db = SessionLocal() try: - data_config = DataConfigRepository.get_by_id(db, config_id) + memory_config = MemoryConfigRepository.get_by_id(db, config_id) finally: db.close() - if data_config and not data_config.emotion_enabled: + if memory_config and not memory_config.emotion_enabled: logger.info("情绪提取已在配置中禁用,跳过情绪提取") return [{} for _ in dialog_data_list] except Exception as e: - logger.warning(f"加载DataConfig失败: {e},将跳过情绪提取") + logger.warning(f"加载MemoryConfig失败: {e},将跳过情绪提取") return [{} for _ in dialog_data_list] else: logger.info("未找到config_id,跳过情绪提取") return [{} for _ in dialog_data_list] # 如果配置未启用情绪提取,直接返回空映射 - if not data_config or not data_config.emotion_enabled: + if not memory_config or not memory_config.emotion_enabled: logger.info("情绪提取未启用,跳过") return [{} for _ in dialog_data_list] @@ -608,7 +608,7 @@ class ExtractionOrchestrator: total_statements += 1 # 只处理用户的陈述句 (role 为 "user") if hasattr(statement, 'speaker') and statement.speaker == "user": - all_statements.append((statement, data_config)) + all_statements.append((statement, memory_config)) statement_metadata.append((d_idx, statement.id)) filtered_statements += 1 @@ -617,7 +617,7 @@ class ExtractionOrchestrator: # 初始化情绪提取服务 from app.services.emotion_extraction_service import EmotionExtractionService emotion_service = EmotionExtractionService( - llm_id=data_config.emotion_model_id if data_config.emotion_model_id else None + llm_id=memory_config.emotion_model_id if memory_config.emotion_model_id else None ) # 全局并行处理所有陈述句 diff --git a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py index ea9a6358..663c89f9 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py +++ b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py @@ -13,7 +13,7 @@ import logging from typing import Optional, Dict, Any from sqlalchemy.orm import Session -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.core.memory.storage_services.forgetting_engine.actr_calculator import ACTRCalculator @@ -66,7 +66,7 @@ def load_actr_config_from_db( """ 从数据库加载 ACT-R 配置参数 - 从 PostgreSQL 的 data_config 表读取配置参数, + 从 PostgreSQL 的 memory_config 表读取配置参数, 并计算派生参数(如 forgetting_rate)。 Args: @@ -99,7 +99,7 @@ def load_actr_config_from_db( # 从数据库加载配置 try: - repository = DataConfigRepository() + repository = MemoryConfigRepository() db_config = repository.get_by_id(db, config_id) if db_config is None: diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py index 6b2d9e99..cde9e115 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py @@ -539,11 +539,11 @@ class ForgettingStrategy: LLM 客户端实例,如果无法获取则返回 None """ try: - from app.repositories.data_config_repository import DataConfigRepository + from app.repositories.memory_config_repository import MemoryConfigRepository from app.core.memory.utils.llm.llm_utils import MemoryClientFactory # 从数据库读取配置 - repository = DataConfigRepository() + repository = MemoryConfigRepository() db_config = repository.get_by_id(db, config_id) if db_config is None or db_config.llm_id is None: diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index 08a2b280..0589cc82 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -22,7 +22,7 @@ class MemoryReadNode(BaseNode): raise RuntimeError("End user id is required") return await MemoryAgentService().read_memory( - group_id=end_user_id, + end_user_id=end_user_id, message=self._render_template(self.typed_config.message, state), config_id=str(self.typed_config.config_id), search_switch=self.typed_config.search_switch, diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index bf3a1b3d..e069b40d 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -18,7 +18,7 @@ from .appshare_model import AppShare from .release_share_model import ReleaseShare from .conversation_model import Conversation, Message from .api_key_model import ApiKey, ApiKeyLog, ApiKeyType -from .data_config_model import DataConfig +from .memory_config_model import MemoryConfig from .multi_agent_model import MultiAgentConfig, AgentInvocation from .workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution from .retrieval_info import RetrievalInfo @@ -57,7 +57,7 @@ __all__ = [ "ApiKey", "ApiKeyLog", "ApiKeyType", - "DataConfig", + "MemoryConfig", "MultiAgentConfig", "AgentInvocation", "WorkflowConfig", diff --git a/api/app/models/data_config_model.py b/api/app/models/data_config_model.py deleted file mode 100644 index 06f87cb2..00000000 --- a/api/app/models/data_config_model.py +++ /dev/null @@ -1,88 +0,0 @@ -import datetime -from sqlalchemy import Column, String, Boolean, DateTime, Integer, Float -from sqlalchemy.dialects.postgresql import UUID -from app.db import Base - - -class DataConfig(Base): - """数据配置表 - 用于存储记忆系统的配置参数""" - __tablename__ = "data_config" - - # 主键 - config_id = Column(Integer, primary_key=True, autoincrement=True, comment="配置ID") - - # 基本信息 - config_name = Column(String, nullable=False, comment="配置名称") - config_desc = Column(String, nullable=True, comment="配置描述") - - # 组织信息 - workspace_id = Column(UUID(as_uuid=True), nullable=True, comment="工作空间ID") - group_id = Column(String, nullable=True, comment="组ID") - user_id = Column(String, nullable=True, comment="用户ID") - apply_id = Column(String, nullable=True, comment="应用ID") - - # 模型选择(从workspace继承) - llm_id = Column(String, nullable=True, comment="LLM模型配置ID") - embedding_id = Column(String, nullable=True, comment="嵌入模型配置ID") - rerank_id = Column(String, nullable=True, comment="重排序模型配置ID") - - # 记忆萃取引擎配置 - enable_llm_dedup_blockwise = Column(Boolean, default=True, comment="启用LLM决策去重") - enable_llm_disambiguation = Column(Boolean, default=True, comment="启用LLM决策消歧") - deep_retrieval = Column(Boolean, default=True, comment="深度检索开关") - - # 阈值配置 (0-1 之间的浮点数) - t_type_strict = Column(Float, default=0.8, comment="类型严格阈值") - t_name_strict = Column(Float, default=0.8, comment="名称严格阈值") - t_overall = Column(Float, default=0.8, comment="综合阈值") - - # 状态配置 - state = Column(Boolean, default=False, comment="配置使用状态") - - # 分块策略 - chunker_strategy = Column(String, default="RecursiveChunker", comment="分块策略") - - # 剪枝配置 - pruning_enabled = Column(Boolean, default=False, comment="是否启动智能语义剪枝") - pruning_scene = Column(String, nullable=True, comment="智能剪枝场景:education/online_service/outbound") - pruning_threshold = Column(Float, nullable=True, comment="智能语义剪枝阈值(0-0.9)") - - # 自我反思配置 - enable_self_reflexion = Column(Boolean, default=False, comment="是否启用自我反思") - iteration_period = Column(String, default="3", comment="反思迭代周期") - reflexion_range = Column(String, default="partial", comment="反思范围:部分/全部") - baseline = Column(String, default="TIME", comment="基线:时间/事实/时间和事实") - reflection_model_id = Column(String, nullable=True, comment="反思模型ID") - memory_verify = Column(Boolean, default=True, comment="记忆验证") - quality_assessment = Column(Boolean, default=True, comment="质量评估") - - # 遗忘引擎配置 - statement_granularity = Column(Integer, default=2, comment="陈述提取颗粒度,挡位 1/2/3") - include_dialogue_context = Column(Boolean, default=False, comment="是否包含对话上下文") - max_context = Column(Integer, default=1000, comment="对话语境中包含字符的最大数量") - lambda_time = Column("lambda_time", Float, default=0.5, comment="最低保持度,0-1 小数") - lambda_mem = Column("lambda_mem", Float, default=0.5, comment="遗忘率,0-1 小数") - offset = Column("offset", Float, default=0.0, comment="偏移度,0-1 小数") - - # ACT-R 遗忘引擎配置 - decay_constant = Column(Float, default=0.5, comment="ACT-R衰减常数d,默认0.5") - forgetting_threshold = Column(Float, default=0.3, comment="遗忘阈值,默认0.3") - forgetting_interval_hours = Column(Integer, default=24, comment="遗忘周期间隔(小时),默认24") - enable_llm_summary = Column(Boolean, default=True, comment="是否使用LLM生成摘要,默认True") - max_merge_batch_size = Column(Integer, default=100, comment="单次最大融合节点对数,默认100") - max_history_length = Column(Integer, default=100, comment="访问历史最大长度,默认100") - min_days_since_access = Column(Integer, default=30, comment="最小未访问天数,默认30") - - # 情绪引擎配置 - emotion_enabled = Column(Boolean, default=True, comment="是否启用情绪提取") - emotion_model_id = Column(String, nullable=True, comment="情绪分析专用模型ID") - emotion_extract_keywords = Column(Boolean, default=True, comment="是否提取情绪关键词") - emotion_min_intensity = Column(Float, default=0.1, comment="最小情绪强度阈值") - emotion_enable_subject = Column(Boolean, default=True, comment="是否启用主体分类") - - # 时间戳 - created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间") - updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间") - - def __repr__(self): - return f"" diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index d47c3b52..55b377e6 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -1,39 +1,88 @@ -# -*- coding: utf-8 -*- -"""Memory Configuration Model - Backward Compatibility +import datetime +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Float +from sqlalchemy.dialects.postgresql import UUID +from app.db import Base -This module provides backward compatibility for imports. -All classes have been moved to app.schemas.memory_config_schema. -DEPRECATED: Import from app.schemas.memory_config_schema instead. -""" +class MemoryConfig(Base): + """记忆配置表 - 用于存储记忆系统的配置参数""" + __tablename__ = "memory_config" -# Re-export for backward compatibility -from app.schemas.memory_config_schema import ( - ConfigurationError, - InvalidConfigError, - MemoryConfig, - MemoryConfigValidation, - ModelInactiveError, - ModelNotFoundError, - ModelValidation, - WorkspaceNotFoundError, - WorkspaceValidation, - validate_memory_config_data, - validate_model_data, - validate_workspace_data, -) + # 主键 + config_id = Column(Integer, primary_key=True, autoincrement=True, comment="配置ID") -__all__ = [ - "ConfigurationError", - "InvalidConfigError", - "MemoryConfig", - "MemoryConfigValidation", - "ModelInactiveError", - "ModelNotFoundError", - "ModelValidation", - "WorkspaceNotFoundError", - "WorkspaceValidation", - "validate_memory_config_data", - "validate_model_data", - "validate_workspace_data", -] + # 基本信息 + config_name = Column(String, nullable=False, comment="配置名称") + config_desc = Column(String, nullable=True, comment="配置描述") + + # 组织信息 + workspace_id = Column(UUID(as_uuid=True), nullable=True, comment="工作空间ID") + group_id = Column(String, nullable=True, comment="组ID") + user_id = Column(String, nullable=True, comment="用户ID") + apply_id = Column(String, nullable=True, comment="应用ID") + + # 模型选择(从workspace继承) + llm_id = Column(String, nullable=True, comment="LLM模型配置ID") + embedding_id = Column(String, nullable=True, comment="嵌入模型配置ID") + rerank_id = Column(String, nullable=True, comment="重排序模型配置ID") + + # 记忆萃取引擎配置 + enable_llm_dedup_blockwise = Column(Boolean, default=True, comment="启用LLM决策去重") + enable_llm_disambiguation = Column(Boolean, default=True, comment="启用LLM决策消歧") + deep_retrieval = Column(Boolean, default=True, comment="深度检索开关") + + # 阈值配置 (0-1 之间的浮点数) + t_type_strict = Column(Float, default=0.8, comment="类型严格阈值") + t_name_strict = Column(Float, default=0.8, comment="名称严格阈值") + t_overall = Column(Float, default=0.8, comment="综合阈值") + + # 状态配置 + state = Column(Boolean, default=False, comment="配置使用状态") + + # 分块策略 + chunker_strategy = Column(String, default="RecursiveChunker", comment="分块策略") + + # 剪枝配置 + pruning_enabled = Column(Boolean, default=False, comment="是否启动智能语义剪枝") + pruning_scene = Column(String, nullable=True, comment="智能剪枝场景:education/online_service/outbound") + pruning_threshold = Column(Float, nullable=True, comment="智能语义剪枝阈值(0-0.9)") + + # 自我反思配置 + enable_self_reflexion = Column(Boolean, default=False, comment="是否启用自我反思") + iteration_period = Column(String, default="3", comment="反思迭代周期") + reflexion_range = Column(String, default="partial", comment="反思范围:部分/全部") + baseline = Column(String, default="TIME", comment="基线:时间/事实/时间和事实") + reflection_model_id = Column(String, nullable=True, comment="反思模型ID") + memory_verify = Column(Boolean, default=True, comment="记忆验证") + quality_assessment = Column(Boolean, default=True, comment="质量评估") + + # 遗忘引擎配置 + statement_granularity = Column(Integer, default=2, comment="陈述提取颗粒度,挡位 1/2/3") + include_dialogue_context = Column(Boolean, default=False, comment="是否包含对话上下文") + max_context = Column(Integer, default=1000, comment="对话语境中包含字符的最大数量") + lambda_time = Column("lambda_time", Float, default=0.5, comment="最低保持度,0-1 小数") + lambda_mem = Column("lambda_mem", Float, default=0.5, comment="遗忘率,0-1 小数") + offset = Column("offset", Float, default=0.0, comment="偏移度,0-1 小数") + + # ACT-R 遗忘引擎配置 + decay_constant = Column(Float, default=0.5, comment="ACT-R衰减常数d,默认0.5") + forgetting_threshold = Column(Float, default=0.3, comment="遗忘阈值,默认0.3") + forgetting_interval_hours = Column(Integer, default=24, comment="遗忘周期间隔(小时),默认24") + enable_llm_summary = Column(Boolean, default=True, comment="是否使用LLM生成摘要,默认True") + max_merge_batch_size = Column(Integer, default=100, comment="单次最大融合节点对数,默认100") + max_history_length = Column(Integer, default=100, comment="访问历史最大长度,默认100") + min_days_since_access = Column(Integer, default=30, comment="最小未访问天数,默认30") + + # 情绪引擎配置 + emotion_enabled = Column(Boolean, default=True, comment="是否启用情绪提取") + emotion_model_id = Column(String, nullable=True, comment="情绪分析专用模型ID") + emotion_extract_keywords = Column(Boolean, default=True, comment="是否提取情绪关键词") + emotion_min_intensity = Column(Float, default=0.1, comment="最小情绪强度阈值") + emotion_enable_subject = Column(Boolean, default=True, comment="是否启用主体分类") + + # 时间戳 + created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间") + + def __repr__(self): + return f"" diff --git a/api/app/repositories/data_config_repository.py b/api/app/repositories/memory_config_repository.py similarity index 78% rename from api/app/repositories/data_config_repository.py rename to api/app/repositories/memory_config_repository.py index 3df7f800..eb513c99 100644 --- a/api/app/repositories/data_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- -"""数据配置Repository模块 +"""记忆配置Repository模块 -本模块提供data_config表的数据访问层,使用SQLAlchemy ORM进行数据库操作。 +本模块提供memory_config表的数据访问层,使用SQLAlchemy ORM进行数据库操作。 包括CRUD操作和Neo4j Cypher查询常量。 Classes: - DataConfigRepository: 数据配置仓储类,提供CRUD操作 + MemoryConfigRepository: 记忆配置仓储类,提供CRUD操作 """ import uuid from typing import Dict, List, Optional, Tuple from app.core.exceptions import BusinessException from app.core.logging_config import get_config_logger, get_db_logger -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig from app.schemas.memory_storage_schema import ( ConfigKey, ConfigParamsCreate, @@ -28,11 +28,11 @@ db_logger = get_db_logger() # 获取配置专用日志器 config_logger = get_config_logger() -TABLE_NAME = "data_config" -class DataConfigRepository: - """数据配置Repository +TABLE_NAME = "memory_config" +class MemoryConfigRepository: + """记忆配置Repository - 提供data_config表的数据访问方法,包括: + 提供memory_config表的数据访问方法,包括: - SQLAlchemy ORM 数据库操作 - Neo4j Cypher查询常量 """ @@ -115,7 +115,7 @@ class DataConfigRepository: reflection_model_id: str, memory_verify: bool, quality_assessment: bool - ) -> DataConfig: + ) -> MemoryConfig: """构建反思配置更新语句(SQLAlchemy text() 命名参数) Args: @@ -130,28 +130,28 @@ class DataConfigRepository: config_id: 配置ID Returns: - Data + MemoryConfig Raises: ValueError: 没有字段需要更新时抛出 """ db_logger.debug(f"构建反思配置更新语句: config_id={config_id}") - stmt = select(DataConfig).where(DataConfig.config_id == config_id) - data_config_obj = db.scalars(stmt).first() - if not data_config_obj: + stmt = select(MemoryConfig).where(MemoryConfig.config_id == config_id) + memory_config_obj = db.scalars(stmt).first() + if not memory_config_obj: raise BusinessException - data_config_obj.enable_self_reflexion = enable_self_reflexion - data_config_obj.iteration_period = iteration_period - data_config_obj.reflexion_range = reflexion_range - data_config_obj.baseline = baseline - data_config_obj.reflection_model_id = reflection_model_id - data_config_obj.memory_verify = memory_verify - data_config_obj.quality_assessment = quality_assessment + memory_config_obj.enable_self_reflexion = enable_self_reflexion + memory_config_obj.iteration_period = iteration_period + memory_config_obj.reflexion_range = reflexion_range + memory_config_obj.baseline = baseline + memory_config_obj.reflection_model_id = reflection_model_id + memory_config_obj.memory_verify = memory_verify + memory_config_obj.quality_assessment = quality_assessment - return data_config_obj + return memory_config_obj @staticmethod - def query_reflection_config_by_id(db: Session, config_id: int) -> DataConfig: + def query_reflection_config_by_id(db: Session, config_id: int) -> MemoryConfig: """构建反思配置查询语句,通过config_id查询反思配置(SQLAlchemy text() 命名参数) Args: @@ -162,13 +162,13 @@ class DataConfigRepository: Tuple[str, Dict]: (SQL查询字符串, 参数字典) """ db_logger.debug(f"构建反思配置查询语句: config_id={config_id}") - stmt = select(DataConfig).where(DataConfig.config_id == config_id) - data_config = db.scalars(stmt).first() - if not data_config: + stmt = select(MemoryConfig).where(MemoryConfig.config_id == config_id) + memory_config = db.scalars(stmt).first() + if not memory_config: raise RuntimeError("reflection config not found") - return data_config + return memory_config @staticmethod - def query_reflection_config_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> DataConfig: + def query_reflection_config_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> MemoryConfig: """构建查询所有配置的语句(SQLAlchemy text() 命名参数) Args: @@ -180,11 +180,11 @@ class DataConfigRepository: """ db_logger.debug(f"构建查询所有配置语句: workspace_id={workspace_id}") - stmt = select(DataConfig).where(DataConfig.workspace_id == workspace_id) - data_config = db.scalars(stmt).first() - if not data_config: + stmt = select(MemoryConfig).where(MemoryConfig.workspace_id == workspace_id) + memory_config = db.scalars(stmt).first() + if not memory_config: raise RuntimeError("reflection config not found") - return data_config + return memory_config @staticmethod @@ -208,20 +208,20 @@ class DataConfigRepository: return query, params @staticmethod - def create(db: Session, params: ConfigParamsCreate) -> DataConfig: - """创建数据配置 + def create(db: Session, params: ConfigParamsCreate) -> MemoryConfig: + """创建记忆配置 Args: db: 数据库会话 params: 配置参数创建模型 Returns: - DataConfig: 创建的配置对象 + MemoryConfig: 创建的配置对象 """ - db_logger.debug(f"创建数据配置: config_name={params.config_name}, workspace_id={params.workspace_id}") + db_logger.debug(f"创建记忆配置: config_name={params.config_name}, workspace_id={params.workspace_id}") try: - db_config = DataConfig( + db_config = MemoryConfig( config_name=params.config_name, config_desc=params.config_desc, workspace_id=params.workspace_id, @@ -232,16 +232,16 @@ class DataConfigRepository: db.add(db_config) db.flush() # 获取自增ID但不提交事务 - db_logger.info(f"数据配置已添加到会话: {db_config.config_name} (ID: {db_config.config_id})") + db_logger.info(f"记忆配置已添加到会话: {db_config.config_name} (ID: {db_config.config_id})") return db_config except Exception as e: db.rollback() - db_logger.error(f"创建数据配置失败: {params.config_name} - {str(e)}") + db_logger.error(f"创建记忆配置失败: {params.config_name} - {str(e)}") raise @staticmethod - def update(db: Session, update: ConfigUpdate) -> Optional[DataConfig]: + def update(db: Session, update: ConfigUpdate) -> Optional[MemoryConfig]: """更新基础配置 Args: @@ -249,17 +249,17 @@ class DataConfigRepository: update: 配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 """ - db_logger.debug(f"更新数据配置: config_id={update.config_id}") + db_logger.debug(f"更新记忆配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段 @@ -277,17 +277,17 @@ class DataConfigRepository: db.commit() db.refresh(db_config) - db_logger.info(f"数据配置更新成功: {db_config.config_name} (ID: {update.config_id})") + db_logger.info(f"记忆配置更新成功: {db_config.config_name} (ID: {update.config_id})") return db_config except Exception as e: db.rollback() - db_logger.error(f"更新数据配置失败: config_id={update.config_id} - {str(e)}") + db_logger.error(f"更新记忆配置失败: config_id={update.config_id} - {str(e)}") raise @staticmethod - def update_extracted(db: Session, update: ConfigUpdateExtracted) -> Optional[DataConfig]: + def update_extracted(db: Session, update: ConfigUpdateExtracted) -> Optional[MemoryConfig]: """更新记忆萃取引擎配置 Args: @@ -295,7 +295,7 @@ class DataConfigRepository: update: 萃取配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 @@ -303,9 +303,9 @@ class DataConfigRepository: db_logger.debug(f"更新萃取配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段映射 @@ -360,7 +360,7 @@ class DataConfigRepository: raise @staticmethod - def update_forget(db: Session, update: ConfigUpdateForget) -> Optional[DataConfig]: + def update_forget(db: Session, update: ConfigUpdateForget) -> Optional[MemoryConfig]: """更新遗忘引擎配置 Args: @@ -368,7 +368,7 @@ class DataConfigRepository: update: 遗忘配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 @@ -376,9 +376,9 @@ class DataConfigRepository: db_logger.debug(f"更新遗忘配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段 @@ -421,7 +421,7 @@ class DataConfigRepository: db_logger.debug(f"查询萃取配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: db_logger.debug(f"萃取配置不存在: config_id={config_id}") return None @@ -470,7 +470,7 @@ class DataConfigRepository: db_logger.debug(f"查询遗忘配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: db_logger.debug(f"遗忘配置不存在: config_id={config_id}") return None @@ -489,39 +489,39 @@ class DataConfigRepository: raise @staticmethod - def get_by_id(db: Session, config_id: int) -> Optional[DataConfig]: - """根据ID获取数据配置 + def get_by_id(db: Session, config_id: int) -> Optional[MemoryConfig]: + """根据ID获取记忆配置 Args: db: 数据库会话 config_id: 配置ID Returns: - Optional[DataConfig]: 配置对象,不存在则返回None + Optional[MemoryConfig]: 配置对象,不存在则返回None """ - db_logger.debug(f"根据ID查询数据配置: config_id={config_id}") + db_logger.debug(f"根据ID查询记忆配置: config_id={config_id}") try: - config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if config: - db_logger.debug(f"数据配置查询成功: {config.config_name} (ID: {config_id})") + db_logger.debug(f"记忆配置查询成功: {config.config_name} (ID: {config_id})") else: - db_logger.debug(f"数据配置不存在: config_id={config_id}") + db_logger.debug(f"记忆配置不存在: config_id={config_id}") return config except Exception as e: - db_logger.error(f"根据ID查询数据配置失败: config_id={config_id} - {str(e)}") + db_logger.error(f"根据ID查询记忆配置失败: config_id={config_id} - {str(e)}") raise @staticmethod def get_config_with_workspace(db: Session, config_id: int) -> Optional[tuple]: - """Get data config and its associated workspace information + """Get memory config and its associated workspace information Args: db: Database session config_id: Configuration ID Returns: - Optional[tuple]: (DataConfig, Workspace) tuple, None if not found + Optional[tuple]: (MemoryConfig, Workspace) tuple, None if not found Raises: ValueError: Raised when config exists but workspace doesn't @@ -541,19 +541,19 @@ class DataConfigRepository: } ) - db_logger.debug(f"Querying data config and workspace: config_id={config_id}") + db_logger.debug(f"Querying memory config and workspace: config_id={config_id}") try: # Use join query to get both config and workspace - result = db.query(DataConfig, Workspace).join( - Workspace, DataConfig.workspace_id == Workspace.id - ).filter(DataConfig.config_id == config_id).first() + result = db.query(MemoryConfig, Workspace).join( + Workspace, MemoryConfig.workspace_id == Workspace.id + ).filter(MemoryConfig.config_id == config_id).first() elapsed_ms = (time.time() - start_time) * 1000 if not result: # Check if config exists but workspace is missing - config_only = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + config_only = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if config_only: if config_only.workspace_id is None: config_logger.error( @@ -566,7 +566,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.error(f"Data config {config_id} has no associated workspace ID") + db_logger.error(f"Memory config {config_id} has no associated workspace ID") raise ValueError(f"Configuration {config_id} has no associated workspace") else: config_logger.error( @@ -579,7 +579,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.error(f"Data config {config_id} references non-existent workspace {config_only.workspace_id}") + db_logger.error(f"Memory config {config_id} references non-existent workspace {config_only.workspace_id}") raise ValueError(f"Workspace {config_only.workspace_id} not found for configuration {config_id}") config_logger.debug( @@ -591,7 +591,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.debug(f"Data config not found: config_id={config_id}") + db_logger.debug(f"Memory config not found: config_id={config_id}") return None config, workspace = result @@ -611,7 +611,7 @@ class DataConfigRepository: } ) - db_logger.debug(f"Data config and workspace query successful: config={config.config_name}, workspace={workspace.name}") + db_logger.debug(f"Memory config and workspace query successful: config={config.config_name}, workspace={workspace.name}") return (config, workspace) except ValueError: @@ -633,10 +633,10 @@ class DataConfigRepository: exc_info=True ) - db_logger.error(f"Failed to query data config and workspace: config_id={config_id} - {str(e)}") + db_logger.error(f"Failed to query memory config and workspace: config_id={config_id} - {str(e)}") raise @staticmethod - def get_all(db: Session, workspace_id: Optional[uuid.UUID] = None) -> List[DataConfig]: + def get_all(db: Session, workspace_id: Optional[uuid.UUID] = None) -> List[MemoryConfig]: """获取所有配置参数 Args: @@ -644,17 +644,17 @@ class DataConfigRepository: workspace_id: 工作空间ID,用于过滤查询结果 Returns: - List[DataConfig]: 配置列表 + List[MemoryConfig]: 配置列表 """ db_logger.debug(f"查询所有配置: workspace_id={workspace_id}") try: - query = db.query(DataConfig) + query = db.query(MemoryConfig) if workspace_id: - query = query.filter(DataConfig.workspace_id == workspace_id) + query = query.filter(MemoryConfig.workspace_id == workspace_id) - configs = query.order_by(desc(DataConfig.updated_at)).all() + configs = query.order_by(desc(MemoryConfig.updated_at)).all() db_logger.debug(f"配置列表查询成功: 数量={len(configs)}") return configs @@ -665,7 +665,7 @@ class DataConfigRepository: @staticmethod def delete(db: Session, config_id: int) -> bool: - """删除数据配置 + """删除记忆配置 Args: db: 数据库会话 @@ -674,22 +674,22 @@ class DataConfigRepository: Returns: bool: 删除成功返回True,配置不存在返回False """ - db_logger.debug(f"删除数据配置: config_id={config_id}") + db_logger.debug(f"删除记忆配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={config_id}") + db_logger.warning(f"记忆配置不存在: config_id={config_id}") return False db.delete(db_config) db.commit() - db_logger.info(f"数据配置删除成功: config_id={config_id}") + db_logger.info(f"记忆配置删除成功: config_id={config_id}") return True except Exception as e: db.rollback() - db_logger.error(f"删除数据配置失败: config_id={config_id} - {str(e)}") + db_logger.error(f"删除记忆配置失败: config_id={config_id} - {str(e)}") raise diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 35d2e424..09410091 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -299,6 +299,18 @@ class AppRelease(BaseModel): created_at: datetime.datetime updated_at: datetime.datetime + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v if v is not None else {} + @field_serializer("created_at", when_used="json") def _serialize_created_at(self, dt: datetime.datetime): return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index 5b1fe6d9..68f15115 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, field_serializer, ConfigDict +from pydantic import BaseModel, Field, field_serializer, field_validator, ConfigDict from typing import Optional, List, Dict, Any import datetime import uuid @@ -91,6 +91,18 @@ class ModelApiKey(ModelApiKeyBase): created_at: datetime.datetime updated_at: datetime.datetime + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v + @field_serializer("created_at", when_used="json") def _serialize_created_at(self, dt: datetime.datetime): return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/schemas/release_share_schema.py b/api/app/schemas/release_share_schema.py index 069b78a9..47897847 100644 --- a/api/app/schemas/release_share_schema.py +++ b/api/app/schemas/release_share_schema.py @@ -1,7 +1,7 @@ import uuid import datetime from typing import Optional, List, Dict, Any -from pydantic import BaseModel, Field, ConfigDict, field_serializer +from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator # ---------- Input Schemas ---------- @@ -88,6 +88,18 @@ class SharedReleaseInfo(BaseModel): # 嵌入配置 allow_embed: bool + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v if v is not None else {} + class EmbedCode(BaseModel): """嵌入代码""" diff --git a/api/app/services/emotion_config_service.py b/api/app/services/emotion_config_service.py index 37171640..f8b4d22a 100644 --- a/api/app/services/emotion_config_service.py +++ b/api/app/services/emotion_config_service.py @@ -10,7 +10,7 @@ Classes: from typing import Dict, Any from sqlalchemy.orm import Session -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig from app.core.logging_config import get_business_logger logger = get_business_logger() @@ -61,8 +61,8 @@ class EmotionConfigService: logger.info(f"获取情绪配置: config_id={config_id}") # 查询配置 - config = self.db.query(DataConfig).filter( - DataConfig.config_id == config_id + config = self.db.query(MemoryConfig).filter( + MemoryConfig.config_id == config_id ).first() if not config: @@ -173,8 +173,8 @@ class EmotionConfigService: self.validate_emotion_config(config_data) # 查询配置 - config = self.db.query(DataConfig).filter( - DataConfig.config_id == config_id + config = self.db.query(MemoryConfig).filter( + MemoryConfig.config_id == config_id ).first() if not config: diff --git a/api/app/services/emotion_extraction_service.py b/api/app/services/emotion_extraction_service.py index d134251d..6b596a80 100644 --- a/api/app/services/emotion_extraction_service.py +++ b/api/app/services/emotion_extraction_service.py @@ -14,7 +14,7 @@ from app.core.memory.llm_tools.llm_client import LLMClientException from app.core.memory.models.emotion_models import EmotionExtraction from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig logger = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class EmotionExtractionService: async def extract_emotion( self, statement: str, - config: DataConfig + config: MemoryConfig ) -> Optional[EmotionExtraction]: """Extract emotion information from a statement. diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index d08cf466..509ed815 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1249,7 +1249,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) """ from app.models.app_release_model import AppRelease from app.models.end_user_model import EndUser - from app.models.data_config_model import DataConfig + from app.models.memory_config_model import MemoryConfig from sqlalchemy import select logger.info(f"Batch getting connected configs for {len(end_user_ids)} end_users") diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index 0099eb18..9afba797 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -14,7 +14,7 @@ from app.core.validators.memory_config_validators import ( validate_embedding_model, validate_model_exists_and_active, ) -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.schemas.memory_config_schema import ( ConfigurationError, InvalidConfigError, @@ -127,7 +127,7 @@ class MemoryConfigService: # Step 1: Get config and workspace db_query_start = time.time() - result = DataConfigRepository.get_config_with_workspace(self.db, validated_config_id) + result = MemoryConfigRepository.get_config_with_workspace(self.db, validated_config_id) db_query_time = time.time() - db_query_start logger.info(f"[PERF] Config+Workspace query: {db_query_time:.4f}s") if not result: diff --git a/api/app/services/memory_forget_service.py b/api/app/services/memory_forget_service.py index 558efe43..204f5df1 100644 --- a/api/app/services/memory_forget_service.py +++ b/api/app/services/memory_forget_service.py @@ -23,7 +23,7 @@ from app.core.memory.storage_services.forgetting_engine.config_utils import ( load_actr_config_from_db, ) from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.forgetting_cycle_history_repository import ForgettingCycleHistoryRepository @@ -70,7 +70,7 @@ class MemoryForgetService: def __init__(self): """初始化服务""" - self.config_repository = DataConfigRepository() + self.config_repository = MemoryConfigRepository() self.history_repository = ForgettingCycleHistoryRepository() def _get_neo4j_connector(self) -> Neo4jConnector: diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index 46e42b46..1ae7c549 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -13,7 +13,7 @@ from app.db import get_db from app.core.logging_config import get_api_logger from app.core.memory.storage_services.reflection_engine import ReflectionConfig, ReflectionEngine from app.core.memory.storage_services.reflection_engine.self_reflexion import ReflectionRange, ReflectionBaseline -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.models.app_model import App from app.models.app_release_model import AppRelease @@ -70,7 +70,7 @@ class WorkspaceAppService: "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, "releases": [], - "data_configs": [], + "memory_configs": [], "end_users": [] } @@ -98,11 +98,11 @@ class WorkspaceAppService: if memory_content: processed_configs.add(memory_content) - data_config_info = self._get_data_config(memory_content) + memory_config_info = self._get_memory_config(memory_content) - if data_config_info: - if not any(dc["config_id"] == data_config_info["config_id"] for dc in app_info["data_configs"]): - app_info["data_configs"].append(data_config_info) + if memory_config_info: + if not any(dc["config_id"] == memory_config_info["config_id"] for dc in app_info["memory_configs"]): + app_info["memory_configs"].append(memory_config_info) app_info["releases"].append(release_info) @@ -117,30 +117,30 @@ class WorkspaceAppService: return None - def _get_data_config(self, memory_content: str) -> Dict[str, Any]: - """Retrieve data_comfig information based on memory_comtent""" + def _get_memory_config(self, memory_content: str) -> Dict[str, Any]: + """Retrieve memory_config information based on memory_content""" try: - data_config_result = DataConfigRepository.query_reflection_config_by_id(self.db, int(memory_content)) + memory_config_result = MemoryConfigRepository.query_reflection_config_by_id(self.db, int(memory_content)) - # data_config_query, data_config_params = DataConfigRepository.build_select_reflection(memory_content) - # data_config_result = self.db.execute(text(data_config_query), data_config_params).fetchone() - # if data_config_result is None: + # memory_config_query, memory_config_params = MemoryConfigRepository.build_select_reflection(memory_content) + # memory_config_result = self.db.execute(text(memory_config_query), memory_config_params).fetchone() + # if memory_config_result is None: # return None - if data_config_result: + if memory_config_result: return { - "config_id": data_config_result.config_id, - "enable_self_reflexion": data_config_result.enable_self_reflexion, - "iteration_period": data_config_result.iteration_period, - "reflexion_range": data_config_result.reflexion_range, - "baseline": data_config_result.baseline, - "reflection_model_id": data_config_result.reflection_model_id, - "memory_verify": data_config_result.memory_verify, - "quality_assessment": data_config_result.quality_assessment, - "user_id": data_config_result.user_id + "config_id": memory_config_result.config_id, + "enable_self_reflexion": memory_config_result.enable_self_reflexion, + "iteration_period": memory_config_result.iteration_period, + "reflexion_range": memory_config_result.reflexion_range, + "baseline": memory_config_result.baseline, + "reflection_model_id": memory_config_result.reflection_model_id, + "memory_verify": memory_config_result.memory_verify, + "quality_assessment": memory_config_result.quality_assessment, + "user_id": memory_config_result.user_id } except Exception as e: - api_logger.warning(f"查询data_config失败,memory_content: {memory_content}, 错误: {str(e)}") + api_logger.warning(f"查询memory_config失败,memory_content: {memory_content}, 错误: {str(e)}") return None @@ -223,7 +223,7 @@ class MemoryReflectionService: } config_data_id = config_data['config_id'] - reflection_config = WorkspaceAppService(self.db)._get_data_config(config_data_id) + reflection_config = WorkspaceAppService(self.db)._get_memory_config(config_data_id) if reflection_config is not None and reflection_config['enable_self_reflexion']: reflection_config = self._create_reflection_config_from_data(reflection_config) # 3. 执行反思引擎 @@ -277,7 +277,7 @@ class MemoryReflectionService: config_data_id=config_data['config_id'] - reflection_config=WorkspaceAppService(self.db)._get_data_config(config_data_id) + reflection_config=WorkspaceAppService(self.db)._get_memory_config(config_data_id) if reflection_config is not None and reflection_config['enable_self_reflexion']: reflection_config= self._create_reflection_config_from_data(reflection_config) iteration_period = int(reflection_config.iteration_period) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 05a84c01..6aa5ac7d 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -15,7 +15,7 @@ from app.core.logging_config import get_config_logger, get_logger from app.core.memory.analytics.hot_memory_tags import get_hot_memory_tags from app.core.memory.analytics.recent_activity_stats import get_recent_activity_stats from app.models.user_model import User -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_config_schema import ConfigurationError from app.schemas.memory_storage_schema import ( @@ -125,7 +125,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) if not params.rerank_id: params.rerank_id = configs.get('rerank') - config = DataConfigRepository.create(self.db, params) + config = MemoryConfigRepository.create(self.db, params) self.db.commit() return {"affected": 1, "config_id": config.config_id} @@ -142,20 +142,20 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Delete --- def delete(self, key: ConfigParamsDelete) -> Dict[str, Any]: # 删除配置参数(按配置ID) - success = DataConfigRepository.delete(self.db, key.config_id) + success = MemoryConfigRepository.delete(self.db, key.config_id) if not success: raise ValueError("未找到配置") return {"affected": 1} # --- Update --- def update(self, update: ConfigUpdate) -> Dict[str, Any]: # 部分更新配置参数 - config = DataConfigRepository.update(self.db, update) + config = MemoryConfigRepository.update(self.db, update) if not config: raise ValueError("未找到配置") return {"affected": 1} def update_extracted(self, update: ConfigUpdateExtracted) -> Dict[str, Any]: # 更新记忆萃取引擎配置参数 - config = DataConfigRepository.update_extracted(self.db, update) + config = MemoryConfigRepository.update_extracted(self.db, update) if not config: raise ValueError("未找到配置") return {"affected": 1} @@ -166,14 +166,14 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Read --- def get_extracted(self, key: ConfigKey) -> Dict[str, Any]: # 获取萃取配置参数 - result = DataConfigRepository.get_extracted_config(self.db, key.config_id) + result = MemoryConfigRepository.get_extracted_config(self.db, key.config_id) if not result: raise ValueError("未找到配置") return result # --- Read All --- def get_all(self, workspace_id = None) -> List[Dict[str, Any]]: # 获取所有配置参数 - configs = DataConfigRepository.get_all(self.db, workspace_id) + configs = MemoryConfigRepository.get_all(self.db, workspace_id) # 将 ORM 对象转换为字典列表 data_list = [] @@ -390,7 +390,7 @@ _neo4j_connector = Neo4jConnector() async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_DIALOGUE, + MemoryConfigRepository.SEARCH_FOR_DIALOGUE, end_user_id=end_user_id, ) data = {"search_for": "dialogue", "num": result[0]["num"]} @@ -399,7 +399,7 @@ async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_CHUNK, + MemoryConfigRepository.SEARCH_FOR_CHUNK, end_user_id=end_user_id, ) data = {"search_for": "chunk", "num": result[0]["num"]} @@ -408,7 +408,7 @@ async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_STATEMENT, + MemoryConfigRepository.SEARCH_FOR_STATEMENT, end_user_id=end_user_id, ) data = {"search_for": "statement", "num": result[0]["num"]} @@ -417,7 +417,7 @@ async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ENTITY, + MemoryConfigRepository.SEARCH_FOR_ENTITY, end_user_id=end_user_id, ) data = {"search_for": "entity", "num": result[0]["num"]} @@ -426,7 +426,7 @@ async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_all(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ALL, + MemoryConfigRepository.SEARCH_FOR_ALL, end_user_id=end_user_id, ) @@ -461,7 +461,7 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A 聚合 dialogue/chunk/statement/entity 四类计数,返回统一的分布结构,便于前端一次性消费。 """ result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ALL, + MemoryConfigRepository.SEARCH_FOR_ALL, end_user_id=end_user_id, ) @@ -492,7 +492,7 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A async def search_detials(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_DETIALS, + MemoryConfigRepository.SEARCH_FOR_DETIALS, end_user_id=end_user_id, ) return result @@ -500,7 +500,7 @@ async def search_detials(end_user_id: Optional[str] = None) -> List[Dict[str, An async def search_edges(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_EDGES, + MemoryConfigRepository.SEARCH_FOR_EDGES, end_user_id=end_user_id, ) return result diff --git a/api/app/utils/app_config_utils.py b/api/app/utils/app_config_utils.py index 514e4565..ae41d8bf 100644 --- a/api/app/utils/app_config_utils.py +++ b/api/app/utils/app_config_utils.py @@ -83,6 +83,13 @@ class AgentConfigProxy: def agent_config_4_app_release(release: AppRelease) -> AgentConfig: config_dict = release.config + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} agent_config = AgentConfig( app_id=release.app_id, @@ -100,6 +107,14 @@ def agent_config_4_app_release(release: AppRelease) -> AgentConfig: def multi_agent_config_4_app_release(release: AppRelease) -> MultiAgentConfig: config_dict = release.config + + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} agent_config = MultiAgentConfig( app_id=release.app_id, @@ -120,6 +135,14 @@ def multi_agent_config_4_app_release(release: AppRelease) -> MultiAgentConfig: def workflow_config_4_app_release(release: AppRelease) -> WorkflowConfig: config_dict = release.config + + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} config = WorkflowConfig( id=config_dict.get("id"), From bcc8b7ce3cf52178bb3395aae7e541cd424ffe87 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 16:11:48 +0800 Subject: [PATCH 033/175] =?UTF-8?q?config=5Fconfig=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=88=90memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/emotion_controller.py | 38 +- api/app/schemas/emotion_schema.py | 8 +- api/uv.lock | 4465 --------------------- 3 files changed, 23 insertions(+), 4488 deletions(-) delete mode 100644 api/uv.lock diff --git a/api/app/controllers/emotion_controller.py b/api/app/controllers/emotion_controller.py index 154a3928..cd199aa7 100644 --- a/api/app/controllers/emotion_controller.py +++ b/api/app/controllers/emotion_controller.py @@ -53,7 +53,7 @@ async def get_emotion_tags( api_logger.info( f"用户 {current_user.username} 请求获取情绪标签统计", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "emotion_type": request.emotion_type, "start_date": request.start_date, "end_date": request.end_date, @@ -63,7 +63,7 @@ async def get_emotion_tags( # 调用服务层 data = await emotion_service.get_emotion_tags( - end_user_id=request.group_id, + end_user_id=request.end_user_id, emotion_type=request.emotion_type, start_date=request.start_date, end_date=request.end_date, @@ -73,7 +73,7 @@ async def get_emotion_tags( api_logger.info( "情绪标签统计获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "total_count": data.get("total_count", 0), "tags_count": len(data.get("tags", [])) } @@ -84,7 +84,7 @@ async def get_emotion_tags( except Exception as e: api_logger.error( f"获取情绪标签统计失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -105,7 +105,7 @@ async def get_emotion_wordcloud( api_logger.info( f"用户 {current_user.username} 请求获取情绪词云数据", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "emotion_type": request.emotion_type, "limit": request.limit } @@ -113,7 +113,7 @@ async def get_emotion_wordcloud( # 调用服务层 data = await emotion_service.get_emotion_wordcloud( - end_user_id=request.group_id, + end_user_id=request.end_user_id, emotion_type=request.emotion_type, limit=request.limit ) @@ -121,7 +121,7 @@ async def get_emotion_wordcloud( api_logger.info( "情绪词云数据获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "total_keywords": data.get("total_keywords", 0) } ) @@ -131,7 +131,7 @@ async def get_emotion_wordcloud( except Exception as e: api_logger.error( f"获取情绪词云数据失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -159,21 +159,21 @@ async def get_emotion_health( api_logger.info( f"用户 {current_user.username} 请求获取情绪健康指数", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "time_range": request.time_range } ) # 调用服务层 data = await emotion_service.calculate_emotion_health_index( - end_user_id=request.group_id, + end_user_id=request.end_user_id, time_range=request.time_range ) api_logger.info( "情绪健康指数获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "health_score": data.get("health_score", 0), "level": data.get("level", "未知") } @@ -186,7 +186,7 @@ async def get_emotion_health( except Exception as e: api_logger.error( f"获取情绪健康指数失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -206,7 +206,7 @@ async def get_emotion_suggestions( """获取个性化情绪建议(从缓存读取) Args: - request: 包含 group_id 和可选的 config_id + request: 包含 end_user_id 和可选的 config_id db: 数据库会话 current_user: 当前用户 @@ -217,22 +217,22 @@ async def get_emotion_suggestions( api_logger.info( f"用户 {current_user.username} 请求获取个性化情绪建议(缓存)", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "config_id": request.config_id } ) # 从缓存获取建议 data = await emotion_service.get_cached_suggestions( - end_user_id=request.group_id, + end_user_id=request.end_user_id, db=db ) if data is None: # 缓存不存在或已过期 api_logger.info( - f"用户 {request.group_id} 的建议缓存不存在或已过期", - extra={"group_id": request.group_id} + f"用户 {request.end_user_id} 的建议缓存不存在或已过期", + extra={"end_user_id": request.end_user_id} ) return fail( BizCode.NOT_FOUND, @@ -243,7 +243,7 @@ async def get_emotion_suggestions( api_logger.info( "个性化建议获取成功(缓存)", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "suggestions_count": len(data.get("suggestions", [])) } ) @@ -253,7 +253,7 @@ async def get_emotion_suggestions( except Exception as e: api_logger.error( f"获取个性化建议失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( diff --git a/api/app/schemas/emotion_schema.py b/api/app/schemas/emotion_schema.py index c48fbd41..fb523887 100644 --- a/api/app/schemas/emotion_schema.py +++ b/api/app/schemas/emotion_schema.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field class EmotionTagsRequest(BaseModel): """获取情绪标签统计请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") emotion_type: Optional[str] = Field(None, description="情绪类型过滤(joy/sadness/anger/fear/surprise/neutral)") start_date: Optional[str] = Field(None, description="开始日期(ISO格式,如:2024-01-01)") end_date: Optional[str] = Field(None, description="结束日期(ISO格式,如:2024-12-31)") @@ -14,14 +14,14 @@ class EmotionTagsRequest(BaseModel): class EmotionWordcloudRequest(BaseModel): """获取情绪词云数据请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") emotion_type: Optional[str] = Field(None, description="情绪类型过滤(joy/sadness/anger/fear/surprise/neutral)") limit: int = Field(50, ge=1, le=200, description="返回词语数量") class EmotionHealthRequest(BaseModel): """获取情绪健康指数请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") time_range: str = Field("30d", description="时间范围(7d/30d/90d)") @@ -29,7 +29,7 @@ class EmotionHealthRequest(BaseModel): class EmotionSuggestionsRequest(BaseModel): """获取个性化情绪建议请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") config_id: Optional[int] = Field(None, description="配置ID(用于指定LLM模型)") diff --git a/api/uv.lock b/api/uv.lock deleted file mode 100644 index bccaef2c..00000000 --- a/api/uv.lock +++ /dev/null @@ -1,4465 +0,0 @@ -version = 1 -revision = 3 -requires-python = "==3.12.*" -resolution-markers = [ - "sys_platform == 'darwin'", - "platform_machine == 'aarch64' and sys_platform == 'linux'", - "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", -] - -[[package]] -name = "aiofile" -version = "3.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "caio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "alembic" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" }, -] - -[[package]] -name = "aliyun-python-sdk-core" -version = "2.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jmespath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3e/09/da9f58eb38b4fdb97ba6523274fbf445ef6a06be64b433693da8307b4bec/aliyun-python-sdk-core-2.16.0.tar.gz", hash = "sha256:651caad597eb39d4fad6cf85133dffe92837d53bdf62db9d8f37dab6508bb8f9", size = 449555, upload-time = "2024-10-09T06:01:01.762Z" } - -[[package]] -name = "aliyun-python-sdk-kms" -version = "2.16.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aliyun-python-sdk-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/2c/9877d0e6b18ecf246df671ac65a5d1d9fecbf85bdcb5d43efbde0d4662eb/aliyun-python-sdk-kms-2.16.5.tar.gz", hash = "sha256:f328a8a19d83ecbb965ffce0ec1e9930755216d104638cd95ecd362753b813b3", size = 12018, upload-time = "2024-08-30T09:01:20.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/5c/0132193d7da2c735669a1ed103b142fd63c9455984d48c5a88a1a516efaa/aliyun_python_sdk_kms-2.16.5-py2.py3-none-any.whl", hash = "sha256:24b6cdc4fd161d2942619479c8d050c63ea9cd22b044fe33b60bbb60153786f0", size = 99495, upload-time = "2024-08-30T09:01:18.462Z" }, -] - -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, -] - -[[package]] -name = "anytree" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/a8/eb55fab589c56f9b6be2b3fd6997aa04bb6f3da93b01154ce6fc8e799db2/anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714", size = 48389, upload-time = "2025-04-08T21:06:30.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/98/f6aa7fe0783e42be3093d8ef1b0ecdc22c34c0d69640dfb37f56925cb141/anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", size = 45077, upload-time = "2025-04-08T21:06:29.494Z" }, -] - -[[package]] -name = "aspose-slides" -version = "24.12.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/db/680408b92f47aa9ff2c70f80b2f5d02155a8ff81ac493c3061099bf56c37/Aspose.Slides-24.12.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:ccfaa61a863ed28cd37b221e31a0edf4a83802599d76fb50861c25149ac5e5e3", size = 87164865, upload-time = "2024-12-05T00:51:15.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/ac/29838004784acb72c9d93f0b327a8e5105f35eb925cdaeccd07907464018/Aspose.Slides-24.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b050659129c5ca92e52fbcd7d5091caa244db731adb68fbea1fd0a8b9fd62a5a", size = 68916630, upload-time = "2024-12-05T00:51:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6e/0b9da3757ce46b63f3fbb10ee352009c20260813d369306438bd3552fc18/Aspose.Slides-24.12.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:a5eb8407bd93fa7851584c3b143000c09d9f5285f3c1da99677bf1d9c0abefe9", size = 102438903, upload-time = "2024-12-05T00:51:27.926Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/023ce536ee861b6b8757b8ebfed3326cd21a48b9e557390cd904fc48ef1e/Aspose.Slides-24.12.0-py3-none-win32.whl", hash = "sha256:6e8bf6e20ff05a81ed9ef8025b20f16c5ada1af908934c82e8290aab26ad4f83", size = 62974346, upload-time = "2024-12-05T00:51:35.318Z" }, - { url = "https://files.pythonhosted.org/packages/58/0b/af65314b471766709627a65096f69e8b70b7840edd98cabaa9b74fda671d/Aspose.Slides-24.12.0-py3-none-win_amd64.whl", hash = "sha256:e816e37a621221e8a73fc631c879ada37cf6a80513a817b687d6f7e189d5a978", size = 72093115, upload-time = "2024-12-05T00:51:40.848Z" }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "authlib" -version = "1.6.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, -] - -[[package]] -name = "autograd" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ea/e16f0c423f7d83cf8b79cae9452040fb7b2e020c7439a167ee7c317de448/autograd-1.8.0-py3-none-any.whl", hash = "sha256:4ab9084294f814cf56c280adbe19612546a35574d67c574b04933c7d2ecb7d78", size = 51478, upload-time = "2025-05-05T12:49:00.585Z" }, -] - -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, -] - -[[package]] -name = "beartype" -version = "0.22.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/09/9003e5662691056e0e8b2e6f57c799e71875fac0be0e785d8cb11557cd2a/beartype-0.22.5.tar.gz", hash = "sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341", size = 1586256, upload-time = "2025-11-01T05:49:20.771Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/f6/073d19f7b571c08327fbba3f8e011578da67ab62a11f98911274ff80653f/beartype-0.22.5-py3-none-any.whl", hash = "sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0", size = 1321700, upload-time = "2025-11-01T05:49:18.436Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, -] - -[[package]] -name = "billiard" -version = "4.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/6a/1405343016bce8354b29d90aad6b0bf6485b5e60404516e4b9a3a9646cf0/billiard-4.2.2.tar.gz", hash = "sha256:e815017a062b714958463e07ba15981d802dc53d41c5b69d28c5a7c238f8ecf3", size = 155592, upload-time = "2025-09-20T14:44:40.456Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/80/ef8dff49aae0e4430f81842f7403e14e0ca59db7bbaf7af41245b67c6b25/billiard-4.2.2-py3-none-any.whl", hash = "sha256:4bc05dcf0d1cc6addef470723aac2a6232f3c7ed7475b0b580473a9145829457", size = 86896, upload-time = "2025-09-20T14:44:39.157Z" }, -] - -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - -[[package]] -name = "boto3" -version = "1.42.32" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/73/2a8065918dcc9f07046f7e87e17f54a62914a8b7f1f9e506799ec533d2e9/boto3-1.42.32.tar.gz", hash = "sha256:0ba535985f139cf38455efd91f3801fe72e5cce6ded2df5aadfd63177d509675", size = 112830, upload-time = "2026-01-21T20:40:10.891Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e3/c86658f1fd0191aa8131cb1baacd337b037546d902980ea5a9c8f0c5cd9b/boto3-1.42.32-py3-none-any.whl", hash = "sha256:695ac7e62dfde28cc1d3b28a581cce37c53c729d48ea0f4cd0dbf599856850cf", size = 140573, upload-time = "2026-01-21T20:40:09.1Z" }, -] - -[[package]] -name = "botocore" -version = "1.42.32" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/5e/84404e094be8e2145c7f6bb8b3709193bc4488c385edffc6cc6890b5c88b/botocore-1.42.32.tar.gz", hash = "sha256:4c0a9fe23e060c019e327cd5e4ea1976a1343faba74e5301ebfc9549cc584ccb", size = 14898756, upload-time = "2026-01-21T20:39:59.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/ab/55062f6eaf9fc537b62b7425ab53ef4366032256e1dda8ef52a9a31f7a6e/botocore-1.42.32-py3-none-any.whl", hash = "sha256:9c1ce43687cc4c0bba12054b229b3464265c699e2de4723998d86791254a5a37", size = 14573367, upload-time = "2026-01-21T20:39:56.65Z" }, -] - -[[package]] -name = "cachetools" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, -] - -[[package]] -name = "caio" -version = "0.9.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, -] - -[[package]] -name = "celery" -version = "5.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "chonkie" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/d7/b7637af29a4bf8073c8e95bb50068288f2e04b3dab389b2ce1f0c549d12f/chonkie-1.3.1.tar.gz", hash = "sha256:7df6c85c721518c5b6add8c40cf3c2747e6b25603c9e930103022871401008c6", size = 365381, upload-time = "2025-09-27T08:21:11.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/64/d53bf2a68dfcb2d76668915536ef35809c92e8eb8b3022ea51c302a2b0db/chonkie-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ddcecbc17aa7de8a05b1f793eb01cef64848436ed55f33d4b731769849c2a4", size = 493390, upload-time = "2025-09-27T08:21:00.801Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0a/182a9f43ea8e8c68b2526578716c291362f24354fd2f3f5f23921b158847/chonkie-1.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e59b94b47c65d74a2b3b9572a6abd9142f3177f0404f34e591723d80e77139b", size = 1003547, upload-time = "2025-09-27T08:21:01.75Z" }, - { url = "https://files.pythonhosted.org/packages/7d/60/1cdcb1024668bba484e24cd93541446f7115f006f0f3249e23c1066afa1f/chonkie-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:49e5f7e745ca8825c5dc3d92140a709776a2dfbc003d23d6c99cce61953c4da9", size = 493105, upload-time = "2025-09-27T08:21:03.021Z" }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, -] - -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - -[[package]] -name = "cn2an" -version = "0.5.23" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "proces" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/0b/35c9379122a2b551b22aa47d67b2a268eba2e77bc7509f52ed3f0ce6363e/cn2an-0.5.23.tar.gz", hash = "sha256:eda06a63e5eff4a64488d9f22e5f2a4ceca6eaa63416e4f771e67edecb1a5bdb", size = 21444, upload-time = "2024-12-21T14:51:29.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/5c/03f0cb3d31c132e09f5523c76e963436fcd13c0318428021bd210f7bb216/cn2an-0.5.23-py3-none-any.whl", hash = "sha256:b19ab3c53676765c038ccdab51f69b7efa4f0b888139c34088935769241f1cbf", size = 224934, upload-time = "2024-12-21T14:51:26.629Z" }, -] - -[[package]] -name = "cobble" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/7a/a507c709be2c96e1bb6102eb7b7f4026c5e5e223ef7d745a17d239e9d844/cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa", size = 3805, upload-time = "2024-06-01T18:11:09.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984, upload-time = "2024-06-01T18:11:07.911Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - -[[package]] -name = "concurrent-log-handler" -version = "0.9.28" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "portalocker" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/ed/68b9c3a07a2331361a09a194e4375c4ee680a799391cfb1ca924ca2b6523/concurrent_log_handler-0.9.28.tar.gz", hash = "sha256:4cc27969b3420239bd153779266f40d9713ece814e312b7aa753ce62c6eacdb8", size = 30935, upload-time = "2025-06-10T19:02:15.622Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/a0/1331c3f12d95adc8d0385dc620001054c509db88376d2e17be36b6353020/concurrent_log_handler-0.9.28-py3-none-any.whl", hash = "sha256:65db25d05506651a61573937880789fc51c7555e7452303042b5a402fd78939c", size = 28983, upload-time = "2025-06-10T19:02:14.223Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, -] - -[[package]] -name = "crcmod" -version = "1.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, -] - -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "cyclopts" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/7b/663f3285c1ac0e5d0854bd9db2c87caa6fa3d1a063185e3394a6cdca9151/cyclopts-4.5.0.tar.gz", hash = "sha256:717ac4235548b58d500baf7e688aa4d024caf0ee68f61a012ffd5e29db3099f9", size = 161980, upload-time = "2026-01-16T02:07:16.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/2e00fececc34a99ae3a5d5702a5dd29c5371e4ed016647301a2b9bcc1976/cyclopts-4.5.0-py3-none-any.whl", hash = "sha256:305b9aa90a9cd0916f0a450b43e50ad5df9c252680731a0719edfb9b20381bf5", size = 199772, upload-time = "2026-01-16T02:07:14.707Z" }, -] - -[[package]] -name = "dashscope" -version = "1.25.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "certifi" }, - { name = "cryptography" }, - { name = "requests" }, - { name = "websocket-client" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/bf/503587663b909427c1906b3b75fc2982bf9e42161d8b687f6e38ad12d042/dashscope-1.25.9-py3-none-any.whl", hash = "sha256:03b587bcb58a2f0a76fa5102925c16609b50af176198af0aeb0fd85aa44d6cfe", size = 1335755, upload-time = "2026-01-21T06:58:14.496Z" }, -] - -[[package]] -name = "dataclasses-json" -version = "0.6.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "typing-inspect" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, -] - -[[package]] -name = "datrie" -version = "0.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/0b/c0f53a14317b304e2e93b29a831b0c83306caae9af7f0e2e037d17c4f63f/datrie-0.8.3.tar.gz", hash = "sha256:ea021ad4c8a8bf14e08a71c7872a622aa399a510f981296825091c7ca0436e80", size = 499040, upload-time = "2025-08-28T12:37:23.227Z" } - -[[package]] -name = "demjson3" -version = "3.0.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/d2/6a81a9b5311d50542e11218b470dafd8adbaf1b3e51fc1fddd8a57eed691/demjson3-3.0.6.tar.gz", hash = "sha256:37c83b0c6eb08d25defc88df0a2a4875d58a7809a9650bd6eee7afd8053cdbac", size = 131477, upload-time = "2022-10-22T19:09:05.379Z" } - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, -] - -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - -[[package]] -name = "ecdsa" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, -] - -[[package]] -name = "editdistance" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/18/9f4f975ca87a390832b1c22478f3702fcdf739f83211e24d054b7551270d/editdistance-0.8.1.tar.gz", hash = "sha256:d1cdf80a5d5014b0c9126a69a42ce55a457b457f6986ff69ca98e4fe4d2d8fed", size = 50006, upload-time = "2024-02-10T07:44:53.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/4c/7f195588949b4e72436dc7fc902632381f96e586af829685b56daebb38b8/editdistance-0.8.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04af61b3fcdd287a07c15b6ae3b02af01c5e3e9c3aca76b8c1d13bd266b6f57", size = 106723, upload-time = "2024-02-10T07:43:50.268Z" }, - { url = "https://files.pythonhosted.org/packages/8d/82/31dc1640d830cd7d36865098329f34e4dad3b77f31cfb9404b347e700196/editdistance-0.8.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:18fc8b6eaae01bfd9cf999af726c1e8dcf667d120e81aa7dbd515bea7427f62f", size = 80998, upload-time = "2024-02-10T07:43:51.259Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2a/6b823e71cef694d6f070a1d82be2842706fa193541aab8856a8f42044cd0/editdistance-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a87839450a5987028738d061ffa5ef6a68bac2ddc68c9147a8aae9806629c7f", size = 79248, upload-time = "2024-02-10T07:43:52.873Z" }, - { url = "https://files.pythonhosted.org/packages/e1/31/bfb8e590f922089dc3471ed7828a6da2fc9453eba38c332efa9ee8749fd7/editdistance-0.8.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24b5f9c9673c823d91b5973d0af8b39f883f414a55ade2b9d097138acd10f31e", size = 415262, upload-time = "2024-02-10T07:43:54.498Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/57423942b2f847cdbbb46494568d00cd8a45500904ea026f0aad6ca01bc7/editdistance-0.8.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c59248eabfad603f0fba47b0c263d5dc728fb01c2b6b50fb6ca187cec547fdb3", size = 418905, upload-time = "2024-02-10T07:43:55.779Z" }, - { url = "https://files.pythonhosted.org/packages/1b/05/dfa4cdcce063596cbf0d7a32c46cd0f4fa70980311b7da64d35f33ad02a0/editdistance-0.8.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e239d88ff52821cf64023fabd06a1d9a07654f364b64bf1284577fd3a79d0e", size = 412511, upload-time = "2024-02-10T07:43:57.567Z" }, - { url = "https://files.pythonhosted.org/packages/0e/14/39608ff724a9523f187c4e28926d78bc68f2798f74777ac6757981108345/editdistance-0.8.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2f7f71698f83e8c83839ac0d876a0f4ef996c86c5460aebd26d85568d4afd0db", size = 917293, upload-time = "2024-02-10T07:43:59.559Z" }, - { url = "https://files.pythonhosted.org/packages/df/92/4a1c61d72da40dedfd0ff950fdc71ae83f478330c58a8bccfd776518bd67/editdistance-0.8.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:04e229d6f4ce0c12abc9f4cd4023a5b5fa9620226e0207b119c3c2778b036250", size = 975580, upload-time = "2024-02-10T07:44:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/47/3d/9877566e724c8a37f2228a84ec5cbf66dbfd0673515baf68a0fe07caff40/editdistance-0.8.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e16721636da6d6b68a2c09eaced35a94f4a4a704ec09f45756d4fd5e128ed18d", size = 929121, upload-time = "2024-02-10T07:44:02.764Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/8c50757d198b8ca30ddb91e8b8f0247a8dca04ff2ec30755245f0ab1ff0c/editdistance-0.8.1-cp312-cp312-win32.whl", hash = "sha256:87533cf2ebc3777088d991947274cd7e1014b9c861a8aa65257bcdc0ee492526", size = 81039, upload-time = "2024-02-10T07:44:04.134Z" }, - { url = "https://files.pythonhosted.org/packages/28/f0/65101e51dc7c850e7b7581a5d8fa8721a1d7479a0dca6c08386328e19882/editdistance-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:09f01ed51746d90178af7dd7ea4ebb41497ef19f53c7f327e864421743dffb0a", size = 79853, upload-time = "2024-02-10T07:44:05.687Z" }, -] - -[[package]] -name = "elastic-transport" -version = "8.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/82/2a544ac3d9c4ae19acc7f53117251bee20dd65dc3dff01fe55ea45ae9bd9/elastic_transport-8.17.0.tar.gz", hash = "sha256:e755f38f99fa6ec5456e236b8e58f0eb18873ac8fe710f74b91a16dd562de2a5", size = 73304, upload-time = "2025-01-07T08:12:37.534Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/0d/2dd25c06078070973164b661e0d79868e434998391f9aed74d4070aab270/elastic_transport-8.17.0-py3-none-any.whl", hash = "sha256:59f553300866750e67a38828fede000576562a0e66930c641adb75249e0c95af", size = 64523, upload-time = "2025-01-07T08:12:34.528Z" }, -] - -[[package]] -name = "elasticsearch" -version = "8.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elastic-transport" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/57/f61579940df4771971aede5b355f64c758f56d8cc9bd4407d669c2f0dd2f/elasticsearch-8.17.0.tar.gz", hash = "sha256:c1069bf2204ba8fab29ff00b2ce6b37324b2cc6ff593283b97df43426ec13053", size = 460268, upload-time = "2024-12-16T06:30:00.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/82/832ff4bdb53429af0025f5032c8b4f3ba18915e08ce16fc55aa09e900e26/elasticsearch-8.17.0-py3-none-any.whl", hash = "sha256:15965240fe297279f0e68b260936d9ced9606aa7ef8910b9b56727f96ef00d5b", size = 571182, upload-time = "2024-12-16T06:29:53.828Z" }, -] - -[[package]] -name = "elasticsearch-dsl" -version = "8.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "elastic-transport" }, - { name = "elasticsearch" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/75/b9c4e7a7ce99bd944076076cc95f8d898e9cd3c927fc3025a5ebbf4c8102/elasticsearch_dsl-8.17.0.tar.gz", hash = "sha256:c204218175462d108a84fb913371e45d3f49e9dd711ca26ec7ed89ab4e8f287d", size = 152052, upload-time = "2024-12-13T10:40:14.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/03/99623669fe32419d4a305b2edc72f72b458f0baba50ace0e25b1d448c5ae/elasticsearch_dsl-8.17.0-py3-none-any.whl", hash = "sha256:2096d196d473e0b11c3b190d0f1d5896e05d52c302c4170b29d3262d1164d555", size = 158872, upload-time = "2024-12-13T10:40:11.685Z" }, -] - -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - -[[package]] -name = "et-xmlfile" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fakeredis" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "redis" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, -] - -[package.optional-dependencies] -lua = [ - { name = "lupa" }, -] - -[[package]] -name = "fastapi" -version = "0.119.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" }, -] - -[[package]] -name = "fastmcp" -version = "2.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "authlib" }, - { name = "cyclopts" }, - { name = "exceptiongroup" }, - { name = "httpx" }, - { name = "jsonschema-path" }, - { name = "mcp" }, - { name = "openapi-pydantic" }, - { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, - { name = "pydantic", extra = ["email"] }, - { name = "pydocket" }, - { name = "pyperclip" }, - { name = "python-dotenv" }, - { name = "rich" }, - { name = "uvicorn" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/b5/7c4744dc41390ed2c17fd462ef2d42f4448a1ec53dda8fe3a01ff2872313/fastmcp-2.14.3.tar.gz", hash = "sha256:abc9113d5fcf79dfb4c060a1e1c55fccb0d4bce4a2e3eab15ca352341eec8dd6", size = 8279206, upload-time = "2026-01-12T20:00:40.789Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/dc/f7dd14213bf511690dccaa5094d436947c253b418c86c86211d1c76e6e44/fastmcp-2.14.3-py3-none-any.whl", hash = "sha256:103c6b4c6e97a9acc251c81d303f110fe4f2bdba31353df515d66272bf1b9414", size = 416220, upload-time = "2026-01-12T20:00:42.543Z" }, -] - -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - -[[package]] -name = "flask" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, -] - -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, -] - -[[package]] -name = "flower" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "celery" }, - { name = "humanize" }, - { name = "prometheus-client" }, - { name = "pytz" }, - { name = "tornado" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a1/357f1b5d8946deafdcfdd604f51baae9de10aafa2908d0b7322597155f92/flower-2.0.1.tar.gz", hash = "sha256:5ab717b979530770c16afb48b50d2a98d23c3e9fe39851dcf6bc4d01845a02a0", size = 3220408, upload-time = "2023-08-13T14:37:46.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" }, -] - -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "fsspec" -version = "2026.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, -] - -[[package]] -name = "future" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, -] - -[[package]] -name = "gensim" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy" }, - { name = "smart-open" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/65/d5285865ca54b93d41ccd8683c2d79952434957c76b411283c7a6c66ca69/gensim-4.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0845b2fa039dbea5667fb278b5414e70f6d48fd208ef51f33e84a78444288d8d", size = 24467245, upload-time = "2025-10-18T01:55:09.924Z" }, - { url = "https://files.pythonhosted.org/packages/32/59/f0ea443cbfb3b06e1d2e060217bb91f954845f6df38cbc9c5468b6c9c638/gensim-4.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1853fc5be730f692c444a826041fef9a2fc8d74c73bb59748904b2e3221daa86", size = 24455775, upload-time = "2025-10-18T01:55:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b8/9b0ba15756e41ccfdd852f9c65cd2b552f240c201dc3237ad8c178642e80/gensim-4.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23a2a4260f01c8f71bae5dd0e8a01bb247a2c789480c033e0eaba100b0ad4239", size = 27771345, upload-time = "2025-10-18T01:56:41.448Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/c29701826c963b04a43d5d7b87573a74040387ab9219e65b10f377d22b5b/gensim-4.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b73ff30af6ddd0d2ddf9473b1eb44603cd79ec14c87d93b75291802b991916c", size = 27864118, upload-time = "2025-10-18T01:57:32.428Z" }, - { url = "https://files.pythonhosted.org/packages/fd/f2/9ec6863143888bf390cdc5261f6d9e71d79bc95d98fb815679dba478d5f6/gensim-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b3a3f9bc8d4178b01d114e1c58c5ab2333f131c7415fb3d8ec8f1ecfe4c5b544", size = 24400277, upload-time = "2025-10-18T01:58:17.629Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, -] - -[[package]] -name = "graspologic" -version = "3.4.5.dev2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anytree" }, - { name = "beartype" }, - { name = "future" }, - { name = "gensim" }, - { name = "graspologic-native" }, - { name = "hyppo" }, - { name = "joblib" }, - { name = "matplotlib" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "pot" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "seaborn" }, - { name = "statsmodels" }, - { name = "typing-extensions" }, - { name = "umap-learn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/d9/3a20586ec6aa7097ea58e6b54a3b7170ae4445872f23d085460611b2a55b/graspologic-3.4.5.dev2.tar.gz", hash = "sha256:0226945c5e5ee31e1dec4e085f365577ab059e498ba842f455211fe35322c026", size = 6111760, upload-time = "2025-11-25T18:20:11.751Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/d2/2752eeba482c6adb7697db70ad47c79c9c7f6ba030ff8bb30b1b1ef064ef/graspologic-3.4.5.dev2-py3-none-any.whl", hash = "sha256:eb1ec49fea530f04aa22ac40d5e89b8511141ea1c9e0d577816bbf1c20aade68", size = 5201199, upload-time = "2025-11-25T18:20:10.112Z" }, -] - -[[package]] -name = "graspologic-native" -version = "1.2.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/2d/62b30d89533643ccf4778a18eb023f291b8877b5d85de3342f07b2d363a7/graspologic_native-1.2.5.tar.gz", hash = "sha256:27ea7e01fa44466c0b4cdd678d4561e5d3dc0cb400015683b7ae1386031257a0", size = 2512729, upload-time = "2025-04-02T19:34:22.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/86/10748f4c474b0c8f6060dd379bb0c4da5d42779244bb13a58656ffb44a03/graspologic_native-1.2.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bf05f2e162ae2a2a8d6e8cfccbe3586d1faa0b808159ff950478348df557c61e", size = 648437, upload-time = "2025-04-02T19:34:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/42/cc/b75ea35755340bedda29727e5388390c639ea533f55b9249f5ac3003f656/graspologic_native-1.2.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fff06ed49c3875cf351bb09a92ae7cbc169ce92dcc4c3439e28e801f822ae", size = 352044, upload-time = "2025-04-02T19:34:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/8e/55/15e6e4f18bf249b529ac4cd1522b03f5c9ef9284a2f7bfaa1fd1f96464fe/graspologic_native-1.2.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e7e993e7d70fe0d860773fc62812fbb8cb4ef2d11d8661a1f06f8772593915", size = 364644, upload-time = "2025-04-02T19:34:19.486Z" }, - { url = "https://files.pythonhosted.org/packages/3b/51/21097af79f3d68626539ab829bdbf6cc42933f020e161972927d916e394c/graspologic_native-1.2.5-cp38-abi3-win_amd64.whl", hash = "sha256:c3ef2172d774083d7e2c8e77daccd218571ddeebeb2c1703cebb1a2cc4c56e07", size = 210438, upload-time = "2025-04-02T19:34:21.139Z" }, -] - -[[package]] -name = "greenlet" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "hanziconv" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/71/b89cb63077fd807fe31cf7c016a06e7e579a289d8a37aa24a30282d02dd2/hanziconv-0.3.2.tar.gz", hash = "sha256:208866da6ae305bca19eb98702b65c93bb3a803b496e4287ca740d68892fc4c4", size = 276775, upload-time = "2016-09-01T05:41:15.254Z" } - -[[package]] -name = "html5lib" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - -[[package]] -name = "huggingface-hub" -version = "0.25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/fd/5f81bae67096c5ab50d29a0230b8374f0245916cca192f8ee2fada51f4f6/huggingface_hub-0.25.2.tar.gz", hash = "sha256:a1014ea111a5f40ccd23f7f7ba8ac46e20fa3b658ced1f86a00c75c06ec6423c", size = 365806, upload-time = "2024-10-09T08:32:41.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/09/a535946bf2dc88e61341f39dc507530411bb3ea4eac493e5ec833e8f35bd/huggingface_hub-0.25.2-py3-none-any.whl", hash = "sha256:1897caf88ce7f97fe0110603d8f66ac264e3ba6accdf30cd66cc0fed5282ad25", size = 436575, upload-time = "2024-10-09T08:32:39.166Z" }, -] - -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, -] - -[[package]] -name = "humanize" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, -] - -[[package]] -name = "hyppo" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "autograd" }, - { name = "future" }, - { name = "numba" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "patsy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "statsmodels" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/c4/d46858cfac3c0aad314a1fc378beae5c8cac499b677650a34b5a6a3d4328/hyppo-0.5.2-py3-none-any.whl", hash = "sha256:5cc18f9e158fe2cf1804c9a1e979e807118ee89a303f29dc5cb8891d92d44ef3", size = 192272, upload-time = "2025-05-24T18:33:25.904Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - -[[package]] -name = "jaraco-classes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, -] - -[[package]] -name = "jaraco-context" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, -] - -[[package]] -name = "jaraco-functools" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, -] - -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - -[[package]] -name = "jieba" -version = "0.42.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" } - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "jiter" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, - { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, - { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, - { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, -] - -[[package]] -name = "jmespath" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/56/3f325b1eef9791759784aa5046a8f6a1aff8f7c898a2e34506771d3b99d8/jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", size = 21607, upload-time = "2020-05-12T22:03:47.267Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/cb/5f001272b6faeb23c1c9e0acc04d48eaaf5c862c17709d20e3469c6e0139/jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f", size = 24489, upload-time = "2020-05-12T22:03:45.643Z" }, -] - -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "json-repair" -version = "0.53.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9c/be1d84106529aeacbe6151c1e1dc202f5a5cfa0d9bac748d4a1039ebb913/json_repair-0.53.0.tar.gz", hash = "sha256:97fcbf1eea0bbcf6d5cc94befc573623ab4bbba6abdc394cfd3b933a2571266d", size = 36204, upload-time = "2025-11-08T13:45:15.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/49/e588ec59b64222c8d38585f9ceffbf71870c3cbfb2873e53297c4f4afd0b/json_repair-0.53.0-py3-none-any.whl", hash = "sha256:17f7439e41ae39964e1d678b1def38cb8ec43d607340564acf3e62d8ce47a727", size = 27404, upload-time = "2025-11-08T13:45:14.464Z" }, -] - -[[package]] -name = "jsonpatch" -version = "1.33" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonpointer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, -] - -[[package]] -name = "jsonpointer" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-path" -version = "0.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pathable" }, - { name = "pyyaml" }, - { name = "referencing" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "keyring" -version = "25.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, -] - -[[package]] -name = "kombu" -version = "5.5.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, -] - -[[package]] -name = "langchain" -version = "1.2.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/bc/d8f506a525baadee99a65c6cc28c1c35c9eaf1cb2009f048e9861d81a600/langchain-1.2.6.tar.gz", hash = "sha256:7d46cbf719d860a16f6fc182d5d3de17453dda187f3d43e9c40ac352a5094fdd", size = 553127, upload-time = "2026-01-16T19:21:19.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/28/d5dc4cb06ccb29d62a590d446072964766555e85863f5044c6e644c07d0d/langchain-1.2.6-py3-none-any.whl", hash = "sha256:a9a6c39f03c09b6eb0f1b47e267ad2a2fd04e124dfaa9753bd6c11d2fe7d944e", size = 108458, upload-time = "2026-01-16T19:21:18.085Z" }, -] - -[[package]] -name = "langchain-aws" -version = "1.0.0a1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "boto3" }, - { name = "langchain-core" }, - { name = "numpy" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/c3/a98c0849c13c6880b5629409cadb22d4070e9c611013da127be975f8c0dc/langchain_aws-1.0.0a1.tar.gz", hash = "sha256:3bb193a5fa915520c52bb47581e892d11ac4d114939a1b3ecfeca56fe153fff7", size = 121650, upload-time = "2025-09-18T20:52:36.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7b/be49a224fe3aa07ed869801356f06e1d7a321bb7f22b6f7935dce86d258a/langchain_aws-1.0.0a1-py3-none-any.whl", hash = "sha256:24207d05c619ea61dfeab0a0f7086ae388cc3f2f5c03a8ae56b12d1b77d72585", size = 146839, upload-time = "2025-09-18T20:52:35.013Z" }, -] - -[[package]] -name = "langchain-classic" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langchain-text-splitters" }, - { name = "langsmith" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/4b/bd03518418ece4c13192a504449b58c28afee915dc4a6f4b02622458cb1b/langchain_classic-1.0.1.tar.gz", hash = "sha256:40a499684df36b005a1213735dc7f8dca8f5eb67978d6ec763e7a49780864fdc", size = 10516020, upload-time = "2025-12-23T22:55:22.615Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl", hash = "sha256:131d83a02bb80044c68fedc1ab4ae885d5b8f8c2c742d8ab9e7534ad9cda8e80", size = 1040666, upload-time = "2025-12-23T22:55:21.025Z" }, -] - -[[package]] -name = "langchain-community" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "dataclasses-json" }, - { name = "httpx-sse" }, - { name = "langchain-classic" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "numpy" }, - { name = "pydantic-settings" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, - { name = "tenacity" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl", hash = "sha256:2135abb2c7748a35c84613108f7ebf30f8505b18c3c18305ffaecfc7651f6c6a", size = 2533285, upload-time = "2025-10-27T15:20:30.767Z" }, -] - -[[package]] -name = "langchain-core" -version = "1.2.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonpatch" }, - { name = "langsmith" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "uuid-utils" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, -] - -[[package]] -name = "langchain-mcp-adapters" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "mcp" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/52/cebf0ef5b1acef6cbc63d671171d43af70f12d19f55577909c7afa79fb6e/langchain_mcp_adapters-0.2.1.tar.gz", hash = "sha256:58e64c44e8df29ca7eb3b656cf8c9931ef64386534d7ca261982e3bdc63f3176", size = 36394, upload-time = "2025-12-09T16:28:38.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/81/b2479eb26861ab36be851026d004b2d391d789b7856e44c272b12828ece0/langchain_mcp_adapters-0.2.1-py3-none-any.whl", hash = "sha256:9f96ad4c64230f6757297fec06fde19d772c99dbdfbca987f7b7cfd51ff77240", size = 22708, upload-time = "2025-12-09T16:28:37.877Z" }, -] - -[[package]] -name = "langchain-ollama" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "ollama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/51/72cd04d74278f3575f921084f34280e2f837211dc008c9671c268c578afe/langchain_ollama-1.0.1.tar.gz", hash = "sha256:e37880c2f41cdb0895e863b1cfd0c2c840a117868b3f32e44fef42569e367443", size = 153850, upload-time = "2025-12-12T21:48:28.68Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/46/f2907da16dc5a5a6c679f83b7de21176178afad8d2ca635a581429580ef6/langchain_ollama-1.0.1-py3-none-any.whl", hash = "sha256:37eb939a4718a0255fe31e19fbb0def044746c717b01b97d397606ebc3e9b440", size = 29207, upload-time = "2025-12-12T21:48:27.832Z" }, -] - -[[package]] -name = "langchain-openai" -version = "1.1.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "openai" }, - { name = "tiktoken" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/b7/30bfc4d1b658a9ee524bcce3b0b2ec9c45a11c853a13c4f0c9da9882784b/langchain_openai-1.1.7.tar.gz", hash = "sha256:f5ec31961ed24777548b63a5fe313548bc6e0eb9730d6552b8c6418765254c81", size = 1039134, upload-time = "2026-01-07T19:44:59.728Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/a1/50e7596aca775d8c3883eceeaf47489fac26c57c1abe243c00174f715a8a/langchain_openai-1.1.7-py3-none-any.whl", hash = "sha256:34e9cd686aac1a120d6472804422792bf8080a2103b5d21ee450c9e42d053815", size = 84753, upload-time = "2026-01-07T19:44:58.629Z" }, -] - -[[package]] -name = "langchain-text-splitters" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/42/c178dcdc157b473330eb7cc30883ea69b8ec60078c7b85e2d521054c4831/langchain_text_splitters-1.1.0.tar.gz", hash = "sha256:75e58acb7585dc9508f3cd9d9809cb14751283226c2d6e21fb3a9ae57582ca22", size = 272230, upload-time = "2025-12-14T01:15:38.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl", hash = "sha256:f00341fe883358786104a5f881375ac830a4dd40253ecd42b4c10536c6e4693f", size = 34182, upload-time = "2025-12-14T01:15:37.382Z" }, -] - -[[package]] -name = "langfuse" -version = "3.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "httpx" }, - { name = "openai" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/d2/33991342653d101715faae8f82c14eb3f0a5c2d22d8c99df9dbb8d099802/langfuse-3.12.0.tar.gz", hash = "sha256:0f75b3d21d4ef4014ebeaa8188eb0c855200412b4e4fb8cceca609a7ce465f91", size = 232651, upload-time = "2026-01-13T14:17:33.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/87/141689c2c2b352ed100de4a63f64f24b4df7f883ba2a3fc0c6733d9d0451/langfuse-3.12.0-py3-none-any.whl", hash = "sha256:644d9bbfa842eb6775b1e069e23f77ad1087f5241682966b8168bbb01f9c357e", size = 416875, upload-time = "2026-01-13T14:17:31.791Z" }, -] - -[[package]] -name = "langgraph" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, - { name = "langgraph-prebuilt" }, - { name = "langgraph-sdk" }, - { name = "pydantic" }, - { name = "xxhash" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/9c/dac99ab1732e9fb2d3b673482ac28f02bee222c0319a3b8f8f73d90727e6/langgraph-1.0.6.tar.gz", hash = "sha256:dd8e754c76d34a07485308d7117221acf63990e7de8f46ddf5fe256b0a22e6c5", size = 495092, upload-time = "2026-01-12T20:33:30.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/45/9960747781416bed4e531ed0c6b2f2c739bc7b5397d8e92155463735a40e/langgraph-1.0.6-py3-none-any.whl", hash = "sha256:bcfce190974519c72e29f6e5b17f0023914fd6f936bfab8894083215b271eb89", size = 157356, upload-time = "2026-01-12T20:33:29.191Z" }, -] - -[[package]] -name = "langgraph-checkpoint" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "ormsgpack" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/76/55a18c59dedf39688d72c4b06af73a5e3ea0d1a01bc867b88fbf0659f203/langgraph_checkpoint-4.0.0.tar.gz", hash = "sha256:814d1bd050fac029476558d8e68d87bce9009a0262d04a2c14b918255954a624", size = 137320, upload-time = "2026-01-12T20:30:26.38Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, -] - -[[package]] -name = "langgraph-prebuilt" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/8c75dace0d729561dce2966e630c5e312193df7e5df41a7e10cd7378c3a7/langgraph_prebuilt-1.0.6.tar.gz", hash = "sha256:c5f6cf0f5a0ac47643d2e26ae6faa38cb28885ecde67911190df9e30c4f72361", size = 162623, upload-time = "2026-01-12T20:31:28.425Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/6c/4045822b0630cfc0f8624c4499ceaf90644142143c063a8dc385a7424fc3/langgraph_prebuilt-1.0.6-py3-none-any.whl", hash = "sha256:9fdc35048ff4ac985a55bd2a019a86d45b8184551504aff6780d096c678b39ae", size = 35322, upload-time = "2026-01-12T20:31:27.161Z" }, -] - -[[package]] -name = "langgraph-sdk" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "orjson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/0f/ed0634c222eed48a31ba48eab6881f94ad690d65e44fe7ca838240a260c1/langgraph_sdk-0.3.3.tar.gz", hash = "sha256:c34c3dce3b6848755eb61f0c94369d1ba04aceeb1b76015db1ea7362c544fb26", size = 130589, upload-time = "2026-01-13T00:30:43.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl", hash = "sha256:a52ebaf09d91143e55378bb2d0b033ed98f57f48c9ad35c8f81493b88705fc7b", size = 67021, upload-time = "2026-01-13T00:30:42.264Z" }, -] - -[[package]] -name = "langsmith" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "uuid-utils" }, - { name = "zstandard" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" }, -] - -[[package]] -name = "llvmlite" -version = "0.46.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" }, -] - -[[package]] -name = "lupa" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, - { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, - { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, - { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, - { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, - { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, - { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, - { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, -] - -[[package]] -name = "mako" -version = "1.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, -] - -[[package]] -name = "mammoth" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cobble" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/3c/a58418d2af00f2da60d4a51e18cd0311307b72d48d2fffec36a97b4a5e44/mammoth-1.11.0.tar.gz", hash = "sha256:a0f59e442f34d5b6447f4b0999306cbf3e67aaabfa8cb516f878fb1456744637", size = 53142, upload-time = "2025-09-19T10:35:20.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/54/2e39566a131b13f6d8d193f974cb6a34e81bb7cc2fa6f7e03de067b36588/mammoth-1.11.0-py2.py3-none-any.whl", hash = "sha256:c077ab0d450bd7c0c6ecd529a23bf7e0fa8190c929e28998308ff4eada3f063b", size = 54752, upload-time = "2025-09-19T10:35:18.699Z" }, -] - -[[package]] -name = "markdown" -version = "3.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906, upload-time = "2025-04-11T14:42:50.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210, upload-time = "2025-04-11T14:42:49.178Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markdown-to-json" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/1a/d235321eac5ba6de9f83dd172b9549eb03fd149ecda4c8c25cdc9a5224bc/markdown_to_json-2.1.1.tar.gz", hash = "sha256:27642c42acd9130d1449f791f57fd0c4bbf58c7a76cfb5af6d42010ca97b1107", size = 51343, upload-time = "2024-05-09T19:08:44.729Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/2b/dac4143951a16c0c03e8fe217c9fa784838d02a29c52ef0e8b265befea8f/markdown_to_json-2.1.1-py3-none-any.whl", hash = "sha256:c73b8a3ac7fbde65463dbaeba8bb925d1d54377cbb01a064cd65e1f3e394bd62", size = 52647, upload-time = "2024-05-09T19:08:42.959Z" }, -] - -[[package]] -name = "markdownify" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/1b/6f2697b51eaca81f08852fd2734745af15718fea10222a1d40f8a239c4ea/markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c", size = 18771, upload-time = "2025-08-09T17:44:15.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, -] - -[[package]] -name = "marshmallow" -version = "3.26.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, -] - -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, -] - -[[package]] -name = "mcp" -version = "1.25.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "more-itertools" -version = "10.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "neo4j" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[package]] -name = "nltk" -version = "3.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, -] - -[[package]] -name = "numba" -version = "0.63.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/9c/c0974cd3d00ff70d30e8ff90522ba5fbb2bcee168a867d2321d8d0457676/numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71", size = 2680981, upload-time = "2025-12-10T02:57:17.579Z" }, - { url = "https://files.pythonhosted.org/packages/cb/70/ea2bc45205f206b7a24ee68a159f5097c9ca7e6466806e7c213587e0c2b1/numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f", size = 3801656, upload-time = "2025-12-10T02:57:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/4f4ba4fd0f99825cbf3cdefd682ca3678be1702b63362011de6e5f71f831/numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0", size = 3501857, upload-time = "2025-12-10T02:57:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/af/fd/6540456efa90b5f6604a86ff50dabefb187e43557e9081adcad3be44f048/numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3", size = 2750282, upload-time = "2025-12-10T02:57:22.474Z" }, -] - -[[package]] -name = "numpy" -version = "1.26.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, - { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, - { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, -] - -[[package]] -name = "nvidia-cublas-cu12" -version = "12.1.3.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/6d/121efd7382d5b0284239f4ab1fc1590d86d34ed4a4a2fdb13b30ca8e5740/nvidia_cublas_cu12-12.1.3.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:ee53ccca76a6fc08fb9701aa95b6ceb242cdaab118c3bb152af4e579af792728", size = 410594774, upload-time = "2023-04-19T15:50:03.519Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti-cu12" -version = "12.1.105" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/00/6b218edd739ecfc60524e585ba8e6b00554dd908de2c9c66c1af3e44e18d/nvidia_cuda_cupti_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:e54fde3983165c624cb79254ae9818a456eb6e87a7fd4d56a2352c24ee542d7e", size = 14109015, upload-time = "2023-04-19T15:47:32.502Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc-cu12" -version = "12.1.105" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/9f/c64c03f49d6fbc56196664d05dba14e3a561038a81a638eeb47f4d4cfd48/nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:339b385f50c309763ca65456ec75e17bbefcbbf2893f462cb8b90584cd27a1c2", size = 23671734, upload-time = "2023-04-19T15:48:32.42Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime-cu12" -version = "12.1.105" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/d5/c68b1d2cdfcc59e72e8a5949a37ddb22ae6cade80cd4a57a84d4c8b55472/nvidia_cuda_runtime_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:6e258468ddf5796e25f1dc591a31029fa317d97a0a94ed93468fc86301d61e40", size = 823596, upload-time = "2023-04-19T15:47:22.471Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu12" -version = "8.9.2.26" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/74/a2e2be7fb83aaedec84f391f082cf765dfb635e7caa9b49065f73e4835d8/nvidia_cudnn_cu12-8.9.2.26-py3-none-manylinux1_x86_64.whl", hash = "sha256:5ccb288774fdfb07a7e7025ffec286971c06d8d7b4fb162525334616d7629ff9", size = 731725872, upload-time = "2023-06-01T19:24:57.328Z" }, -] - -[[package]] -name = "nvidia-cufft-cu12" -version = "11.0.2.54" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/94/eb540db023ce1d162e7bea9f8f5aa781d57c65aed513c33ee9a5123ead4d/nvidia_cufft_cu12-11.0.2.54-py3-none-manylinux1_x86_64.whl", hash = "sha256:794e3948a1aa71fd817c3775866943936774d1c14e7628c74f6f7417224cdf56", size = 121635161, upload-time = "2023-04-19T15:50:46Z" }, -] - -[[package]] -name = "nvidia-curand-cu12" -version = "10.3.2.106" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/31/4890b1c9abc496303412947fc7dcea3d14861720642b49e8ceed89636705/nvidia_curand_cu12-10.3.2.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:9d264c5036dde4e64f1de8c50ae753237c12e0b1348738169cd0f8a536c0e1e0", size = 56467784, upload-time = "2023-04-19T15:51:04.804Z" }, -] - -[[package]] -name = "nvidia-cusolver-cu12" -version = "11.4.5.107" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/1d/8de1e5c67099015c834315e333911273a8c6aaba78923dd1d1e25fc5f217/nvidia_cusolver_cu12-11.4.5.107-py3-none-manylinux1_x86_64.whl", hash = "sha256:8a7ec542f0412294b15072fa7dab71d31334014a69f953004ea7a118206fe0dd", size = 124161928, upload-time = "2023-04-19T15:51:25.781Z" }, -] - -[[package]] -name = "nvidia-cusparse-cu12" -version = "12.1.0.106" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/5b/cfaeebf25cd9fdec14338ccb16f6b2c4c7fa9163aefcf057d86b9cc248bb/nvidia_cusparse_cu12-12.1.0.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:f3b50f42cf363f86ab21f720998517a659a48131e8d538dc02f8768237bd884c", size = 195958278, upload-time = "2023-04-19T15:51:49.939Z" }, -] - -[[package]] -name = "nvidia-nccl-cu12" -version = "2.19.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/00/d0d4e48aef772ad5aebcf70b73028f88db6e5640b36c38e90445b7a57c45/nvidia_nccl_cu12-2.19.3-py3-none-manylinux1_x86_64.whl", hash = "sha256:a9734707a2c96443331c1e48c717024aa6678a0e2a4cb66b2c364d18cee6b48d", size = 165987969, upload-time = "2023-10-24T16:16:24.789Z" }, -] - -[[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.9.86" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/0c/c75bbfb967457a0b7670b8ad267bfc4fffdf341c074e0a80db06c24ccfd4/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e3f1171dbdc83c5932a45f0f4c99180a70de9bd2718c1ab77d14104f6d7147f9", size = 39748338, upload-time = "2025-06-05T20:10:25.613Z" }, -] - -[[package]] -name = "nvidia-nvtx-cu12" -version = "12.1.105" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/d3/8057f0587683ed2fcd4dbfbdfdfa807b9160b809976099d36b8f60d08f03/nvidia_nvtx_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:dc21cf308ca5691e7c04d962e213f8a4aa9bbfa23d95412f452254c2caeb09e5", size = 99138, upload-time = "2023-04-19T15:48:43.556Z" }, -] - -[[package]] -name = "olefile" -version = "0.47" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, -] - -[[package]] -name = "ollama" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, -] - -[[package]] -name = "onnxruntime" -version = "1.20.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, - { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, -] - -[[package]] -name = "openai" -version = "2.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, -] - -[[package]] -name = "openapi-pydantic" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, -] - -[[package]] -name = "opencv-python" -version = "4.10.0.84" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/b70a2d9ab205110d715906fc8ec83fbb00404aeb3a37a0654fdb68eb0c8c/opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526", size = 95103981, upload-time = "2024-06-17T18:29:56.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/82/564168a349148298aca281e342551404ef5521f33fba17b388ead0a84dc5/opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251", size = 54835524, upload-time = "2024-06-18T04:57:32.973Z" }, - { url = "https://files.pythonhosted.org/packages/64/4a/016cda9ad7cf18c58ba074628a4eaae8aa55f3fd06a266398cef8831a5b9/opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98", size = 56475426, upload-time = "2024-06-17T19:34:10.927Z" }, - { url = "https://files.pythonhosted.org/packages/81/e4/7a987ebecfe5ceaf32db413b67ff18eb3092c598408862fff4d7cc3fd19b/opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6", size = 41746971, upload-time = "2024-06-17T20:00:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/3f/a4/d2537f47fd7fcfba966bd806e3ec18e7ee1681056d4b0a9c8d983983e4d5/opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f", size = 62548253, upload-time = "2024-06-17T18:29:43.659Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/bbf57e7b9dab623e8773f6ff36385456b7ae7fa9357a5e53db732c347eac/opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236", size = 28737688, upload-time = "2024-06-17T18:28:13.177Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6c/fab8113424af5049f85717e8e527ca3773299a3c6b02506e66436e19874f/opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe", size = 38842521, upload-time = "2024-06-17T18:28:21.813Z" }, -] - -[[package]] -name = "openpyxl" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, -] - -[[package]] -name = "opentelemetry-api" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, -] - -[[package]] -name = "opentelemetry-exporter-prometheus" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, -] - -[[package]] -name = "orjson" -version = "3.11.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, - { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, - { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, -] - -[[package]] -name = "ormsgpack" -version = "1.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, - { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, - { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, -] - -[[package]] -name = "oss2" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aliyun-python-sdk-core" }, - { name = "aliyun-python-sdk-kms" }, - { name = "crcmod" }, - { name = "pycryptodome" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" } - -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pandas" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, -] - -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[[package]] -name = "pathable" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, -] - -[[package]] -name = "pathvalidate" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, -] - -[[package]] -name = "patsy" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, -] - -[[package]] -name = "pdfminer-six" -version = "20250506" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "charset-normalizer" }, - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/46/5223d613ac4963e1f7c07b2660fe0e9e770102ec6bda8c038400113fb215/pdfminer_six-20250506.tar.gz", hash = "sha256:b03cc8df09cf3c7aba8246deae52e0bca7ebb112a38895b5e1d4f5dd2b8ca2e7", size = 7387678, upload-time = "2025-05-06T16:17:00.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/16/7a432c0101fa87457e75cb12c879e1749c5870a786525e2e0f42871d6462/pdfminer_six-20250506-py3-none-any.whl", hash = "sha256:d81ad173f62e5f841b53a8ba63af1a4a355933cfc0ffabd608e568b9193909e3", size = 5620187, upload-time = "2025-05-06T16:16:58.669Z" }, -] - -[[package]] -name = "pdfplumber" -version = "0.11.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pdfminer-six" }, - { name = "pillow" }, - { name = "pypdfium2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/0d/4135821aa7b1a0b77a29fac881ef0890b46b0b002290d04915ed7acc0043/pdfplumber-0.11.7.tar.gz", hash = "sha256:fa67773e5e599de1624255e9b75d1409297c5e1d7493b386ce63648637c67368", size = 115518, upload-time = "2025-06-12T11:30:49.864Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/e0/52b67d4f00e09e497aec4f71bc44d395605e8ebcea52543242ed34c25ef9/pdfplumber-0.11.7-py3-none-any.whl", hash = "sha256:edd2195cca68bd770da479cf528a737e362968ec2351e62a6c0b71ff612ac25e", size = 60029, upload-time = "2025-06-12T11:30:48.89Z" }, -] - -[[package]] -name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "portalocker" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, -] - -[[package]] -name = "pot" -version = "0.9.6.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/8b/5f939eaf1fbeb7ff914fe540d659486951a056e5537b8f454362045b6c72/pot-0.9.6.post1.tar.gz", hash = "sha256:9b6cc14a8daecfe1268268168cf46548f9130976b22b24a9e8ec62a734be6c43", size = 604243, upload-time = "2025-09-22T12:51:14.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/28/13622807461f9f6082a8cd6768f9b4a810bc3a8fda474b81572da94b4d23/pot-0.9.6.post1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f7c542fc20662e35c24dd82eeff8a737220757434d7f0038664a7322221452f7", size = 599240, upload-time = "2025-09-22T12:50:44.848Z" }, - { url = "https://files.pythonhosted.org/packages/c6/5c/b4e017560531f53d06798c681b0d0a9488bb8116bc98da9d399a3d096391/pot-0.9.6.post1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c1755516a7354cbd6110ad2e5f341b98b9968240c2f0f67b0ff5e3ebcb3105bd", size = 464695, upload-time = "2025-09-22T12:50:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/07/9f/57e49b3f7173359741053c5e2766a45dcf649d767c2e967ef93526c9045f/pot-0.9.6.post1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3207362d3e3b5aaa783f452aa85f66e83edbefb5764f34662860af54ac72ee6", size = 454726, upload-time = "2025-09-22T12:50:47.953Z" }, - { url = "https://files.pythonhosted.org/packages/30/60/fa72dd6094f7dbe6b38e2c6907af8cd0f18c6bd107e0cf4874deddaba883/pot-0.9.6.post1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f6659c5657e6d7e9f98f4a82e0ed64f88e9fce69b2e557416d156343919ba3", size = 1503391, upload-time = "2025-09-22T12:50:49.336Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3f/cc519c1176116271b6282268a705162fa042c16cc922bc56039445c9d697/pot-0.9.6.post1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f1b0148ae17bec0ed12264c6da3a05e13913b716e2a8c9043242b5d8349d8df", size = 1528170, upload-time = "2025-09-22T12:50:50.625Z" }, - { url = "https://files.pythonhosted.org/packages/f5/01/0132c94404cd0b1b2f21c4a49698db9dcd6107c47c02b22df1ed38206b2a/pot-0.9.6.post1-cp312-cp312-win32.whl", hash = "sha256:571e543cc2b0a462365002203595baf2b89c3d064cce4fce70fd1231e832c21f", size = 440577, upload-time = "2025-09-22T12:50:51.716Z" }, - { url = "https://files.pythonhosted.org/packages/c1/6d/23229c0e198a4f7fb27750b3ef8497e6ebed23fe531ed64b5194da8b2b02/pot-0.9.6.post1-cp312-cp312-win_amd64.whl", hash = "sha256:b1d8bd9a334c72baa37f9a2b268de5366c23c0f9c9e3d6dc25d150137ec2823c", size = 455404, upload-time = "2025-09-22T12:50:52.956Z" }, -] - -[[package]] -name = "proces" -version = "0.1.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/3d/4159b57736ced0fd22553226df20a985ef7655519c80ffcb8a9fb49ebeee/proces-0.1.7.tar.gz", hash = "sha256:70a05d9e973dd685f7a9092c58be695a8181a411d63796c213232fd3fdc43775", size = 31188, upload-time = "2023-09-09T03:27:38.158Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/88/06cc0c7d890ed8d7e16ef0e56880dea516a21643fb1f3a69a50f4cc6f716/proces-0.1.7-py3-none-any.whl", hash = "sha256:308325bbc96877263f06e57e5e9c760c4b42cc722887ad60be6b18fc37d68762", size = 137718, upload-time = "2023-09-09T03:27:35.463Z" }, -] - -[[package]] -name = "prometheus-client" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - -[[package]] -name = "protobuf" -version = "6.33.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, - { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, - { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, - { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, -] - -[[package]] -name = "psycopg2-binary" -version = "2.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, -] - -[[package]] -name = "py-key-value-aio" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "py-key-value-shared" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, -] - -[package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, -] -keyring = [ - { name = "keyring" }, -] -memory = [ - { name = "cachetools" }, -] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, -] - -[[package]] -name = "pyclipper" -version = "1.3.0.post6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/b2/550fe500e49c464d73fabcb8cb04d47e4885d6ca4cfc1f5b0a125a95b19a/pyclipper-1.3.0.post6.tar.gz", hash = "sha256:42bff0102fa7a7f2abdd795a2594654d62b786d0c6cd67b72d469114fdeb608c", size = 165909, upload-time = "2024-10-18T12:23:09.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/c8/197d9a1d8354922d24d11d22fb2e0cc1ebc182f8a30496b7ddbe89467ce1/pyclipper-1.3.0.post6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6363b9d79ba1b5d8f32d1623e797c1e9f994600943402e68d5266067bdde173e", size = 270487, upload-time = "2024-10-18T12:22:14.852Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8e/eb14eadf054494ad81446e21c4ea163b941747610b0eb9051644395f567e/pyclipper-1.3.0.post6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32cd7fb9c1c893eb87f82a072dbb5e26224ea7cebbad9dc306d67e1ac62dd229", size = 143469, upload-time = "2024-10-18T12:22:16.109Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e5/6c4a8df6e904c133bb4c5309d211d31c751db60cbd36a7250c02b05494a1/pyclipper-1.3.0.post6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3aab10e3c10ed8fa60c608fb87c040089b83325c937f98f06450cf9fcfdaf1d", size = 944206, upload-time = "2024-10-18T12:22:17.216Z" }, - { url = "https://files.pythonhosted.org/packages/76/65/cb014acc41cd5bf6bbfa4671c7faffffb9cee01706642c2dec70c5209ac8/pyclipper-1.3.0.post6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58eae2ff92a8cae1331568df076c4c5775bf946afab0068b217f0cf8e188eb3c", size = 963797, upload-time = "2024-10-18T12:22:18.881Z" }, - { url = "https://files.pythonhosted.org/packages/80/ec/b40cd81ab7598984167508a5369a2fa31a09fe3b3e3d0b73aa50e06d4b3f/pyclipper-1.3.0.post6-cp312-cp312-win32.whl", hash = "sha256:793b0aa54b914257aa7dc76b793dd4dcfb3c84011d48df7e41ba02b571616eaf", size = 99456, upload-time = "2024-10-18T12:22:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/24/3a/7d6292e3c94fb6b872d8d7e80d909dc527ee6b0af73b753c63fdde65a7da/pyclipper-1.3.0.post6-cp312-cp312-win_amd64.whl", hash = "sha256:d3f9da96f83b8892504923beb21a481cd4516c19be1d39eb57a92ef1c9a29548", size = 110278, upload-time = "2024-10-18T12:22:21.178Z" }, -] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, -] - -[[package]] -name = "pycryptodome" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, -] - -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, - { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, - { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, - { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, - { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, - { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, - { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, - { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, - { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, - { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, - { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, -] - -[[package]] -name = "pydocket" -version = "0.16.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "fakeredis", extra = ["lua"] }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-instrumentation" }, - { name = "prometheus-client" }, - { name = "py-key-value-aio", extra = ["memory", "redis"] }, - { name = "python-json-logger" }, - { name = "redis" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pynndescent" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "llvmlite" }, - { name = "numba" }, - { name = "scikit-learn" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - -[[package]] -name = "pypdf" -version = "6.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/3d/b6ead84ee437444f96862beb68f9796da8c199793bed08e9397b77579f23/pypdf-6.1.3.tar.gz", hash = "sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d", size = 5076271, upload-time = "2025-10-22T16:13:46.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/ed/494fd0cc1190a7c335e6958eeaee6f373a281869830255c2ed4785dac135/pypdf-6.1.3-py3-none-any.whl", hash = "sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5", size = 323863, upload-time = "2025-10-22T16:13:44.174Z" }, -] - -[[package]] -name = "pypdf2" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" }, -] - -[[package]] -name = "pypdfium2" -version = "5.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/83/173dab58beb6c7e772b838199014c173a2436018dd7cfde9bbf4a3be15da/pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885", size = 268742, upload-time = "2026-01-05T16:29:03.02Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/a4/6bb5b5918c7fc236ec426be8a0205a984fe0a26ae23d5e4dd497398a6571/pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540", size = 2763287, upload-time = "2026-01-05T16:28:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/3e/64/24b41b906006bf07099b095f0420ee1f01a3a83a899f3e3731e4da99c06a/pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48", size = 2303285, upload-time = "2026-01-05T16:28:26.274Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c0/3ec73f4ded83ba6c02acf6e9d228501759d5d74fe57f1b93849ab92dcc20/pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523", size = 2816066, upload-time = "2026-01-05T16:28:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/62/ca/e553b3b8b5c2cdc3d955cc313493ac27bbe63fc22624769d56ded585dd5e/pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907", size = 2945545, upload-time = "2026-01-05T16:28:29.489Z" }, - { url = "https://files.pythonhosted.org/packages/a1/56/615b776071e95c8570d579038256d0c77969ff2ff381e427be4ab8967f44/pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca", size = 2979892, upload-time = "2026-01-05T16:28:31.088Z" }, - { url = "https://files.pythonhosted.org/packages/df/10/27114199b765bdb7d19a9514c07036ad2fc3a579b910e7823ba167ead6de/pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27", size = 2765738, upload-time = "2026-01-05T16:28:33.18Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d7/2a3afa35e6c205a4f6264c33b8d2f659707989f93c30b336aa58575f66fa/pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75", size = 3064338, upload-time = "2026-01-05T16:28:34.731Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/6658755cf6e369bb51d0bccb81c51c300404fbe67c2f894c90000b6442dd/pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3", size = 3415059, upload-time = "2026-01-05T16:28:37.313Z" }, - { url = "https://files.pythonhosted.org/packages/f5/34/f86482134fa641deb1f524c45ec7ebd6fc8d404df40c5657ddfce528593e/pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987", size = 2998517, upload-time = "2026-01-05T16:28:38.873Z" }, - { url = "https://files.pythonhosted.org/packages/09/34/40ab99425dcf503c172885904c5dc356c052bfdbd085f9f3cc920e0b8b25/pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481", size = 3673154, upload-time = "2026-01-05T16:28:40.347Z" }, - { url = "https://files.pythonhosted.org/packages/a5/67/0f7532f80825a7728a5cbff3f1104857f8f9fe49ebfd6cb25582a89ae8e1/pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8", size = 2965002, upload-time = "2026-01-05T16:28:42.143Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6c/c03d2a3d6621b77aac9604bce1c060de2af94950448787298501eac6c6a2/pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330", size = 4130530, upload-time = "2026-01-05T16:28:44.264Z" }, - { url = "https://files.pythonhosted.org/packages/af/39/9ad1f958cbe35d4693ae87c09ebafda4bb3e4709c7ccaec86c1a829163a3/pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47", size = 3746568, upload-time = "2026-01-05T16:28:45.897Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e2/4d32310166c2d6955d924737df8b0a3e3efc8d133344a98b10f96320157d/pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686", size = 4336683, upload-time = "2026-01-05T16:28:47.584Z" }, - { url = "https://files.pythonhosted.org/packages/14/ea/38c337ff12a8cec4b00fd4fdb0a63a70597a344581e20b02addbd301ab56/pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285", size = 4375030, upload-time = "2026-01-05T16:28:49.5Z" }, - { url = "https://files.pythonhosted.org/packages/a1/77/9d8de90c35d2fc383be8819bcde52f5821dacbd7404a0225e4010b99d080/pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f", size = 3928914, upload-time = "2026-01-05T16:28:51.433Z" }, - { url = "https://files.pythonhosted.org/packages/a5/39/9d4a6fbd78fcb6803b0ea5e4952a31d6182a0aaa2609cfcd0eb88446fdb8/pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e", size = 4997777, upload-time = "2026-01-05T16:28:53.589Z" }, - { url = "https://files.pythonhosted.org/packages/9d/38/cdd4ed085c264234a59ad32df1dfe432c77a7403da2381e0fcc1ba60b74e/pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5", size = 4179895, upload-time = "2026-01-05T16:28:55.322Z" }, - { url = "https://files.pythonhosted.org/packages/93/4c/d2f40145c9012482699664f615d7ae540a346c84f68a8179449e69dcc4d8/pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9", size = 2993570, upload-time = "2026-01-05T16:28:58.37Z" }, - { url = "https://files.pythonhosted.org/packages/2c/dc/1388ea650020c26ef3f68856b9227e7f153dcaf445e7e4674a0b8f26891e/pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574", size = 3102340, upload-time = "2026-01-05T16:28:59.933Z" }, - { url = "https://files.pythonhosted.org/packages/c8/71/a433668d33999b3aeb2c2dda18aaf24948e862ea2ee148078a35daac6c1c/pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12", size = 2940987, upload-time = "2026-01-05T16:29:01.511Z" }, -] - -[[package]] -name = "pyperclip" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, -] - -[[package]] -name = "pyreadline3" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "python-calamine" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/32/99a794a1ca7b654cecdb76d4d61f21658b6f76574321341eb47df4365807/python_calamine-0.6.1.tar.gz", hash = "sha256:5974989919aa0bb55a136c1822d6f8b967d13c0fd0f245e3293abb4e63ab0f4b", size = 138354, upload-time = "2025-11-26T10:48:35.331Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/ad/f7cd7281dbd15c63c106963bdc2474354eeac58afb5484da23cfb89f650e/python_calamine-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b06e10ce5a83ed32d7322b79b929eccde02fa69cdca74a0af69f373f4a0ba38e", size = 877325, upload-time = "2025-11-26T10:46:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/76/4f/d29f20e48adc1e7bab38f74498935dd3047c3ffc31fdf8424a68d821965b/python_calamine-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57fc3dd9a4b293ad1300c35b10f4f6bdffb80861b6b4fe7e5bb05ef12dc6bc43", size = 854967, upload-time = "2025-11-26T10:46:27.38Z" }, - { url = "https://files.pythonhosted.org/packages/94/04/c8eac3245010eaa0a39b27c4c53d401eae8719a0a8044106d7cb7761d57d/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6b44d98d29769595af6d17443607156da55b8ee7338011abd20f51a3c540d1", size = 928722, upload-time = "2025-11-26T10:46:28.807Z" }, - { url = "https://files.pythonhosted.org/packages/3b/0d/a08871caf15673a7af94a42ae7af183ef9f6790851c027e97d425a7285ba/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:599928d30ef294c688c2a2db0c24e05a81a7dff08fec7865f6724694ab68950a", size = 912566, upload-time = "2025-11-26T10:46:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7b/5547c90b5d9b0ca10dd81398673968a08040ad0b6a757e2ca05d8deef6eb/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28a4799efc9d163130edb8b4f7b35a0e51f46b40e3ce57c024fa2c52d10bbe4b", size = 1073608, upload-time = "2025-11-26T10:46:31.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f3/4b8007cab8084d5d5c1b3da1f4490035033692d12b66a5fcc2903fb76554/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a57a1876748746c9e41237fd1dd49c2f231628c5f97ca1ef1b100db97af7a0e2", size = 964662, upload-time = "2025-11-26T10:46:33.193Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d2/71ea99fd1b06864791267c9ff43480fa569d0f7700506bbb84d9a17cb749/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73c9b06cac54d0b4350d6935bab6fead954b997062854aeaba3c7a966db5ac0", size = 933579, upload-time = "2025-11-26T10:46:34.62Z" }, - { url = "https://files.pythonhosted.org/packages/53/68/5556f44fdd1ed3e48c043e407e4ca7cd311787934b1ded9870d2dd1e5f4e/python_calamine-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c9e3db8502f59234bcd72cb3042c628fb2a99e59e721dbd11e8ee6106cee3513", size = 975141, upload-time = "2025-11-26T10:46:36.026Z" }, - { url = "https://files.pythonhosted.org/packages/c8/fa/595c254014c863b8f9ed68cef6dcdb58c3ea3bb0166fe6f120808441b427/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:978006312127727bb0f481992aa1e2f0d2109efe5d4a3fe248471efb1591d06d", size = 1110935, upload-time = "2025-11-26T10:46:37.531Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ae/9377b92cf380f7d5843348de148646c630665a32c2efcc7a88f3e8056eaf/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8a39d1e58610674f4fcc3648aff885897998228f6bb6d09e09dccd73c4b59e64", size = 1179688, upload-time = "2025-11-26T10:46:39.14Z" }, - { url = "https://files.pythonhosted.org/packages/47/23/d439d9dc61aa6bb5dcae4ee95de8cded53d2099d9d309531159e7050be26/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7d5874a1d83361a32099bfe6dce806498a4d9cf070dde0b48fd3e691789c1322", size = 1108864, upload-time = "2025-11-26T10:46:41.53Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/b54f124f03fff0c5439e899f6e3fb89636def08ac04f5c24184d2bfdc17f/python_calamine-0.6.1-cp312-cp312-win32.whl", hash = "sha256:9dca5bc0490b377fc619b4e93bff91a3ba296fefa2aab3eb7a652c7c7606ad61", size = 695346, upload-time = "2025-11-26T10:46:44.203Z" }, - { url = "https://files.pythonhosted.org/packages/c4/d2/2df6e2ae9c63a7ffb6ceb3f8f36e2711e772bb96ddb0785e37107996d562/python_calamine-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:1675ff630d439144ad5805a28bf4f65afd100b38f2a8703ceebe7c7e47039bc5", size = 747324, upload-time = "2025-11-26T10:46:45.478Z" }, - { url = "https://files.pythonhosted.org/packages/f7/3f/1e55ccab357f653dfe5f7991ff7f7a38b1892e88610a8873db1549e7c0c5/python_calamine-0.6.1-cp312-cp312-win_arm64.whl", hash = "sha256:4f7a68b31474a39a0f22e1f1464857222877e740255db196e141ff9db0d3229c", size = 716731, upload-time = "2025-11-26T10:46:47.351Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-docx" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-jose" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ecdsa" }, - { name = "pyasn1" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, -] - -[[package]] -name = "python-json-logger" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, -] - -[[package]] -name = "python-pptx" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lxml" }, - { name = "pillow" }, - { name = "typing-extensions" }, - { name = "xlsxwriter" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, -] - -[[package]] -name = "redbear-mem" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "aiofile" }, - { name = "alembic" }, - { name = "amqp" }, - { name = "annotated-types" }, - { name = "anyio" }, - { name = "aspose-slides" }, - { name = "async-timeout" }, - { name = "bcrypt" }, - { name = "beartype" }, - { name = "beautifulsoup4" }, - { name = "billiard" }, - { name = "cachetools" }, - { name = "celery" }, - { name = "cffi" }, - { name = "chardet" }, - { name = "chonkie" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "cn2an" }, - { name = "concurrent-log-handler" }, - { name = "cryptography" }, - { name = "dashscope" }, - { name = "datrie" }, - { name = "demjson3" }, - { name = "deprecated" }, - { name = "ecdsa" }, - { name = "editdistance" }, - { name = "elastic-transport" }, - { name = "elasticsearch" }, - { name = "elasticsearch-dsl" }, - { name = "email-validator" }, - { name = "exceptiongroup" }, - { name = "fastapi" }, - { name = "fastmcp" }, - { name = "flask" }, - { name = "flower" }, - { name = "graspologic" }, - { name = "greenlet" }, - { name = "h11" }, - { name = "hanziconv" }, - { name = "html5lib" }, - { name = "httptools" }, - { name = "huggingface-hub" }, - { name = "idna" }, - { name = "jieba" }, - { name = "jinja2" }, - { name = "json-repair" }, - { name = "kombu" }, - { name = "langchain" }, - { name = "langchain-aws" }, - { name = "langchain-community" }, - { name = "langchain-mcp-adapters" }, - { name = "langchain-ollama" }, - { name = "langchain-openai" }, - { name = "langfuse" }, - { name = "mako" }, - { name = "mammoth" }, - { name = "markdown" }, - { name = "markdown-to-json" }, - { name = "markdownify" }, - { name = "markupsafe" }, - { name = "matplotlib" }, - { name = "mcp" }, - { name = "neo4j" }, - { name = "networkx" }, - { name = "nltk" }, - { name = "numpy" }, - { name = "olefile" }, - { name = "onnxruntime" }, - { name = "opencv-python" }, - { name = "openpyxl" }, - { name = "oss2" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "passlib" }, - { name = "pdfplumber" }, - { name = "pillow" }, - { name = "prompt-toolkit" }, - { name = "psycopg2-binary" }, - { name = "pyasn1" }, - { name = "pyclipper" }, - { name = "pycparser" }, - { name = "pydantic" }, - { name = "pydantic-core" }, - { name = "pypdf" }, - { name = "pypdf2" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "python-calamine" }, - { name = "python-dateutil" }, - { name = "python-docx" }, - { name = "python-dotenv" }, - { name = "python-jose" }, - { name = "python-multipart" }, - { name = "python-pptx" }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "requests" }, - { name = "roman-numbers" }, - { name = "rsa" }, - { name = "ruamel-yaml" }, - { name = "scikit-learn" }, - { name = "shapely" }, - { name = "simpleeval" }, - { name = "six" }, - { name = "sniffio" }, - { name = "sqlalchemy" }, - { name = "starlette" }, - { name = "strenum" }, - { name = "tika" }, - { name = "tiktoken" }, - { name = "tomli" }, - { name = "torch" }, - { name = "trio" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "tzdata" }, - { name = "uvicorn" }, - { name = "uvloop", marker = "sys_platform != 'win32'" }, - { name = "valkey" }, - { name = "vine" }, - { name = "watchfiles" }, - { name = "wcwidth" }, - { name = "websockets" }, - { name = "word2number" }, - { name = "xgboost" }, - { name = "xinference-client" }, - { name = "xlrd" }, - { name = "xpinyin" }, - { name = "xxhash" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiofile", specifier = ">=3.9.0" }, - { name = "alembic", specifier = "==1.17.0" }, - { name = "amqp", specifier = "==5.3.1" }, - { name = "annotated-types", specifier = "==0.7.0" }, - { name = "anyio", specifier = "==4.11.0" }, - { name = "aspose-slides", specifier = "==24.12.0" }, - { name = "async-timeout", specifier = "==5.0.1" }, - { name = "bcrypt", specifier = "==5.0.0" }, - { name = "beartype", specifier = "==0.22.5" }, - { name = "beautifulsoup4", specifier = "==4.14.2" }, - { name = "billiard", specifier = "==4.2.2" }, - { name = "cachetools", specifier = "==6.2.1" }, - { name = "celery", specifier = "==5.5.3" }, - { name = "celery", specifier = ">=5.5.2" }, - { name = "cffi", specifier = "==2.0.0" }, - { name = "chardet", specifier = "==5.2.0" }, - { name = "chonkie", specifier = ">=1.1.2" }, - { name = "click", specifier = "==8.3.0" }, - { name = "click-didyoumean", specifier = "==0.3.1" }, - { name = "click-plugins", specifier = "==1.1.1.2" }, - { name = "click-repl", specifier = "==0.3.0" }, - { name = "cn2an", specifier = "==0.5.23" }, - { name = "concurrent-log-handler", specifier = ">=0.9.28" }, - { name = "cryptography", specifier = "==46.0.3" }, - { name = "dashscope", specifier = ">=1.25.0" }, - { name = "datrie", specifier = "==0.8.3" }, - { name = "demjson3", specifier = "==3.0.6" }, - { name = "deprecated", specifier = ">=1.3.1" }, - { name = "ecdsa", specifier = "==0.19.1" }, - { name = "editdistance", specifier = "==0.8.1" }, - { name = "elastic-transport", specifier = "==8.17.0" }, - { name = "elasticsearch", specifier = "==8.17.0" }, - { name = "elasticsearch-dsl", specifier = "==8.17.0" }, - { name = "email-validator", specifier = ">=2.3.0" }, - { name = "exceptiongroup", specifier = "==1.3.0" }, - { name = "fastapi", specifier = "==0.119.0" }, - { name = "fastmcp", specifier = ">=2.13.1" }, - { name = "flask", specifier = "==3.1.2" }, - { name = "flower", specifier = ">=2.0.1" }, - { name = "graspologic", specifier = "==3.4.5.dev2" }, - { name = "greenlet", specifier = "==3.2.4" }, - { name = "h11", specifier = "==0.16.0" }, - { name = "hanziconv", specifier = "==0.3.2" }, - { name = "html5lib", specifier = "==1.1" }, - { name = "httptools", specifier = "==0.7.1" }, - { name = "huggingface-hub", specifier = "==0.25.2" }, - { name = "idna", specifier = "==3.11" }, - { name = "jieba", specifier = ">=0.42.1" }, - { name = "jinja2", specifier = "==3.1.6" }, - { name = "jinja2", specifier = ">=3.1.6" }, - { name = "json-repair", specifier = "==0.53.0" }, - { name = "kombu", specifier = "==5.5.4" }, - { name = "langchain", specifier = ">=1.0.3" }, - { name = "langchain-aws", specifier = ">=1.0.0a1" }, - { name = "langchain-community", specifier = ">=0.3.31" }, - { name = "langchain-mcp-adapters", specifier = ">=0.1.13" }, - { name = "langchain-ollama" }, - { name = "langchain-openai", specifier = ">=1.0.2" }, - { name = "langfuse", specifier = ">=3.10.0" }, - { name = "mako", specifier = "==1.3.10" }, - { name = "mammoth", specifier = "==1.11.0" }, - { name = "markdown", specifier = "==3.8" }, - { name = "markdown-to-json", specifier = "==2.1.1" }, - { name = "markdownify", specifier = "==1.2.0" }, - { name = "markupsafe", specifier = "==3.0.3" }, - { name = "matplotlib", specifier = ">=3.10.7" }, - { name = "mcp", specifier = ">=1.21.1" }, - { name = "neo4j", specifier = ">=6.0.3" }, - { name = "networkx", specifier = ">=3.4.2" }, - { name = "nltk", specifier = "==3.9.2" }, - { name = "numpy", specifier = ">=1.26.0,<2.0.0" }, - { name = "olefile", specifier = "==0.47" }, - { name = "onnxruntime", specifier = "==1.20.1" }, - { name = "opencv-python", specifier = "==4.10.0.84" }, - { name = "openpyxl", specifier = "==3.1.5" }, - { name = "oss2", specifier = ">=2.19.1" }, - { name = "packaging", specifier = "==25.0" }, - { name = "pandas", specifier = "==2.3.3" }, - { name = "pandas", specifier = ">=2.3.3" }, - { name = "passlib", specifier = "==1.7.4" }, - { name = "pdfplumber", specifier = "==0.11.7" }, - { name = "pillow", specifier = "==12.0.0" }, - { name = "prompt-toolkit", specifier = "==3.0.52" }, - { name = "psycopg2-binary", specifier = "==2.9.11" }, - { name = "pyasn1", specifier = "==0.6.1" }, - { name = "pyclipper", specifier = "==1.3.0.post6" }, - { name = "pycparser", specifier = "==2.23" }, - { name = "pydantic", specifier = "==2.12.2" }, - { name = "pydantic-core", specifier = "==2.41.4" }, - { name = "pypdf", specifier = "==6.1.3" }, - { name = "pypdf2", specifier = "==3.0.1" }, - { name = "pytest", specifier = ">=9.0.1" }, - { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "python-calamine", specifier = ">=0.4.0" }, - { name = "python-dateutil", specifier = "==2.9.0.post0" }, - { name = "python-docx", specifier = "==1.2.0" }, - { name = "python-dotenv", specifier = "==1.1.1" }, - { name = "python-jose", specifier = "==3.5.0" }, - { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "python-pptx", specifier = "==1.0.2" }, - { name = "pyyaml", specifier = "==6.0.3" }, - { name = "redis", specifier = "==6.4.0" }, - { name = "requests", specifier = "==2.32.5" }, - { name = "roman-numbers", specifier = "==1.0.2" }, - { name = "rsa", specifier = "==4.9.1" }, - { name = "ruamel-yaml", specifier = "==0.18.10" }, - { name = "scikit-learn", specifier = "==1.7.2" }, - { name = "shapely", specifier = "==2.1.2" }, - { name = "simpleeval", specifier = ">=1.0.3" }, - { name = "six", specifier = "==1.17.0" }, - { name = "sniffio", specifier = "==1.3.1" }, - { name = "sqlalchemy", specifier = "==2.0.44" }, - { name = "starlette", specifier = "==0.48.0" }, - { name = "strenum", specifier = "==0.4.15" }, - { name = "tika", specifier = "==3.1.0" }, - { name = "tiktoken", specifier = "==0.12.0" }, - { name = "tomli", specifier = "==2.3.0" }, - { name = "torch", specifier = "==2.2.2" }, - { name = "trio", specifier = "==0.32.0" }, - { name = "typing-extensions", specifier = "==4.15.0" }, - { name = "typing-inspection", specifier = "==0.4.2" }, - { name = "tzdata", specifier = "==2025.2" }, - { name = "uvicorn", specifier = "==0.37.0" }, - { name = "uvicorn", specifier = ">=0.34.0" }, - { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" }, - { name = "valkey", specifier = "==6.0.2" }, - { name = "vine", specifier = "==5.1.0" }, - { name = "watchfiles", specifier = "==1.1.1" }, - { name = "wcwidth", specifier = "==0.2.14" }, - { name = "websockets", specifier = "==15.0.1" }, - { name = "word2number", specifier = "==1.1" }, - { name = "xgboost", specifier = "==3.0.0" }, - { name = "xinference-client", specifier = "==1.11.0" }, - { name = "xlrd", specifier = "==2.0.2" }, - { name = "xpinyin", specifier = "==0.7.7" }, - { name = "xxhash", specifier = "==3.6.0" }, -] - -[[package]] -name = "redis" -version = "6.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, -] - -[[package]] -name = "referencing" -version = "0.36.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, -] - -[[package]] -name = "regex" -version = "2026.1.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, - { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, - { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, - { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, - { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, - { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, - { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, - { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - -[[package]] -name = "rich-rst" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, -] - -[[package]] -name = "roman-numbers" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/ce/e9f6b0d260f48713f2d735e0986ee4ead311cd168c217c5f94b0fad6817b/roman_numbers-1.0.2.tar.gz", hash = "sha256:fb84b7755ba972d549e73fac1c100f0eeb9fc247474d43d0f433c0b72152c699", size = 2574, upload-time = "2021-01-11T11:54:59.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/85/09e9e6bd6cd4cc0ed463d2b6ce3c7741698d45ca157318730a1346df4819/roman_numbers-1.0.2-py3-none-any.whl", hash = "sha256:ffbc00aaf41538208f975d1b1ccfe80372bae1866e7cd632862d8c6b45edf447", size = 3724, upload-time = "2021-01-11T11:54:57.686Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - -[[package]] -name = "ruamel-yaml" -version = "0.18.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447, upload-time = "2025-01-06T14:08:51.334Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729, upload-time = "2025-01-06T14:08:47.471Z" }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, - { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, - { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, - { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, - { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, - { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, - { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, -] - -[[package]] -name = "s3transfer" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, - { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, - { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, - { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, - { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, -] - -[[package]] -name = "seaborn" -version = "0.13.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pandas" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, -] - -[[package]] -name = "secretstorage" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography", marker = "sys_platform != 'darwin'" }, - { name = "jeepney", marker = "sys_platform != 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, -] - -[[package]] -name = "setuptools" -version = "80.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, -] - -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "simpleeval" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/6f/15be211749430f52f2c8f0c69158a6fc961c03aac93fa28d44d1a6f5ebc7/simpleeval-1.0.3.tar.gz", hash = "sha256:67bbf246040ac3b57c29cf048657b9cf31d4e7b9d6659684daa08ca8f1e45829", size = 24358, upload-time = "2024-11-02T10:29:46.912Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e9/e58082fbb8cecbb6fb4133033c40cc50c248b1a331582be3a0f39138d65b/simpleeval-1.0.3-py3-none-any.whl", hash = "sha256:e3bdbb8c82c26297c9a153902d0fd1858a6c3774bf53ff4f134788c3f2035c38", size = 15762, upload-time = "2024-11-02T10:29:45.706Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "smart-open" -version = "7.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/9a/0a7acb748b86e2922982366d780ca4b16c33f7246fa5860d26005c97e4f3/smart_open-7.5.0.tar.gz", hash = "sha256:f394b143851d8091011832ac8113ea4aba6b92e6c35f6e677ddaaccb169d7cb9", size = 53920, upload-time = "2025-11-08T21:38:40.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/95/bc978be7ea0babf2fb48a414b6afaad414c6a9e8b1eafc5b8a53c030381a/smart_open-7.5.0-py3-none-any.whl", hash = "sha256:87e695c5148bbb988f15cec00971602765874163be85acb1c9fb8abc012e6599", size = 63940, upload-time = "2025-11-08T21:38:39.024Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "soupsieve" -version = "2.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.44" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, - { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, - { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, - { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, -] - -[[package]] -name = "sse-starlette" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, -] - -[[package]] -name = "starlette" -version = "0.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, -] - -[[package]] -name = "statsmodels" -version = "0.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "patsy" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, - { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, -] - -[[package]] -name = "strenum" -version = "0.4.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, -] - -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, -] - -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - -[[package]] -name = "tika" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/e2/28022574d12239d6c158f903c7697ebb55591a05bfd3177146b2c7cd72d2/tika-3.1.0.tar.gz", hash = "sha256:4c3a404c3d846437c942d6a6fd7b71d50285690fae5489aa8a6f00ff9ccd0fc7", size = 32697, upload-time = "2025-03-26T16:12:27.693Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/c6/9b549ca412bb03ad64632f5e47a53dc1b56a267809d7d3f9ef6d5b3c0559/tika-3.1.0-py3-none-any.whl", hash = "sha256:c6171c947d6410813f236c988a1fde4a6ad11cbaa95ec4e700eb9aef7c848093", size = 38053, upload-time = "2025-03-26T16:12:26.301Z" }, -] - -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "torch" -version = "2.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "sympy" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0c/d8f77363a7a3350c96e6c9db4ffb101d1c0487cc0b8cdaae1e4bfb2800ad/torch-2.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:cf12cdb66c9c940227ad647bc9cf5dba7e8640772ae10dfe7569a0c1e2a28aca", size = 755466713, upload-time = "2024-03-27T21:08:48.868Z" }, - { url = "https://files.pythonhosted.org/packages/05/9b/e5c0df26435f3d55b6699e1c61f07652b8c8a3ac5058a75d0e991f92c2b0/torch-2.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:89ddac2a8c1fb6569b90890955de0c34e1724f87431cacff4c1979b5f769203c", size = 86515814, upload-time = "2024-03-27T21:09:07.247Z" }, - { url = "https://files.pythonhosted.org/packages/72/ce/beca89dcdcf4323880d3b959ef457a4c61a95483af250e6892fec9174162/torch-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:451331406b760f4b1ab298ddd536486ab3cfb1312614cfe0532133535be60bea", size = 198528804, upload-time = "2024-03-27T21:09:14.691Z" }, - { url = "https://files.pythonhosted.org/packages/79/78/29dcab24a344ffd9ee9549ec0ab2c7885c13df61cde4c65836ee275efaeb/torch-2.2.2-cp312-none-macosx_10_9_x86_64.whl", hash = "sha256:eb4d6e9d3663e26cd27dc3ad266b34445a16b54908e74725adb241aa56987533", size = 150797270, upload-time = "2024-03-27T21:08:29.623Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0e/e4e033371a7cba9da0db5ccb507a9174e41b9c29189a932d01f2f61ecfc0/torch-2.2.2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:bf9558da7d2bf7463390b3b2a61a6a3dbb0b45b161ee1dd5ec640bf579d479fc", size = 59678388, upload-time = "2024-03-27T21:08:35.869Z" }, -] - -[[package]] -name = "tornado" -version = "6.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, -] - -[[package]] -name = "trio" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "(implementation_name != 'pypy' and os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (implementation_name != 'pypy' and os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, -] - -[[package]] -name = "typer" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspect" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "tzdata" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, -] - -[[package]] -name = "umap-learn" -version = "0.5.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numba" }, - { name = "numpy" }, - { name = "pynndescent" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/9a/a1e4a257a9aa979dac4f6d5781dac929cbb0949959e2003ed82657d10b0f/umap_learn-0.5.11.tar.gz", hash = "sha256:31566ffd495fbf05d7ab3efcba703861c0f5e6fc6998a838d0e2becdd00e54f5", size = 96409, upload-time = "2026-01-12T20:44:47.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/d2/fcf7192dd1cd8c090b6cfd53fa223c4fb2887a17c47e06bc356d44f40dfb/umap_learn-0.5.11-py3-none-any.whl", hash = "sha256:cb17adbde9d544ba79481b3ab4d81ac222e940f3d9219307bea6044f869af3cc", size = 90890, upload-time = "2026-01-12T20:44:46.511Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "uuid-utils" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" }, - { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" }, - { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" }, - { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" }, - { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" }, - { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" }, - { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, -] - -[[package]] -name = "valkey" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/f7/b552b7a67017e6233cd8a3b783ce8c4b548e29df98daedd7fb4c4c2cc8f8/valkey-6.0.2.tar.gz", hash = "sha256:dc2e91512b82d1da0b91ab0cdbd8c97c0c0250281728cb32f9398760df9caeae", size = 4602149, upload-time = "2024-09-11T11:54:05.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/cb/b1eac0fe9cbdbba0a5cf189f5778fe54ba7d7c9f26c2f62ca8d759b38f52/valkey-6.0.2-py3-none-any.whl", hash = "sha256:dbbdd65439ee0dc5689502c54f1899504cc7268e85cb7fe8935f062178ff5805", size = 260101, upload-time = "2024-09-11T11:54:02.963Z" }, -] - -[[package]] -name = "vine" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.2.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] - -[[package]] -name = "werkzeug" -version = "3.1.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, -] - -[[package]] -name = "word2number" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/29/a31940c848521f0725f0df6b25dca8917f13a2025b0e8fcbe5d0457e45e6/word2number-1.1.zip", hash = "sha256:70e27a5d387f67b04c71fbb7621c05930b19bfd26efd6851e6e0f9969dcde7d0", size = 9723, upload-time = "2017-06-02T15:45:14.488Z" } - -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - -[[package]] -name = "xgboost" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/18/f58f9dcbcca811b30be945fd7f890f87a0ee822f7316e2ddd7a3e3056f08/xgboost-3.0.0.tar.gz", hash = "sha256:45e95416df6f6f01d9a62e60cf09fc57e5ee34697f3858337c796fac9ce3b9ed", size = 1156620, upload-time = "2025-03-15T13:45:01.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/6c/d2a1636591e204667f320481b036acc9c526608bcc2319be71cce148102d/xgboost-3.0.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:ed8cffd7998bd9431c3b0287a70bec8e45c09b43c9474d9dfd261627713bd890", size = 2245638, upload-time = "2025-03-15T13:46:14.653Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0b/f9f815f240a9610d42367172b9f7ef7e8c9113a09b1bb35d4d85f96b910a/xgboost-3.0.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:314104bd3a1426a40f0c9662eef40e9ab22eb7a8068a42a8d198ce40412db75c", size = 2025491, upload-time = "2025-03-15T13:46:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/3c/aa/f4597dc989317d2431cd71998cac1f9205c773658e0cf6680bb9011b26eb/xgboost-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72c3405e8dfc37048f9fe339a058fa12b9f0f03bc31d3e56f0887eed2ed2baa1", size = 4841002, upload-time = "2025-03-15T13:47:19.693Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/b89d99b89946afc842feb8f47302e8022f1405f01212d3e186b6d674b2ba/xgboost-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:72d39e74649e9b628c4221111aa6a8caa860f2e853b25480424403ee61085126", size = 4903442, upload-time = "2025-03-15T13:47:44.917Z" }, - { url = "https://files.pythonhosted.org/packages/bb/68/a7a274bedf43bbf9de120104b438b45e232395f8b9861519040b7b725535/xgboost-3.0.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7bdee5787f86b83bebd75e2c96caf854760788e5f4203d063da50db5bf0efc5f", size = 4602694, upload-time = "2025-03-15T13:48:16.498Z" }, - { url = "https://files.pythonhosted.org/packages/63/f1/653afe1a1b7e1d03f26fd4bd30f3eebcfac2d8982e1a85b6be3355dcae25/xgboost-3.0.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:61c7e391e373b8a5312503525c0689f83ef1912a1236377022865ab340f465a4", size = 253877333, upload-time = "2025-03-15T13:51:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/15cd49e855c62226ecf1831bbe4c8e73a4324856077a23c495538a36e557/xgboost-3.0.0-py3-none-win_amd64.whl", hash = "sha256:0ea74e97f95b1eddfd27a46b7f22f72ec5a5322e1dc7cb41c9c23fb580763df9", size = 149971978, upload-time = "2025-03-15T13:53:26.596Z" }, -] - -[[package]] -name = "xinference-client" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/e5/5ed26708062db451d827b4a3e6585e2091b75ffae1bf47a105827bf2e405/xinference-client-1.11.0.tar.gz", hash = "sha256:2fec1eb61bad9c3eb93db3dfadb47aaea106e83724494cf7f82897c38b9dc9e9", size = 57345, upload-time = "2025-10-19T15:05:08.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/b9/04ca69d2d012f2f73f59d7ef64bc6bab38133a9e6ba595a08d9e95a75e80/xinference_client-1.11.0-py3-none-any.whl", hash = "sha256:b5d64341b8f2f1e4f82988899788ab50ab05027e653c1321772d386282498403", size = 39127, upload-time = "2025-10-19T15:05:07.333Z" }, -] - -[[package]] -name = "xlrd" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, -] - -[[package]] -name = "xlsxwriter" -version = "3.2.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, -] - -[[package]] -name = "xpinyin" -version = "0.7.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/41397274f9447ba29a947778b669b6f21717839ed164eba6b68cd168e705/xpinyin-0.7.7.tar.gz", hash = "sha256:89a1f3f12c7119b4526b93360a74fbe48ee24847481a4bab3b4fee8f4536079d", size = 133954, upload-time = "2025-06-02T04:02:11.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/bd/f7865b810ca7cc82fa566ec224786f674aab878d89866370ea7a20063b4c/xpinyin-0.7.7-py3-none-any.whl", hash = "sha256:1317a0140b704e03e5f368c2dc7618887a2fc45cdc288b6d648e2ebabf571f0e", size = 129774, upload-time = "2025-06-02T04:02:08.354Z" }, -] - -[[package]] -name = "xxhash" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, - { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, - { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, - { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, - { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, - { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, - { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, - { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, -] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] - -[[package]] -name = "zstandard" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, - { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, - { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, - { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, - { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, - { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, -] From 3a4a7590c2fc6b359fd43692c1b9665a598f9ebf Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Thu, 22 Jan 2026 16:36:12 +0800 Subject: [PATCH 034/175] [fix]Fix the memory interface to use end_user_id. --- .../controllers/implicit_memory_controller.py | 80 +++++++++---------- .../memory_perceptual_controller.py | 66 +++++++-------- .../controllers/memory_working_controller.py | 16 ++-- api/app/models/memory_perceptual_model.py | 2 +- .../repositories/memory_config_repository.py | 26 +++--- .../memory_perceptual_repository.py | 4 +- api/app/repositories/neo4j/cypher_queries.py | 6 +- .../repositories/neo4j/dialog_repository.py | 2 +- .../repositories/neo4j/emotion_repository.py | 2 +- .../neo4j/memory_summary_repository.py | 8 +- api/app/schemas/memory_perceptual_schema.py | 8 +- api/app/schemas/memory_storage_schema.py | 2 +- api/app/services/memory_perceptual_service.py | 10 ++- 13 files changed, 118 insertions(+), 114 deletions(-) diff --git a/api/app/controllers/implicit_memory_controller.py b/api/app/controllers/implicit_memory_controller.py index a53290e2..96e437d6 100644 --- a/api/app/controllers/implicit_memory_controller.py +++ b/api/app/controllers/implicit_memory_controller.py @@ -122,10 +122,10 @@ def validate_confidence_threshold(threshold: float) -> None: raise ValueError("confidence_threshold must be between 0.0 and 1.0") -@router.get("/preferences/{user_id}", response_model=ApiResponse) +@router.get("/preferences/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_preference_tags( - user_id: str, + end_user_id: str, confidence_threshold: float = Query(0.5, ge=0.0, le=1.0, description="Minimum confidence threshold"), tag_category: Optional[str] = Query(None, description="Filter by tag category"), start_date: Optional[datetime] = Query(None, description="Filter start date"), @@ -137,7 +137,7 @@ async def get_preference_tags( Get user preference tags from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID confidence_threshold: Minimum confidence score (0.0-1.0) tag_category: Optional category filter start_date: Optional start date filter @@ -146,20 +146,20 @@ async def get_preference_tags( Returns: List of preference tags from cache """ - api_logger.info(f"Preference tags requested for user: {user_id} (from cache)") + api_logger.info(f"Preference tags requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -192,17 +192,17 @@ async def get_preference_tags( filtered_preferences.append(pref) - api_logger.info(f"Retrieved {len(filtered_preferences)} preference tags for user: {user_id} (from cache)") + api_logger.info(f"Retrieved {len(filtered_preferences)} preference tags for user: {end_user_id} (from cache)") return success(data=filtered_preferences, msg="偏好标签获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "偏好标签获取", user_id) + return handle_implicit_memory_error(e, "偏好标签获取", end_user_id) -@router.get("/portrait/{user_id}", response_model=ApiResponse) +@router.get("/portrait/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_dimension_portrait( - user_id: str, + end_user_id: str, include_history: bool = Query(False, description="Include historical trends"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -211,26 +211,26 @@ async def get_dimension_portrait( Get user's four-dimension personality portrait from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID include_history: Whether to include historical trend data (ignored for cached data) Returns: Four-dimension personality portrait from cache """ - api_logger.info(f"Dimension portrait requested for user: {user_id} (from cache)") + api_logger.info(f"Dimension portrait requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -240,17 +240,17 @@ async def get_dimension_portrait( # Extract portrait from cache portrait = cached_profile.get("portrait", {}) - api_logger.info(f"Dimension portrait retrieved for user: {user_id} (from cache)") + api_logger.info(f"Dimension portrait retrieved for user: {end_user_id} (from cache)") return success(data=portrait, msg="四维画像获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "四维画像获取", user_id) + return handle_implicit_memory_error(e, "四维画像获取", end_user_id) -@router.get("/interest-areas/{user_id}", response_model=ApiResponse) +@router.get("/interest-areas/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_interest_area_distribution( - user_id: str, + end_user_id: str, include_trends: bool = Query(False, description="Include trend analysis"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -259,26 +259,26 @@ async def get_interest_area_distribution( Get user's interest area distribution from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID include_trends: Whether to include trend analysis data (ignored for cached data) Returns: Interest area distribution from cache """ - api_logger.info(f"Interest area distribution requested for user: {user_id} (from cache)") + api_logger.info(f"Interest area distribution requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -288,17 +288,17 @@ async def get_interest_area_distribution( # Extract interest areas from cache interest_areas = cached_profile.get("interest_areas", {}) - api_logger.info(f"Interest area distribution retrieved for user: {user_id} (from cache)") + api_logger.info(f"Interest area distribution retrieved for user: {end_user_id} (from cache)") return success(data=interest_areas, msg="兴趣领域分布获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "兴趣领域分布获取", user_id) + return handle_implicit_memory_error(e, "兴趣领域分布获取", end_user_id) -@router.get("/habits/{user_id}", response_model=ApiResponse) +@router.get("/habits/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_behavior_habits( - user_id: str, + end_user_id: str, confidence_level: Optional[str] = Query(None, regex="^(high|medium|low)$", description="Filter by confidence level"), frequency_pattern: Optional[str] = Query(None, regex="^(daily|weekly|monthly|seasonal|occasional|event_triggered)$", description="Filter by frequency pattern"), time_period: Optional[str] = Query(None, regex="^(current|past)$", description="Filter by time period"), @@ -309,7 +309,7 @@ async def get_behavior_habits( Get user's behavioral habits from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID confidence_level: Filter by confidence level (high, medium, low) frequency_pattern: Filter by frequency pattern (daily, weekly, monthly, seasonal, occasional, event_triggered) time_period: Filter by time period (current, past) @@ -317,20 +317,20 @@ async def get_behavior_habits( Returns: List of behavioral habits from cache """ - api_logger.info(f"Behavior habits requested for user: {user_id} (from cache)") + api_logger.info(f"Behavior habits requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -368,11 +368,11 @@ async def get_behavior_habits( filtered_habits.append(habit) - api_logger.info(f"Retrieved {len(filtered_habits)} behavior habits for user: {user_id} (from cache)") + api_logger.info(f"Retrieved {len(filtered_habits)} behavior habits for user: {end_user_id} (from cache)") return success(data=filtered_habits, msg="行为习惯获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "行为习惯获取", user_id) + return handle_implicit_memory_error(e, "行为习惯获取", end_user_id) diff --git a/api/app/controllers/memory_perceptual_controller.py b/api/app/controllers/memory_perceptual_controller.py index 5154c763..44750808 100644 --- a/api/app/controllers/memory_perceptual_controller.py +++ b/api/app/controllers/memory_perceptual_controller.py @@ -27,27 +27,27 @@ router = APIRouter( ) -@router.get("/{group_id}/count", response_model=ApiResponse) +@router.get("/{end_user_id}/count", response_model=ApiResponse) def get_memory_count( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve perceptual memory statistics for a user group. Args: - group_id: ID of the user group (usually end_user_id in this context) + end_user_id: ID of the user group (usually end_user_id in this context) current_user: Current authenticated user db: Database session Returns: ApiResponse: Response containing memory count statistics """ - api_logger.info(f"Fetching perceptual memory statistics: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching perceptual memory statistics: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - count_stats = service.get_memory_count(group_id) + count_stats = service.get_memory_count(end_user_id) api_logger.info(f"Memory statistics fetched successfully: total={count_stats.get('total', 0)}") @@ -57,37 +57,37 @@ def get_memory_count( ) except Exception as e: - api_logger.error(f"Failed to fetch memory statistics: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch memory statistics: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch memory statistics", ) -@router.get("/{group_id}/last_visual", response_model=ApiResponse) +@router.get("/{end_user_id}/last_visual", response_model=ApiResponse) def get_last_visual_memory( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent VISION-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest visual memory """ - api_logger.info(f"Fetching latest visual memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest visual memory: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - visual_memory = service.get_latest_visual_memory(group_id) + visual_memory = service.get_latest_visual_memory(end_user_id) if visual_memory is None: - api_logger.info(f"No visual memory found: group_id={group_id}") + api_logger.info(f"No visual memory found: end_user_id={end_user_id}") return success( data=None, msg="No visual memory available" @@ -101,37 +101,37 @@ def get_last_visual_memory( ) except Exception as e: - api_logger.error(f"Failed to fetch latest visual memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest visual memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest visual memory", ) -@router.get("/{group_id}/last_listen", response_model=ApiResponse) +@router.get("/{end_user_id}/last_listen", response_model=ApiResponse) def get_last_memory_listen( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent AUDIO-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest audio memory """ - api_logger.info(f"Fetching latest audio memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest audio memory: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - audio_memory = service.get_latest_audio_memory(group_id) + audio_memory = service.get_latest_audio_memory(end_user_id) if audio_memory is None: - api_logger.info(f"No audio memory found: group_id={group_id}") + api_logger.info(f"No audio memory found: end_user_id={end_user_id}") return success( data=None, msg="No audio memory available" @@ -145,38 +145,38 @@ def get_last_memory_listen( ) except Exception as e: - api_logger.error(f"Failed to fetch latest audio memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest audio memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest audio memory", ) -@router.get("/{group_id}/last_text", response_model=ApiResponse) +@router.get("/{end_user_id}/last_text", response_model=ApiResponse) def get_last_text_memory( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent TEXT-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest text memory """ - api_logger.info(f"Fetching latest text memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest text memory: user={current_user.username}, end_user_id={end_user_id}") try: # 调用服务层获取最近的文本记忆 service = MemoryPerceptualService(db) - text_memory = service.get_latest_text_memory(group_id) + text_memory = service.get_latest_text_memory(end_user_id) if text_memory is None: - api_logger.info(f"No text memory found: group_id={group_id}") + api_logger.info(f"No text memory found: end_user_id={end_user_id}") return success( data=None, msg="No text memory available" @@ -190,16 +190,16 @@ def get_last_text_memory( ) except Exception as e: - api_logger.error(f"Failed to fetch latest text memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest text memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest text memory", ) -@router.get("/{group_id}/timeline", response_model=ApiResponse) +@router.get("/{end_user_id}/timeline", response_model=ApiResponse) def get_memory_time_line( - group_id: uuid.UUID, + end_user_id: uuid.UUID, perceptual_type: Optional[PerceptualType] = Query(None, description="感知类型过滤"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(10, ge=1, le=100, description="每页大小"), @@ -209,7 +209,7 @@ def get_memory_time_line( """Retrieve a timeline of perceptual memories for a user group. Args: - group_id: ID of the user group + end_user_id: ID of the user group perceptual_type: Optional filter for perceptual type page: Page number for pagination page_size: Number of items per page @@ -221,7 +221,7 @@ def get_memory_time_line( """ api_logger.info( f"Fetching perceptual memory timeline: user={current_user.username}, " - f"group_id={group_id}, type={perceptual_type}, page={page}" + f"end_user_id={end_user_id}, type={perceptual_type}, page={page}" ) try: @@ -232,7 +232,7 @@ def get_memory_time_line( ) service = MemoryPerceptualService(db) - timeline_data = service.get_time_line(group_id, query) + timeline_data = service.get_time_line(end_user_id, query) api_logger.info( f"Perceptual memory timeline retrieved successfully: total={timeline_data.total}, " @@ -246,7 +246,7 @@ def get_memory_time_line( except Exception as e: api_logger.error( - f"Failed to fetch perceptual memory timeline: group_id={group_id}, " + f"Failed to fetch perceptual memory timeline: end_user_id={end_user_id}, " f"error={str(e)}" ) return fail( diff --git a/api/app/controllers/memory_working_controller.py b/api/app/controllers/memory_working_controller.py index dfd64044..e5de3c04 100644 --- a/api/app/controllers/memory_working_controller.py +++ b/api/app/controllers/memory_working_controller.py @@ -20,18 +20,18 @@ router = APIRouter( ) -@router.get("/{group_id}/count", response_model=ApiResponse) +@router.get("/{end_user_id}/count", response_model=ApiResponse) def get_memory_count( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): pass -@router.get("/{group_id}/conversations", response_model=ApiResponse) +@router.get("/{end_user_id}/conversations", response_model=ApiResponse) def get_conversations( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -39,7 +39,7 @@ def get_conversations( Retrieve all conversations for the current user in a specific group. Args: - group_id (UUID): The group identifier. + end_user_id (UUID): The group identifier. current_user (User, optional): The authenticated user. db (Session, optional): SQLAlchemy session. @@ -53,7 +53,7 @@ def get_conversations( """ conversation_service = ConversationService(db) conversations = conversation_service.get_user_conversations( - group_id + end_user_id ) return success(data=[ { @@ -63,7 +63,7 @@ def get_conversations( ], msg="get conversations success") -@router.get("/{group_id}/messages", response_model=ApiResponse) +@router.get("/{end_user_id}/messages", response_model=ApiResponse) def get_messages( conversation_id: uuid.UUID, current_user: User = Depends(get_current_user), @@ -100,7 +100,7 @@ def get_messages( return success(data=messages, msg="get conversation history success") -@router.get("/{group_id}/detail", response_model=ApiResponse) +@router.get("/{end_user_id}/detail", response_model=ApiResponse) async def get_conversation_detail( conversation_id: uuid.UUID, current_user: User = Depends(get_current_user), diff --git a/api/app/models/memory_perceptual_model.py b/api/app/models/memory_perceptual_model.py index 59eb0222..cafb18d4 100644 --- a/api/app/models/memory_perceptual_model.py +++ b/api/app/models/memory_perceptual_model.py @@ -16,7 +16,7 @@ class PerceptualType(IntEnum): CONVERSATION = 4 -class FileStorageType(IntEnum): +class FileStorageService(IntEnum): LOCAL = 1 REMOTE = 2 diff --git a/api/app/repositories/memory_config_repository.py b/api/app/repositories/memory_config_repository.py index eb513c99..aca87189 100644 --- a/api/app/repositories/memory_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -41,48 +41,48 @@ class MemoryConfigRepository: # Dialogue count by group SEARCH_FOR_DIALOGUE = """ - MATCH (n:Dialogue) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Dialogue) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # Chunk count by group SEARCH_FOR_CHUNK = """ - MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # Statement count by group SEARCH_FOR_STATEMENT = """ - MATCH (n:Statement) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Statement) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # ExtractedEntity count by group SEARCH_FOR_ENTITY = """ - MATCH (n:ExtractedEntity) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:ExtractedEntity) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # All counts by label and total SEARCH_FOR_ALL = """ - OPTIONAL MATCH (n:Dialogue) WHERE n.group_id = $group_id RETURN 'Dialogue' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Dialogue) WHERE n.end_user_id = $end_user_id RETURN 'Dialogue' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN 'Chunk' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN 'Chunk' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:Statement) WHERE n.group_id = $group_id RETURN 'Statement' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Statement) WHERE n.end_user_id = $end_user_id RETURN 'Statement' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:ExtractedEntity) WHERE n.group_id = $group_id RETURN 'ExtractedEntity' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:ExtractedEntity) WHERE n.end_user_id = $end_user_id RETURN 'ExtractedEntity' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n) WHERE n.group_id = $group_id RETURN 'ALL' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n) WHERE n.end_user_id = $end_user_id RETURN 'ALL' AS Label, COUNT(n) AS Count """ # Extracted entity details within group/app/user SEARCH_FOR_DETIALS = """ MATCH (n:ExtractedEntity) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN n.entity_idx AS entity_idx, n.connect_strength AS connect_strength, n.description AS description, n.entity_type AS entity_type, n.name AS name, COALESCE(n.fact_summary, '') AS fact_summary, - n.group_id AS group_id, + n.end_user_id AS end_user_id, n.apply_id AS apply_id, n.user_id AS user_id, n.id AS id @@ -91,9 +91,9 @@ class MemoryConfigRepository: # Edges between extracted entities within group/app/user SEARCH_FOR_EDGES = """ MATCH (n:ExtractedEntity)-[r]->(m:ExtractedEntity) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN - r.group_id AS group_id, + r.end_user_id AS end_user_id, r.apply_id AS apply_id, r.user_id AS user_id, elementId(r) AS rel_id, diff --git a/api/app/repositories/memory_perceptual_repository.py b/api/app/repositories/memory_perceptual_repository.py index 8415c2d0..9fa9536e 100644 --- a/api/app/repositories/memory_perceptual_repository.py +++ b/api/app/repositories/memory_perceptual_repository.py @@ -6,7 +6,7 @@ from sqlalchemy import and_, desc from sqlalchemy.orm import Session from app.core.logging_config import get_db_logger -from app.models.memory_perceptual_model import MemoryPerceptualModel, PerceptualType, FileStorageType +from app.models.memory_perceptual_model import MemoryPerceptualModel, PerceptualType, FileStorageService from app.schemas.memory_perceptual_schema import PerceptualQuerySchema db_logger = get_db_logger() @@ -28,7 +28,7 @@ class MemoryPerceptualRepository: file_ext: str, summary: Optional[str] = None, meta_data: Optional[dict] = None, - storage_service: FileStorageType = FileStorageType.LOCAL + storage_service: FileStorageService = FileStorageService.LOCAL ) -> MemoryPerceptualModel: diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index eaef1e7a..c93e75b3 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -700,7 +700,7 @@ MATCH (ms:MemorySummary {id: e.summary_id, run_id: e.run_id}) MATCH (c:Chunk {id: e.chunk_id, run_id: e.run_id}) MATCH (c)-[:CONTAINS]->(s:Statement {run_id: e.run_id}) MERGE (ms)-[r:DERIVED_FROM_STATEMENT]->(s) -SET r.group_id = e.group_id, +SET r.end_user_id = e.end_user_id, r.run_id = e.run_id, r.created_at = e.created_at, r.expired_at = e.expired_at @@ -729,7 +729,7 @@ FOREACH (rel IN CASE WHEN r IS NOT NULL THEN [r] ELSE [] END | source_statement_id: rel.source_statement_id, valid_at: rel.valid_at, invalid_at: rel.invalid_at, - group_id: rel.group_id, + end_user_id: rel.end_user_id, user_id: rel.user_id, apply_id: rel.apply_id, run_id: rel.run_id, @@ -751,7 +751,7 @@ FOREACH (rel IN CASE WHEN r IS NOT NULL THEN [r] ELSE [] END | source_statement_id: rel.source_statement_id, valid_at: rel.valid_at, invalid_at: rel.invalid_at, - group_id: rel.group_id, + end_user_id: rel.end_user_id, user_id: rel.user_id, apply_id: rel.apply_id, run_id: rel.run_id, diff --git a/api/app/repositories/neo4j/dialog_repository.py b/api/app/repositories/neo4j/dialog_repository.py index 48376c2a..020e7346 100644 --- a/api/app/repositories/neo4j/dialog_repository.py +++ b/api/app/repositories/neo4j/dialog_repository.py @@ -180,6 +180,6 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): List[DialogueNode]: 对话列表 """ return await self.find( - {"config_id": config_id, "group_id": group_id}, + {"config_id": config_id, "end_user_id": end_user_id}, limit=limit ) diff --git a/api/app/repositories/neo4j/emotion_repository.py b/api/app/repositories/neo4j/emotion_repository.py index 7a8ebcf9..e39968ac 100644 --- a/api/app/repositories/neo4j/emotion_repository.py +++ b/api/app/repositories/neo4j/emotion_repository.py @@ -227,7 +227,7 @@ class EmotionRepository: try: results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, start_date=start_date ) formatted_results = [ diff --git a/api/app/repositories/neo4j/memory_summary_repository.py b/api/app/repositories/neo4j/memory_summary_repository.py index 2564aeab..81bf2cc9 100644 --- a/api/app/repositories/neo4j/memory_summary_repository.py +++ b/api/app/repositories/neo4j/memory_summary_repository.py @@ -233,7 +233,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ # Build keyword search conditions keyword_conditions = [] - params = {"group_id": group_id, "limit": limit} + params = {"end_user_id": group_id, "limit": limit} for i, keyword in enumerate(keywords): keyword_conditions.append(f"toLower(n.content) CONTAINS toLower($keyword_{i})") @@ -243,7 +243,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND ({keyword_filter}) RETURN n ORDER BY n.created_at DESC @@ -264,10 +264,10 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - results = await self.connector.execute_query(query, group_id=group_id) + results = await self.connector.execute_query(query, end_user_id=group_id) return results[0]['count'] if results else 0 \ No newline at end of file diff --git a/api/app/schemas/memory_perceptual_schema.py b/api/app/schemas/memory_perceptual_schema.py index 05e01d2a..e82d9526 100644 --- a/api/app/schemas/memory_perceptual_schema.py +++ b/api/app/schemas/memory_perceptual_schema.py @@ -4,7 +4,7 @@ from typing import Optional from pydantic import BaseModel, Field -from app.models.memory_perceptual_model import PerceptualType, FileStorageType +from app.models.memory_perceptual_model import PerceptualType, FileStorageService class PerceptualFilter(BaseModel): @@ -38,12 +38,14 @@ class PerceptualMemoryItem(BaseModel): """感知记忆项""" id: uuid.UUID = Field(..., description="Unique memory ID") perceptual_type: PerceptualType = Field(..., description="Type of perception, e.g., text, audio, or video") + storage_service: FileStorageService = Field(..., description="Storage service for file") file_path: str = Field(..., description="File path in the storage service") - file_ext: str = Field(..., description="File extension") file_name: str = Field(..., description="File name") + file_ext: str = Field(..., description="File extension") summary: Optional[str] = Field(None, description="summary") - storage_type: FileStorageType = Field(..., description="Storage type for file") + meta_data: str = Field(...,description="") created_time: int = Field(..., description="create time") + topic: str = Field(..., description="topic") domain: str = Field(..., description="domain") keywords: list[str] = Field(..., description="keywords") diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index d17a9f2c..80b62b8a 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -1,5 +1,5 @@ """ -所有的内容是放错误地方了,应该放在models + """ from typing import Any, Optional, List, Dict, Literal, Union diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index d257e80f..29b45474 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.logging_config import get_business_logger -from app.models.memory_perceptual_model import PerceptualType, FileStorageType +from app.models.memory_perceptual_model import PerceptualType, FileStorageService from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository from app.schemas.memory_perceptual_schema import ( PerceptualQuerySchema, @@ -92,15 +92,17 @@ class MemoryPerceptualService: result = { "id": str(memory.id), + "perceptual_type": perceptual_type, "file_name": memory.file_name, "file_path": memory.file_path, - "storage_type": memory.storage_service, + "file_ext": memory.file_ext, + "storage_service": memory.storage_service, + "meta_data": memory.meta_data, "summary": memory.summary, "keywords": content.keywords, "topic": content.topic, "domain": content.domain, "created_time": int(memory.created_time.timestamp()*1000), - **detail } business_logger.info( @@ -150,7 +152,7 @@ class MemoryPerceptualService: domain=content.domain, keywords=content.keywords, created_time=int(memory.created_time.timestamp()*1000), - storage_type=FileStorageType(memory.storage_service), + storage_service=FileStorageService(memory.storage_service), ) memory_items.append(memory_item) From a2a69840f7889b3fc74fa1e3442d19303bd34f82 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 16:38:24 +0800 Subject: [PATCH 035/175] =?UTF-8?q?config=5Fconfig=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=88=90memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 509ed815..f8861258 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1207,6 +1207,16 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An # 3. 从 config 中提取 memory_config_id config = latest_release.config or {} + + # 如果 config 是字符串,解析为字典 + if isinstance(config, str): + import json + try: + config = json.loads(config) + except json.JSONDecodeError: + logger.warning(f"Failed to parse config JSON for release {latest_release.id}") + config = {} + memory_obj = config.get('memory', {}) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None From f3f9211c9cd4740e211a92a5cb9c40ca5a65b11c Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 16:59:40 +0800 Subject: [PATCH 036/175] =?UTF-8?q?config=5Fconfig=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=88=90memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/uv.lock | 4465 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4465 insertions(+) create mode 100644 api/uv.lock diff --git a/api/uv.lock b/api/uv.lock new file mode 100644 index 00000000..f3b23325 --- /dev/null +++ b/api/uv.lock @@ -0,0 +1,4465 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" +resolution-markers = [ + "sys_platform == 'darwin'", + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')", +] + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alembic" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" }, +] + +[[package]] +name = "aliyun-python-sdk-core" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jmespath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/09/da9f58eb38b4fdb97ba6523274fbf445ef6a06be64b433693da8307b4bec/aliyun-python-sdk-core-2.16.0.tar.gz", hash = "sha256:651caad597eb39d4fad6cf85133dffe92837d53bdf62db9d8f37dab6508bb8f9", size = 449555, upload-time = "2024-10-09T06:01:01.762Z" } + +[[package]] +name = "aliyun-python-sdk-kms" +version = "2.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aliyun-python-sdk-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/2c/9877d0e6b18ecf246df671ac65a5d1d9fecbf85bdcb5d43efbde0d4662eb/aliyun-python-sdk-kms-2.16.5.tar.gz", hash = "sha256:f328a8a19d83ecbb965ffce0ec1e9930755216d104638cd95ecd362753b813b3", size = 12018, upload-time = "2024-08-30T09:01:20.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/5c/0132193d7da2c735669a1ed103b142fd63c9455984d48c5a88a1a516efaa/aliyun_python_sdk_kms-2.16.5-py2.py3-none-any.whl", hash = "sha256:24b6cdc4fd161d2942619479c8d050c63ea9cd22b044fe33b60bbb60153786f0", size = 99495, upload-time = "2024-08-30T09:01:18.462Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "anytree" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/a8/eb55fab589c56f9b6be2b3fd6997aa04bb6f3da93b01154ce6fc8e799db2/anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714", size = 48389, upload-time = "2025-04-08T21:06:30.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/98/f6aa7fe0783e42be3093d8ef1b0ecdc22c34c0d69640dfb37f56925cb141/anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", size = 45077, upload-time = "2025-04-08T21:06:29.494Z" }, +] + +[[package]] +name = "aspose-slides" +version = "24.12.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/db/680408b92f47aa9ff2c70f80b2f5d02155a8ff81ac493c3061099bf56c37/Aspose.Slides-24.12.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:ccfaa61a863ed28cd37b221e31a0edf4a83802599d76fb50861c25149ac5e5e3", size = 87164865, upload-time = "2024-12-05T00:51:15.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/ac/29838004784acb72c9d93f0b327a8e5105f35eb925cdaeccd07907464018/Aspose.Slides-24.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b050659129c5ca92e52fbcd7d5091caa244db731adb68fbea1fd0a8b9fd62a5a", size = 68916630, upload-time = "2024-12-05T00:51:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6e/0b9da3757ce46b63f3fbb10ee352009c20260813d369306438bd3552fc18/Aspose.Slides-24.12.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:a5eb8407bd93fa7851584c3b143000c09d9f5285f3c1da99677bf1d9c0abefe9", size = 102438903, upload-time = "2024-12-05T00:51:27.926Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/023ce536ee861b6b8757b8ebfed3326cd21a48b9e557390cd904fc48ef1e/Aspose.Slides-24.12.0-py3-none-win32.whl", hash = "sha256:6e8bf6e20ff05a81ed9ef8025b20f16c5ada1af908934c82e8290aab26ad4f83", size = 62974346, upload-time = "2024-12-05T00:51:35.318Z" }, + { url = "https://files.pythonhosted.org/packages/58/0b/af65314b471766709627a65096f69e8b70b7840edd98cabaa9b74fda671d/Aspose.Slides-24.12.0-py3-none-win_amd64.whl", hash = "sha256:e816e37a621221e8a73fc631c879ada37cf6a80513a817b687d6f7e189d5a978", size = 72093115, upload-time = "2024-12-05T00:51:40.848Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, +] + +[[package]] +name = "autograd" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ea/e16f0c423f7d83cf8b79cae9452040fb7b2e020c7439a167ee7c317de448/autograd-1.8.0-py3-none-any.whl", hash = "sha256:4ab9084294f814cf56c280adbe19612546a35574d67c574b04933c7d2ecb7d78", size = 51478, upload-time = "2025-05-05T12:49:00.585Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/09/9003e5662691056e0e8b2e6f57c799e71875fac0be0e785d8cb11557cd2a/beartype-0.22.5.tar.gz", hash = "sha256:516a9096cc77103c96153474fa35c3ebcd9d36bd2ec8d0e3a43307ced0fa6341", size = 1586256, upload-time = "2025-11-01T05:49:20.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/f6/073d19f7b571c08327fbba3f8e011578da67ab62a11f98911274ff80653f/beartype-0.22.5-py3-none-any.whl", hash = "sha256:d9743dd7cd6d193696eaa1e025f8a70fb09761c154675679ff236e61952dfba0", size = 1321700, upload-time = "2025-11-01T05:49:18.436Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/6a/1405343016bce8354b29d90aad6b0bf6485b5e60404516e4b9a3a9646cf0/billiard-4.2.2.tar.gz", hash = "sha256:e815017a062b714958463e07ba15981d802dc53d41c5b69d28c5a7c238f8ecf3", size = 155592, upload-time = "2025-09-20T14:44:40.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/80/ef8dff49aae0e4430f81842f7403e14e0ca59db7bbaf7af41245b67c6b25/billiard-4.2.2-py3-none-any.whl", hash = "sha256:4bc05dcf0d1cc6addef470723aac2a6232f3c7ed7475b0b580473a9145829457", size = 86896, upload-time = "2025-09-20T14:44:39.157Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/73/2a8065918dcc9f07046f7e87e17f54a62914a8b7f1f9e506799ec533d2e9/boto3-1.42.32.tar.gz", hash = "sha256:0ba535985f139cf38455efd91f3801fe72e5cce6ded2df5aadfd63177d509675", size = 112830, upload-time = "2026-01-21T20:40:10.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e3/c86658f1fd0191aa8131cb1baacd337b037546d902980ea5a9c8f0c5cd9b/boto3-1.42.32-py3-none-any.whl", hash = "sha256:695ac7e62dfde28cc1d3b28a581cce37c53c729d48ea0f4cd0dbf599856850cf", size = 140573, upload-time = "2026-01-21T20:40:09.1Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/5e/84404e094be8e2145c7f6bb8b3709193bc4488c385edffc6cc6890b5c88b/botocore-1.42.32.tar.gz", hash = "sha256:4c0a9fe23e060c019e327cd5e4ea1976a1343faba74e5301ebfc9549cc584ccb", size = 14898756, upload-time = "2026-01-21T20:39:59.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/ab/55062f6eaf9fc537b62b7425ab53ef4366032256e1dda8ef52a9a31f7a6e/botocore-1.42.32-py3-none-any.whl", hash = "sha256:9c1ce43687cc4c0bba12054b229b3464265c699e2de4723998d86791254a5a37", size = 14573367, upload-time = "2026-01-21T20:39:56.65Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "chonkie" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/d7/b7637af29a4bf8073c8e95bb50068288f2e04b3dab389b2ce1f0c549d12f/chonkie-1.3.1.tar.gz", hash = "sha256:7df6c85c721518c5b6add8c40cf3c2747e6b25603c9e930103022871401008c6", size = 365381, upload-time = "2025-09-27T08:21:11.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/64/d53bf2a68dfcb2d76668915536ef35809c92e8eb8b3022ea51c302a2b0db/chonkie-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ddcecbc17aa7de8a05b1f793eb01cef64848436ed55f33d4b731769849c2a4", size = 493390, upload-time = "2025-09-27T08:21:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/182a9f43ea8e8c68b2526578716c291362f24354fd2f3f5f23921b158847/chonkie-1.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e59b94b47c65d74a2b3b9572a6abd9142f3177f0404f34e591723d80e77139b", size = 1003547, upload-time = "2025-09-27T08:21:01.75Z" }, + { url = "https://files.pythonhosted.org/packages/7d/60/1cdcb1024668bba484e24cd93541446f7115f006f0f3249e23c1066afa1f/chonkie-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:49e5f7e745ca8825c5dc3d92140a709776a2dfbc003d23d6c99cce61953c4da9", size = 493105, upload-time = "2025-09-27T08:21:03.021Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "cn2an" +version = "0.5.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "proces" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/0b/35c9379122a2b551b22aa47d67b2a268eba2e77bc7509f52ed3f0ce6363e/cn2an-0.5.23.tar.gz", hash = "sha256:eda06a63e5eff4a64488d9f22e5f2a4ceca6eaa63416e4f771e67edecb1a5bdb", size = 21444, upload-time = "2024-12-21T14:51:29.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/5c/03f0cb3d31c132e09f5523c76e963436fcd13c0318428021bd210f7bb216/cn2an-0.5.23-py3-none-any.whl", hash = "sha256:b19ab3c53676765c038ccdab51f69b7efa4f0b888139c34088935769241f1cbf", size = 224934, upload-time = "2024-12-21T14:51:26.629Z" }, +] + +[[package]] +name = "cobble" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/7a/a507c709be2c96e1bb6102eb7b7f4026c5e5e223ef7d745a17d239e9d844/cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa", size = 3805, upload-time = "2024-06-01T18:11:09.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984, upload-time = "2024-06-01T18:11:07.911Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "concurrent-log-handler" +version = "0.9.28" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "portalocker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/ed/68b9c3a07a2331361a09a194e4375c4ee680a799391cfb1ca924ca2b6523/concurrent_log_handler-0.9.28.tar.gz", hash = "sha256:4cc27969b3420239bd153779266f40d9713ece814e312b7aa753ce62c6eacdb8", size = 30935, upload-time = "2025-06-10T19:02:15.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/a0/1331c3f12d95adc8d0385dc620001054c509db88376d2e17be36b6353020/concurrent_log_handler-0.9.28-py3-none-any.whl", hash = "sha256:65db25d05506651a61573937880789fc51c7555e7452303042b5a402fd78939c", size = 28983, upload-time = "2025-06-10T19:02:14.223Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, +] + +[[package]] +name = "crcmod" +version = "1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/663f3285c1ac0e5d0854bd9db2c87caa6fa3d1a063185e3394a6cdca9151/cyclopts-4.5.0.tar.gz", hash = "sha256:717ac4235548b58d500baf7e688aa4d024caf0ee68f61a012ffd5e29db3099f9", size = 161980, upload-time = "2026-01-16T02:07:16.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/2e00fececc34a99ae3a5d5702a5dd29c5371e4ed016647301a2b9bcc1976/cyclopts-4.5.0-py3-none-any.whl", hash = "sha256:305b9aa90a9cd0916f0a450b43e50ad5df9c252680731a0719edfb9b20381bf5", size = 199772, upload-time = "2026-01-16T02:07:14.707Z" }, +] + +[[package]] +name = "dashscope" +version = "1.25.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "cryptography" }, + { name = "requests" }, + { name = "websocket-client" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/bf/503587663b909427c1906b3b75fc2982bf9e42161d8b687f6e38ad12d042/dashscope-1.25.9-py3-none-any.whl", hash = "sha256:03b587bcb58a2f0a76fa5102925c16609b50af176198af0aeb0fd85aa44d6cfe", size = 1335755, upload-time = "2026-01-21T06:58:14.496Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "datrie" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/0b/c0f53a14317b304e2e93b29a831b0c83306caae9af7f0e2e037d17c4f63f/datrie-0.8.3.tar.gz", hash = "sha256:ea021ad4c8a8bf14e08a71c7872a622aa399a510f981296825091c7ca0436e80", size = 499040, upload-time = "2025-08-28T12:37:23.227Z" } + +[[package]] +name = "demjson3" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/d2/6a81a9b5311d50542e11218b470dafd8adbaf1b3e51fc1fddd8a57eed691/demjson3-3.0.6.tar.gz", hash = "sha256:37c83b0c6eb08d25defc88df0a2a4875d58a7809a9650bd6eee7afd8053cdbac", size = 131477, upload-time = "2022-10-22T19:09:05.379Z" } + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "editdistance" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/18/9f4f975ca87a390832b1c22478f3702fcdf739f83211e24d054b7551270d/editdistance-0.8.1.tar.gz", hash = "sha256:d1cdf80a5d5014b0c9126a69a42ce55a457b457f6986ff69ca98e4fe4d2d8fed", size = 50006, upload-time = "2024-02-10T07:44:53.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/4c/7f195588949b4e72436dc7fc902632381f96e586af829685b56daebb38b8/editdistance-0.8.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04af61b3fcdd287a07c15b6ae3b02af01c5e3e9c3aca76b8c1d13bd266b6f57", size = 106723, upload-time = "2024-02-10T07:43:50.268Z" }, + { url = "https://files.pythonhosted.org/packages/8d/82/31dc1640d830cd7d36865098329f34e4dad3b77f31cfb9404b347e700196/editdistance-0.8.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:18fc8b6eaae01bfd9cf999af726c1e8dcf667d120e81aa7dbd515bea7427f62f", size = 80998, upload-time = "2024-02-10T07:43:51.259Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2a/6b823e71cef694d6f070a1d82be2842706fa193541aab8856a8f42044cd0/editdistance-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a87839450a5987028738d061ffa5ef6a68bac2ddc68c9147a8aae9806629c7f", size = 79248, upload-time = "2024-02-10T07:43:52.873Z" }, + { url = "https://files.pythonhosted.org/packages/e1/31/bfb8e590f922089dc3471ed7828a6da2fc9453eba38c332efa9ee8749fd7/editdistance-0.8.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24b5f9c9673c823d91b5973d0af8b39f883f414a55ade2b9d097138acd10f31e", size = 415262, upload-time = "2024-02-10T07:43:54.498Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/57423942b2f847cdbbb46494568d00cd8a45500904ea026f0aad6ca01bc7/editdistance-0.8.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c59248eabfad603f0fba47b0c263d5dc728fb01c2b6b50fb6ca187cec547fdb3", size = 418905, upload-time = "2024-02-10T07:43:55.779Z" }, + { url = "https://files.pythonhosted.org/packages/1b/05/dfa4cdcce063596cbf0d7a32c46cd0f4fa70980311b7da64d35f33ad02a0/editdistance-0.8.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e239d88ff52821cf64023fabd06a1d9a07654f364b64bf1284577fd3a79d0e", size = 412511, upload-time = "2024-02-10T07:43:57.567Z" }, + { url = "https://files.pythonhosted.org/packages/0e/14/39608ff724a9523f187c4e28926d78bc68f2798f74777ac6757981108345/editdistance-0.8.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2f7f71698f83e8c83839ac0d876a0f4ef996c86c5460aebd26d85568d4afd0db", size = 917293, upload-time = "2024-02-10T07:43:59.559Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/4a1c61d72da40dedfd0ff950fdc71ae83f478330c58a8bccfd776518bd67/editdistance-0.8.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:04e229d6f4ce0c12abc9f4cd4023a5b5fa9620226e0207b119c3c2778b036250", size = 975580, upload-time = "2024-02-10T07:44:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/47/3d/9877566e724c8a37f2228a84ec5cbf66dbfd0673515baf68a0fe07caff40/editdistance-0.8.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e16721636da6d6b68a2c09eaced35a94f4a4a704ec09f45756d4fd5e128ed18d", size = 929121, upload-time = "2024-02-10T07:44:02.764Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/8c50757d198b8ca30ddb91e8b8f0247a8dca04ff2ec30755245f0ab1ff0c/editdistance-0.8.1-cp312-cp312-win32.whl", hash = "sha256:87533cf2ebc3777088d991947274cd7e1014b9c861a8aa65257bcdc0ee492526", size = 81039, upload-time = "2024-02-10T07:44:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/65101e51dc7c850e7b7581a5d8fa8721a1d7479a0dca6c08386328e19882/editdistance-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:09f01ed51746d90178af7dd7ea4ebb41497ef19f53c7f327e864421743dffb0a", size = 79853, upload-time = "2024-02-10T07:44:05.687Z" }, +] + +[[package]] +name = "elastic-transport" +version = "8.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/82/2a544ac3d9c4ae19acc7f53117251bee20dd65dc3dff01fe55ea45ae9bd9/elastic_transport-8.17.0.tar.gz", hash = "sha256:e755f38f99fa6ec5456e236b8e58f0eb18873ac8fe710f74b91a16dd562de2a5", size = 73304, upload-time = "2025-01-07T08:12:37.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/0d/2dd25c06078070973164b661e0d79868e434998391f9aed74d4070aab270/elastic_transport-8.17.0-py3-none-any.whl", hash = "sha256:59f553300866750e67a38828fede000576562a0e66930c641adb75249e0c95af", size = 64523, upload-time = "2025-01-07T08:12:34.528Z" }, +] + +[[package]] +name = "elasticsearch" +version = "8.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elastic-transport" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/57/f61579940df4771971aede5b355f64c758f56d8cc9bd4407d669c2f0dd2f/elasticsearch-8.17.0.tar.gz", hash = "sha256:c1069bf2204ba8fab29ff00b2ce6b37324b2cc6ff593283b97df43426ec13053", size = 460268, upload-time = "2024-12-16T06:30:00.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/82/832ff4bdb53429af0025f5032c8b4f3ba18915e08ce16fc55aa09e900e26/elasticsearch-8.17.0-py3-none-any.whl", hash = "sha256:15965240fe297279f0e68b260936d9ced9606aa7ef8910b9b56727f96ef00d5b", size = 571182, upload-time = "2024-12-16T06:29:53.828Z" }, +] + +[[package]] +name = "elasticsearch-dsl" +version = "8.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "elastic-transport" }, + { name = "elasticsearch" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/75/b9c4e7a7ce99bd944076076cc95f8d898e9cd3c927fc3025a5ebbf4c8102/elasticsearch_dsl-8.17.0.tar.gz", hash = "sha256:c204218175462d108a84fb913371e45d3f49e9dd711ca26ec7ed89ab4e8f287d", size = 152052, upload-time = "2024-12-13T10:40:14.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/03/99623669fe32419d4a305b2edc72f72b458f0baba50ace0e25b1d448c5ae/elasticsearch_dsl-8.17.0-py3-none-any.whl", hash = "sha256:2096d196d473e0b11c3b190d0f1d5896e05d52c302c4170b29d3262d1164d555", size = 158872, upload-time = "2024-12-13T10:40:11.685Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + +[[package]] +name = "fastapi" +version = "0.119.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" }, +] + +[[package]] +name = "fastmcp" +version = "2.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pydocket" }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/b5/7c4744dc41390ed2c17fd462ef2d42f4448a1ec53dda8fe3a01ff2872313/fastmcp-2.14.3.tar.gz", hash = "sha256:abc9113d5fcf79dfb4c060a1e1c55fccb0d4bce4a2e3eab15ca352341eec8dd6", size = 8279206, upload-time = "2026-01-12T20:00:40.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/dc/f7dd14213bf511690dccaa5094d436947c253b418c86c86211d1c76e6e44/fastmcp-2.14.3-py3-none-any.whl", hash = "sha256:103c6b4c6e97a9acc251c81d303f110fe4f2bdba31353df515d66272bf1b9414", size = 416220, upload-time = "2026-01-12T20:00:42.543Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "flower" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "humanize" }, + { name = "prometheus-client" }, + { name = "pytz" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a1/357f1b5d8946deafdcfdd604f51baae9de10aafa2908d0b7322597155f92/flower-2.0.1.tar.gz", hash = "sha256:5ab717b979530770c16afb48b50d2a98d23c3e9fe39851dcf6bc4d01845a02a0", size = 3220408, upload-time = "2023-08-13T14:37:46.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" }, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "gensim" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, + { name = "smart-open" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/65/d5285865ca54b93d41ccd8683c2d79952434957c76b411283c7a6c66ca69/gensim-4.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0845b2fa039dbea5667fb278b5414e70f6d48fd208ef51f33e84a78444288d8d", size = 24467245, upload-time = "2025-10-18T01:55:09.924Z" }, + { url = "https://files.pythonhosted.org/packages/32/59/f0ea443cbfb3b06e1d2e060217bb91f954845f6df38cbc9c5468b6c9c638/gensim-4.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1853fc5be730f692c444a826041fef9a2fc8d74c73bb59748904b2e3221daa86", size = 24455775, upload-time = "2025-10-18T01:55:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/9b0ba15756e41ccfdd852f9c65cd2b552f240c201dc3237ad8c178642e80/gensim-4.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23a2a4260f01c8f71bae5dd0e8a01bb247a2c789480c033e0eaba100b0ad4239", size = 27771345, upload-time = "2025-10-18T01:56:41.448Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/c29701826c963b04a43d5d7b87573a74040387ab9219e65b10f377d22b5b/gensim-4.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b73ff30af6ddd0d2ddf9473b1eb44603cd79ec14c87d93b75291802b991916c", size = 27864118, upload-time = "2025-10-18T01:57:32.428Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f2/9ec6863143888bf390cdc5261f6d9e71d79bc95d98fb815679dba478d5f6/gensim-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b3a3f9bc8d4178b01d114e1c58c5ab2333f131c7415fb3d8ec8f1ecfe4c5b544", size = 24400277, upload-time = "2025-10-18T01:58:17.629Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[[package]] +name = "graspologic" +version = "3.4.5.dev2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anytree" }, + { name = "beartype" }, + { name = "future" }, + { name = "gensim" }, + { name = "graspologic-native" }, + { name = "hyppo" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "pot" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "seaborn" }, + { name = "statsmodels" }, + { name = "typing-extensions" }, + { name = "umap-learn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/d9/3a20586ec6aa7097ea58e6b54a3b7170ae4445872f23d085460611b2a55b/graspologic-3.4.5.dev2.tar.gz", hash = "sha256:0226945c5e5ee31e1dec4e085f365577ab059e498ba842f455211fe35322c026", size = 6111760, upload-time = "2025-11-25T18:20:11.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/d2/2752eeba482c6adb7697db70ad47c79c9c7f6ba030ff8bb30b1b1ef064ef/graspologic-3.4.5.dev2-py3-none-any.whl", hash = "sha256:eb1ec49fea530f04aa22ac40d5e89b8511141ea1c9e0d577816bbf1c20aade68", size = 5201199, upload-time = "2025-11-25T18:20:10.112Z" }, +] + +[[package]] +name = "graspologic-native" +version = "1.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/2d/62b30d89533643ccf4778a18eb023f291b8877b5d85de3342f07b2d363a7/graspologic_native-1.2.5.tar.gz", hash = "sha256:27ea7e01fa44466c0b4cdd678d4561e5d3dc0cb400015683b7ae1386031257a0", size = 2512729, upload-time = "2025-04-02T19:34:22.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/86/10748f4c474b0c8f6060dd379bb0c4da5d42779244bb13a58656ffb44a03/graspologic_native-1.2.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bf05f2e162ae2a2a8d6e8cfccbe3586d1faa0b808159ff950478348df557c61e", size = 648437, upload-time = "2025-04-02T19:34:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/cc/b75ea35755340bedda29727e5388390c639ea533f55b9249f5ac3003f656/graspologic_native-1.2.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7fff06ed49c3875cf351bb09a92ae7cbc169ce92dcc4c3439e28e801f822ae", size = 352044, upload-time = "2025-04-02T19:34:18.153Z" }, + { url = "https://files.pythonhosted.org/packages/8e/55/15e6e4f18bf249b529ac4cd1522b03f5c9ef9284a2f7bfaa1fd1f96464fe/graspologic_native-1.2.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e7e993e7d70fe0d860773fc62812fbb8cb4ef2d11d8661a1f06f8772593915", size = 364644, upload-time = "2025-04-02T19:34:19.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/21097af79f3d68626539ab829bdbf6cc42933f020e161972927d916e394c/graspologic_native-1.2.5-cp38-abi3-win_amd64.whl", hash = "sha256:c3ef2172d774083d7e2c8e77daccd218571ddeebeb2c1703cebb1a2cc4c56e07", size = 210438, upload-time = "2025-04-02T19:34:21.139Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hanziconv" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/71/b89cb63077fd807fe31cf7c016a06e7e579a289d8a37aa24a30282d02dd2/hanziconv-0.3.2.tar.gz", hash = "sha256:208866da6ae305bca19eb98702b65c93bb3a803b496e4287ca740d68892fc4c4", size = 276775, upload-time = "2016-09-01T05:41:15.254Z" } + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/fd/5f81bae67096c5ab50d29a0230b8374f0245916cca192f8ee2fada51f4f6/huggingface_hub-0.25.2.tar.gz", hash = "sha256:a1014ea111a5f40ccd23f7f7ba8ac46e20fa3b658ced1f86a00c75c06ec6423c", size = 365806, upload-time = "2024-10-09T08:32:41.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/09/a535946bf2dc88e61341f39dc507530411bb3ea4eac493e5ec833e8f35bd/huggingface_hub-0.25.2-py3-none-any.whl", hash = "sha256:1897caf88ce7f97fe0110603d8f66ac264e3ba6accdf30cd66cc0fed5282ad25", size = 436575, upload-time = "2024-10-09T08:32:39.166Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "hyppo" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autograd" }, + { name = "future" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "statsmodels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/c4/d46858cfac3c0aad314a1fc378beae5c8cac499b677650a34b5a6a3d4328/hyppo-0.5.2-py3-none-any.whl", hash = "sha256:5cc18f9e158fe2cf1804c9a1e979e807118ee89a303f29dc5cb8891d92d44ef3", size = 192272, upload-time = "2025-05-24T18:33:25.904Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" } + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, + { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, + { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, + { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, + { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, +] + +[[package]] +name = "jmespath" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/56/3f325b1eef9791759784aa5046a8f6a1aff8f7c898a2e34506771d3b99d8/jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", size = 21607, upload-time = "2020-05-12T22:03:47.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/cb/5f001272b6faeb23c1c9e0acc04d48eaaf5c862c17709d20e3469c6e0139/jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f", size = 24489, upload-time = "2020-05-12T22:03:45.643Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json-repair" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9c/be1d84106529aeacbe6151c1e1dc202f5a5cfa0d9bac748d4a1039ebb913/json_repair-0.53.0.tar.gz", hash = "sha256:97fcbf1eea0bbcf6d5cc94befc573623ab4bbba6abdc394cfd3b933a2571266d", size = 36204, upload-time = "2025-11-08T13:45:15.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/49/e588ec59b64222c8d38585f9ceffbf71870c3cbfb2873e53297c4f4afd0b/json_repair-0.53.0-py3-none-any.whl", hash = "sha256:17f7439e41ae39964e1d678b1def38cb8ec43d607340564acf3e62d8ce47a727", size = 27404, upload-time = "2025-11-08T13:45:14.464Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, +] + +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + +[[package]] +name = "langchain" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/bc/d8f506a525baadee99a65c6cc28c1c35c9eaf1cb2009f048e9861d81a600/langchain-1.2.6.tar.gz", hash = "sha256:7d46cbf719d860a16f6fc182d5d3de17453dda187f3d43e9c40ac352a5094fdd", size = 553127, upload-time = "2026-01-16T19:21:19.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/28/d5dc4cb06ccb29d62a590d446072964766555e85863f5044c6e644c07d0d/langchain-1.2.6-py3-none-any.whl", hash = "sha256:a9a6c39f03c09b6eb0f1b47e267ad2a2fd04e124dfaa9753bd6c11d2fe7d944e", size = 108458, upload-time = "2026-01-16T19:21:18.085Z" }, +] + +[[package]] +name = "langchain-aws" +version = "1.0.0a1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "langchain-core" }, + { name = "numpy" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/c3/a98c0849c13c6880b5629409cadb22d4070e9c611013da127be975f8c0dc/langchain_aws-1.0.0a1.tar.gz", hash = "sha256:3bb193a5fa915520c52bb47581e892d11ac4d114939a1b3ecfeca56fe153fff7", size = 121650, upload-time = "2025-09-18T20:52:36.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7b/be49a224fe3aa07ed869801356f06e1d7a321bb7f22b6f7935dce86d258a/langchain_aws-1.0.0a1-py3-none-any.whl", hash = "sha256:24207d05c619ea61dfeab0a0f7086ae388cc3f2f5c03a8ae56b12d1b77d72585", size = 146839, upload-time = "2025-09-18T20:52:35.013Z" }, +] + +[[package]] +name = "langchain-classic" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/4b/bd03518418ece4c13192a504449b58c28afee915dc4a6f4b02622458cb1b/langchain_classic-1.0.1.tar.gz", hash = "sha256:40a499684df36b005a1213735dc7f8dca8f5eb67978d6ec763e7a49780864fdc", size = 10516020, upload-time = "2025-12-23T22:55:22.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0f/eab87f017d7fe28e8c11fff614f4cdbfae32baadb77d0f79e9f922af1df2/langchain_classic-1.0.1-py3-none-any.whl", hash = "sha256:131d83a02bb80044c68fedc1ab4ae885d5b8f8c2c742d8ab9e7534ad9cda8e80", size = 1040666, upload-time = "2025-12-23T22:55:21.025Z" }, +] + +[[package]] +name = "langchain-community" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "dataclasses-json" }, + { name = "httpx-sse" }, + { name = "langchain-classic" }, + { name = "langchain-core" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl", hash = "sha256:2135abb2c7748a35c84613108f7ebf30f8505b18c3c18305ffaecfc7651f6c6a", size = 2533285, upload-time = "2025-10-27T15:20:30.767Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, +] + +[[package]] +name = "langchain-mcp-adapters" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "mcp" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/52/cebf0ef5b1acef6cbc63d671171d43af70f12d19f55577909c7afa79fb6e/langchain_mcp_adapters-0.2.1.tar.gz", hash = "sha256:58e64c44e8df29ca7eb3b656cf8c9931ef64386534d7ca261982e3bdc63f3176", size = 36394, upload-time = "2025-12-09T16:28:38.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/81/b2479eb26861ab36be851026d004b2d391d789b7856e44c272b12828ece0/langchain_mcp_adapters-0.2.1-py3-none-any.whl", hash = "sha256:9f96ad4c64230f6757297fec06fde19d772c99dbdfbca987f7b7cfd51ff77240", size = 22708, upload-time = "2025-12-09T16:28:37.877Z" }, +] + +[[package]] +name = "langchain-ollama" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ollama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/51/72cd04d74278f3575f921084f34280e2f837211dc008c9671c268c578afe/langchain_ollama-1.0.1.tar.gz", hash = "sha256:e37880c2f41cdb0895e863b1cfd0c2c840a117868b3f32e44fef42569e367443", size = 153850, upload-time = "2025-12-12T21:48:28.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/46/f2907da16dc5a5a6c679f83b7de21176178afad8d2ca635a581429580ef6/langchain_ollama-1.0.1-py3-none-any.whl", hash = "sha256:37eb939a4718a0255fe31e19fbb0def044746c717b01b97d397606ebc3e9b440", size = 29207, upload-time = "2025-12-12T21:48:27.832Z" }, +] + +[[package]] +name = "langchain-openai" +version = "1.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/30bfc4d1b658a9ee524bcce3b0b2ec9c45a11c853a13c4f0c9da9882784b/langchain_openai-1.1.7.tar.gz", hash = "sha256:f5ec31961ed24777548b63a5fe313548bc6e0eb9730d6552b8c6418765254c81", size = 1039134, upload-time = "2026-01-07T19:44:59.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a1/50e7596aca775d8c3883eceeaf47489fac26c57c1abe243c00174f715a8a/langchain_openai-1.1.7-py3-none-any.whl", hash = "sha256:34e9cd686aac1a120d6472804422792bf8080a2103b5d21ee450c9e42d053815", size = 84753, upload-time = "2026-01-07T19:44:58.629Z" }, +] + +[[package]] +name = "langchain-text-splitters" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/42/c178dcdc157b473330eb7cc30883ea69b8ec60078c7b85e2d521054c4831/langchain_text_splitters-1.1.0.tar.gz", hash = "sha256:75e58acb7585dc9508f3cd9d9809cb14751283226c2d6e21fb3a9ae57582ca22", size = 272230, upload-time = "2025-12-14T01:15:38.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1a/a84ed1c046deecf271356b0179c1b9fba95bfdaa6f934e1849dee26fad7b/langchain_text_splitters-1.1.0-py3-none-any.whl", hash = "sha256:f00341fe883358786104a5f881375ac830a4dd40253ecd42b4c10536c6e4693f", size = 34182, upload-time = "2025-12-14T01:15:37.382Z" }, +] + +[[package]] +name = "langfuse" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/d2/33991342653d101715faae8f82c14eb3f0a5c2d22d8c99df9dbb8d099802/langfuse-3.12.0.tar.gz", hash = "sha256:0f75b3d21d4ef4014ebeaa8188eb0c855200412b4e4fb8cceca609a7ce465f91", size = 232651, upload-time = "2026-01-13T14:17:33.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/87/141689c2c2b352ed100de4a63f64f24b4df7f883ba2a3fc0c6733d9d0451/langfuse-3.12.0-py3-none-any.whl", hash = "sha256:644d9bbfa842eb6775b1e069e23f77ad1087f5241682966b8168bbb01f9c357e", size = 416875, upload-time = "2026-01-13T14:17:31.791Z" }, +] + +[[package]] +name = "langgraph" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/9c/dac99ab1732e9fb2d3b673482ac28f02bee222c0319a3b8f8f73d90727e6/langgraph-1.0.6.tar.gz", hash = "sha256:dd8e754c76d34a07485308d7117221acf63990e7de8f46ddf5fe256b0a22e6c5", size = 495092, upload-time = "2026-01-12T20:33:30.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/45/9960747781416bed4e531ed0c6b2f2c739bc7b5397d8e92155463735a40e/langgraph-1.0.6-py3-none-any.whl", hash = "sha256:bcfce190974519c72e29f6e5b17f0023914fd6f936bfab8894083215b271eb89", size = 157356, upload-time = "2026-01-12T20:33:29.191Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/76/55a18c59dedf39688d72c4b06af73a5e3ea0d1a01bc867b88fbf0659f203/langgraph_checkpoint-4.0.0.tar.gz", hash = "sha256:814d1bd050fac029476558d8e68d87bce9009a0262d04a2c14b918255954a624", size = 137320, upload-time = "2026-01-12T20:30:26.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/de/ddd53b7032e623f3c7bcdab2b44e8bf635e468f62e10e5ff1946f62c9356/langgraph_checkpoint-4.0.0-py3-none-any.whl", hash = "sha256:3fa9b2635a7c5ac28b338f631abf6a030c3b508b7b9ce17c22611513b589c784", size = 46329, upload-time = "2026-01-12T20:30:25.2Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f5/8c75dace0d729561dce2966e630c5e312193df7e5df41a7e10cd7378c3a7/langgraph_prebuilt-1.0.6.tar.gz", hash = "sha256:c5f6cf0f5a0ac47643d2e26ae6faa38cb28885ecde67911190df9e30c4f72361", size = 162623, upload-time = "2026-01-12T20:31:28.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/4045822b0630cfc0f8624c4499ceaf90644142143c063a8dc385a7424fc3/langgraph_prebuilt-1.0.6-py3-none-any.whl", hash = "sha256:9fdc35048ff4ac985a55bd2a019a86d45b8184551504aff6780d096c678b39ae", size = 35322, upload-time = "2026-01-12T20:31:27.161Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/0f/ed0634c222eed48a31ba48eab6881f94ad690d65e44fe7ca838240a260c1/langgraph_sdk-0.3.3.tar.gz", hash = "sha256:c34c3dce3b6848755eb61f0c94369d1ba04aceeb1b76015db1ea7362c544fb26", size = 130589, upload-time = "2026-01-13T00:30:43.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl", hash = "sha256:a52ebaf09d91143e55378bb2d0b033ed98f57f48c9ad35c8f81493b88705fc7b", size = 67021, upload-time = "2026-01-13T00:30:42.264Z" }, +] + +[[package]] +name = "langsmith" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/9c7933052a997da1b85bc5c774f3865e9b1da1c8d71541ea133178b13229/langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8", size = 919964, upload-time = "2026-01-15T20:02:28.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/0f/09a6637a7ba777eb307b7c80852d9ee26438e2bdafbad6fcc849ff9d9192/langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486", size = 283503, upload-time = "2026-01-15T20:02:26.662Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" }, +] + +[[package]] +name = "lupa" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, + { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "mammoth" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cobble" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/3c/a58418d2af00f2da60d4a51e18cd0311307b72d48d2fffec36a97b4a5e44/mammoth-1.11.0.tar.gz", hash = "sha256:a0f59e442f34d5b6447f4b0999306cbf3e67aaabfa8cb516f878fb1456744637", size = 53142, upload-time = "2025-09-19T10:35:20.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/54/2e39566a131b13f6d8d193f974cb6a34e81bb7cc2fa6f7e03de067b36588/mammoth-1.11.0-py2.py3-none-any.whl", hash = "sha256:c077ab0d450bd7c0c6ecd529a23bf7e0fa8190c929e28998308ff4eada3f063b", size = 54752, upload-time = "2025-09-19T10:35:18.699Z" }, +] + +[[package]] +name = "markdown" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906, upload-time = "2025-04-11T14:42:50.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210, upload-time = "2025-04-11T14:42:49.178Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markdown-to-json" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/1a/d235321eac5ba6de9f83dd172b9549eb03fd149ecda4c8c25cdc9a5224bc/markdown_to_json-2.1.1.tar.gz", hash = "sha256:27642c42acd9130d1449f791f57fd0c4bbf58c7a76cfb5af6d42010ca97b1107", size = 51343, upload-time = "2024-05-09T19:08:44.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/2b/dac4143951a16c0c03e8fe217c9fa784838d02a29c52ef0e8b265befea8f/markdown_to_json-2.1.1-py3-none-any.whl", hash = "sha256:c73b8a3ac7fbde65463dbaeba8bb925d1d54377cbb01a064cd65e1f3e394bd62", size = 52647, upload-time = "2024-05-09T19:08:42.959Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/1b/6f2697b51eaca81f08852fd2734745af15718fea10222a1d40f8a239c4ea/markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c", size = 18771, upload-time = "2025-08-09T17:44:15.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, +] + +[[package]] +name = "mcp" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, + { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, + { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, + { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nltk" +version = "3.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, +] + +[[package]] +name = "numba" +version = "0.63.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/9c/c0974cd3d00ff70d30e8ff90522ba5fbb2bcee168a867d2321d8d0457676/numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71", size = 2680981, upload-time = "2025-12-10T02:57:17.579Z" }, + { url = "https://files.pythonhosted.org/packages/cb/70/ea2bc45205f206b7a24ee68a159f5097c9ca7e6466806e7c213587e0c2b1/numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f", size = 3801656, upload-time = "2025-12-10T02:57:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/4f4ba4fd0f99825cbf3cdefd682ca3678be1702b63362011de6e5f71f831/numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0", size = 3501857, upload-time = "2025-12-10T02:57:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/af/fd/6540456efa90b5f6604a86ff50dabefb187e43557e9081adcad3be44f048/numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3", size = 2750282, upload-time = "2025-12-10T02:57:22.474Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.1.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/6d/121efd7382d5b0284239f4ab1fc1590d86d34ed4a4a2fdb13b30ca8e5740/nvidia_cublas_cu12-12.1.3.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:ee53ccca76a6fc08fb9701aa95b6ceb242cdaab118c3bb152af4e579af792728", size = 410594774, upload-time = "2023-04-19T15:50:03.519Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.1.105" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/00/6b218edd739ecfc60524e585ba8e6b00554dd908de2c9c66c1af3e44e18d/nvidia_cuda_cupti_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:e54fde3983165c624cb79254ae9818a456eb6e87a7fd4d56a2352c24ee542d7e", size = 14109015, upload-time = "2023-04-19T15:47:32.502Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.1.105" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/9f/c64c03f49d6fbc56196664d05dba14e3a561038a81a638eeb47f4d4cfd48/nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:339b385f50c309763ca65456ec75e17bbefcbbf2893f462cb8b90584cd27a1c2", size = 23671734, upload-time = "2023-04-19T15:48:32.42Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.1.105" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d5/c68b1d2cdfcc59e72e8a5949a37ddb22ae6cade80cd4a57a84d4c8b55472/nvidia_cuda_runtime_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:6e258468ddf5796e25f1dc591a31029fa317d97a0a94ed93468fc86301d61e40", size = 823596, upload-time = "2023-04-19T15:47:22.471Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "8.9.2.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/74/a2e2be7fb83aaedec84f391f082cf765dfb635e7caa9b49065f73e4835d8/nvidia_cudnn_cu12-8.9.2.26-py3-none-manylinux1_x86_64.whl", hash = "sha256:5ccb288774fdfb07a7e7025ffec286971c06d8d7b4fb162525334616d7629ff9", size = 731725872, upload-time = "2023-06-01T19:24:57.328Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.0.2.54" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/94/eb540db023ce1d162e7bea9f8f5aa781d57c65aed513c33ee9a5123ead4d/nvidia_cufft_cu12-11.0.2.54-py3-none-manylinux1_x86_64.whl", hash = "sha256:794e3948a1aa71fd817c3775866943936774d1c14e7628c74f6f7417224cdf56", size = 121635161, upload-time = "2023-04-19T15:50:46Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.2.106" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/31/4890b1c9abc496303412947fc7dcea3d14861720642b49e8ceed89636705/nvidia_curand_cu12-10.3.2.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:9d264c5036dde4e64f1de8c50ae753237c12e0b1348738169cd0f8a536c0e1e0", size = 56467784, upload-time = "2023-04-19T15:51:04.804Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.4.5.107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/1d/8de1e5c67099015c834315e333911273a8c6aaba78923dd1d1e25fc5f217/nvidia_cusolver_cu12-11.4.5.107-py3-none-manylinux1_x86_64.whl", hash = "sha256:8a7ec542f0412294b15072fa7dab71d31334014a69f953004ea7a118206fe0dd", size = 124161928, upload-time = "2023-04-19T15:51:25.781Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.1.0.106" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/5b/cfaeebf25cd9fdec14338ccb16f6b2c4c7fa9163aefcf057d86b9cc248bb/nvidia_cusparse_cu12-12.1.0.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:f3b50f42cf363f86ab21f720998517a659a48131e8d538dc02f8768237bd884c", size = 195958278, upload-time = "2023-04-19T15:51:49.939Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.19.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/00/d0d4e48aef772ad5aebcf70b73028f88db6e5640b36c38e90445b7a57c45/nvidia_nccl_cu12-2.19.3-py3-none-manylinux1_x86_64.whl", hash = "sha256:a9734707a2c96443331c1e48c717024aa6678a0e2a4cb66b2c364d18cee6b48d", size = 165987969, upload-time = "2023-10-24T16:16:24.789Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/0c/c75bbfb967457a0b7670b8ad267bfc4fffdf341c074e0a80db06c24ccfd4/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e3f1171dbdc83c5932a45f0f4c99180a70de9bd2718c1ab77d14104f6d7147f9", size = 39748338, upload-time = "2025-06-05T20:10:25.613Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.1.105" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d3/8057f0587683ed2fcd4dbfbdfdfa807b9160b809976099d36b8f60d08f03/nvidia_nvtx_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:dc21cf308ca5691e7c04d962e213f8a4aa9bbfa23d95412f452254c2caeb09e5", size = 99138, upload-time = "2023-04-19T15:48:43.556Z" }, +] + +[[package]] +name = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, +] + +[[package]] +name = "ollama" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, +] + +[[package]] +name = "openai" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.10.0.84" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/b70a2d9ab205110d715906fc8ec83fbb00404aeb3a37a0654fdb68eb0c8c/opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526", size = 95103981, upload-time = "2024-06-17T18:29:56.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/82/564168a349148298aca281e342551404ef5521f33fba17b388ead0a84dc5/opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251", size = 54835524, upload-time = "2024-06-18T04:57:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/64/4a/016cda9ad7cf18c58ba074628a4eaae8aa55f3fd06a266398cef8831a5b9/opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98", size = 56475426, upload-time = "2024-06-17T19:34:10.927Z" }, + { url = "https://files.pythonhosted.org/packages/81/e4/7a987ebecfe5ceaf32db413b67ff18eb3092c598408862fff4d7cc3fd19b/opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6", size = 41746971, upload-time = "2024-06-17T20:00:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a4/d2537f47fd7fcfba966bd806e3ec18e7ee1681056d4b0a9c8d983983e4d5/opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f", size = 62548253, upload-time = "2024-06-17T18:29:43.659Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/bbf57e7b9dab623e8773f6ff36385456b7ae7fa9357a5e53db732c347eac/opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236", size = 28737688, upload-time = "2024-06-17T18:28:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6c/fab8113424af5049f85717e8e527ca3773299a3c6b02506e66436e19874f/opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe", size = 38842521, upload-time = "2024-06-17T18:28:21.813Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-exporter-prometheus" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, + { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, + { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, +] + +[[package]] +name = "oss2" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aliyun-python-sdk-core" }, + { name = "aliyun-python-sdk-kms" }, + { name = "crcmod" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" } + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + +[[package]] +name = "pdfminer-six" +version = "20250506" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/46/5223d613ac4963e1f7c07b2660fe0e9e770102ec6bda8c038400113fb215/pdfminer_six-20250506.tar.gz", hash = "sha256:b03cc8df09cf3c7aba8246deae52e0bca7ebb112a38895b5e1d4f5dd2b8ca2e7", size = 7387678, upload-time = "2025-05-06T16:17:00.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/16/7a432c0101fa87457e75cb12c879e1749c5870a786525e2e0f42871d6462/pdfminer_six-20250506-py3-none-any.whl", hash = "sha256:d81ad173f62e5f841b53a8ba63af1a4a355933cfc0ffabd608e568b9193909e3", size = 5620187, upload-time = "2025-05-06T16:16:58.669Z" }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/0d/4135821aa7b1a0b77a29fac881ef0890b46b0b002290d04915ed7acc0043/pdfplumber-0.11.7.tar.gz", hash = "sha256:fa67773e5e599de1624255e9b75d1409297c5e1d7493b386ce63648637c67368", size = 115518, upload-time = "2025-06-12T11:30:49.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/e0/52b67d4f00e09e497aec4f71bc44d395605e8ebcea52543242ed34c25ef9/pdfplumber-0.11.7-py3-none-any.whl", hash = "sha256:edd2195cca68bd770da479cf528a737e362968ec2351e62a6c0b71ff612ac25e", size = 60029, upload-time = "2025-06-12T11:30:48.89Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "pot" +version = "0.9.6.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/8b/5f939eaf1fbeb7ff914fe540d659486951a056e5537b8f454362045b6c72/pot-0.9.6.post1.tar.gz", hash = "sha256:9b6cc14a8daecfe1268268168cf46548f9130976b22b24a9e8ec62a734be6c43", size = 604243, upload-time = "2025-09-22T12:51:14.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/28/13622807461f9f6082a8cd6768f9b4a810bc3a8fda474b81572da94b4d23/pot-0.9.6.post1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f7c542fc20662e35c24dd82eeff8a737220757434d7f0038664a7322221452f7", size = 599240, upload-time = "2025-09-22T12:50:44.848Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5c/b4e017560531f53d06798c681b0d0a9488bb8116bc98da9d399a3d096391/pot-0.9.6.post1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c1755516a7354cbd6110ad2e5f341b98b9968240c2f0f67b0ff5e3ebcb3105bd", size = 464695, upload-time = "2025-09-22T12:50:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/07/9f/57e49b3f7173359741053c5e2766a45dcf649d767c2e967ef93526c9045f/pot-0.9.6.post1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3207362d3e3b5aaa783f452aa85f66e83edbefb5764f34662860af54ac72ee6", size = 454726, upload-time = "2025-09-22T12:50:47.953Z" }, + { url = "https://files.pythonhosted.org/packages/30/60/fa72dd6094f7dbe6b38e2c6907af8cd0f18c6bd107e0cf4874deddaba883/pot-0.9.6.post1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f6659c5657e6d7e9f98f4a82e0ed64f88e9fce69b2e557416d156343919ba3", size = 1503391, upload-time = "2025-09-22T12:50:49.336Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3f/cc519c1176116271b6282268a705162fa042c16cc922bc56039445c9d697/pot-0.9.6.post1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f1b0148ae17bec0ed12264c6da3a05e13913b716e2a8c9043242b5d8349d8df", size = 1528170, upload-time = "2025-09-22T12:50:50.625Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/0132c94404cd0b1b2f21c4a49698db9dcd6107c47c02b22df1ed38206b2a/pot-0.9.6.post1-cp312-cp312-win32.whl", hash = "sha256:571e543cc2b0a462365002203595baf2b89c3d064cce4fce70fd1231e832c21f", size = 440577, upload-time = "2025-09-22T12:50:51.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6d/23229c0e198a4f7fb27750b3ef8497e6ebed23fe531ed64b5194da8b2b02/pot-0.9.6.post1-cp312-cp312-win_amd64.whl", hash = "sha256:b1d8bd9a334c72baa37f9a2b268de5366c23c0f9c9e3d6dc25d150137ec2823c", size = 455404, upload-time = "2025-09-22T12:50:52.956Z" }, +] + +[[package]] +name = "proces" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/3d/4159b57736ced0fd22553226df20a985ef7655519c80ffcb8a9fb49ebeee/proces-0.1.7.tar.gz", hash = "sha256:70a05d9e973dd685f7a9092c58be695a8181a411d63796c213232fd3fdc43775", size = 31188, upload-time = "2023-09-09T03:27:38.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/88/06cc0c7d890ed8d7e16ef0e56880dea516a21643fb1f3a69a50f4cc6f716/proces-0.1.7-py3-none-any.whl", hash = "sha256:308325bbc96877263f06e57e5e9c760c4b42cc722887ad60be6b18fc37d68762", size = 137718, upload-time = "2023-09-09T03:27:35.463Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, + { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyclipper" +version = "1.3.0.post6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/b2/550fe500e49c464d73fabcb8cb04d47e4885d6ca4cfc1f5b0a125a95b19a/pyclipper-1.3.0.post6.tar.gz", hash = "sha256:42bff0102fa7a7f2abdd795a2594654d62b786d0c6cd67b72d469114fdeb608c", size = 165909, upload-time = "2024-10-18T12:23:09.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/c8/197d9a1d8354922d24d11d22fb2e0cc1ebc182f8a30496b7ddbe89467ce1/pyclipper-1.3.0.post6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6363b9d79ba1b5d8f32d1623e797c1e9f994600943402e68d5266067bdde173e", size = 270487, upload-time = "2024-10-18T12:22:14.852Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8e/eb14eadf054494ad81446e21c4ea163b941747610b0eb9051644395f567e/pyclipper-1.3.0.post6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32cd7fb9c1c893eb87f82a072dbb5e26224ea7cebbad9dc306d67e1ac62dd229", size = 143469, upload-time = "2024-10-18T12:22:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e5/6c4a8df6e904c133bb4c5309d211d31c751db60cbd36a7250c02b05494a1/pyclipper-1.3.0.post6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3aab10e3c10ed8fa60c608fb87c040089b83325c937f98f06450cf9fcfdaf1d", size = 944206, upload-time = "2024-10-18T12:22:17.216Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/cb014acc41cd5bf6bbfa4671c7faffffb9cee01706642c2dec70c5209ac8/pyclipper-1.3.0.post6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58eae2ff92a8cae1331568df076c4c5775bf946afab0068b217f0cf8e188eb3c", size = 963797, upload-time = "2024-10-18T12:22:18.881Z" }, + { url = "https://files.pythonhosted.org/packages/80/ec/b40cd81ab7598984167508a5369a2fa31a09fe3b3e3d0b73aa50e06d4b3f/pyclipper-1.3.0.post6-cp312-cp312-win32.whl", hash = "sha256:793b0aa54b914257aa7dc76b793dd4dcfb3c84011d48df7e41ba02b571616eaf", size = 99456, upload-time = "2024-10-18T12:22:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/7d6292e3c94fb6b872d8d7e80d909dc527ee6b0af73b753c63fdde65a7da/pyclipper-1.3.0.post6-cp312-cp312-win_amd64.whl", hash = "sha256:d3f9da96f83b8892504923beb21a481cd4516c19be1d39eb57a92ef1c9a29548", size = 110278, upload-time = "2024-10-18T12:22:21.178Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pydocket" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "fakeredis", extra = ["lua"] }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-prometheus" }, + { name = "opentelemetry-instrumentation" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/00/26befe5f58df7cd1aeda4a8d10bc7d1908ffd86b80fd995e57a2a7b3f7bd/pydocket-0.16.6.tar.gz", hash = "sha256:b96c96ad7692827214ed4ff25fcf941ec38371314db5dcc1ae792b3e9d3a0294", size = 299054, upload-time = "2026-01-09T22:09:15.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3f/7483e5a6dc6326b6e0c640619b5c5bd1d6e3c20e54d58f5fb86267cef00e/pydocket-0.16.6-py3-none-any.whl", hash = "sha256:683d21e2e846aa5106274e7d59210331b242d7fb0dce5b08d3b82065663ed183", size = 67697, upload-time = "2026-01-09T22:09:13.436Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynndescent" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pypdf" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/3d/b6ead84ee437444f96862beb68f9796da8c199793bed08e9397b77579f23/pypdf-6.1.3.tar.gz", hash = "sha256:8d420d1e79dc1743f31a57707cabb6dcd5b17e8b9a302af64b30202c5700ab9d", size = 5076271, upload-time = "2025-10-22T16:13:46.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/ed/494fd0cc1190a7c335e6958eeaee6f373a281869830255c2ed4785dac135/pypdf-6.1.3-py3-none-any.whl", hash = "sha256:eb049195e46f014fc155f566fa20e09d70d4646a9891164ac25fa0cbcfcdbcb5", size = 323863, upload-time = "2025-10-22T16:13:44.174Z" }, +] + +[[package]] +name = "pypdf2" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/83/173dab58beb6c7e772b838199014c173a2436018dd7cfde9bbf4a3be15da/pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885", size = 268742, upload-time = "2026-01-05T16:29:03.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/a4/6bb5b5918c7fc236ec426be8a0205a984fe0a26ae23d5e4dd497398a6571/pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540", size = 2763287, upload-time = "2026-01-05T16:28:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/3e/64/24b41b906006bf07099b095f0420ee1f01a3a83a899f3e3731e4da99c06a/pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48", size = 2303285, upload-time = "2026-01-05T16:28:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c0/3ec73f4ded83ba6c02acf6e9d228501759d5d74fe57f1b93849ab92dcc20/pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523", size = 2816066, upload-time = "2026-01-05T16:28:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/ca/e553b3b8b5c2cdc3d955cc313493ac27bbe63fc22624769d56ded585dd5e/pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907", size = 2945545, upload-time = "2026-01-05T16:28:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/615b776071e95c8570d579038256d0c77969ff2ff381e427be4ab8967f44/pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca", size = 2979892, upload-time = "2026-01-05T16:28:31.088Z" }, + { url = "https://files.pythonhosted.org/packages/df/10/27114199b765bdb7d19a9514c07036ad2fc3a579b910e7823ba167ead6de/pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27", size = 2765738, upload-time = "2026-01-05T16:28:33.18Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d7/2a3afa35e6c205a4f6264c33b8d2f659707989f93c30b336aa58575f66fa/pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75", size = 3064338, upload-time = "2026-01-05T16:28:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/6658755cf6e369bb51d0bccb81c51c300404fbe67c2f894c90000b6442dd/pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3", size = 3415059, upload-time = "2026-01-05T16:28:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/f86482134fa641deb1f524c45ec7ebd6fc8d404df40c5657ddfce528593e/pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987", size = 2998517, upload-time = "2026-01-05T16:28:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/40ab99425dcf503c172885904c5dc356c052bfdbd085f9f3cc920e0b8b25/pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481", size = 3673154, upload-time = "2026-01-05T16:28:40.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/67/0f7532f80825a7728a5cbff3f1104857f8f9fe49ebfd6cb25582a89ae8e1/pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8", size = 2965002, upload-time = "2026-01-05T16:28:42.143Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/c03d2a3d6621b77aac9604bce1c060de2af94950448787298501eac6c6a2/pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330", size = 4130530, upload-time = "2026-01-05T16:28:44.264Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/9ad1f958cbe35d4693ae87c09ebafda4bb3e4709c7ccaec86c1a829163a3/pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47", size = 3746568, upload-time = "2026-01-05T16:28:45.897Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e2/4d32310166c2d6955d924737df8b0a3e3efc8d133344a98b10f96320157d/pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686", size = 4336683, upload-time = "2026-01-05T16:28:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/14/ea/38c337ff12a8cec4b00fd4fdb0a63a70597a344581e20b02addbd301ab56/pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285", size = 4375030, upload-time = "2026-01-05T16:28:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/a1/77/9d8de90c35d2fc383be8819bcde52f5821dacbd7404a0225e4010b99d080/pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f", size = 3928914, upload-time = "2026-01-05T16:28:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/a5/39/9d4a6fbd78fcb6803b0ea5e4952a31d6182a0aaa2609cfcd0eb88446fdb8/pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e", size = 4997777, upload-time = "2026-01-05T16:28:53.589Z" }, + { url = "https://files.pythonhosted.org/packages/9d/38/cdd4ed085c264234a59ad32df1dfe432c77a7403da2381e0fcc1ba60b74e/pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5", size = 4179895, upload-time = "2026-01-05T16:28:55.322Z" }, + { url = "https://files.pythonhosted.org/packages/93/4c/d2f40145c9012482699664f615d7ae540a346c84f68a8179449e69dcc4d8/pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9", size = 2993570, upload-time = "2026-01-05T16:28:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/2c/dc/1388ea650020c26ef3f68856b9227e7f153dcaf445e7e4674a0b8f26891e/pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574", size = 3102340, upload-time = "2026-01-05T16:28:59.933Z" }, + { url = "https://files.pythonhosted.org/packages/c8/71/a433668d33999b3aeb2c2dda18aaf24948e862ea2ee148078a35daac6c1c/pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12", size = 2940987, upload-time = "2026-01-05T16:29:01.511Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-calamine" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/32/99a794a1ca7b654cecdb76d4d61f21658b6f76574321341eb47df4365807/python_calamine-0.6.1.tar.gz", hash = "sha256:5974989919aa0bb55a136c1822d6f8b967d13c0fd0f245e3293abb4e63ab0f4b", size = 138354, upload-time = "2025-11-26T10:48:35.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/ad/f7cd7281dbd15c63c106963bdc2474354eeac58afb5484da23cfb89f650e/python_calamine-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b06e10ce5a83ed32d7322b79b929eccde02fa69cdca74a0af69f373f4a0ba38e", size = 877325, upload-time = "2025-11-26T10:46:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/76/4f/d29f20e48adc1e7bab38f74498935dd3047c3ffc31fdf8424a68d821965b/python_calamine-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57fc3dd9a4b293ad1300c35b10f4f6bdffb80861b6b4fe7e5bb05ef12dc6bc43", size = 854967, upload-time = "2025-11-26T10:46:27.38Z" }, + { url = "https://files.pythonhosted.org/packages/94/04/c8eac3245010eaa0a39b27c4c53d401eae8719a0a8044106d7cb7761d57d/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6b44d98d29769595af6d17443607156da55b8ee7338011abd20f51a3c540d1", size = 928722, upload-time = "2025-11-26T10:46:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0d/a08871caf15673a7af94a42ae7af183ef9f6790851c027e97d425a7285ba/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:599928d30ef294c688c2a2db0c24e05a81a7dff08fec7865f6724694ab68950a", size = 912566, upload-time = "2025-11-26T10:46:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7b/5547c90b5d9b0ca10dd81398673968a08040ad0b6a757e2ca05d8deef6eb/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28a4799efc9d163130edb8b4f7b35a0e51f46b40e3ce57c024fa2c52d10bbe4b", size = 1073608, upload-time = "2025-11-26T10:46:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f3/4b8007cab8084d5d5c1b3da1f4490035033692d12b66a5fcc2903fb76554/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a57a1876748746c9e41237fd1dd49c2f231628c5f97ca1ef1b100db97af7a0e2", size = 964662, upload-time = "2025-11-26T10:46:33.193Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d2/71ea99fd1b06864791267c9ff43480fa569d0f7700506bbb84d9a17cb749/python_calamine-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73c9b06cac54d0b4350d6935bab6fead954b997062854aeaba3c7a966db5ac0", size = 933579, upload-time = "2025-11-26T10:46:34.62Z" }, + { url = "https://files.pythonhosted.org/packages/53/68/5556f44fdd1ed3e48c043e407e4ca7cd311787934b1ded9870d2dd1e5f4e/python_calamine-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c9e3db8502f59234bcd72cb3042c628fb2a99e59e721dbd11e8ee6106cee3513", size = 975141, upload-time = "2025-11-26T10:46:36.026Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/595c254014c863b8f9ed68cef6dcdb58c3ea3bb0166fe6f120808441b427/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:978006312127727bb0f481992aa1e2f0d2109efe5d4a3fe248471efb1591d06d", size = 1110935, upload-time = "2025-11-26T10:46:37.531Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ae/9377b92cf380f7d5843348de148646c630665a32c2efcc7a88f3e8056eaf/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8a39d1e58610674f4fcc3648aff885897998228f6bb6d09e09dccd73c4b59e64", size = 1179688, upload-time = "2025-11-26T10:46:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/47/23/d439d9dc61aa6bb5dcae4ee95de8cded53d2099d9d309531159e7050be26/python_calamine-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7d5874a1d83361a32099bfe6dce806498a4d9cf070dde0b48fd3e691789c1322", size = 1108864, upload-time = "2025-11-26T10:46:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/b54f124f03fff0c5439e899f6e3fb89636def08ac04f5c24184d2bfdc17f/python_calamine-0.6.1-cp312-cp312-win32.whl", hash = "sha256:9dca5bc0490b377fc619b4e93bff91a3ba296fefa2aab3eb7a652c7c7606ad61", size = 695346, upload-time = "2025-11-26T10:46:44.203Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d2/2df6e2ae9c63a7ffb6ceb3f8f36e2711e772bb96ddb0785e37107996d562/python_calamine-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:1675ff630d439144ad5805a28bf4f65afd100b38f2a8703ceebe7c7e47039bc5", size = 747324, upload-time = "2025-11-26T10:46:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3f/1e55ccab357f653dfe5f7991ff7f7a38b1892e88610a8873db1549e7c0c5/python_calamine-0.6.1-cp312-cp312-win_arm64.whl", hash = "sha256:4f7a68b31474a39a0f22e1f1464857222877e740255db196e141ff9db0d3229c", size = 716731, upload-time = "2025-11-26T10:46:47.351Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "redbear-mem" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiofile" }, + { name = "alembic" }, + { name = "amqp" }, + { name = "annotated-types" }, + { name = "anyio" }, + { name = "aspose-slides" }, + { name = "async-timeout" }, + { name = "bcrypt" }, + { name = "beartype" }, + { name = "beautifulsoup4" }, + { name = "billiard" }, + { name = "cachetools" }, + { name = "celery" }, + { name = "cffi" }, + { name = "chardet" }, + { name = "chonkie" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "cn2an" }, + { name = "concurrent-log-handler" }, + { name = "cryptography" }, + { name = "dashscope" }, + { name = "datrie" }, + { name = "demjson3" }, + { name = "deprecated" }, + { name = "ecdsa" }, + { name = "editdistance" }, + { name = "elastic-transport" }, + { name = "elasticsearch" }, + { name = "elasticsearch-dsl" }, + { name = "email-validator" }, + { name = "exceptiongroup" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "flask" }, + { name = "flower" }, + { name = "graspologic" }, + { name = "greenlet" }, + { name = "h11" }, + { name = "hanziconv" }, + { name = "html5lib" }, + { name = "httptools" }, + { name = "huggingface-hub" }, + { name = "idna" }, + { name = "jieba" }, + { name = "jinja2" }, + { name = "json-repair" }, + { name = "kombu" }, + { name = "langchain" }, + { name = "langchain-aws" }, + { name = "langchain-community" }, + { name = "langchain-mcp-adapters" }, + { name = "langchain-ollama" }, + { name = "langchain-openai" }, + { name = "langfuse" }, + { name = "mako" }, + { name = "mammoth" }, + { name = "markdown" }, + { name = "markdown-to-json" }, + { name = "markdownify" }, + { name = "markupsafe" }, + { name = "matplotlib" }, + { name = "mcp" }, + { name = "neo4j" }, + { name = "networkx" }, + { name = "nltk" }, + { name = "numpy" }, + { name = "olefile" }, + { name = "onnxruntime" }, + { name = "opencv-python" }, + { name = "openpyxl" }, + { name = "oss2" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "passlib" }, + { name = "pdfplumber" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "psycopg2-binary" }, + { name = "pyasn1" }, + { name = "pyclipper" }, + { name = "pycparser" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "pypdf" }, + { name = "pypdf2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "python-calamine" }, + { name = "python-dateutil" }, + { name = "python-docx" }, + { name = "python-dotenv" }, + { name = "python-jose" }, + { name = "python-multipart" }, + { name = "python-pptx" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "roman-numbers" }, + { name = "rsa" }, + { name = "ruamel-yaml" }, + { name = "scikit-learn" }, + { name = "shapely" }, + { name = "simpleeval" }, + { name = "six" }, + { name = "sniffio" }, + { name = "sqlalchemy" }, + { name = "starlette" }, + { name = "strenum" }, + { name = "tika" }, + { name = "tiktoken" }, + { name = "tomli" }, + { name = "torch" }, + { name = "trio" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "tzdata" }, + { name = "uvicorn" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "valkey" }, + { name = "vine" }, + { name = "watchfiles" }, + { name = "wcwidth" }, + { name = "websockets" }, + { name = "word2number" }, + { name = "xgboost" }, + { name = "xinference-client" }, + { name = "xlrd" }, + { name = "xpinyin" }, + { name = "xxhash" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofile", specifier = ">=3.9.0" }, + { name = "alembic", specifier = "==1.17.0" }, + { name = "amqp", specifier = "==5.3.1" }, + { name = "annotated-types", specifier = "==0.7.0" }, + { name = "anyio", specifier = "==4.11.0" }, + { name = "aspose-slides", specifier = "==24.12.0" }, + { name = "async-timeout", specifier = "==5.0.1" }, + { name = "bcrypt", specifier = "==5.0.0" }, + { name = "beartype", specifier = "==0.22.5" }, + { name = "beautifulsoup4", specifier = "==4.14.2" }, + { name = "billiard", specifier = "==4.2.2" }, + { name = "cachetools", specifier = "==6.2.1" }, + { name = "celery", specifier = "==5.5.3" }, + { name = "celery", specifier = ">=5.5.2" }, + { name = "cffi", specifier = "==2.0.0" }, + { name = "chardet", specifier = "==5.2.0" }, + { name = "chonkie", specifier = ">=1.1.2" }, + { name = "click", specifier = "==8.3.0" }, + { name = "click-didyoumean", specifier = "==0.3.1" }, + { name = "click-plugins", specifier = "==1.1.1.2" }, + { name = "click-repl", specifier = "==0.3.0" }, + { name = "cn2an", specifier = "==0.5.23" }, + { name = "concurrent-log-handler", specifier = ">=0.9.28" }, + { name = "cryptography", specifier = "==46.0.3" }, + { name = "dashscope", specifier = ">=1.25.0" }, + { name = "datrie", specifier = "==0.8.3" }, + { name = "demjson3", specifier = "==3.0.6" }, + { name = "deprecated", specifier = ">=1.3.1" }, + { name = "ecdsa", specifier = "==0.19.1" }, + { name = "editdistance", specifier = "==0.8.1" }, + { name = "elastic-transport", specifier = "==8.17.0" }, + { name = "elasticsearch", specifier = "==8.17.0" }, + { name = "elasticsearch-dsl", specifier = "==8.17.0" }, + { name = "email-validator", specifier = ">=2.3.0" }, + { name = "exceptiongroup", specifier = "==1.3.0" }, + { name = "fastapi", specifier = "==0.119.0" }, + { name = "fastmcp", specifier = ">=2.13.1" }, + { name = "flask", specifier = "==3.1.2" }, + { name = "flower", specifier = ">=2.0.1" }, + { name = "graspologic", specifier = "==3.4.5.dev2" }, + { name = "greenlet", specifier = "==3.2.4" }, + { name = "h11", specifier = "==0.16.0" }, + { name = "hanziconv", specifier = "==0.3.2" }, + { name = "html5lib", specifier = "==1.1" }, + { name = "httptools", specifier = "==0.7.1" }, + { name = "huggingface-hub", specifier = "==0.25.2" }, + { name = "idna", specifier = "==3.11" }, + { name = "jieba", specifier = ">=0.42.1" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "json-repair", specifier = "==0.53.0" }, + { name = "kombu", specifier = "==5.5.4" }, + { name = "langchain", specifier = ">=1.0.3" }, + { name = "langchain-aws", specifier = ">=1.0.0a1" }, + { name = "langchain-community", specifier = ">=0.3.31" }, + { name = "langchain-mcp-adapters", specifier = ">=0.1.13" }, + { name = "langchain-ollama" }, + { name = "langchain-openai", specifier = ">=1.0.2" }, + { name = "langfuse", specifier = ">=3.10.0" }, + { name = "mako", specifier = "==1.3.10" }, + { name = "mammoth", specifier = "==1.11.0" }, + { name = "markdown", specifier = "==3.8" }, + { name = "markdown-to-json", specifier = "==2.1.1" }, + { name = "markdownify", specifier = "==1.2.0" }, + { name = "markupsafe", specifier = "==3.0.3" }, + { name = "matplotlib", specifier = ">=3.10.7" }, + { name = "mcp", specifier = ">=1.21.1" }, + { name = "neo4j", specifier = ">=6.0.3" }, + { name = "networkx", specifier = ">=3.4.2" }, + { name = "nltk", specifier = "==3.9.2" }, + { name = "numpy", specifier = ">=1.26.0,<2.0.0" }, + { name = "olefile", specifier = "==0.47" }, + { name = "onnxruntime", specifier = "==1.20.1" }, + { name = "opencv-python", specifier = "==4.10.0.84" }, + { name = "openpyxl", specifier = "==3.1.5" }, + { name = "oss2", specifier = ">=2.19.1" }, + { name = "packaging", specifier = "==25.0" }, + { name = "pandas", specifier = "==2.3.3" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "passlib", specifier = "==1.7.4" }, + { name = "pdfplumber", specifier = "==0.11.7" }, + { name = "pillow", specifier = "==12.0.0" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "psycopg2-binary", specifier = "==2.9.11" }, + { name = "pyasn1", specifier = "==0.6.1" }, + { name = "pyclipper", specifier = "==1.3.0.post6" }, + { name = "pycparser", specifier = "==2.23" }, + { name = "pydantic", specifier = "==2.12.2" }, + { name = "pydantic-core", specifier = "==2.41.4" }, + { name = "pypdf", specifier = "==6.1.3" }, + { name = "pypdf2", specifier = "==3.0.1" }, + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "python-calamine", specifier = ">=0.4.0" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "python-docx", specifier = "==1.2.0" }, + { name = "python-dotenv", specifier = "==1.1.1" }, + { name = "python-jose", specifier = "==3.5.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-pptx", specifier = "==1.0.2" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "redis", specifier = "==6.4.0" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "roman-numbers", specifier = "==1.0.2" }, + { name = "rsa", specifier = "==4.9.1" }, + { name = "ruamel-yaml", specifier = "==0.18.10" }, + { name = "scikit-learn", specifier = "==1.7.2" }, + { name = "shapely", specifier = "==2.1.2" }, + { name = "simpleeval", specifier = ">=1.0.3" }, + { name = "six", specifier = "==1.17.0" }, + { name = "sniffio", specifier = "==1.3.1" }, + { name = "sqlalchemy", specifier = "==2.0.44" }, + { name = "starlette", specifier = "==0.48.0" }, + { name = "strenum", specifier = "==0.4.15" }, + { name = "tika", specifier = "==3.1.0" }, + { name = "tiktoken", specifier = "==0.12.0" }, + { name = "tomli", specifier = "==2.3.0" }, + { name = "torch", specifier = "==2.2.2" }, + { name = "trio", specifier = "==0.32.0" }, + { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "typing-inspection", specifier = "==0.4.2" }, + { name = "tzdata", specifier = "==2025.2" }, + { name = "uvicorn", specifier = "==0.37.0" }, + { name = "uvicorn", specifier = ">=0.34.0" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" }, + { name = "valkey", specifier = "==6.0.2" }, + { name = "vine", specifier = "==5.1.0" }, + { name = "watchfiles", specifier = "==1.1.1" }, + { name = "wcwidth", specifier = "==0.2.14" }, + { name = "websockets", specifier = "==15.0.1" }, + { name = "word2number", specifier = "==1.1" }, + { name = "xgboost", specifier = "==3.0.0" }, + { name = "xinference-client", specifier = "==1.11.0" }, + { name = "xlrd", specifier = "==2.0.2" }, + { name = "xpinyin", specifier = "==0.7.7" }, + { name = "xxhash", specifier = "==3.6.0" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "roman-numbers" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ce/e9f6b0d260f48713f2d735e0986ee4ead311cd168c217c5f94b0fad6817b/roman_numbers-1.0.2.tar.gz", hash = "sha256:fb84b7755ba972d549e73fac1c100f0eeb9fc247474d43d0f433c0b72152c699", size = 2574, upload-time = "2021-01-11T11:54:59.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/85/09e9e6bd6cd4cc0ed463d2b6ce3c7741698d45ca157318730a1346df4819/roman_numbers-1.0.2-py3-none-any.whl", hash = "sha256:ffbc00aaf41538208f975d1b1ccfe80372bae1866e7cd632862d8c6b45edf447", size = 3724, upload-time = "2021-01-11T11:54:57.686Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447, upload-time = "2025-01-06T14:08:51.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729, upload-time = "2025-01-06T14:08:47.471Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, + { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "sys_platform != 'darwin'" }, + { name = "jeepney", marker = "sys_platform != 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simpleeval" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/6f/15be211749430f52f2c8f0c69158a6fc961c03aac93fa28d44d1a6f5ebc7/simpleeval-1.0.3.tar.gz", hash = "sha256:67bbf246040ac3b57c29cf048657b9cf31d4e7b9d6659684daa08ca8f1e45829", size = 24358, upload-time = "2024-11-02T10:29:46.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e9/e58082fbb8cecbb6fb4133033c40cc50c248b1a331582be3a0f39138d65b/simpleeval-1.0.3-py3-none-any.whl", hash = "sha256:e3bdbb8c82c26297c9a153902d0fd1858a6c3774bf53ff4f134788c3f2035c38", size = 15762, upload-time = "2024-11-02T10:29:45.706Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/9a/0a7acb748b86e2922982366d780ca4b16c33f7246fa5860d26005c97e4f3/smart_open-7.5.0.tar.gz", hash = "sha256:f394b143851d8091011832ac8113ea4aba6b92e6c35f6e677ddaaccb169d7cb9", size = 53920, upload-time = "2025-11-08T21:38:40.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/95/bc978be7ea0babf2fb48a414b6afaad414c6a9e8b1eafc5b8a53c030381a/smart_open-7.5.0-py3-none-any.whl", hash = "sha256:87e695c5148bbb988f15cec00971602765874163be85acb1c9fb8abc012e6599", size = 63940, upload-time = "2025-11-08T21:38:39.024Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" }, + { url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tika" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/e2/28022574d12239d6c158f903c7697ebb55591a05bfd3177146b2c7cd72d2/tika-3.1.0.tar.gz", hash = "sha256:4c3a404c3d846437c942d6a6fd7b71d50285690fae5489aa8a6f00ff9ccd0fc7", size = 32697, upload-time = "2025-03-26T16:12:27.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c6/9b549ca412bb03ad64632f5e47a53dc1b56a267809d7d3f9ef6d5b3c0559/tika-3.1.0-py3-none-any.whl", hash = "sha256:c6171c947d6410813f236c988a1fde4a6ad11cbaa95ec4e700eb9aef7c848093", size = 38053, upload-time = "2025-03-26T16:12:26.301Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "torch" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/0c/d8f77363a7a3350c96e6c9db4ffb101d1c0487cc0b8cdaae1e4bfb2800ad/torch-2.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:cf12cdb66c9c940227ad647bc9cf5dba7e8640772ae10dfe7569a0c1e2a28aca", size = 755466713, upload-time = "2024-03-27T21:08:48.868Z" }, + { url = "https://files.pythonhosted.org/packages/05/9b/e5c0df26435f3d55b6699e1c61f07652b8c8a3ac5058a75d0e991f92c2b0/torch-2.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:89ddac2a8c1fb6569b90890955de0c34e1724f87431cacff4c1979b5f769203c", size = 86515814, upload-time = "2024-03-27T21:09:07.247Z" }, + { url = "https://files.pythonhosted.org/packages/72/ce/beca89dcdcf4323880d3b959ef457a4c61a95483af250e6892fec9174162/torch-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:451331406b760f4b1ab298ddd536486ab3cfb1312614cfe0532133535be60bea", size = 198528804, upload-time = "2024-03-27T21:09:14.691Z" }, + { url = "https://files.pythonhosted.org/packages/79/78/29dcab24a344ffd9ee9549ec0ab2c7885c13df61cde4c65836ee275efaeb/torch-2.2.2-cp312-none-macosx_10_9_x86_64.whl", hash = "sha256:eb4d6e9d3663e26cd27dc3ad266b34445a16b54908e74725adb241aa56987533", size = 150797270, upload-time = "2024-03-27T21:08:29.623Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0e/e4e033371a7cba9da0db5ccb507a9174e41b9c29189a932d01f2f61ecfc0/torch-2.2.2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:bf9558da7d2bf7463390b3b2a61a6a3dbb0b45b161ee1dd5ec640bf579d479fc", size = 59678388, upload-time = "2024-03-27T21:08:35.869Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "trio" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "(implementation_name != 'pypy' and os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (implementation_name != 'pypy' and os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "umap-learn" +version = "0.5.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "pynndescent" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/9a/a1e4a257a9aa979dac4f6d5781dac929cbb0949959e2003ed82657d10b0f/umap_learn-0.5.11.tar.gz", hash = "sha256:31566ffd495fbf05d7ab3efcba703861c0f5e6fc6998a838d0e2becdd00e54f5", size = 96409, upload-time = "2026-01-12T20:44:47.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/d2/fcf7192dd1cd8c090b6cfd53fa223c4fb2887a17c47e06bc356d44f40dfb/umap_learn-0.5.11-py3-none-any.whl", hash = "sha256:cb17adbde9d544ba79481b3ab4d81ac222e940f3d9219307bea6044f869af3cc", size = 90890, upload-time = "2026-01-12T20:44:46.511Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid-utils" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" }, + { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" }, + { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" }, + { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" }, + { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "valkey" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/f7/b552b7a67017e6233cd8a3b783ce8c4b548e29df98daedd7fb4c4c2cc8f8/valkey-6.0.2.tar.gz", hash = "sha256:dc2e91512b82d1da0b91ab0cdbd8c97c0c0250281728cb32f9398760df9caeae", size = 4602149, upload-time = "2024-09-11T11:54:05.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/cb/b1eac0fe9cbdbba0a5cf189f5778fe54ba7d7c9f26c2f62ca8d759b38f52/valkey-6.0.2-py3-none-any.whl", hash = "sha256:dbbdd65439ee0dc5689502c54f1899504cc7268e85cb7fe8935f062178ff5805", size = 260101, upload-time = "2024-09-11T11:54:02.963Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + +[[package]] +name = "word2number" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/29/a31940c848521f0725f0df6b25dca8917f13a2025b0e8fcbe5d0457e45e6/word2number-1.1.zip", hash = "sha256:70e27a5d387f67b04c71fbb7621c05930b19bfd26efd6851e6e0f9969dcde7d0", size = 9723, upload-time = "2017-06-02T15:45:14.488Z" } + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "xgboost" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/18/f58f9dcbcca811b30be945fd7f890f87a0ee822f7316e2ddd7a3e3056f08/xgboost-3.0.0.tar.gz", hash = "sha256:45e95416df6f6f01d9a62e60cf09fc57e5ee34697f3858337c796fac9ce3b9ed", size = 1156620, upload-time = "2025-03-15T13:45:01.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/6c/d2a1636591e204667f320481b036acc9c526608bcc2319be71cce148102d/xgboost-3.0.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:ed8cffd7998bd9431c3b0287a70bec8e45c09b43c9474d9dfd261627713bd890", size = 2245638, upload-time = "2025-03-15T13:46:14.653Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0b/f9f815f240a9610d42367172b9f7ef7e8c9113a09b1bb35d4d85f96b910a/xgboost-3.0.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:314104bd3a1426a40f0c9662eef40e9ab22eb7a8068a42a8d198ce40412db75c", size = 2025491, upload-time = "2025-03-15T13:46:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/3c/aa/f4597dc989317d2431cd71998cac1f9205c773658e0cf6680bb9011b26eb/xgboost-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72c3405e8dfc37048f9fe339a058fa12b9f0f03bc31d3e56f0887eed2ed2baa1", size = 4841002, upload-time = "2025-03-15T13:47:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/b89d99b89946afc842feb8f47302e8022f1405f01212d3e186b6d674b2ba/xgboost-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:72d39e74649e9b628c4221111aa6a8caa860f2e853b25480424403ee61085126", size = 4903442, upload-time = "2025-03-15T13:47:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/bb/68/a7a274bedf43bbf9de120104b438b45e232395f8b9861519040b7b725535/xgboost-3.0.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7bdee5787f86b83bebd75e2c96caf854760788e5f4203d063da50db5bf0efc5f", size = 4602694, upload-time = "2025-03-15T13:48:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/653afe1a1b7e1d03f26fd4bd30f3eebcfac2d8982e1a85b6be3355dcae25/xgboost-3.0.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:61c7e391e373b8a5312503525c0689f83ef1912a1236377022865ab340f465a4", size = 253877333, upload-time = "2025-03-15T13:51:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/15cd49e855c62226ecf1831bbe4c8e73a4324856077a23c495538a36e557/xgboost-3.0.0-py3-none-win_amd64.whl", hash = "sha256:0ea74e97f95b1eddfd27a46b7f22f72ec5a5322e1dc7cb41c9c23fb580763df9", size = 149971978, upload-time = "2025-03-15T13:53:26.596Z" }, +] + +[[package]] +name = "xinference-client" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/e5/5ed26708062db451d827b4a3e6585e2091b75ffae1bf47a105827bf2e405/xinference-client-1.11.0.tar.gz", hash = "sha256:2fec1eb61bad9c3eb93db3dfadb47aaea106e83724494cf7f82897c38b9dc9e9", size = 57345, upload-time = "2025-10-19T15:05:08.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/b9/04ca69d2d012f2f73f59d7ef64bc6bab38133a9e6ba595a08d9e95a75e80/xinference_client-1.11.0-py3-none-any.whl", hash = "sha256:b5d64341b8f2f1e4f82988899788ab50ab05027e653c1321772d386282498403", size = 39127, upload-time = "2025-10-19T15:05:07.333Z" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + +[[package]] +name = "xpinyin" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/41397274f9447ba29a947778b669b6f21717839ed164eba6b68cd168e705/xpinyin-0.7.7.tar.gz", hash = "sha256:89a1f3f12c7119b4526b93360a74fbe48ee24847481a4bab3b4fee8f4536079d", size = 133954, upload-time = "2025-06-02T04:02:11.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/bd/f7865b810ca7cc82fa566ec224786f674aab878d89866370ea7a20063b4c/xpinyin-0.7.7-py3-none-any.whl", hash = "sha256:1317a0140b704e03e5f368c2dc7618887a2fc45cdc288b6d648e2ebabf571f0e", size = 129774, upload-time = "2025-06-02T04:02:08.354Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, +] \ No newline at end of file From 8db4f914d8220aa0f5cba26a67b903fc99a72431 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 18:43:22 +0800 Subject: [PATCH 037/175] =?UTF-8?q?config=5Fconfig=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=88=90memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/emotion_config_controller.py | 7 +- .../controllers/memory_forget_controller.py | 3 +- .../memory_reflection_controller.py | 5 +- .../controllers/memory_storage_controller.py | 5 +- .../forgetting_engine/config_utils.py | 10 +- .../forgetting_engine/forgetting_scheduler.py | 3 +- .../forgetting_engine/forgetting_strategy.py | 7 +- .../validators/memory_config_validators.py | 10 +- api/app/core/workflow/nodes/memory/config.py | 5 +- api/app/models/memory_config_model.py | 2 +- .../repositories/memory_config_repository.py | 16 ++- api/app/schemas/emotion_schema.py | 3 +- api/app/schemas/memory_agent_schema.py | 3 + api/app/schemas/memory_config_schema.py | 20 +-- api/app/schemas/memory_reflection_schemas.py | 3 +- api/app/schemas/memory_storage_schema.py | 20 +-- api/app/services/emotion_config_service.py | 6 +- api/app/services/memory_agent_service.py | 130 +++++++----------- api/app/services/memory_config_service.py | 81 ++++------- api/app/services/memory_forget_service.py | 13 +- api/app/tasks.py | 7 +- 21 files changed, 158 insertions(+), 201 deletions(-) diff --git a/api/app/controllers/emotion_config_controller.py b/api/app/controllers/emotion_config_controller.py index 76450d8a..b0015bc2 100644 --- a/api/app/controllers/emotion_config_controller.py +++ b/api/app/controllers/emotion_config_controller.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, Query, HTTPException, status from pydantic import BaseModel, Field from typing import Optional from sqlalchemy.orm import Session +from uuid import UUID from app.core.response_utils import success from app.dependencies import get_current_user @@ -32,11 +33,11 @@ router = APIRouter( class EmotionConfigQuery(BaseModel): """情绪配置查询请求模型""" - config_id: int = Field(..., description="配置ID") + config_id: UUID = Field(..., description="配置ID") class EmotionConfigUpdate(BaseModel): """情绪配置更新请求模型""" - config_id: int = Field(..., description="配置ID") + config_id: UUID = Field(..., description="配置ID") emotion_enabled: bool = Field(..., description="是否启用情绪提取") emotion_model_id: Optional[str] = Field(None, description="情绪分析专用模型ID") emotion_extract_keywords: bool = Field(..., description="是否提取情绪关键词") @@ -45,7 +46,7 @@ class EmotionConfigUpdate(BaseModel): @router.get("/read_config", response_model=ApiResponse) def get_emotion_config( - config_id: int = Query(..., description="配置ID"), + config_id: UUID = Query(..., description="配置ID"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index ca628d0c..a71de487 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -11,6 +11,7 @@ """ from typing import Optional +from uuid import UUID from fastapi import APIRouter, Depends from sqlalchemy.orm import Session @@ -128,7 +129,7 @@ async def trigger_forgetting_cycle( @router.get("/read_config", response_model=ApiResponse) async def read_forgetting_config( - config_id: int, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index f17fcf7f..ccf9485f 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -1,6 +1,7 @@ import asyncio import time import uuid +from uuid import UUID from app.core.logging_config import get_api_logger from app.core.memory.storage_services.reflection_engine.self_reflexion import ( @@ -156,7 +157,7 @@ async def start_workspace_reflection( @router.get("/reflection/configs") async def start_reflection_configs( - config_id: int, + config_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -191,7 +192,7 @@ async def start_reflection_configs( @router.get("/reflection/run") async def reflection_run( - config_id: int, + config_id: UUID, language_type: str = Header(default="zh", alias="X-Language-Type"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index f4175923..15dbc2ec 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -1,5 +1,6 @@ import os from typing import Optional +from uuid import UUID from app.core.error_codes import BizCode from app.core.logging_config import get_api_logger @@ -160,7 +161,7 @@ def create_config( @router.delete("/delete_config", response_model=ApiResponse) # 删除数据库中的内容(按配置名称) def delete_config( - config_id: str, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -232,7 +233,7 @@ def update_config_extracted( @router.get("/read_config_extracted", response_model=ApiResponse) # 通过查询参数读取某条配置(固定路径) 没有意义的话就删除 def read_config_extracted( - config_id: str, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: diff --git a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py index 663c89f9..25daa968 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py +++ b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py @@ -11,6 +11,7 @@ Functions: import logging from typing import Optional, Dict, Any +from uuid import UUID from sqlalchemy.orm import Session from app.repositories.memory_config_repository import MemoryConfigRepository @@ -61,7 +62,7 @@ def calculate_forgetting_rate(lambda_time: float, lambda_mem: float) -> float: def load_actr_config_from_db( db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 从数据库加载 ACT-R 配置参数 @@ -150,7 +151,7 @@ def load_actr_config_from_db( def create_actr_calculator_from_config( db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> ACTRCalculator: """ 从数据库配置创建 ACTRCalculator 实例 @@ -168,11 +169,6 @@ def create_actr_calculator_from_config( ValueError: 如果指定的 config_id 不存在 Examples: - >>> from sqlalchemy.orm import Session - >>> db = Session() - >>> calculator = create_actr_calculator_from_config(db, config_id=1) - >>> # 使用计算器 - >>> activation = calculator.calculate_memory_activation(...) """ # 加载配置 config = load_actr_config_from_db(db, config_id) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py index e9d4c144..5a178fc2 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py @@ -16,6 +16,7 @@ Classes: import logging from typing import Dict, Any, Optional +from uuid import UUID from datetime import datetime from app.core.memory.storage_services.forgetting_engine.forgetting_strategy import ForgettingStrategy @@ -69,7 +70,7 @@ class ForgettingScheduler: end_user_id: Optional[str] = None, max_merge_batch_size: int = 100, min_days_since_access: int = 30, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> Dict[str, Any]: """ diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py index cde9e115..a8c62dd4 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py @@ -13,6 +13,7 @@ Classes: import logging from typing import List, Dict, Any, Optional +from uuid import UUID from datetime import datetime, timedelta from app.repositories.neo4j.neo4j_connector import Neo4jConnector @@ -176,7 +177,7 @@ class ForgettingStrategy: self, statement_node: Dict[str, Any], entity_node: Dict[str, Any], - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> str: """ @@ -462,7 +463,7 @@ class ForgettingStrategy: statement_text: str, entity_name: str, entity_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> str: """ @@ -527,7 +528,7 @@ class ForgettingStrategy: statement_text, entity_name, entity_type ) - async def _get_llm_client(self, db, config_id: int): + async def _get_llm_client(self, db, config_id: UUID): """ 从数据库获取 LLM 客户端 diff --git a/api/app/core/validators/memory_config_validators.py b/api/app/core/validators/memory_config_validators.py index 333572e6..ba26c5f2 100644 --- a/api/app/core/validators/memory_config_validators.py +++ b/api/app/core/validators/memory_config_validators.py @@ -26,7 +26,7 @@ logger = get_config_logger() def _parse_model_id(model_id: Union[str, UUID, None], model_type: str, - config_id: Optional[int] = None, workspace_id: Optional[UUID] = None) -> Optional[UUID]: + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None) -> Optional[UUID]: """Parse model ID from string or UUID.""" if model_id is None: return None @@ -59,7 +59,7 @@ def validate_model_exists_and_active( model_type: str, db: Session, tenant_id: Optional[UUID] = None, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None ) -> tuple[str, bool]: """Validate that a model exists and is active. @@ -166,7 +166,7 @@ def validate_and_resolve_model_id( db: Session, tenant_id: Optional[UUID] = None, required: bool = False, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None ) -> tuple[Optional[UUID], Optional[str]]: """Validate and resolve a model ID, checking existence and active status. @@ -204,7 +204,7 @@ def validate_and_resolve_model_id( def validate_embedding_model( - config_id: int, + config_id: UUID, embedding_id: Union[str, UUID, None], db: Session, tenant_id: Optional[UUID] = None, @@ -256,7 +256,7 @@ def validate_embedding_model( def validate_llm_model( - config_id: int, + config_id: UUID, llm_id: Union[str, UUID, None], db: Session, tenant_id: Optional[UUID] = None, diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 987230c1..4c8c43eb 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -1,4 +1,5 @@ import uuid +from uuid import UUID from pydantic import Field from typing import Literal @@ -11,7 +12,7 @@ class MemoryReadNodeConfig(BaseNodeConfig): ... ) - config_id: int = Field( + config_id: UUID = Field( ... ) @@ -26,6 +27,6 @@ class MemoryWriteNodeConfig(BaseNodeConfig): ... ) - config_id: int = Field( + config_id: UUID = Field( ... ) diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index 55b377e6..710315db 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -9,7 +9,7 @@ class MemoryConfig(Base): __tablename__ = "memory_config" # 主键 - config_id = Column(Integer, primary_key=True, autoincrement=True, comment="配置ID") + config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") # 基本信息 config_name = Column(String, nullable=False, comment="配置名称") diff --git a/api/app/repositories/memory_config_repository.py b/api/app/repositories/memory_config_repository.py index aca87189..12e564e2 100644 --- a/api/app/repositories/memory_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -9,6 +9,7 @@ Classes: """ import uuid +from uuid import UUID from typing import Dict, List, Optional, Tuple from app.core.exceptions import BusinessException from app.core.logging_config import get_config_logger, get_db_logger @@ -107,7 +108,7 @@ class MemoryConfigRepository: @staticmethod def update_reflection_config( db: Session, - config_id: int, + config_id: uuid.UUID, enable_self_reflexion: bool, iteration_period: str, reflexion_range: str, @@ -151,7 +152,7 @@ class MemoryConfigRepository: return memory_config_obj @staticmethod - def query_reflection_config_by_id(db: Session, config_id: int) -> MemoryConfig: + def query_reflection_config_by_id(db: Session, config_id: uuid.UUID) -> MemoryConfig: """构建反思配置查询语句,通过config_id查询反思配置(SQLAlchemy text() 命名参数) Args: @@ -222,6 +223,7 @@ class MemoryConfigRepository: try: db_config = MemoryConfig( + config_id=uuid.uuid4(), config_name=params.config_name, config_desc=params.config_desc, workspace_id=params.workspace_id, @@ -408,7 +410,7 @@ class MemoryConfigRepository: raise @staticmethod - def get_extracted_config(db: Session, config_id: int) -> Optional[Dict]: + def get_extracted_config(db: Session, config_id: UUID) -> Optional[Dict]: """获取萃取配置,通过主键查询某条配置 Args: @@ -457,7 +459,7 @@ class MemoryConfigRepository: raise @staticmethod - def get_forget_config(db: Session, config_id: int) -> Optional[Dict]: + def get_forget_config(db: Session, config_id: UUID) -> Optional[Dict]: """获取遗忘配置,通过主键查询某条配置 Args: @@ -489,7 +491,7 @@ class MemoryConfigRepository: raise @staticmethod - def get_by_id(db: Session, config_id: int) -> Optional[MemoryConfig]: + def get_by_id(db: Session, config_id: uuid.UUID) -> Optional[MemoryConfig]: """根据ID获取记忆配置 Args: @@ -513,7 +515,7 @@ class MemoryConfigRepository: db_logger.error(f"根据ID查询记忆配置失败: config_id={config_id} - {str(e)}") raise @staticmethod - def get_config_with_workspace(db: Session, config_id: int) -> Optional[tuple]: + def get_config_with_workspace(db: Session, config_id: uuid.UUID) -> Optional[tuple]: """Get memory config and its associated workspace information Args: @@ -664,7 +666,7 @@ class MemoryConfigRepository: raise @staticmethod - def delete(db: Session, config_id: int) -> bool: + def delete(db: Session, config_id: uuid.UUID) -> bool: """删除记忆配置 Args: diff --git a/api/app/schemas/emotion_schema.py b/api/app/schemas/emotion_schema.py index fb523887..13c802b5 100644 --- a/api/app/schemas/emotion_schema.py +++ b/api/app/schemas/emotion_schema.py @@ -1,6 +1,7 @@ """情绪分析相关的请求和响应模型""" from typing import Optional +from uuid import UUID from pydantic import BaseModel, Field class EmotionTagsRequest(BaseModel): @@ -30,7 +31,7 @@ class EmotionHealthRequest(BaseModel): class EmotionSuggestionsRequest(BaseModel): """获取个性化情绪建议请求""" end_user_id: str = Field(..., description="组ID") - config_id: Optional[int] = Field(None, description="配置ID(用于指定LLM模型)") + config_id: Optional[UUID] = Field(None, description="配置ID(用于指定LLM模型)") class EmotionGenerateSuggestionsRequest(BaseModel): diff --git a/api/app/schemas/memory_agent_schema.py b/api/app/schemas/memory_agent_schema.py index e7b1fe65..a83bd3af 100644 --- a/api/app/schemas/memory_agent_schema.py +++ b/api/app/schemas/memory_agent_schema.py @@ -15,3 +15,6 @@ class Write_UserInput(BaseModel): messages: list[dict] end_user_id: str config_id: Optional[str] = None +class End_User_Information(BaseModel): + end_user_name: str # 这是要更新的用户名 + id: str # 宿主ID,用于匹配条件 \ No newline at end of file diff --git a/api/app/schemas/memory_config_schema.py b/api/app/schemas/memory_config_schema.py index 0443dcc4..76acee5c 100644 --- a/api/app/schemas/memory_config_schema.py +++ b/api/app/schemas/memory_config_schema.py @@ -35,7 +35,7 @@ class ConfigurationError(Exception): def __init__( self, message: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, context: Optional[Dict[str, Any]] = None, ): @@ -72,7 +72,7 @@ class WorkspaceNotFoundError(ConfigurationError): def __init__( self, workspace_id: UUID, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, message: Optional[str] = None, ): if message is None: @@ -89,7 +89,7 @@ class ModelNotFoundError(ConfigurationError): self, model_id: Union[str, UUID], model_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, message: Optional[str] = None, ): @@ -112,7 +112,7 @@ class ModelInactiveError(ConfigurationError): model_id: Union[str, UUID], model_name: str, model_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, message: Optional[str] = None, ): @@ -136,7 +136,7 @@ class InvalidConfigError(ConfigurationError): message: str, field_name: Optional[str] = None, invalid_value: Optional[Any] = None, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, ): context = {} @@ -155,7 +155,7 @@ class InvalidConfigError(ConfigurationError): class MemoryConfigValidation(BaseModel): """Pydantic model for validating memory configuration data from database.""" - config_id: int = Field(..., gt=0, description="Configuration ID must be positive") + config_id: UUID = Field(..., description="Configuration ID (UUID)") config_name: str = Field(..., min_length=1, max_length=255) workspace_id: UUID = Field(..., description="Workspace UUID") workspace_name: str = Field(..., min_length=1, max_length=255) @@ -275,7 +275,7 @@ class ModelValidation(BaseModel): def validate_memory_config_data( - config_data: Dict[str, Any], config_id: Optional[int] = None + config_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> MemoryConfigValidation: """Validate memory configuration data using Pydantic model.""" try: @@ -302,7 +302,7 @@ def validate_memory_config_data( def validate_workspace_data( - workspace_data: Dict[str, Any], config_id: Optional[int] = None + workspace_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> WorkspaceValidation: """Validate workspace data using Pydantic model.""" try: @@ -331,7 +331,7 @@ def validate_workspace_data( def validate_model_data( - model_data: Dict[str, Any], config_id: Optional[int] = None + model_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> ModelValidation: """Validate model data using Pydantic model.""" try: @@ -364,7 +364,7 @@ def validate_model_data( class MemoryConfig: """Immutable memory configuration loaded from database.""" - config_id: int + config_id: UUID config_name: str workspace_id: UUID workspace_name: str diff --git a/api/app/schemas/memory_reflection_schemas.py b/api/app/schemas/memory_reflection_schemas.py index 860f1ef1..df841fb1 100644 --- a/api/app/schemas/memory_reflection_schemas.py +++ b/api/app/schemas/memory_reflection_schemas.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field from typing import Optional +from uuid import UUID from enum import Enum @@ -9,7 +10,7 @@ class OptimizationStrategy(str, Enum): ACCURACY_FIRST = "accuracy_first" BALANCED = "balanced" class Memory_Reflection(BaseModel): - config_id: Optional[int] = None + config_id: Optional[UUID] = None reflection_enabled: bool reflection_period_in_hours: str reflexion_range: Optional[str] = "partial" diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index 80b62b8a..e5a4cde6 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -159,7 +159,7 @@ class ReflexionResultSchema(BaseModel): # Composite key identifying a config row class ConfigKey(BaseModel): # 配置参数键模型 model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field("config_id", description="配置唯一标识(字符串)") + config_id: uuid.UUID = Field("config_id", description="配置唯一标识(UUID)") user_id: str = Field("user_id", description="用户标识(字符串)") apply_id: str = Field("apply_id", description="应用或场景标识(字符串)") @@ -250,17 +250,17 @@ class ConfigParamsCreate(BaseModel): # 创建配置参数模型(仅 body, class ConfigParamsDelete(BaseModel): # 删除配置参数模型(请求体) model_config = ConfigDict(populate_by_name=True, extra="forbid") # config_name: str = Field("配置名称", description="配置名称(字符串)") - config_id: int = Field("配置ID", description="配置ID(字符串)") + config_id: uuid.UUID = Field("配置ID", description="配置ID(UUID)") class ConfigUpdate(BaseModel): # 更新记忆萃取引擎配置参数时使用的模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None config_name: str = Field("配置名称", description="配置名称(字符串)") config_desc: str = Field("配置描述", description="配置描述(字符串)") class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数时使用的模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None llm_id: Optional[str] = Field(None, description="LLM模型配置ID") embedding_id: Optional[str] = Field(None, description="嵌入模型配置ID") rerank_id: Optional[str] = Field(None, description="重排序模型配置ID") @@ -327,14 +327,14 @@ class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数 class ConfigUpdateForget(BaseModel): # 更新遗忘引擎配置参数时使用的模型 # 遗忘引擎配置参数更新模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None lambda_time: Optional[float] = Field(0.5, ge=0.0, le=1.0, description="最低保持度,0-1 小数;默认 0.5") lambda_mem: Optional[float] = Field(0.5, ge=0.0, le=1.0, description="遗忘率,0-1 小数;默认 0.5") offset: Optional[float] = Field(0.0, ge=0.0, le=1.0, description="偏移度,0-1 小数;默认 0.0") class ConfigPilotRun(BaseModel): # 试运行触发请求模型 - config_id: int = Field(..., description="配置ID(唯一)") + config_id: uuid.UUID = Field(..., description="配置ID(唯一)") dialogue_text: str = Field(..., description="前端传入的对话文本,格式如 '用户: ...\nAI: ...' 可多行,试运行必填") model_config = ConfigDict(populate_by_name=True, extra="forbid") @@ -342,7 +342,7 @@ class ConfigPilotRun(BaseModel): # 试运行触发请求模型 class ConfigFilter(BaseModel): # 查询配置参数时使用的模型 model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None user_id: Optional[str] = None apply_id: Optional[str] = None @@ -418,7 +418,7 @@ class ForgettingConfigResponse(BaseModel): """遗忘引擎配置响应模型""" model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field(..., description="配置ID") + config_id: uuid.UUID = Field(..., description="配置ID") decay_constant: float = Field(..., description="衰减常数 d") lambda_time: float = Field(..., description="时间衰减参数") lambda_mem: float = Field(..., description="记忆衰减参数") @@ -436,7 +436,7 @@ class ForgettingConfigUpdateRequest(BaseModel): """遗忘引擎配置更新请求模型""" model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field(..., description="配置ID") + config_id: uuid.UUID = Field(..., description="配置ID") decay_constant: Optional[float] = Field(None, ge=0.0, le=1.0, description="衰减常数 d") lambda_time: Optional[float] = Field(None, ge=0.0, le=1.0, description="时间衰减参数") lambda_mem: Optional[float] = Field(None, ge=0.0, le=1.0, description="记忆衰减参数") @@ -511,7 +511,7 @@ class ForgettingCurveRequest(BaseModel): importance_score: float = Field(0.5, ge=0.0, le=1.0, description="重要性分数(0-1)") days: int = Field(60, ge=1, le=365, description="模拟天数(默认60天)") - config_id: Optional[int] = Field(None, description="配置ID(可选,如果为None则使用默认配置)") + config_id: Optional[uuid.UUID] = Field(None, description="配置ID(可选,如果为None则使用默认配置)") class ForgettingCurveResponse(BaseModel): diff --git a/api/app/services/emotion_config_service.py b/api/app/services/emotion_config_service.py index f8b4d22a..9880d4e1 100644 --- a/api/app/services/emotion_config_service.py +++ b/api/app/services/emotion_config_service.py @@ -8,6 +8,8 @@ Classes: """ from typing import Dict, Any +from uuid import UUID + from sqlalchemy.orm import Session from app.models.memory_config_model import MemoryConfig @@ -37,7 +39,7 @@ class EmotionConfigService: self.db = db logger.info("情绪配置服务初始化完成") - def get_emotion_config(self, config_id: int) -> Dict[str, Any]: + def get_emotion_config(self, config_id: UUID) -> Dict[str, Any]: """获取情绪引擎配置 查询指定配置ID的情绪相关配置字段。 @@ -144,7 +146,7 @@ class EmotionConfigService: def update_emotion_config( self, - config_id: int, + config_id: UUID, config_data: Dict[str, Any] ) -> Dict[str, Any]: """更新情绪引擎配置 diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index f8861258..e4ab7087 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -9,6 +9,7 @@ import os import re import time import uuid +from uuid import UUID from typing import Any, AsyncGenerator, Dict, List, Optional import redis @@ -266,7 +267,7 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, end_user_id: str, messages: str, config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID], db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id @@ -319,85 +320,52 @@ class MemoryAgentService: raise ValueError(error_msg) - async with make_write_graph() as graph: - config = {"configurable": {"thread_id": end_user_id}} - # Convert structured messages to LangChain messages - langchain_messages = [] - for msg in messages: - if msg['role'] == 'user': - langchain_messages.append(HumanMessage(content=msg['content'])) - elif msg['role'] == 'assistant': - langchain_messages.append(AIMessage(content=msg['content'])) + try: + if storage_type == "rag": + # For RAG storage, convert messages to single string + message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) + result = await write_rag(end_user_id, message_text, user_rag_memory_id) + return result + else: + async with make_write_graph() as graph: + config = {"configurable": {"thread_id": end_user_id}} + # Convert structured messages to LangChain messages + langchain_messages = [] + for msg in messages: + if msg['role'] == 'user': + langchain_messages.append(HumanMessage(content=msg['content'])) + elif msg['role'] == 'assistant': + langchain_messages.append(AIMessage(content=msg['content'])) - # 初始状态 - 包含所有必要字段 - initial_state = { - "messages": langchain_messages, - "end_user_id": end_user_id, - "memory_config": memory_config - } + # 初始状态 - 包含所有必要字段 + initial_state = { + "messages": langchain_messages, + "end_user_id": end_user_id, + "memory_config": memory_config + } - # 获取节点更新信息 - async for update_event in graph.astream( - initial_state, - stream_mode="updates", - config=config - ): - for node_name, node_data in update_event.items(): - if 'save_neo4j' == node_name: - massages = node_data - print(massages) - massagesstatus = massages.get('write_result')['status'] - contents = massages.get('write_result') - # Convert messages back to string for logging - message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) - - # try: - # if storage_type == "rag": - # # For RAG storage, convert messages to single string - # message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - # result = await write_rag(end_user_id, message_text, user_rag_memory_id) - # return result - # else: - # async with make_write_graph() as graph: - # config = {"configurable": {"thread_id": end_user_id}} - # # Convert structured messages to LangChain messages - # langchain_messages = [] - # for msg in messages: - # if msg['role'] == 'user': - # langchain_messages.append(HumanMessage(content=msg['content'])) - # elif msg['role'] == 'assistant': - # langchain_messages.append(AIMessage(content=msg['content'])) - # - # # 初始状态 - 包含所有必要字段 - # initial_state = { - # "messages": langchain_messages, - # "end_user_id": end_user_id, - # "memory_config": memory_config - # } - # - # # 获取节点更新信息 - # async for update_event in graph.astream( - # initial_state, - # stream_mode="updates", - # config=config - # ): - # for node_name, node_data in update_event.items(): - # if 'save_neo4j' == node_name: - # massages = node_data - # massagesstatus = massages.get('write_result')['status'] - # contents = massages.get('write_result') - # # Convert messages back to string for logging - # message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - # return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) - # except Exception as e: - # # Ensure proper error handling and logging - # error_msg = f"Write operation failed: {str(e)}" - # logger.error(error_msg) - # if audit_logger: - # duration = time.time() - start_time - # audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) - # raise ValueError(error_msg) + # 获取节点更新信息 + async for update_event in graph.astream( + initial_state, + stream_mode="updates", + config=config + ): + for node_name, node_data in update_event.items(): + if 'save_neo4j' == node_name: + massages = node_data + massagesstatus = massages.get('write_result')['status'] + contents = massages.get('write_result') + # Convert messages back to string for logging + message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) + return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) + except Exception as e: + # Ensure proper error handling and logging + error_msg = f"Write operation failed: {str(e)}" + logger.error(error_msg) + if audit_logger: + duration = time.time() - start_time + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) + raise ValueError(error_msg) @@ -408,7 +376,7 @@ class MemoryAgentService: message: str, history: List[Dict], search_switch: str, - config_id: Optional[str], + config_id: Optional[UUID], db: Session, storage_type: str, user_rag_memory_id: str) -> Dict: @@ -685,7 +653,7 @@ class MemoryAgentService: logger.info(f"Validation successful: Structured message list, count: {len(user_input.messages)}") return user_input.messages - async def classify_message_type(self, message: str, config_id: int, db: Session) -> Dict: + async def classify_message_type(self, message: str, config_id: UUID, db: Session) -> Dict: """ Determine the type of user message (read or write) Updated to eliminate global variables in favor of explicit parameters. @@ -716,7 +684,7 @@ class MemoryAgentService: retrieve_info: str, history: List[Dict], query: str, - config_id: str, + config_id: UUID, db: Session ) -> str: """ diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index 9afba797..d7f7c8a6 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -23,53 +23,12 @@ from app.schemas.memory_config_schema import ( ModelNotFoundError, ) from sqlalchemy.orm import Session +from uuid import UUID logger = get_logger(__name__) config_logger = get_config_logger() -def _validate_config_id(config_id): - """Validate configuration ID format.""" - if config_id is None: - raise InvalidConfigError( - "Configuration ID cannot be None", - field_name="config_id", - invalid_value=config_id, - ) - - if isinstance(config_id, int): - if config_id <= 0: - raise InvalidConfigError( - f"Configuration ID must be positive: {config_id}", - field_name="config_id", - invalid_value=config_id, - ) - return config_id - - if isinstance(config_id, str): - try: - parsed_id = int(config_id.strip()) - if parsed_id <= 0: - raise InvalidConfigError( - f"Configuration ID must be positive: {parsed_id}", - field_name="config_id", - invalid_value=config_id, - ) - return parsed_id - except ValueError: - raise InvalidConfigError( - f"Invalid configuration ID format: '{config_id}'", - field_name="config_id", - invalid_value=config_id, - ) - - raise InvalidConfigError( - f"Invalid type for configuration ID: expected int or str, got {type(config_id).__name__}", - field_name="config_id", - invalid_value=config_id, - ) - - class MemoryConfigService: """ Centralized service for memory configuration loading and validation. @@ -93,14 +52,14 @@ class MemoryConfigService: def load_memory_config( self, - config_id: int, + config_id: UUID, service_name: str = "MemoryConfigService", ) -> MemoryConfig: """ Load memory configuration from database by config_id. Args: - config_id: Configuration ID from database + config_id: Configuration ID (UUID) from database service_name: Name of the calling service (for logging purposes) Returns: @@ -116,18 +75,34 @@ class MemoryConfigService: extra={ "operation": "load_memory_config", "service": service_name, - "config_id": config_id, + "config_id": str(config_id), }, ) logger.info(f"Loading memory configuration from database: config_id={config_id}") try: - validated_config_id = _validate_config_id(config_id) + # Validate config_id is UUID + if not isinstance(config_id, UUID): + if isinstance(config_id, str): + try: + config_id = UUID(config_id) + except ValueError: + raise InvalidConfigError( + f"Invalid UUID format for config_id: {config_id}", + field_name="config_id", + invalid_value=config_id, + ) + else: + raise InvalidConfigError( + f"config_id must be UUID or valid UUID string, got {type(config_id).__name__}", + field_name="config_id", + invalid_value=config_id, + ) # Step 1: Get config and workspace db_query_start = time.time() - result = MemoryConfigRepository.get_config_with_workspace(self.db, validated_config_id) + result = MemoryConfigRepository.get_config_with_workspace(self.db, config_id) db_query_time = time.time() - db_query_start logger.info(f"[PERF] Config+Workspace query: {db_query_time:.4f}s") if not result: @@ -136,14 +111,14 @@ class MemoryConfigService: "Configuration not found in database", extra={ "operation": "load_memory_config", - "config_id": validated_config_id, + "config_id": str(config_id), "load_result": "not_found", "elapsed_ms": elapsed_ms, "service": service_name, }, ) raise ConfigurationError( - f"Configuration {validated_config_id} not found in database" + f"Configuration {config_id} not found in database" ) memory_config, workspace = result @@ -151,7 +126,7 @@ class MemoryConfigService: # Step 2: Validate embedding model (returns both UUID and name) embed_start = time.time() embedding_uuid, embedding_name = validate_embedding_model( - validated_config_id, + config_id, memory_config.embedding_id, self.db, workspace.tenant_id, @@ -168,7 +143,7 @@ class MemoryConfigService: self.db, workspace.tenant_id, required=True, - config_id=validated_config_id, + config_id=config_id, workspace_id=workspace.id, ) llm_time = time.time() - llm_start @@ -185,7 +160,7 @@ class MemoryConfigService: self.db, workspace.tenant_id, required=False, - config_id=validated_config_id, + config_id=config_id, workspace_id=workspace.id, ) rerank_time = time.time() - rerank_start @@ -243,7 +218,7 @@ class MemoryConfigService: extra={ "operation": "load_memory_config", "service": service_name, - "config_id": validated_config_id, + "config_id": str(config_id), "config_name": config.config_name, "workspace_id": str(config.workspace_id), "load_result": "success", diff --git a/api/app/services/memory_forget_service.py b/api/app/services/memory_forget_service.py index 204f5df1..e1030b24 100644 --- a/api/app/services/memory_forget_service.py +++ b/api/app/services/memory_forget_service.py @@ -12,6 +12,7 @@ from typing import Optional, Dict, Any, Tuple from datetime import datetime, timezone +from uuid import UUID from sqlalchemy.orm import Session @@ -87,7 +88,7 @@ class MemoryForgetService: async def _get_forgetting_components( self, db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Tuple[ACTRCalculator, ForgettingStrategy, ForgettingScheduler, Dict[str, Any]]: """ 获取遗忘引擎组件(计算器、策略、调度器) @@ -294,7 +295,7 @@ class MemoryForgetService: end_user_id: str, max_merge_batch_size: Optional[int] = None, min_days_since_access: Optional[int] = None, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 手动触发遗忘周期 @@ -389,7 +390,7 @@ class MemoryForgetService: def read_forgetting_config( self, db: Session, - config_id: int + config_id: UUID ) -> Dict[str, Any]: """ 获取遗忘引擎配置 @@ -416,7 +417,7 @@ class MemoryForgetService: def update_forgetting_config( self, db: Session, - config_id: int, + config_id: UUID, update_fields: Dict[str, Any] ) -> Dict[str, Any]: """ @@ -466,7 +467,7 @@ class MemoryForgetService: self, db: Session, end_user_id: Optional[str] = None, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 获取遗忘引擎统计信息 @@ -677,7 +678,7 @@ class MemoryForgetService: db: Session, importance_score: float, days: int, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 获取遗忘曲线数据 diff --git a/api/app/tasks.py b/api/app/tasks.py index f4b5f78f..3ef2653a 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -4,6 +4,7 @@ import os import re import time import uuid +from uuid import UUID from datetime import datetime, timezone from math import ceil from typing import Any, Dict, List, Optional @@ -382,7 +383,7 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): @celery_app.task(name="app.core.memory.agent.read_message", bind=True) -def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: +def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: uuid.UUID, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a read message via MemoryAgentService. @@ -472,7 +473,7 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s @celery_app.task(name="app.core.memory.agent.write_message", bind=True) -def write_message_task(self, end_user_id: str, message: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: +def write_message_task(self, end_user_id: str, message: str, config_id: uuid.UUID, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a write message via MemoryAgentService. Args: @@ -1084,7 +1085,7 @@ def workspace_reflection_task(self) -> Dict[str, Any]: @celery_app.task(name="app.tasks.run_forgetting_cycle_task", bind=True) -def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str, Any]: +def run_forgetting_cycle_task(self, config_id: Optional[uuid.UUID] = None) -> Dict[str, Any]: """定时任务:运行遗忘周期 定期执行遗忘周期,识别并融合低激活值的知识节点。 From b84c82880c8caf3ec1b9167050e9920cc76d1480 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 18:45:26 +0800 Subject: [PATCH 038/175] =?UTF-8?q?config=5Fid=E5=AD=97=E6=AE=B5=E6=94=B9?= =?UTF-8?q?=E6=88=90UUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/memory_agent_schema.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/app/schemas/memory_agent_schema.py b/api/app/schemas/memory_agent_schema.py index a83bd3af..b6f50dd7 100644 --- a/api/app/schemas/memory_agent_schema.py +++ b/api/app/schemas/memory_agent_schema.py @@ -14,7 +14,4 @@ class UserInput(BaseModel): class Write_UserInput(BaseModel): messages: list[dict] end_user_id: str - config_id: Optional[str] = None -class End_User_Information(BaseModel): - end_user_name: str # 这是要更新的用户名 - id: str # 宿主ID,用于匹配条件 \ No newline at end of file + config_id: Optional[str] = None \ No newline at end of file From f2d6fd7b085aef6e7ede111cd8f9a3946f887f8a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 20:40:41 +0800 Subject: [PATCH 039/175] =?UTF-8?q?config=5Fid=E5=AD=97=E6=AE=B5=E6=94=B9?= =?UTF-8?q?=E6=88=90UUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_agent_controller.py | 3 +- .../langgraph_graph/nodes/write_nodes.py | 42 +++++---- .../core/memory/agent/utils/get_dialogs.py | 59 ++++++------ .../core/memory/agent/utils/write_tools.py | 27 ++---- .../core/memory/llm_tools/chunker_client.py | 24 ++--- api/app/services/memory_config_service.py | 92 ++++++++++++++----- api/app/tasks.py | 38 ++++++-- 7 files changed, 177 insertions(+), 108 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 3f3a513e..e9ae8bae 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -162,9 +162,10 @@ async def write_server( api_logger.info(f"Write service requested for group {user_input.end_user_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") try: + messages_list = memory_agent_service.get_messages_list(user_input) result = await memory_agent_service.write_memory( user_input.end_user_id, - user_input.messages, + messages_list, config_id, db, storage_type, diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py index 1dab1b0a..b85130ad 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py @@ -1,44 +1,54 @@ - -from app.core.memory.agent.utils.llm_tools import WriteState +from app.core.memory.agent.utils.llm_tools import WriteState from app.core.memory.agent.utils.write_tools import write from app.core.logging_config import get_agent_logger logger = get_agent_logger(__name__) + + async def write_node(state: WriteState) -> WriteState: """ Write data to the database/file system. Args: - content: Data content to write - end_user_id: End user identifier - memory_config: MemoryConfig object containing all configuration + state: WriteState containing messages, end_user_id, and memory_config Returns: - dict: Contains 'status', 'saved_to', and 'data' fields + dict: Contains 'write_result' with status and data fields """ - content=state.get('data','') - end_user_id=state.get('end_user_id','') - memory_config=state.get('memory_config', '') + messages = state.get('messages', []) + end_user_id = state.get('end_user_id', '') + memory_config = state.get('memory_config', '') + + # Convert LangChain messages to structured format expected by write() + structured_messages = [] + for msg in messages: + if hasattr(msg, 'type') and hasattr(msg, 'content'): + # Map LangChain message types to role names + role = 'user' if msg.type == 'human' else 'assistant' if msg.type == 'ai' else msg.type + structured_messages.append({ + "role": role, + "content": msg.content # content is now guaranteed to be a string + }) + try: - result=await write( + result = await write( + messages=structured_messages, end_user_id=end_user_id, memory_config=memory_config, - messages=content, # 修复:使用正确的参数名 messages ) logger.info(f"Write completed successfully! Config: {memory_config.config_name}") - write_result= { + write_result = { "status": "success", - "data": content, + "data": structured_messages, "config_id": memory_config.config_id, "config_name": memory_config.config_name, } - return {"write_result":write_result} - + return {"write_result": write_result} except Exception as e: logger.error(f"Data_write failed: {e}", exc_info=True) - write_result= { + write_result = { "status": "error", "message": str(e), } diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index 4751f18c..a56a32fa 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -10,55 +10,58 @@ from app.core.memory.models.message_models import DialogData, ConversationContex async def get_chunked_dialogs( chunker_strategy: str = "RecursiveChunker", end_user_id: str = "group_1", - content: str = "这是用户的输入", + messages: list = None, ref_id: str = "wyl_20251027", config_id: str = None ) -> List[DialogData]: - """Generate chunks from all test data entries using the specified chunker strategy. + """Generate chunks from structured messages using the specified chunker strategy. Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - end_user_id: End user identifier - content: Dialog content + group_id: Group identifier + messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference identifier config_id: Configuration ID for processing Returns: - List of DialogData objects with generated chunks for each test entry + List of DialogData objects with generated chunks """ - dialog_data_list = [] - messages = [] + from app.core.logging_config import get_agent_logger + logger = get_agent_logger(__name__) - messages.append(ConversationMessage(role="用户", msg=content)) + if not messages or not isinstance(messages, list) or len(messages) == 0: + raise ValueError("messages parameter must be a non-empty list") - # Create DialogData - conversation_context = ConversationContext(msgs=messages) - # Create DialogData with end_user_id + conversation_messages = [] + + for idx, msg in enumerate(messages): + if not isinstance(msg, dict) or 'role' not in msg or 'content' not in msg: + raise ValueError(f"Message {idx} format error: must contain 'role' and 'content' fields") + + role = msg['role'] + content = msg['content'] + + if role not in ['user', 'assistant']: + raise ValueError(f"Message {idx} role must be 'user' or 'assistant', got: {role}") + + if content.strip(): + conversation_messages.append(ConversationMessage(role=role, msg=content.strip())) + + if not conversation_messages: + raise ValueError("Message list cannot be empty after filtering") + + conversation_context = ConversationContext(msgs=conversation_messages) dialog_data = DialogData( context=conversation_context, ref_id=ref_id, end_user_id=end_user_id, config_id=config_id ) - # Create DialogueChunker and process the dialogue + chunker = DialogueChunker(chunker_strategy) extracted_chunks = await chunker.process_dialogue(dialog_data) dialog_data.chunks = extracted_chunks - dialog_data_list.append(dialog_data) + logger.info(f"DialogData created with {len(extracted_chunks)} chunks") - # Convert to dict with datetime serialized - def serialize_datetime(obj): - if isinstance(obj, datetime): - return obj.isoformat() - raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") - - combined_output = [dd.model_dump() for dd in dialog_data_list] - - print(dialog_data_list) - - # with open(os.path.join(os.path.dirname(__file__), "chunker_test_output.txt"), "w", encoding="utf-8") as f: - # json.dump(combined_output, f, ensure_ascii=False, indent=4, default=serialize_datetime) - - - return dialog_data_list + return [dialog_data] diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index b8bc58eb..d32d152c 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -36,9 +36,11 @@ async def write( ) -> None: """ Execute the complete knowledge extraction pipeline. - + Args: - end_user_id: End user identifier + user_id: User identifier + apply_id: Application identifier + group_id: Group identifier memory_config: MemoryConfig object containing all configuration messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference ID, defaults to "wyl20251027" @@ -47,14 +49,14 @@ async def write( embedding_model_id = str(memory_config.embedding_model_id) chunker_strategy = memory_config.chunker_strategy config_id = str(memory_config.config_id) - + logger.info("=== MemSci Knowledge Extraction Pipeline ===") logger.info(f"Config: {memory_config.config_name} (ID: {config_id})") logger.info(f"Workspace: {memory_config.workspace_name}") logger.info(f"LLM model: {memory_config.llm_model_name}") logger.info(f"Embedding model: {memory_config.embedding_model_name}") logger.info(f"Chunker strategy: {chunker_strategy}") - logger.info(f"End User ID: {end_user_id}") + logger.info(f"end_user_id ID: {end_user_id}") # Construct clients from memory_config using factory pattern with db session with get_db_context() as db: @@ -77,25 +79,10 @@ async def write( # Step 1: Load and chunk data step_start = time.time() - - # Convert messages list to content string - # messages format: [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}, ...] - if isinstance(messages, list) and len(messages) > 0: - # Extract content from the last user message or concatenate all messages - if isinstance(messages[-1], dict) and 'content' in messages[-1]: - content = messages[-1]['content'] - else: - # Fallback: concatenate all message contents - content = " ".join([msg.get('content', '') for msg in messages if isinstance(msg, dict)]) - elif isinstance(messages, str): - content = messages - else: - content = str(messages) - chunked_dialogs = await get_chunked_dialogs( chunker_strategy=chunker_strategy, end_user_id=end_user_id, - content=content, # 修复:使用 content 参数而不是 messages + messages=messages, ref_id=ref_id, config_id=config_id, ) diff --git a/api/app/core/memory/llm_tools/chunker_client.py b/api/app/core/memory/llm_tools/chunker_client.py index 87cdb9f4..93a2df82 100644 --- a/api/app/core/memory/llm_tools/chunker_client.py +++ b/api/app/core/memory/llm_tools/chunker_client.py @@ -187,11 +187,11 @@ class ChunkerClient: async def generate_chunks(self, dialogue: DialogData): """ Generate chunks following 1 Message = 1 Chunk strategy. - + Each message creates one chunk, directly inheriting role information. If a message is too long, it will be split into multiple sub-chunks, each maintaining the same speaker. - + Raises: ValueError: If dialogue has no messages or chunking fails """ @@ -201,9 +201,9 @@ class ChunkerClient: f"Dialogue {dialogue.ref_id} has no messages. " f"Cannot generate chunks from empty dialogue." ) - + dialogue.chunks = [] - + # 按消息分块:每个消息创建一个或多个 chunk,直接继承角色 for msg_idx, msg in enumerate(dialogue.context.msgs): # Validate message has required attributes @@ -212,13 +212,13 @@ class ChunkerClient: f"Message {msg_idx} in dialogue {dialogue.ref_id} " f"missing 'role' or 'msg' attribute" ) - + msg_content = msg.msg.strip() - + # Skip empty messages if not msg_content: continue - + # 如果消息太长,可以进一步分块 if len(msg_content) > self.chunk_size: # 对单个消息的内容进行分块 @@ -228,14 +228,14 @@ class ChunkerClient: raise ValueError( f"Failed to chunk long message {msg_idx} in dialogue {dialogue.ref_id}: {e}" ) - + for idx, sub_chunk in enumerate(sub_chunks): sub_chunk_text = sub_chunk.text if hasattr(sub_chunk, 'text') else str(sub_chunk) sub_chunk_text = sub_chunk_text.strip() - + if len(sub_chunk_text) < (self.min_characters_per_chunk or 50): continue - + chunk = Chunk( content=f"{msg.role}: {sub_chunk_text}", speaker=msg.role, # 直接继承角色 @@ -260,7 +260,7 @@ class ChunkerClient: }, ) dialogue.chunks.append(chunk) - + # Validate we generated at least one chunk if not dialogue.chunks: raise ValueError( @@ -268,7 +268,7 @@ class ChunkerClient: f"All messages were either empty or too short. " f"Messages count: {len(dialogue.context.msgs)}" ) - + return dialogue def evaluate_chunking(self, dialogue: DialogData) -> dict: diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index d7f7c8a6..af9c0c5d 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -27,29 +27,73 @@ from uuid import UUID logger = get_logger(__name__) config_logger = get_config_logger() +import uuid + +def _validate_config_id(config_id): + """Validate configuration ID format.""" + if isinstance(config_id, uuid.UUID): + return config_id + if config_id is None: + raise InvalidConfigError( + "Configuration ID cannot be None", + field_name="config_id", + invalid_value=config_id, + ) + + if isinstance(config_id, int): + if config_id <= 0: + raise InvalidConfigError( + f"Configuration ID must be positive: {config_id}", + field_name="config_id", + invalid_value=config_id, + ) + return config_id + + if isinstance(config_id, str): + try: + parsed_id = int(config_id.strip()) + if parsed_id <= 0: + raise InvalidConfigError( + f"Configuration ID must be positive: {parsed_id}", + field_name="config_id", + invalid_value=config_id, + ) + return parsed_id + except ValueError: + raise InvalidConfigError( + f"Invalid configuration ID format: '{config_id}'", + field_name="config_id", + invalid_value=config_id, + ) + + raise InvalidConfigError( + f"Invalid type for configuration ID: expected int or str, got {type(config_id).__name__}", + field_name="config_id", + invalid_value=config_id, + ) class MemoryConfigService: """ Centralized service for memory configuration loading and validation. - + This class provides a single implementation of configuration loading logic that can be shared across multiple services, eliminating code duplication. - + Usage: config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config(config_id) model_config = config_service.get_model_config(model_id) """ - + def __init__(self, db: Session): """Initialize the service with a database session. - + Args: db: SQLAlchemy database session """ self.db = db - + def load_memory_config( self, config_id: UUID, @@ -57,19 +101,19 @@ class MemoryConfigService: ) -> MemoryConfig: """ Load memory configuration from database by config_id. - + Args: config_id: Configuration ID (UUID) from database service_name: Name of the calling service (for logging purposes) - + Returns: MemoryConfig: Immutable configuration object - + Raises: ConfigurationError: If validation fails """ start_time = time.time() - + validated_config_id = _validate_config_id(config_id) config_logger.info( "Starting memory configuration loading", extra={ @@ -78,9 +122,9 @@ class MemoryConfigService: "config_id": str(config_id), }, ) - + logger.info(f"Loading memory configuration from database: config_id={config_id}") - + try: # Validate config_id is UUID if not isinstance(config_id, UUID): @@ -99,7 +143,7 @@ class MemoryConfigService: field_name="config_id", invalid_value=config_id, ) - + # Step 1: Get config and workspace db_query_start = time.time() result = MemoryConfigRepository.get_config_with_workspace(self.db, config_id) @@ -120,9 +164,9 @@ class MemoryConfigService: raise ConfigurationError( f"Configuration {config_id} not found in database" ) - + memory_config, workspace = result - + # Step 2: Validate embedding model (returns both UUID and name) embed_start = time.time() embedding_uuid, embedding_name = validate_embedding_model( @@ -134,7 +178,7 @@ class MemoryConfigService: ) embed_time = time.time() - embed_start logger.info(f"[PERF] Embedding validation: {embed_time:.4f}s") - + # Step 3: Resolve LLM model llm_start = time.time() llm_uuid, llm_name = validate_and_resolve_model_id( @@ -148,7 +192,7 @@ class MemoryConfigService: ) llm_time = time.time() - llm_start logger.info(f"[PERF] LLM validation: {llm_time:.4f}s") - + # Step 4: Resolve optional rerank model rerank_start = time.time() rerank_uuid = None @@ -166,10 +210,10 @@ class MemoryConfigService: rerank_time = time.time() - rerank_start if memory_config.rerank_id: logger.info(f"[PERF] Rerank validation: {rerank_time:.4f}s") - + # Note: embedding_name is now returned from validate_embedding_model above # No need for redundant query! - + # Create immutable MemoryConfig object config = MemoryConfig( config_id=memory_config.config_id, @@ -210,9 +254,9 @@ class MemoryConfigService: pruning_scene=memory_config.pruning_scene or "education", pruning_threshold=float(memory_config.pruning_threshold) if memory_config.pruning_threshold is not None else 0.5, ) - + elapsed_ms = (time.time() - start_time) * 1000 - + config_logger.info( "Memory configuration loaded successfully", extra={ @@ -225,13 +269,13 @@ class MemoryConfigService: "elapsed_ms": elapsed_ms, }, ) - + logger.info(f"Memory configuration loaded successfully: {config.config_name}") return config - + except Exception as e: elapsed_ms = (time.time() - start_time) * 1000 - + config_logger.error( "Failed to load memory configuration", extra={ @@ -245,7 +289,7 @@ class MemoryConfigService: }, exc_info=True, ) - + logger.error(f"Failed to load memory configuration {config_id}: {e}") if isinstance(e, (ConfigurationError, ValueError)): raise diff --git a/api/app/tasks.py b/api/app/tasks.py index 3ef2653a..38488aa5 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -383,7 +383,7 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): @celery_app.task(name="app.core.memory.agent.read_message", bind=True) -def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: uuid.UUID, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: +def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a read message via MemoryAgentService. @@ -392,7 +392,7 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s message: User message to process history: Conversation history search_switch: Search switch parameter - config_id: Optional configuration ID + config_id: Configuration ID as string (will be converted to UUID) Returns: Dict containing the result and metadata @@ -402,8 +402,16 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s """ start_time = time.time() + # Convert config_id string to UUID + actual_config_id = None + if config_id: + try: + actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id + except (ValueError, AttributeError): + # If conversion fails, leave as None and try to resolve + pass + # Resolve config_id if None - actual_config_id = config_id if actual_config_id is None: try: from app.services.memory_agent_service import get_end_user_connected_config @@ -473,13 +481,13 @@ def read_message_task(self, end_user_id: str, message: str, history: List[Dict[s @celery_app.task(name="app.core.memory.agent.write_message", bind=True) -def write_message_task(self, end_user_id: str, message: str, config_id: uuid.UUID, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: +def write_message_task(self, end_user_id: str, message: str, config_id: str, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a write message via MemoryAgentService. Args: end_user_id: Group ID for the memory agent (also used as end_user_id) message: Message to write - config_id: Optional configuration ID + config_id: Configuration ID as string (will be converted to UUID) Returns: Dict containing the result and metadata @@ -493,8 +501,24 @@ def write_message_task(self, end_user_id: str, message: str, config_id: uuid.UUI logger.info(f"[CELERY WRITE] Starting write task - end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") start_time = time.time() + # Convert config_id string to UUID + actual_config_id = None + if config_id: + try: + actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id + logger.info(f"[CELERY WRITE] Converted config_id to UUID: {actual_config_id} (type: {type(actual_config_id).__name__})") + except (ValueError, AttributeError) as e: + logger.error(f"[CELERY WRITE] Invalid config_id format: {config_id}, error: {e}") + return { + "status": "FAILURE", + "error": f"Invalid config_id format: {config_id}", + "end_user_id": end_user_id, + "config_id": config_id, + "elapsed_time": 0.0, + "task_id": self.request.id + } + # Resolve config_id if None - actual_config_id = config_id if actual_config_id is None: try: from app.services.memory_agent_service import get_end_user_connected_config @@ -511,7 +535,7 @@ def write_message_task(self, end_user_id: str, message: str, config_id: uuid.UUI async def _run() -> str: db = next(get_db()) try: - logger.info(f"[CELERY WRITE] Executing MemoryAgentService.write_memory") + logger.info(f"[CELERY WRITE] Executing MemoryAgentService.write_memory with config_id={actual_config_id} (type: {type(actual_config_id).__name__})") service = MemoryAgentService() result = await service.write_memory(end_user_id, message, actual_config_id, db, storage_type, user_rag_memory_id) logger.info(f"[CELERY WRITE] Write completed successfully: {result}") From 940d3d4567b67de68ea0360df99a435fa8a36ea2 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 20:48:51 +0800 Subject: [PATCH 040/175] =?UTF-8?q?config=5Fid=E5=AD=97=E6=AE=B5=E6=94=B9?= =?UTF-8?q?=E6=88=90UUID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_agent_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index e9ae8bae..1ae01aec 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -171,6 +171,7 @@ async def write_server( storage_type, user_rag_memory_id ) + return success(data=result, msg="写入成功") except BaseException as e: # Handle ExceptionGroup from TaskGroup (Python 3.11+) or BaseExceptionGroup From 9e828b17502d4c280b7d8e6c8b73f00016bd3745 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Thu, 22 Jan 2026 21:53:15 +0800 Subject: [PATCH 041/175] =?UTF-8?q?config=5Fid=E5=AD=97=E6=AE=B5=E6=94=B9?= =?UTF-8?q?=E6=88=90UUID=EF=BC=8C=E4=B8=8Edevelop=E6=A0=A1=E5=AF=B9?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_agent_controller.py | 7 +- .../controllers/memory_forget_controller.py | 16 ++-- .../service/memory_api_controller.py | 1 - api/app/core/agent/langchain_agent.py | 57 ++++++------ .../agent/langgraph_graph/write_graph.py | 12 ++- .../evaluation/locomo/qwen_search_eval.py | 2 +- .../memory/evaluation/memsciqa/evaluate_qa.py | 4 +- .../evaluation/memsciqa/memsciqa-test.py | 2 +- api/app/core/memory/evaluation/run_eval.py | 4 +- api/app/core/memory/src/search.py | 3 - .../neo4j/memory_summary_repository.py | 6 +- api/app/repositories/neo4j/neo4j_connector.py | 32 +------ api/app/services/memory_agent_service.py | 2 +- api/app/services/memory_api_service.py | 2 +- api/app/services/memory_config_service.py | 48 +++++----- api/app/services/memory_storage_service.py | 23 ----- api/app/tasks.py | 88 +++++++++---------- 17 files changed, 131 insertions(+), 178 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 1ae01aec..7b0ddf23 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -223,9 +223,12 @@ async def write_server_async( if knowledge: user_rag_memory_id = str(knowledge.id) api_logger.info(f"Async write: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") try: + # 获取标准化的消息列表 + messages_list = memory_agent_service.get_messages_list(user_input) + task = celery_app.send_task( "app.core.memory.agent.write_message", - args=[user_input.end_user_id, user_input.message, config_id, storage_type, user_rag_memory_id] + args=[user_input.end_user_id, messages_list, config_id, storage_type, user_rag_memory_id] ) api_logger.info(f"Write task queued: {task.id}") @@ -598,7 +601,7 @@ async def status_type( last_user_message = " ".join([msg.get('content', '') for msg in messages_list]) result = await memory_agent_service.classify_message_type( - user_input.messages, + last_user_message, user_input.config_id, db ) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index a71de487..44351f92 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -237,7 +237,7 @@ async def update_forgetting_config( @router.get("/stats", response_model=ApiResponse) async def get_forgetting_stats( - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -263,18 +263,18 @@ async def get_forgetting_stats( # 如果提供了 group_id,通过它获取 config_id config_id = None - if group_id: + if end_user_id: try: from app.services.memory_agent_service import get_end_user_connected_config - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if config_id is None: - api_logger.warning(f"终端用户 {group_id} 未关联记忆配置") - return fail(BizCode.INVALID_PARAMETER, f"终端用户 {group_id} 未关联记忆配置", "memory_config_id is None") + api_logger.warning(f"终端用户 {end_user_id} 未关联记忆配置") + return fail(BizCode.INVALID_PARAMETER, f"终端用户 {end_user_id} 未关联记忆配置", "memory_config_id is None") - api_logger.debug(f"通过 group_id={group_id} 获取到 config_id={config_id}") + api_logger.debug(f"通过 group_id={end_user_id} 获取到 config_id={config_id}") except ValueError as e: api_logger.warning(f"获取终端用户配置失败: {str(e)}") return fail(BizCode.INVALID_PARAMETER, str(e), "ValueError") @@ -284,14 +284,14 @@ async def get_forgetting_stats( api_logger.info( f"用户 {current_user.username} 在工作空间 {workspace_id} 请求获取遗忘引擎统计: " - f"group_id={group_id}, config_id={config_id}" + f"group_id={end_user_id}, config_id={config_id}" ) try: # 调用服务层获取统计信息 stats = await forget_service.get_forgetting_stats( db=db, - group_id=group_id, + end_user_id=end_user_id, config_id=config_id ) diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index 87c1aa20..accd749e 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -50,7 +50,6 @@ async def write_memory_api_service( config_id=payload.config_id, storage_type=payload.storage_type, user_rag_memory_id=payload.user_rag_memory_id, - tenant_id=api_key_auth.tenant_id, ) logger.info(f"Memory write successful for end_user: {payload.end_user_id}") diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index e6c59a79..4cff933d 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -145,33 +145,36 @@ class LangChainAgent: messages.append(HumanMessage(content=user_content)) return messages - async def term_memory_save(self,messages,end_user_end,aimessages): - '''短长期存储redis,为不影响正常使用6句一段话,存储用户名加一个前缀,当数据存够6条返回给neo4j''' - end_user_end=f"Term_{end_user_end}" - print(messages) - print(aimessages) - session_id = store.save_session( - userid=end_user_end, - messages=messages, - apply_id=end_user_end, - end_user_id=end_user_end, - aimessages=aimessages - ) - store.delete_duplicate_sessions() - # logger.info(f'Redis_Agent:{end_user_end};{session_id}') - return session_id - async def term_memory_redis_read(self,end_user_end): - end_user_end = f"Term_{end_user_end}" - history = store.find_user_apply_group(end_user_end, end_user_end, end_user_end) - # logger.info(f'Redis_Agent:{end_user_end};{history}') - messagss_list=[] - retrieved_content=[] - for messages in history: - query = messages.get("Query") - aimessages = messages.get("Answer") - messagss_list.append(f'用户:{query}。AI回复:{aimessages}') - retrieved_content.append({query: aimessages}) - return messagss_list,retrieved_content +# TODO 乐力齐 - 累积多组对话批量写入功能已禁用 + # async def term_memory_save(self,messages,end_user_end,aimessages): + # '''短长期存储redis,为不影响正常使用6句一段话,存储用户名加一个前缀,当数据存够6条返回给neo4j''' + # end_user_end=f"Term_{end_user_end}" + # print(messages) + # print(aimessages) + # session_id = store.save_session( + # userid=end_user_end, + # messages=messages, + # apply_id=end_user_end, + # group_id=end_user_end, + # aimessages=aimessages + # ) + # store.delete_duplicate_sessions() + # # logger.info(f'Redis_Agent:{end_user_end};{session_id}') + # return session_id + +# TODO 乐力齐 - 累积多组对话批量写入功能已禁用 + # async def term_memory_redis_read(self,end_user_end): + # end_user_end = f"Term_{end_user_end}" + # history = store.find_user_apply_group(end_user_end, end_user_end, end_user_end) + # # logger.info(f'Redis_Agent:{end_user_end};{history}') + # messagss_list=[] + # retrieved_content=[] + # for messages in history: + # query = messages.get("Query") + # aimessages = messages.get("Answer") + # messagss_list.append(f'用户:{query}。AI回复:{aimessages}') + # retrieved_content.append({query: aimessages}) + # return messagss_list,retrieved_content async def write(self, storage_type, end_user_id, user_message, ai_message, user_rag_memory_id, actual_end_user_id, actual_config_id): """ diff --git a/api/app/core/memory/agent/langgraph_graph/write_graph.py b/api/app/core/memory/agent/langgraph_graph/write_graph.py index d8fcf210..8b5de444 100644 --- a/api/app/core/memory/agent/langgraph_graph/write_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/write_graph.py @@ -34,11 +34,17 @@ async def make_write_graph(): end_user_id: Group identifier memory_config: MemoryConfig object containing all configuration """ + # workflow = StateGraph(WriteState) + # workflow.add_node("content_input", content_input_write) + # workflow.add_node("save_neo4j", write_node) + # workflow.add_edge(START, "content_input") + # workflow.add_edge("content_input", "save_neo4j") + # workflow.add_edge("save_neo4j", END) + # + # graph = workflow.compile() workflow = StateGraph(WriteState) - workflow.add_node("content_input", content_input_write) workflow.add_node("save_neo4j", write_node) - workflow.add_edge(START, "content_input") - workflow.add_edge("content_input", "save_neo4j") + workflow.add_edge(START, "save_neo4j") workflow.add_edge("save_neo4j", END) graph = workflow.compile() diff --git a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py index 3147e880..6a5caa0c 100644 --- a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py @@ -30,7 +30,7 @@ from app.core.memory.storage_services.search import run_hybrid_search from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_end_user_id, + SELECTED_GROUP_ID, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory diff --git a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py index ec147f3c..869fdb60 100644 --- a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py +++ b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py @@ -27,7 +27,7 @@ from app.core.memory.storage_services.search import run_hybrid_search from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_end_user_id, + SELECTED_GROUP_ID, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -136,7 +136,7 @@ def _combine_dialogues_for_hybrid(results: Dict[str, Any]) -> List[Dict[str, Any async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: - end_user_id = end_user_id or SELECTED_end_user_id + end_user_id = end_user_id or SELECTED_GROUP_ID # Load data data_path = os.path.join(PROJECT_ROOT, "data", "msc_self_instruct.jsonl") if not os.path.exists(data_path): diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py index 631035aa..8c6d643d 100644 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py @@ -33,7 +33,7 @@ from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient from app.core.memory.utils.config.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_end_user_id, + SELECTED_GROUP_ID, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory diff --git a/api/app/core/memory/evaluation/run_eval.py b/api/app/core/memory/evaluation/run_eval.py index f665bdb8..c5aacb2f 100644 --- a/api/app/core/memory/evaluation/run_eval.py +++ b/api/app/core/memory/evaluation/run_eval.py @@ -15,7 +15,7 @@ except Exception: return None from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.utils.config.definitions import SELECTED_end_user_id, PROJECT_ROOT +from app.core.memory.utils.config.definitions import SELECTED_GROUP_ID, PROJECT_ROOT from app.core.memory.evaluation.memsciqa.evaluate_qa import run_memsciqa_eval from app.core.memory.evaluation.longmemeval.qwen_search_eval import run_longmemeval_test @@ -37,7 +37,7 @@ async def run( max_contexts_per_item: int | None = None, ) -> Dict[str, Any]: # 恢复原始风格:统一入口做路由,并沿用各数据集既有默认 - end_user_id = end_user_id or SELECTED_end_user_id + end_user_id = end_user_id or SELECTED_GROUP_ID if reset_group: connector = Neo4jConnector() diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 87a5dd6f..5985d04f 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -693,9 +693,6 @@ async def run_hybrid_search( # Start overall timing search_start_time = time.time() latency_metrics = {} - print(100*'-') - print(memory_config) - print(100 * '-') logger.info(f"using embedding_id:{memory_config.embedding_model_id}...") # Clean and normalize the incoming query before use/logging diff --git a/api/app/repositories/neo4j/memory_summary_repository.py b/api/app/repositories/neo4j/memory_summary_repository.py index 81bf2cc9..5fcff41a 100644 --- a/api/app/repositories/neo4j/memory_summary_repository.py +++ b/api/app/repositories/neo4j/memory_summary_repository.py @@ -209,7 +209,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, days=days, limit=limit ) @@ -253,7 +253,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): results = await self.connector.execute_query(query, **params) return [self._map_to_dict(r) for r in results] - async def get_summary_count_by_group(self, group_id: str) -> int: + async def get_summary_count_by_group(self, end_user_id: str) -> int: """Get count of memory summaries for a group Args: @@ -268,6 +268,6 @@ class MemorySummaryRepository(BaseNeo4jRepository): RETURN count(n) as count """ - results = await self.connector.execute_query(query, end_user_id=group_id) + results = await self.connector.execute_query(query, end_user_id=end_user_id) return results[0]['count'] if results else 0 \ No newline at end of file diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index 456c4e08..d96e4431 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -70,11 +70,7 @@ class Neo4jConnector: List[Dict[str, Any]]: 查询结果列表,每个元素是一个字典 Example: - >>> connector = Neo4jConnector() - >>> results = await connector.execute_query( - ... "MATCH (n:Person {name: $name}) RETURN n", - ... name="Alice" - ... ) + """ result = await self.driver.execute_query( query, @@ -98,17 +94,7 @@ class Neo4jConnector: Any: 事务函数的返回值 Example: - >>> async def create_node(tx, name): - ... result = await tx.run( - ... "CREATE (n:Person {name: $name}) RETURN n", - ... name=name - ... ) - ... return await result.single() - >>> - >>> connector = Neo4jConnector() - >>> result = await connector.execute_write_transaction( - ... create_node, name="Alice" - ... ) + """ async with self.driver.session(database="neo4j") as session: return await session.execute_write(transaction_func, **kwargs) @@ -126,17 +112,7 @@ class Neo4jConnector: Any: 事务函数的返回值 Example: - >>> async def get_node(tx, name): - ... result = await tx.run( - ... "MATCH (n:Person {name: $name}) RETURN n", - ... name=name - ... ) - ... return await result.single() - >>> - >>> connector = Neo4jConnector() - >>> result = await connector.execute_read_transaction( - ... get_node, name="Alice" - ... ) + """ async with self.driver.session(database="neo4j") as session: return await session.execute_read(transaction_func, **kwargs) @@ -151,8 +127,6 @@ class Neo4jConnector: end_user_id: 要删除的组ID Example: - >>> connector = Neo4jConnector() - >>> await connector.delete_group("group_123") Group group_123 deleted. """ # 删除节点(DETACH DELETE会同时删除相关的边) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index e4ab7087..890a88f7 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -564,7 +564,7 @@ class MemoryAgentService: # 使用 upsert 方法 repo.upsert( end_user_id=end_user_id, - messages=message, + messages=ori_message, aimessages=summary, retrieved_content=retrieved_content, search_switch=str(search_switch) diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index c33c9c6b..37150aff 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -139,7 +139,7 @@ class MemoryAPIService: # Delegate to MemoryAgentService result = await MemoryAgentService().write_memory( end_user_id=end_user_id, - message=message, + messages=message, config_id=config_id, db=self.db, storage_type=storage_type, diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index af9c0c5d..692104bb 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -30,9 +30,10 @@ config_logger = get_config_logger() import uuid def _validate_config_id(config_id): - """Validate configuration ID format.""" + """Validate configuration ID format (supports both UUID and integer).""" if isinstance(config_id, uuid.UUID): return config_id + if config_id is None: raise InvalidConfigError( "Configuration ID cannot be None", @@ -50,8 +51,17 @@ def _validate_config_id(config_id): return config_id if isinstance(config_id, str): + config_id_stripped = config_id.strip() + + # Try parsing as UUID first try: - parsed_id = int(config_id.strip()) + return uuid.UUID(config_id_stripped) + except ValueError: + pass + + # Fall back to integer parsing + try: + parsed_id = int(config_id_stripped) if parsed_id <= 0: raise InvalidConfigError( f"Configuration ID must be positive: {parsed_id}", @@ -61,13 +71,13 @@ def _validate_config_id(config_id): return parsed_id except ValueError: raise InvalidConfigError( - f"Invalid configuration ID format: '{config_id}'", + f"Invalid configuration ID format: '{config_id}' (must be UUID or positive integer)", field_name="config_id", invalid_value=config_id, ) raise InvalidConfigError( - f"Invalid type for configuration ID: expected int or str, got {type(config_id).__name__}", + f"Invalid type for configuration ID: expected UUID, int or str, got {type(config_id).__name__}", field_name="config_id", invalid_value=config_id, ) @@ -113,7 +123,7 @@ class MemoryConfigService: ConfigurationError: If validation fails """ start_time = time.time() - validated_config_id = _validate_config_id(config_id) + config_logger.info( "Starting memory configuration loading", extra={ @@ -126,27 +136,11 @@ class MemoryConfigService: logger.info(f"Loading memory configuration from database: config_id={config_id}") try: - # Validate config_id is UUID - if not isinstance(config_id, UUID): - if isinstance(config_id, str): - try: - config_id = UUID(config_id) - except ValueError: - raise InvalidConfigError( - f"Invalid UUID format for config_id: {config_id}", - field_name="config_id", - invalid_value=config_id, - ) - else: - raise InvalidConfigError( - f"config_id must be UUID or valid UUID string, got {type(config_id).__name__}", - field_name="config_id", - invalid_value=config_id, - ) + validated_config_id = _validate_config_id(config_id) # Step 1: Get config and workspace db_query_start = time.time() - result = MemoryConfigRepository.get_config_with_workspace(self.db, config_id) + result = MemoryConfigRepository.get_config_with_workspace(self.db, validated_config_id) db_query_time = time.time() - db_query_start logger.info(f"[PERF] Config+Workspace query: {db_query_time:.4f}s") if not result: @@ -170,7 +164,7 @@ class MemoryConfigService: # Step 2: Validate embedding model (returns both UUID and name) embed_start = time.time() embedding_uuid, embedding_name = validate_embedding_model( - config_id, + validated_config_id, memory_config.embedding_id, self.db, workspace.tenant_id, @@ -187,7 +181,7 @@ class MemoryConfigService: self.db, workspace.tenant_id, required=True, - config_id=config_id, + config_id=validated_config_id, workspace_id=workspace.id, ) llm_time = time.time() - llm_start @@ -204,7 +198,7 @@ class MemoryConfigService: self.db, workspace.tenant_id, required=False, - config_id=config_id, + config_id=validated_config_id, workspace_id=workspace.id, ) rerank_time = time.time() - rerank_start @@ -262,7 +256,7 @@ class MemoryConfigService: extra={ "operation": "load_memory_config", "service": service_name, - "config_id": str(config_id), + "config_id": validated_config_id, "config_name": config.config_name, "workspace_id": str(config.workspace_id), "load_result": "success", diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 6aa5ac7d..57ad725d 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -505,29 +505,6 @@ async def search_edges(end_user_id: Optional[str] = None) -> List[Dict[str, Any] ) return result - -async def search_entity_graph(end_user_id: Optional[str] = None) -> Dict[str, Any]: - """搜索所有实体之间的关系网络(group 维度)。""" - result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ENTITY_GRAPH, - end_user_id=end_user_id, - ) - # 对source_node 和 target_node 的 fact_summary进行截取,只截取前三条的内容(需要提取前三条“来源”) - for item in result: - source_fact = item["sourceNode"]["fact_summary"] - target_fact = item["targetNode"]["fact_summary"] - # 截取前三条“来源” - item["sourceNode"]["fact_summary"] = source_fact.split("\n")[:4] if source_fact else [] - item["targetNode"]["fact_summary"] = target_fact.split("\n")[:4] if target_fact else [] - # 与现有返回风格保持一致,携带搜索类型、数量与详情 - data = { - "search_for": "entity_graph", - "num": len(result), - "detials": result, - } - return data - - async def analytics_hot_memory_tags( db: Session, current_user: User, diff --git a/api/app/tasks.py b/api/app/tasks.py index 38488aa5..3374a90b 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -531,7 +531,7 @@ def write_message_task(self, end_user_id: str, message: str, config_id: str, sto except Exception: # Log but continue - will fail later with proper error pass - + async def _run() -> str: db = next(get_db()) try: @@ -619,53 +619,53 @@ def reflection_timer_task() -> None: """ reflection_engine() - -@celery_app.task(name="app.core.memory.agent.health.check_read_service") -def check_read_service_task() -> Dict[str, str]: - """Call read_service and write latest status to Redis. +# unused task +# @celery_app.task(name="app.core.memory.agent.health.check_read_service") +# def check_read_service_task() -> Dict[str, str]: +# """Call read_service and write latest status to Redis. - Returns status data dict that gets written to Redis. - """ - client = redis.Redis( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - db=settings.REDIS_DB, - password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None - ) - try: - api_url = f"http://{settings.SERVER_IP}:8000/api/memory/read_service" - payload = { - "user_id": "健康检查", - "apply_id": "健康检查", - "end_user_id": "健康检查", - "message": "你好", - "history": [], - "search_switch": "2", - } - resp = requests.post(api_url, json=payload, timeout=15) - ok = resp.status_code == 200 - status = "Success" if ok else "Fail" - msg = "接口请求成功" if ok else f"接口请求失败: {resp.status_code}" - error = "" if ok else resp.text - code = 0 if ok else 500 - except Exception as e: - status = "Fail" - msg = "接口请求失败" - error = str(e) - code = 500 +# Returns status data dict that gets written to Redis. +# """ +# client = redis.Redis( +# host=settings.REDIS_HOST, +# port=settings.REDIS_PORT, +# db=settings.REDIS_DB, +# password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None +# ) +# try: +# api_url = f"http://{settings.SERVER_IP}:8000/api/memory/read_service" +# payload = { +# "user_id": "健康检查", +# "apply_id": "健康检查", +# "group_id": "健康检查", +# "message": "你好", +# "history": [], +# "search_switch": "2", +# } +# resp = requests.post(api_url, json=payload, timeout=15) +# ok = resp.status_code == 200 +# status = "Success" if ok else "Fail" +# msg = "接口请求成功" if ok else f"接口请求失败: {resp.status_code}" +# error = "" if ok else resp.text +# code = 0 if ok else 500 +# except Exception as e: +# status = "Fail" +# msg = "接口请求失败" +# error = str(e) +# code = 500 - data = { - "status": status, - "msg": msg, - "error": error, - "code": str(code), - "time": str(int(time.time())), - } +# data = { +# "status": status, +# "msg": msg, +# "error": error, +# "code": str(code), +# "time": str(int(time.time())), +# } - client.hset("memsci:health:read_service", mapping=data) - client.expire("memsci:health:read_service", int(settings.HEALTH_CHECK_SECONDS)) +# client.hset("memsci:health:read_service", mapping=data) +# client.expire("memsci:health:read_service", int(settings.HEALTH_CHECK_SECONDS)) - return data +# return data @celery_app.task(name="app.controllers.memory_storage_controller.search_all") From 86fe6fe5ab47433a8e67f909d0cd79030f4eb0a7 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 10:35:41 +0800 Subject: [PATCH 042/175] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dgroup=5Fid=E7=9A=84=E9=81=97?= =?UTF-8?q?=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../memory/evaluation/longmemeval/qwen_search_eval.py | 4 ++-- api/app/core/memory/evaluation/longmemeval/test_eval.py | 8 ++++---- api/app/models/memory_config_model.py | 2 +- api/app/repositories/neo4j/memory_summary_repository.py | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py index 320f9de7..d3577d9e 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py @@ -1009,7 +1009,7 @@ async def run_longmemeval_test( kw_fallback = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=max(search_limit, 5), ) fb_dialogs = kw_fallback.get("dialogues", []) or [] @@ -1223,7 +1223,7 @@ async def run_longmemeval_test( "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, }, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, diff --git a/api/app/core/memory/evaluation/longmemeval/test_eval.py b/api/app/core/memory/evaluation/longmemeval/test_eval.py index a49d48d0..67bd6ec2 100644 --- a/api/app/core/memory/evaluation/longmemeval/test_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/test_eval.py @@ -876,7 +876,7 @@ async def run_longmemeval_test( opt_res = await search_graph( connector=connector, q=str(opt), - end_user_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(opt_res, dict): @@ -971,7 +971,7 @@ async def run_longmemeval_test( kw_fallback = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=max(search_limit, 5), ) fb_dialogs = kw_fallback.get("dialogues", []) or [] @@ -1199,7 +1199,7 @@ async def run_longmemeval_test( "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, }, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, @@ -1278,7 +1278,7 @@ def main(): result = asyncio.run( run_longmemeval_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index 710315db..b468e2a2 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -17,7 +17,7 @@ class MemoryConfig(Base): # 组织信息 workspace_id = Column(UUID(as_uuid=True), nullable=True, comment="工作空间ID") - group_id = Column(String, nullable=True, comment="组ID") + end_user_id = Column(String, nullable=True, comment="组ID") user_id = Column(String, nullable=True, comment="用户ID") apply_id = Column(String, nullable=True, comment="应用ID") diff --git a/api/app/repositories/neo4j/memory_summary_repository.py b/api/app/repositories/neo4j/memory_summary_repository.py index 5fcff41a..d7cd4fd4 100644 --- a/api/app/repositories/neo4j/memory_summary_repository.py +++ b/api/app/repositories/neo4j/memory_summary_repository.py @@ -217,14 +217,14 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_by_content_keywords( self, - group_id: str, + end_user_id: str, keywords: List[str], limit: int = 100 ) -> List[Dict[str, Any]]: """Query memory summaries by content keywords Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by keywords: List of keywords to search for in content limit: Maximum number of results to return @@ -233,7 +233,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ # Build keyword search conditions keyword_conditions = [] - params = {"end_user_id": group_id, "limit": limit} + params = {"end_user_id": end_user_id, "limit": limit} for i, keyword in enumerate(keywords): keyword_conditions.append(f"toLower(n.content) CONTAINS toLower($keyword_{i})") @@ -257,7 +257,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """Get count of memory summaries for a group Args: - group_id: Group ID to count summaries for + end_user_id: Group ID to count summaries for Returns: int: Number of memory summaries From ebe018347b02e7a2c795ede4467db95990603b15 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 10:39:10 +0800 Subject: [PATCH 043/175] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8Dgroup=5Fid=E7=9A=84=E9=81=97?= =?UTF-8?q?=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/core/memory/src/search.py | 57 ------------------------------- 1 file changed, 57 deletions(-) diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 5985d04f..0e1d8424 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -1017,60 +1017,3 @@ async def search_chunk_by_chunk_id( ) return {"chunks": chunks} - -if __name__ == '__main__': - # 测试混合检索功能 - from app.schemas.memory_config_schema import MemoryConfig - from app.db import get_db - from app.services.memory_config_service import MemoryConfigService - - # 从数据库获取真实配置 - db = next(get_db()) - try: - config_service = MemoryConfigService(db) - - # 使用 config_id=17 获取配置 - memory_config = config_service.load_memory_config(config_id=17) - - if not memory_config: - print("错误:找不到 config_id=17 的配置") - print("请先在数据库中创建配置,或修改 config_id") - exit(1) - - print(f"✓ 成功加载配置: {memory_config.config_name}") - print(f" - Workspace: {memory_config.workspace_name}") - print(f" - LLM Model: {memory_config.llm_model_name}") - print(f" - Embedding Model: {memory_config.embedding_model_name}") - print(f" - Storage Type: {memory_config.storage_type}") - print() - - # 修改这里的参数进行测试 - test_end_user_id = "021886bc-fab9-4fd5-b607-497b262e0381" # 修改为你的 end_user_id - test_query = "小明擅长什么?" # 修改为你的查询 - - print(f"开始测试检索...") - print(f" - Query: {test_query}") - print(f" - End User ID: {test_end_user_id}") - print(f" - Search Type: hybrid") - print() - - results = asyncio.run(run_hybrid_search( - query_text=test_query, - search_type="hybrid", # 可选: "keyword", "embedding", "hybrid" - end_user_id=test_end_user_id, - limit=10, - include=["statements", "entities", "chunks", "summaries"], - output_path=None, - memory_config=memory_config, - rerank_alpha=0.6, - use_forgetting_rerank=False, - use_llm_rerank=False - )) - - except Exception as e: - print(f"错误: {e}") - import traceback - - traceback.print_exc() - finally: - db.close() \ No newline at end of file From 7870c6c33f8048df8cd7351aec7904f3c62bbdd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:50:24 +0800 Subject: [PATCH 044/175] Fix/interface home (#182) * [fix]Fix the interface for statistics of recent activities and applications * [changes]Modify the code based on the AI review 1.Use the boolean auxiliary methods provided by SQLAlchemy instead of using == True in the is_active filter. 2.The calculation of the "PROJECT_ROOT" has now been hardcoded with five levels of nested os.path.dirname calls. * [fix]Fix the interface for statistics of recent activities and applications * [changes]Modify the code based on the AI review 1.Use the boolean auxiliary methods provided by SQLAlchemy instead of using == True in the is_active filter. 2.The calculation of the "PROJECT_ROOT" has now been hardcoded with five levels of nested os.path.dirname calls. --- .../controllers/public_share_controller.py | 7 +- api/app/controllers/workflow_controller.py | 14 +-- api/app/core/memory/agent/utils/llm_tools.py | 3 +- .../core/memory/analytics/api_docs_parser.py | 3 +- .../memory/analytics/recent_activity_stats.py | 88 +++++++++---------- .../memory/evaluation/locomo/locomo_test.py | 5 +- .../longmemeval/qwen_search_eval.py | 5 +- .../evaluation/memsciqa/memsciqa-test.py | 5 +- api/app/repositories/app_repository.py | 10 ++- api/app/repositories/home_page_repository.py | 30 +++---- api/app/repositories/user_repository.py | 4 +- api/app/repositories/workflow_repository.py | 2 +- api/app/repositories/workspace_repository.py | 24 ++--- api/app/services/agent_registry.py | 4 +- api/app/services/app_service.py | 10 +-- api/app/services/draft_run_service.py | 2 +- api/app/services/memory_agent_service.py | 17 ++-- api/app/services/memory_api_service.py | 5 +- api/app/services/memory_reflection_service.py | 5 +- api/app/services/memory_storage_service.py | 3 +- api/app/services/multi_agent_orchestrator.py | 4 +- api/app/services/multi_agent_service.py | 4 +- api/app/services/shared_chat_service.py | 8 +- api/app/services/workflow_service.py | 5 +- api/app/tasks.py | 7 +- api/migrations/env.py | 3 +- 26 files changed, 148 insertions(+), 129 deletions(-) diff --git a/api/app/controllers/public_share_controller.py b/api/app/controllers/public_share_controller.py index 17ad70a7..6e2d383c 100644 --- a/api/app/controllers/public_share_controller.py +++ b/api/app/controllers/public_share_controller.py @@ -317,9 +317,12 @@ async def chat( appid = share.app_id """获取存储类型和工作空间的ID""" - # 直接通过 SQLAlchemy 查询 app + # 直接通过 SQLAlchemy 查询 app(仅查询未删除的应用) from app.models.app_model import App - app = db.query(App).filter(App.id == appid).first() + app = db.query(App).filter( + App.id == appid, + App.is_active.is_(True) + ).first() if not app: raise BusinessException("应用不存在", BizCode.APP_NOT_FOUND) diff --git a/api/app/controllers/workflow_controller.py b/api/app/controllers/workflow_controller.py index c6d9ddab..8a15f717 100644 --- a/api/app/controllers/workflow_controller.py +++ b/api/app/controllers/workflow_controller.py @@ -54,7 +54,7 @@ async def create_workflow_config( app = db.query(App).filter( App.id == app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -214,7 +214,7 @@ async def delete_workflow_config( app = db.query(App).filter( App.id == app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -259,7 +259,7 @@ async def validate_workflow_config( app = db.query(App).filter( App.id == app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -329,7 +329,7 @@ async def get_workflow_executions( app = db.query(App).filter( App.id == app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -389,7 +389,7 @@ async def get_workflow_execution( app = db.query(App).filter( App.id == execution.app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -440,7 +440,7 @@ async def run_workflow( app = db.query(App).filter( App.id == app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: @@ -578,7 +578,7 @@ async def cancel_workflow_execution( app = db.query(App).filter( App.id == execution.app_id, App.workspace_id == current_user.current_workspace_id, - App.is_active == True + App.is_active.is_(True) ).first() if not app: diff --git a/api/app/core/memory/agent/utils/llm_tools.py b/api/app/core/memory/agent/utils/llm_tools.py index 8dd2f1d3..e73d5653 100644 --- a/api/app/core/memory/agent/utils/llm_tools.py +++ b/api/app/core/memory/agent/utils/llm_tools.py @@ -1,11 +1,12 @@ import os from collections import defaultdict +from pathlib import Path from typing import Annotated, TypedDict from langchain_core.messages import AnyMessage from langgraph.graph import add_messages -PROJECT_ROOT_ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PROJECT_ROOT_ = str(Path(__file__).resolve().parents[3]) class WriteState(TypedDict): ''' diff --git a/api/app/core/memory/analytics/api_docs_parser.py b/api/app/core/memory/analytics/api_docs_parser.py index 94ed0f00..4a116520 100644 --- a/api/app/core/memory/analytics/api_docs_parser.py +++ b/api/app/core/memory/analytics/api_docs_parser.py @@ -139,7 +139,8 @@ def parse_api_docs(file_path: str) -> Dict[str, Any]: def get_default_docs_path() -> str: - project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + from pathlib import Path + project_root = str(Path(__file__).resolve().parents[2]) return os.path.join(project_root, "src", "analytics", "API接口.md") diff --git a/api/app/core/memory/analytics/recent_activity_stats.py b/api/app/core/memory/analytics/recent_activity_stats.py index c41f4208..71f70c09 100644 --- a/api/app/core/memory/analytics/recent_activity_stats.py +++ b/api/app/core/memory/analytics/recent_activity_stats.py @@ -2,13 +2,16 @@ import os import re import glob import json +from pathlib import Path from typing import Tuple try: from app.core.memory.utils.config.definitions import PROJECT_ROOT except Exception: # Fallback: derive project root from this file location - PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # 当前文件在 api/app/core/memory/analytics/recent_activity_stats.py + # 需要向上 5 级到达 api/ 目录 + PROJECT_ROOT = str(Path(__file__).resolve().parents[4]) def _get_latest_prompt_log_path() -> str | None: @@ -67,44 +70,43 @@ def parse_stats_from_log(log_path: str) -> dict: triplet_relations_count = 0 temporal_count = 0 - # Patterns + # 正则表达式模式 - 匹配当前日志格式 pat_chunk_render = re.compile(r"===\s*RENDERED\s*STATEMENT\s*EXTRACTION\s*PROMPT\s*===") - pat_triplet_start = re.compile(r"\[Triplet\].*statements_to_process\s*=\s*(\d+)") - pat_triplet_done = re.compile( - r"\[Triplet\].*completed,\s*total_triplets\s*=\s*(\d+),\s*total_entities\s*=\s*(\d+)" + pat_triplet_started = re.compile(r"\[Triplet\]\s+Started\s+-\s+statement_id=") + pat_triplet_completed = re.compile( + r"\[Triplet\]\s+Completed\s+-\s+statement_id=[^,]+,\s+triplets=(\d+),\s+entities=(\d+)" ) - pat_temporal_done = re.compile( - r"\[Temporal\].*completed,\s*extracted_valid_ranges\s*=\s*(\d+)" + pat_temporal_completed = re.compile( + r"\[Temporal\]\s+Completed\s+-\s+statement_id=[^,]+,\s+valid_ranges=(\d+)" ) with open(log_path, "r", encoding="utf-8", errors="ignore") as f: for line in f: - # Chunk prompts count (each chunk triggers one statement-extraction prompt render) + # 文本块数量(每个块触发一次陈述提取提示) if pat_chunk_render.search(line): chunk_count += 1 continue - m1 = pat_triplet_start.search(line) - if m1: + # 陈述数量(每个 Triplet Started 代表一个陈述被处理) + if pat_triplet_started.search(line): + statements_count += 1 + continue + + # 三元组完成:[Triplet] Completed - statement_id=xxx, triplets=X, entities=Y + m_triplet = pat_triplet_completed.search(line) + if m_triplet: try: - statements_count += int(m1.group(1)) + triplet_relations_count += int(m_triplet.group(1)) + triplet_entities_count += int(m_triplet.group(2)) except Exception: pass continue - m2 = pat_triplet_done.search(line) - if m2: + # 时间信息完成:[Temporal] Completed - statement_id=xxx, valid_ranges=X + m_temporal = pat_temporal_completed.search(line) + if m_temporal: try: - triplet_relations_count += int(m2.group(1)) - triplet_entities_count += int(m2.group(2)) - except Exception: - pass - continue - - m3 = pat_temporal_done.search(line) - if m3: - try: - temporal_count += int(m3.group(1)) + temporal_count += int(m_temporal.group(1)) except Exception: pass continue @@ -120,15 +122,20 @@ def parse_stats_from_log(log_path: str) -> dict: def get_recent_activity_stats() -> Tuple[dict, str]: - """Get aggregated stats from all prompt logs in logs/. + """Get stats from the latest prompt log file only. Returns (stats_dict, message). """ - all_logs = _get_all_prompt_logs() - # Fallback to recursive search if none found in logs/ - if not all_logs: + # 获取最新的日志文件 + latest_log = _get_latest_prompt_log_path() + + # 如果没有找到,尝试递归搜索 + if not latest_log: all_logs = _get_any_logs_recursive() - if not all_logs: + if all_logs: + latest_log = all_logs[-1] # 取最新的 + + if not latest_log: return ( { "chunk_count": 0, @@ -141,24 +148,13 @@ def get_recent_activity_stats() -> Tuple[dict, str]: "未找到日志文件,请确认已运行过提取流程。", ) - agg = { - "chunk_count": 0, - "statements_count": 0, - "triplet_entities_count": 0, - "triplet_relations_count": 0, - "temporal_count": 0, - } - for path in all_logs: - s = parse_stats_from_log(path) - agg["chunk_count"] += s.get("chunk_count", 0) - agg["statements_count"] += s.get("statements_count", 0) - agg["triplet_entities_count"] += s.get("triplet_entities_count", 0) - agg["triplet_relations_count"] += s.get("triplet_relations_count", 0) - agg["temporal_count"] += s.get("temporal_count", 0) - - # Attach a summary of files combined - agg["log_path"] = f"{len(all_logs)} 个日志文件,最新:{all_logs[-1]}" - return agg, "成功汇总 logs 目录中所有提示日志。" + # 只解析最新的日志文件 + stats = parse_stats_from_log(latest_log) + + # 添加日志文件路径信息 + stats["log_path"] = f"最新:{latest_log}" + + return stats, "成功读取最近一次记忆活动统计。" def _format_summary(stats: dict) -> str: diff --git a/api/app/core/memory/evaluation/locomo/locomo_test.py b/api/app/core/memory/evaluation/locomo/locomo_test.py index b5ad5820..affedd0f 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_test.py +++ b/api/app/core/memory/evaluation/locomo/locomo_test.py @@ -8,13 +8,14 @@ import sys import time from datetime import datetime, timedelta from typing import Any, Dict, List +from pathlib import Path from dotenv import load_dotenv # 1 # 添加项目根目录到路径 -current_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname(current_dir) +current_dir = Path(__file__).resolve().parent +project_root = str(current_dir.parent) if project_root not in sys.path: sys.path.insert(0, project_root) # 关键:将 src 目录置于最前,确保从当前仓库加载模块 diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py index 53c5ce19..292e7288 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py @@ -16,9 +16,10 @@ except Exception: # 确保可以找到 src 及项目根路径 import sys +from pathlib import Path -_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) -_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(_THIS_DIR))) +_THIS_DIR = Path(__file__).resolve().parent +_PROJECT_ROOT = str(_THIS_DIR.parents[2]) _SRC_DIR = os.path.join(_PROJECT_ROOT, "src") for _p in (_SRC_DIR, _PROJECT_ROOT): if _p not in sys.path: diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py index 279f4042..900cda9d 100644 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py @@ -15,9 +15,10 @@ except Exception: # 路径与模块导入保持与现有评估脚本一致 import sys +from pathlib import Path -_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) -_PROJECT_ROOT = os.path.dirname(os.path.dirname(_THIS_DIR)) +_THIS_DIR = Path(__file__).resolve().parent +_PROJECT_ROOT = str(_THIS_DIR.parents[1]) _SRC_DIR = os.path.join(_PROJECT_ROOT, "src") for _p in (_SRC_DIR, _PROJECT_ROOT): if _p not in sys.path: diff --git a/api/app/repositories/app_repository.py b/api/app/repositories/app_repository.py index 11a2ea3e..0c7ba6a4 100644 --- a/api/app/repositories/app_repository.py +++ b/api/app/repositories/app_repository.py @@ -15,9 +15,13 @@ class AppRepository: self.db = db def get_apps_by_workspace_id(self, workspace_id: uuid.UUID) -> list[App]: - """根据工作空间ID查询应用""" + """根据工作空间ID查询应用(仅返回未删除的应用)""" try: - apps = self.db.query(App).filter(App.workspace_id == workspace_id).all() + apps = ( + self.db.query(App) + .filter(App.workspace_id == workspace_id, App.is_active.is_(True)) + .all() + ) db_logger.info(f"成功查询工作空间 {workspace_id} 下的 {len(apps)} 个应用") return apps except Exception as e: @@ -26,7 +30,7 @@ class AppRepository: def get_apps_by_id(self, app_id: uuid.UUID) -> App: try: - app = self.db.query(App).filter(App.id == app_id, App.is_active == True).first() + app = self.db.query(App).filter(App.id == app_id, App.is_active.is_(True)).first() return app except Exception as e: raise diff --git a/api/app/repositories/home_page_repository.py b/api/app/repositories/home_page_repository.py index 888071ac..bcb3b622 100644 --- a/api/app/repositories/home_page_repository.py +++ b/api/app/repositories/home_page_repository.py @@ -17,24 +17,24 @@ class HomePageRepository: """获取模型统计数据""" total_models = db.query(ModelConfig).filter( ModelConfig.tenant_id == tenant_id, - ModelConfig.is_active == True + ModelConfig.is_active.is_(True) ).count() total_llm = db.query(ModelConfig).filter( ModelConfig.tenant_id == tenant_id, - ModelConfig.is_active == True, + ModelConfig.is_active.is_(True), ModelConfig.type == "llm" ).count() total_embedding = db.query(ModelConfig).filter( ModelConfig.tenant_id == tenant_id, - ModelConfig.is_active == True, + ModelConfig.is_active.is_(True), ModelConfig.type == "embedding" ).count() new_models_this_week = db.query(ModelConfig).filter( ModelConfig.tenant_id == tenant_id, - ModelConfig.is_active == True, + ModelConfig.is_active.is_(True), ModelConfig.created_at >= week_start ).count() @@ -56,12 +56,12 @@ class HomePageRepository: """获取工作空间统计数据""" active_workspaces = db.query(Workspace).filter( Workspace.tenant_id == tenant_id, - Workspace.is_active == True + Workspace.is_active.is_(True) ).count() new_workspaces_this_week = db.query(Workspace).filter( Workspace.tenant_id == tenant_id, - Workspace.is_active == True, + Workspace.is_active.is_(True), Workspace.created_at >= week_start ).count() @@ -83,7 +83,7 @@ class HomePageRepository: """获取用户统计数据""" workspace_ids = db.query(Workspace.id).filter( Workspace.tenant_id == tenant_id, - Workspace.is_active == True + Workspace.is_active.is_(True) ).subquery() total_users = db.query(EndUser).join( @@ -91,7 +91,7 @@ class HomePageRepository: EndUser.app_id == App.id ).filter( App.workspace_id.in_(workspace_ids), - App.is_active == True, + App.is_active.is_(True), App.status == "active" ).count() @@ -100,7 +100,7 @@ class HomePageRepository: EndUser.app_id == App.id ).filter( App.workspace_id.in_(workspace_ids), - App.is_active == True, + App.is_active.is_(True), App.status == "active", EndUser.created_at >= week_start ).count() @@ -123,18 +123,18 @@ class HomePageRepository: """获取应用统计数据""" workspace_ids = db.query(Workspace.id).filter( Workspace.tenant_id == tenant_id, - Workspace.is_active == True + Workspace.is_active.is_(True) ).subquery() running_apps = db.query(App).filter( App.workspace_id.in_(workspace_ids), - App.is_active == True, + App.is_active.is_(True), App.status == "active" ).count() new_apps_this_week = db.query(App).filter( App.workspace_id.in_(workspace_ids), - App.is_active == True, + App.is_active.is_(True), App.status == "active", App.created_at >= week_start ).count() @@ -158,7 +158,7 @@ class HomePageRepository: # 获取工作空间列表 workspaces = db.query(Workspace).filter( Workspace.tenant_id == tenant_id, - Workspace.is_active == True + Workspace.is_active.is_(True) ).all() workspace_ids = [ws.id for ws in workspaces] @@ -169,7 +169,7 @@ class HomePageRepository: func.count(App.id).label('count') ).filter( App.workspace_id.in_(workspace_ids), - App.is_active, + App.is_active.is_(True), App.status == "active" ).group_by(App.workspace_id).all() @@ -184,7 +184,7 @@ class HomePageRepository: EndUser.app_id == App.id ).filter( App.workspace_id.in_(workspace_ids), - App.is_active, + App.is_active.is_(True), App.status == "active" ).group_by(App.workspace_id).all() diff --git a/api/app/repositories/user_repository.py b/api/app/repositories/user_repository.py index a43c5869..b4c11aa4 100644 --- a/api/app/repositories/user_repository.py +++ b/api/app/repositories/user_repository.py @@ -68,7 +68,7 @@ class UserRepository: db_logger.debug("查询超级用户") try: - user = self.db.query(User).options(joinedload(User.tenant)).filter(User.is_active == True).filter(User.is_superuser == True).first() + user = self.db.query(User).options(joinedload(User.tenant)).filter(User.is_active.is_(True)).filter(User.is_superuser.is_(True)).first() if user: db_logger.debug(f"超级用户查询成功: {user.username}") else: @@ -82,7 +82,7 @@ class UserRepository: db_logger.debug("检查是否只有一个超级用户") try: - count = self.db.query(User).options(joinedload(User.tenant)).filter(User.is_active == True).filter(User.is_superuser == True).count() + count = self.db.query(User).options(joinedload(User.tenant)).filter(User.is_active.is_(True)).filter(User.is_superuser.is_(True)).count() return count == 1 except Exception as e: db_logger.error(f"检查超级用户数量失败: {str(e)}") diff --git a/api/app/repositories/workflow_repository.py b/api/app/repositories/workflow_repository.py index 04734640..b22673e6 100644 --- a/api/app/repositories/workflow_repository.py +++ b/api/app/repositories/workflow_repository.py @@ -33,7 +33,7 @@ class WorkflowConfigRepository: """ return self.db.query(WorkflowConfig).filter( WorkflowConfig.app_id == app_id, - WorkflowConfig.is_active == True + WorkflowConfig.is_active.is_(True) ).first() def create_or_update( diff --git a/api/app/repositories/workspace_repository.py b/api/app/repositories/workspace_repository.py index 106830be..70ed7521 100644 --- a/api/app/repositories/workspace_repository.py +++ b/api/app/repositories/workspace_repository.py @@ -103,7 +103,7 @@ class WorkspaceRepository: workspaces = ( self.db.query(Workspace) .filter(Workspace.tenant_id == user.tenant_id) - .filter(Workspace.is_active == True) + .filter(Workspace.is_active.is_(True)) .order_by(Workspace.updated_at.desc()) .all() ) @@ -115,7 +115,7 @@ class WorkspaceRepository: self.db.query(Workspace) .join(WorkspaceMember, Workspace.id == WorkspaceMember.workspace_id) .filter(WorkspaceMember.user_id == user_id) - .filter(Workspace.is_active == True) + .filter(Workspace.is_active.is_(True)) .order_by(Workspace.updated_at.desc()) .all() ) @@ -134,7 +134,7 @@ class WorkspaceRepository: workspaces = ( self.db.query(Workspace) .filter(Workspace.tenant_id == tenant_id) - .filter(Workspace.is_active == True) + .filter(Workspace.is_active.is_(True)) .all() ) db_logger.debug(f"租户工作空间查询成功: tenant_id={tenant_id}, 数量={len(workspaces)}") @@ -169,7 +169,7 @@ class WorkspaceRepository: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.user_id == user_id, WorkspaceMember.workspace_id == workspace_id, - WorkspaceMember.is_active == True, + WorkspaceMember.is_active.is_(True), ).first() if member: db_logger.debug(f"工作空间成员查询成功: user_id={user_id}, workspace_id={workspace_id}, role={member.role}") @@ -189,8 +189,8 @@ class WorkspaceRepository: .join(User, WorkspaceMember.user_id == User.id) .options(joinedload(WorkspaceMember.user), joinedload(WorkspaceMember.workspace)) .filter(WorkspaceMember.workspace_id == workspace_id) - .filter(WorkspaceMember.is_active == True) - .filter(User.is_active == True) + .filter(WorkspaceMember.is_active.is_(True)) + .filter(User.is_active.is_(True)) .all() ) db_logger.debug(f"成员列表查询成功: workspace_id={workspace_id}, 数量={len(members)}") @@ -208,8 +208,8 @@ class WorkspaceRepository: .join(User, WorkspaceMember.user_id == User.id) .options(joinedload(WorkspaceMember.user), joinedload(WorkspaceMember.workspace)) .filter(WorkspaceMember.id == member_id) - .filter(WorkspaceMember.is_active == True) - .filter(User.is_active == True) + .filter(WorkspaceMember.is_active.is_(True)) + .filter(User.is_active.is_(True)) .first() ) if member: @@ -226,7 +226,7 @@ class WorkspaceRepository: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == user_id, - WorkspaceMember.is_active == True, + WorkspaceMember.is_active.is_(True), ).first() if not member: return None @@ -243,7 +243,7 @@ class WorkspaceRepository: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.workspace_id == workspace_id, WorkspaceMember.user_id == user_id, - WorkspaceMember.is_active == True, + WorkspaceMember.is_active.is_(True), ).first() if not member: return None @@ -259,7 +259,7 @@ class WorkspaceRepository: try: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.id == member_id, - WorkspaceMember.is_active == True, + WorkspaceMember.is_active.is_(True), ).first() if not member: return None @@ -275,7 +275,7 @@ class WorkspaceRepository: try: member = self.db.query(WorkspaceMember).filter( WorkspaceMember.id == id, - WorkspaceMember.is_active == True, + WorkspaceMember.is_active.is_(True), ).first() if not member: return None diff --git a/api/app/services/agent_registry.py b/api/app/services/agent_registry.py index 2b6d92e3..d221bbf5 100644 --- a/api/app/services/agent_registry.py +++ b/api/app/services/agent_registry.py @@ -55,8 +55,8 @@ class AgentRegistry: """ # 构建查询 stmt = select(AgentConfig).join(App).where( - AgentConfig.is_active == True, - App.is_active == True + AgentConfig.is_active.is_(True), + App.is_active.is_(True) ) # 工作空间过滤(同工作空间或公开) diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index 68acab1d..7ec4bc0e 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -758,7 +758,7 @@ class AppService: ) # 构建查询条件 - filters = [App.is_active == True] + filters = [App.is_active.is_(True)] if type: filters.append(App.type == type) if visibility: @@ -873,7 +873,7 @@ class AppService: self._validate_workspace_access(app, workspace_id) - stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active == True).order_by( + stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active.is_(True)).order_by( AgentConfig.updated_at.desc()) agent_cfg: Optional[AgentConfig] = self.db.scalars(stmt).first() now = datetime.datetime.now() @@ -1204,7 +1204,7 @@ class AppService: default_model_config_id = None if app.type == AppType.AGENT: - stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active == True).order_by( + stmt = select(AgentConfig).where(AgentConfig.app_id == app_id, AgentConfig.is_active.is_(True)).order_by( AgentConfig.updated_at.desc()) agent_cfg = self.db.scalars(stmt).first() if not agent_cfg: @@ -1226,7 +1226,7 @@ class AppService: select(MultiAgentConfig) .where( MultiAgentConfig.app_id == app_id, - MultiAgentConfig.is_active == True + MultiAgentConfig.is_active.is_(True) ) .order_by(MultiAgentConfig.updated_at.desc()) ) @@ -1380,7 +1380,7 @@ class AppService: stmt = ( select(AppRelease) - .where(AppRelease.app_id == app_id, AppRelease.is_active == True) + .where(AppRelease.app_id == app_id, AppRelease.is_active.is_(True)) .order_by(AppRelease.version.desc()) ) return list(self.db.scalars(stmt).all()) diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 46bda5f6..4f20f6d9 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -728,7 +728,7 @@ class DraftRunService: select(ModelApiKey) .where( ModelApiKey.model_config_id == model_config_id, - ModelApiKey.is_active == True + ModelApiKey.is_active.is_(True) ) .order_by(ModelApiKey.priority.desc()) .limit(1) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 8170bdd8..7c8ee9ac 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -173,10 +173,9 @@ class MemoryAgentService: """ logger.info("Reading log file") - - current_file = os.path.abspath(__file__) # app/services/memory_agent_service.py - app_dir = os.path.dirname(os.path.dirname(current_file)) # app directory - project_root = os.path.dirname(app_dir) # redbear-mem directory + # Get log file path - use project root directory + from pathlib import Path + project_root = str(Path(__file__).resolve().parents[2]) # api directory log_path = os.path.join(project_root, "logs", "agent_service.log") summer = '' @@ -215,9 +214,8 @@ class MemoryAgentService: logger.info("Starting log content streaming") # Get log file path - use project root directory - current_file = os.path.abspath(__file__) # app/services/memory_agent_service.py - app_dir = os.path.dirname(os.path.dirname(current_file)) # app directory - project_root = os.path.dirname(app_dir) # redbear-mem directory + from pathlib import Path + project_root = str(Path(__file__).resolve().parents[2]) # api directory log_path = os.path.join(project_root, "logs", "agent_service.log") # Check if file exists before starting stream @@ -1079,9 +1077,8 @@ class MemoryAgentService: logger.info("Starting log content streaming") # Get log file path - use project root directory - current_file = os.path.abspath(__file__) # app/services/memory_agent_service.py - app_dir = os.path.dirname(os.path.dirname(current_file)) # app directory - project_root = os.path.dirname(app_dir) # redbear-mem directory + from pathlib import Path + project_root = str(Path(__file__).resolve().parents[2]) # api directory log_path = os.path.join(project_root, "logs", "agent_service.log") # Check if file exists before starting stream diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index 0ae2b965..2d3d047e 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -77,7 +77,10 @@ class MemoryAPIService: ) # Verify end_user belongs to the workspace via App relationship - app = self.db.query(App).filter(App.id == end_user.app_id).first() + app = self.db.query(App).filter( + App.id == end_user.app_id, + App.is_active.is_(True) + ).first() if not app: logger.warning(f"App not found for end_user: {end_user_id}") diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index 46e42b46..af72e3cc 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -38,7 +38,10 @@ class WorkspaceAppService: Returns: Dictionary containing detailed application information """ - apps = self.db.query(App).filter(App.workspace_id == workspace_id).all() + apps = self.db.query(App).filter( + App.workspace_id == workspace_id, + App.is_active.is_(True) + ).all() app_ids = [str(app.id) for app in apps] apps_detailed_info = [] diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 83d5923d..48c3abf1 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -237,7 +237,8 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) ValueError: 当配置无效或参数缺失时 RuntimeError: 当管线执行失败时 """ - project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from pathlib import Path + project_root = str(Path(__file__).resolve().parents[2]) try: # 发出初始进度事件 diff --git a/api/app/services/multi_agent_orchestrator.py b/api/app/services/multi_agent_orchestrator.py index 1972f344..4bcd28cd 100644 --- a/api/app/services/multi_agent_orchestrator.py +++ b/api/app/services/multi_agent_orchestrator.py @@ -2548,7 +2548,7 @@ class MultiAgentOrchestrator: # 获取 API Key 配置 api_key_config = self.db.query(ModelApiKey).filter( ModelApiKey.model_config_id == default_model_config_id, - ModelApiKey.is_active == True + ModelApiKey.is_active.is_(True) ).first() if not api_key_config: @@ -2705,7 +2705,7 @@ class MultiAgentOrchestrator: # 获取 API Key 配置 api_key_config = self.db.query(ModelApiKey).filter( ModelApiKey.model_config_id == default_model_config_id, - ModelApiKey.is_active == True + ModelApiKey.is_active.is_(True) ).first() if not api_key_config: diff --git a/api/app/services/multi_agent_service.py b/api/app/services/multi_agent_service.py index 1a08a5af..da984d16 100644 --- a/api/app/services/multi_agent_service.py +++ b/api/app/services/multi_agent_service.py @@ -74,7 +74,7 @@ class MultiAgentService: select(MultiAgentConfig) .where( MultiAgentConfig.app_id == app_id, - MultiAgentConfig.is_active == True + MultiAgentConfig.is_active.is_(True) ) .order_by(MultiAgentConfig.updated_at.desc()) ).first() @@ -144,7 +144,7 @@ class MultiAgentService: select(MultiAgentConfig) .where( MultiAgentConfig.app_id == app_id, - MultiAgentConfig.is_active == True + MultiAgentConfig.is_active.is_(True) ) .order_by(MultiAgentConfig.updated_at.desc()) ).first() diff --git a/api/app/services/shared_chat_service.py b/api/app/services/shared_chat_service.py index e5247e5e..5eee5edc 100644 --- a/api/app/services/shared_chat_service.py +++ b/api/app/services/shared_chat_service.py @@ -168,7 +168,7 @@ class SharedChatService: select(ModelApiKey) .where( ModelApiKey.model_config_id == model_config_id, - ModelApiKey.is_active == True + ModelApiKey.is_active.is_(True) ) .order_by(ModelApiKey.priority.desc()) .limit(1) @@ -362,7 +362,7 @@ class SharedChatService: select(ModelApiKey) .where( ModelApiKey.model_config_id == model_config_id, - ModelApiKey.is_active == True + ModelApiKey.is_active.is_(True) ) .order_by(ModelApiKey.priority.desc()) .limit(1) @@ -598,7 +598,7 @@ class SharedChatService: # 获取多 Agent 配置 multi_agent_config = self.db.query(MultiAgentConfig).filter( MultiAgentConfig.app_id == release.app_id, - MultiAgentConfig.is_active == True + MultiAgentConfig.is_active.is_(True) ).first() if not multi_agent_config: @@ -695,7 +695,7 @@ class SharedChatService: # 获取多 Agent 配置 multi_agent_config = self.db.query(MultiAgentConfig).filter( MultiAgentConfig.app_id == release.app_id, - MultiAgentConfig.is_active == True + MultiAgentConfig.is_active.is_(True) ).first() if not multi_agent_config: diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index b7d5df02..f9426c87 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -761,7 +761,10 @@ class WorkflowService: # 4. 获取工作空间 ID(从 app 获取) from app.models import App - app = self.db.query(App).filter(App.id == app_id).first() + app = self.db.query(App).filter( + App.id == app_id, + App.is_active.is_(True) + ).first() if not app: raise BusinessException( code=BizCode.NOT_FOUND, diff --git a/api/app/tasks.py b/api/app/tasks.py index fa9d1fdf..5f2b1ef5 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -635,8 +635,11 @@ def write_total_memory_task(workspace_id: str) -> Dict[str, Any]: try: workspace_uuid = uuid.UUID(workspace_id) - # 1. 查询当前workspace下的所有app - apps = db.query(App).filter(App.workspace_id == workspace_uuid).all() + # 1. 查询当前workspace下的所有app(仅未删除的) + apps = db.query(App).filter( + App.workspace_id == workspace_uuid, + App.is_active.is_(True) + ).all() if not apps: # 如果没有app,总量为0 diff --git a/api/migrations/env.py b/api/migrations/env.py index 95d74019..e4cd6dfb 100644 --- a/api/migrations/env.py +++ b/api/migrations/env.py @@ -46,7 +46,8 @@ def import_all_models_from_package(package_name: str): # Add the project root to sys.path if not already there # This is crucial for relative imports like 'app.db' to work - project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + from pathlib import Path + project_root = str(Path(__file__).resolve().parent.parent) if project_root not in sys.path: sys.path.insert(0, project_root) From 6e18c92a130ceeb4c158bbeb9a267376d88ab74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:21:28 +0800 Subject: [PATCH 045/175] Fix/optimize inerface (#183) * [changes]Optimize the time consumption of the "/end_users" interface * [fix]Optimize the time consumption of the "/hot_memory_tags" interface * [changes]Optimize the time consumption of the "/end_users" interface * [fix]Optimize the time consumption of the "/hot_memory_tags" interface * [changes]Improve the code based on AI review --- .../memory_dashboard_controller.py | 146 +++++++++++++----- .../controllers/memory_storage_controller.py | 82 +++++++++- api/app/services/memory_dashboard_service.py | 87 ++++++++++- api/app/services/memory_storage_service.py | 86 +++++++++-- 4 files changed, 340 insertions(+), 61 deletions(-) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index e03c1846..6181c319 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -49,63 +49,135 @@ async def get_workspace_end_users( current_user: User = Depends(get_current_user), ): """ - 获取工作空间的宿主列表 + 获取工作空间的宿主列表(高性能优化版本 v2) - 返回格式与原 memory_list 接口中的 end_users 字段相同, - 并包含每个用户的记忆配置信息(memory_config_id 和 memory_config_name) + 优化策略: + 1. 批量查询 end_users(一次查询而非循环) + 2. 并发查询所有用户的记忆数量(Neo4j) + 3. RAG 模式使用批量查询(一次 SQL) + 4. 只返回必要字段减少数据传输 + 5. 添加短期缓存减少重复查询 + 6. 并发执行配置查询和记忆数量查询 + + 返回格式: + { + "end_user": {"id": "uuid", "other_name": "名称"}, + "memory_num": {"total": 数量}, + "memory_config": {"memory_config_id": "id", "memory_config_name": "名称"} + } """ + import asyncio + import json + from app.aioRedis import aio_redis_get, aio_redis_set + workspace_id = current_user.current_workspace_id + + # 尝试从缓存获取(30秒缓存) + cache_key = f"end_users:workspace:{workspace_id}" + try: + cached_data = await aio_redis_get(cache_key) + if cached_data: + api_logger.info(f"从缓存获取宿主列表: workspace_id={workspace_id}") + return success(data=json.loads(cached_data), msg="宿主列表获取成功") + except Exception as e: + api_logger.warning(f"Redis 缓存读取失败: {str(e)}") + # 获取当前空间类型 current_workspace_type = memory_dashboard_service.get_current_workspace_type(db, workspace_id, current_user) api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的宿主列表") + + # 获取 end_users(已优化为批量查询) end_users = memory_dashboard_service.get_workspace_end_users( db=db, workspace_id=workspace_id, current_user=current_user ) - # 批量获取所有用户的记忆配置信息(优化:一次查询而非 N 次) - end_user_ids = [str(user.id) for user in end_users] - memory_configs_map = {} - if end_user_ids: + if not end_users: + api_logger.info("工作空间下没有宿主") + # 缓存空结果,避免重复查询 try: - memory_configs_map = get_end_users_connected_configs_batch(end_user_ids, db) + await aio_redis_set(cache_key, json.dumps([]), expire=30) + except Exception as e: + api_logger.warning(f"Redis 缓存写入失败: {str(e)}") + return success(data=[], msg="宿主列表获取成功") + + end_user_ids = [str(user.id) for user in end_users] + + # 并发执行两个独立的查询任务 + async def get_memory_configs(): + """获取记忆配置(在线程池中执行同步查询)""" + try: + return await asyncio.to_thread( + get_end_users_connected_configs_batch, + end_user_ids, db + ) except Exception as e: api_logger.error(f"批量获取记忆配置失败: {str(e)}") - # 失败时使用空字典,不影响其他数据返回 + return {} + async def get_memory_nums(): + """获取记忆数量""" + if current_workspace_type == "rag": + # RAG 模式:批量查询 + try: + chunk_map = await asyncio.to_thread( + memory_dashboard_service.get_users_total_chunk_batch, + end_user_ids, db, current_user + ) + return {uid: {"total": count} for uid, count in chunk_map.items()} + except Exception as e: + api_logger.error(f"批量获取 RAG chunk 数量失败: {str(e)}") + return {uid: {"total": 0} for uid in end_user_ids} + + elif current_workspace_type == "neo4j": + # Neo4j 模式:并发查询(带并发限制) + # 使用信号量限制并发数,避免大量用户时压垮 Neo4j + MAX_CONCURRENT_QUERIES = 10 + semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES) + + async def get_neo4j_memory_num(end_user_id: str): + async with semaphore: + try: + return await memory_storage_service.search_all(end_user_id) + except Exception as e: + api_logger.error(f"获取用户 {end_user_id} Neo4j 记忆数量失败: {str(e)}") + return {"total": 0} + + memory_nums_list = await asyncio.gather(*[get_neo4j_memory_num(uid) for uid in end_user_ids]) + return {end_user_ids[i]: memory_nums_list[i] for i in range(len(end_user_ids))} + + return {uid: {"total": 0} for uid in end_user_ids} + + # 并发执行配置查询和记忆数量查询 + memory_configs_map, memory_nums_map = await asyncio.gather( + get_memory_configs(), + get_memory_nums() + ) + + # 构建结果(优化:使用列表推导式) result = [] for end_user in end_users: - memory_num = {} - if current_workspace_type == "neo4j": - # EndUser 是 Pydantic 模型,直接访问属性而不是使用 .get() - memory_num = await memory_storage_service.search_all(str(end_user.id)) - elif current_workspace_type == "rag": - memory_num = { - "total":memory_dashboard_service.get_current_user_total_chunk(str(end_user.id), db, current_user) - } - - # 从批量查询结果中获取配置信息 user_id = str(end_user.id) - memory_config_info = memory_configs_map.get(user_id, { - "memory_config_id": None, - "memory_config_name": None - }) - - # 只保留需要的字段,移除 error 字段(如果有) - memory_config = { - "memory_config_id": memory_config_info.get("memory_config_id"), - "memory_config_name": memory_config_info.get("memory_config_name") - } - - result.append( - { - 'end_user': end_user, - 'memory_num': memory_num, - 'memory_config': memory_config + config_info = memory_configs_map.get(user_id, {}) + result.append({ + 'end_user': { + 'id': user_id, + 'other_name': end_user.other_name + }, + 'memory_num': memory_nums_map.get(user_id, {"total": 0}), + 'memory_config': { + "memory_config_id": config_info.get("memory_config_id"), + "memory_config_name": config_info.get("memory_config_name") } - ) - + }) + + # 写入缓存(30秒过期) + try: + await aio_redis_set(cache_key, json.dumps(result), expire=30) + except Exception as e: + api_logger.warning(f"Redis 缓存写入失败: {str(e)}") + api_logger.info(f"成功获取 {len(end_users)} 个宿主记录") return success(data=result, msg="宿主列表获取成功") diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index f4175923..3722be3d 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -420,15 +420,95 @@ async def get_hot_memory_tags_api( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> dict: - api_logger.info(f"Hot memory tags requested for current_user: {current_user.id}") + """ + 获取热门记忆标签(带Redis缓存) + + 缓存策略: + - 缓存键:workspace_id + limit + - 过期时间:5分钟(300秒) + - 缓存命中:~50ms + - 缓存未命中:~600-800ms(取决于LLM速度) + """ + workspace_id = current_user.current_workspace_id + + # 构建缓存键 + cache_key = f"hot_memory_tags:{workspace_id}:{limit}" + + api_logger.info(f"Hot memory tags requested for workspace: {workspace_id}, limit: {limit}") + try: + # 尝试从Redis缓存获取 + from app.aioRedis import aio_redis_get, aio_redis_set + import json + + cached_result = await aio_redis_get(cache_key) + if cached_result: + api_logger.info(f"Cache hit for key: {cache_key}") + try: + data = json.loads(cached_result) + return success(data=data, msg="查询成功(缓存)") + except json.JSONDecodeError: + api_logger.warning(f"Failed to parse cached data, will refresh") + + # 缓存未命中,执行查询 + api_logger.info(f"Cache miss for key: {cache_key}, executing query") result = await analytics_hot_memory_tags(db, current_user, limit) + + # 写入缓存(过期时间:5分钟) + # 注意:result是列表,需要转换为JSON字符串 + try: + cache_data = json.dumps(result, ensure_ascii=False) + await aio_redis_set(cache_key, cache_data, expire=300) + api_logger.info(f"Cached result for key: {cache_key}") + except Exception as cache_error: + # 缓存写入失败不影响主流程 + api_logger.warning(f"Failed to cache result: {str(cache_error)}") + return success(data=result, msg="查询成功") + except Exception as e: api_logger.error(f"Hot memory tags failed: {str(e)}") return fail(BizCode.INTERNAL_ERROR, "热门标签查询失败", str(e)) +@router.delete("/analytics/hot_memory_tags/cache", response_model=ApiResponse) +async def clear_hot_memory_tags_cache( + current_user: User = Depends(get_current_user), + ) -> dict: + """ + 清除热门标签缓存 + + 用于: + - 手动刷新数据 + - 调试和测试 + - 数据更新后立即生效 + """ + workspace_id = current_user.current_workspace_id + + api_logger.info(f"Clear hot memory tags cache requested for workspace: {workspace_id}") + + try: + from app.aioRedis import aio_redis_delete + + # 清除所有limit的缓存(常见的limit值) + cleared_count = 0 + for limit in [5, 10, 15, 20, 30, 50]: + cache_key = f"hot_memory_tags:{workspace_id}:{limit}" + result = await aio_redis_delete(cache_key) + if result: + cleared_count += 1 + api_logger.info(f"Cleared cache for key: {cache_key}") + + return success( + data={"cleared_count": cleared_count}, + msg=f"成功清除 {cleared_count} 个缓存" + ) + + except Exception as e: + api_logger.error(f"Clear cache failed: {str(e)}") + return fail(BizCode.INTERNAL_ERROR, "清除缓存失败", str(e)) + + @router.get("/analytics/recent_activity_stats", response_model=ApiResponse) async def get_recent_activity_stats_api( current_user: User = Depends(get_current_user), diff --git a/api/app/services/memory_dashboard_service.py b/api/app/services/memory_dashboard_service.py index a774647e..06a94060 100644 --- a/api/app/services/memory_dashboard_service.py +++ b/api/app/services/memory_dashboard_service.py @@ -53,18 +53,28 @@ def get_workspace_end_users( workspace_id: uuid.UUID, current_user: User ) -> List[EndUser]: - """获取工作空间的所有宿主""" + """获取工作空间的所有宿主(优化版本:减少数据库查询次数)""" business_logger.info(f"获取工作空间宿主列表: workspace_id={workspace_id}, 操作者: {current_user.username}") try: - # 查询应用(ORM)并转换为 Pydantic 模型 + # 查询应用(ORM) apps_orm = app_repository.get_apps_by_workspace_id(db, workspace_id) - apps = [AppSchema.model_validate(h) for h in apps_orm] - app_ids = [app.id for app in apps] - end_users = [] - for app_id in app_ids: - end_user_orm_list = end_user_repository.get_end_users_by_app_id(db, app_id) - end_users.extend([EndUserSchema.model_validate(h) for h in end_user_orm_list]) + + if not apps_orm: + business_logger.info("工作空间下没有应用") + return [] + + # 提取所有 app_id + app_ids = [app.id for app in apps_orm] + + # 批量查询所有 end_users(一次查询而非循环查询) + from app.models.end_user_model import EndUser as EndUserModel + end_users_orm = db.query(EndUserModel).filter( + EndUserModel.app_id.in_(app_ids) + ).all() + + # 转换为 Pydantic 模型(只在需要时转换) + end_users = [EndUserSchema.model_validate(eu) for eu in end_users_orm] business_logger.info(f"成功获取 {len(end_users)} 个宿主记录") return end_users @@ -414,6 +424,67 @@ def get_current_user_total_chunk( business_logger.error(f"获取用户总chunk数失败: end_user_id={end_user_id} - {str(e)}") raise + +def get_users_total_chunk_batch( + end_user_ids: List[str], + db: Session, + current_user: User +) -> dict: + """ + 批量获取多个用户的总chunk数(性能优化版本) + + Args: + end_user_ids: 用户ID列表 + db: 数据库会话 + current_user: 当前用户 + + Returns: + 字典,key为end_user_id,value为chunk总数 + 格式: {"user_id_1": 100, "user_id_2": 50, ...} + """ + business_logger.info(f"批量获取 {len(end_user_ids)} 个用户的总chunk数, 操作者: {current_user.username}") + + try: + from app.models.document_model import Document + from sqlalchemy import func, case + + if not end_user_ids: + return {} + + # 构造所有文件名 + file_names = [f"{user_id}.txt" for user_id in end_user_ids] + + # 一次查询获取所有用户的chunk总数 + # 使用 GROUP BY file_name 来分组统计 + results = db.query( + Document.file_name, + func.sum(Document.chunk_num).label('total_chunk') + ).filter( + Document.file_name.in_(file_names) + ).group_by( + Document.file_name + ).all() + + # 构建结果字典 + chunk_map = {} + for file_name, total_chunk in results: + # 从文件名中提取 end_user_id (去掉 .txt 后缀) + user_id = file_name.replace('.txt', '') + chunk_map[user_id] = int(total_chunk or 0) + + # 对于没有记录的用户,设置为0 + for user_id in end_user_ids: + if user_id not in chunk_map: + chunk_map[user_id] = 0 + + business_logger.info(f"成功批量获取 {len(chunk_map)} 个用户的总chunk数") + return chunk_map + + except Exception as e: + business_logger.error(f"批量获取用户总chunk数失败: {str(e)}") + raise + + def get_rag_content( end_user_id: str, limit: int, diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 48c3abf1..c276f337 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -12,7 +12,11 @@ from datetime import datetime from typing import Any, AsyncGenerator, Dict, List, Optional from app.core.logging_config import get_config_logger, get_logger -from app.core.memory.analytics.hot_memory_tags import get_hot_memory_tags +from app.core.memory.analytics.hot_memory_tags import ( + get_hot_memory_tags, + get_raw_tags_from_db, + filter_tags_with_llm, +) from app.core.memory.analytics.recent_activity_stats import get_recent_activity_stats from app.models.user_model import User from app.repositories.data_config_repository import DataConfigRepository @@ -515,27 +519,79 @@ async def analytics_hot_memory_tags( ) -> List[Dict[str, Any]]: """ 获取热门记忆标签,按数量排序并返回前N个 + + 优化策略: + 1. 先从所有用户收集原始标签(不调用LLM) + 2. 聚合并合并相同标签的频率 + 3. 排序后取前N个 + 4. 只调用一次LLM进行筛选 """ workspace_id = current_user.current_workspace_id # 获取更多标签供LLM筛选(获取limit*4个标签) raw_limit = limit * 4 from app.services.memory_dashboard_service import get_workspace_end_users - end_users = get_workspace_end_users(db, workspace_id, current_user) + # 使用 asyncio.to_thread 避免阻塞事件循环 + end_users = await asyncio.to_thread(get_workspace_end_users, db, workspace_id, current_user) - tags = [] - for end_user in end_users: - tag = await get_hot_memory_tags(str(end_user.id), limit=raw_limit) - if tag: - # 将每个用户的标签列表展平到总列表中 - tags.extend(tag) - - # 按频率降序排序(虽然数据库已经排序,但为了确保正确性再次排序) - sorted_tags = sorted(tags, key=lambda x: x[1], reverse=True) + if not end_users: + return [] - # 只返回前limit个 - top_tags = sorted_tags[:limit] - - return [{"name": t, "frequency": f} for t, f in top_tags] + # 步骤1: 收集所有用户的原始标签(不调用LLM) + connector = Neo4jConnector() + try: + all_raw_tags = [] + for end_user in end_users: + raw_tags = await get_raw_tags_from_db( + connector, + str(end_user.id), + limit=raw_limit, + by_user=False + ) + if raw_tags: + all_raw_tags.extend(raw_tags) + + if not all_raw_tags: + return [] + + # 步骤2: 聚合相同标签的频率 + tag_frequency_map = {} + for tag_name, frequency in all_raw_tags: + if tag_name in tag_frequency_map: + tag_frequency_map[tag_name] += frequency + else: + tag_frequency_map[tag_name] = frequency + + # 步骤3: 按频率降序排序,取前raw_limit个 + sorted_tags = sorted( + tag_frequency_map.items(), + key=lambda x: x[1], + reverse=True + )[:raw_limit] + + if not sorted_tags: + return [] + + # 步骤4: 只调用一次LLM进行筛选 + tag_names = [tag for tag, _ in sorted_tags] + + # 使用第一个用户的group_id来获取LLM配置 + # 因为同一工作空间下的用户应该使用相同的配置 + first_end_user_id = str(end_users[0].id) + filtered_tag_names = await filter_tags_with_llm(tag_names, first_end_user_id) + + # 步骤5: 根据LLM筛选结果构建最终列表(保留频率) + final_tags = [] + for tag, freq in sorted_tags: + if tag in filtered_tag_names: + final_tags.append((tag, freq)) + + # 步骤6: 只返回前limit个 + top_tags = final_tags[:limit] + + return [{"name": t, "frequency": f} for t, f in top_tags] + + finally: + await connector.close() async def analytics_recent_activity_stats() -> Dict[str, Any]: From 15f9c49418c93bd1521fd64baddc11f63b3ee6c9 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:21:54 +0800 Subject: [PATCH 046/175] Fix/memory mcp2 1 (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化快速检索的回复内容 * 优化快速检索的回复内容 --- .../controllers/memory_agent_controller.py | 2 + .../utils/prompt/direct_summary_prompt.jinja2 | 61 +++++++++++++++++++ api/app/services/memory_agent_service.py | 2 +- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 api/app/core/memory/agent/utils/prompt/direct_summary_prompt.jinja2 diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 78a5771f..8b5a55b9 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -306,6 +306,8 @@ async def read_server( config_id=config_id, db=db ) + if "信息不足,无法回答" in result['answer']: + result['answer']=retrieve_info return success(data=result, msg="回复对话消息成功") except BaseException as e: # Handle ExceptionGroup from TaskGroup (Python 3.11+) or BaseExceptionGroup diff --git a/api/app/core/memory/agent/utils/prompt/direct_summary_prompt.jinja2 b/api/app/core/memory/agent/utils/prompt/direct_summary_prompt.jinja2 new file mode 100644 index 00000000..1e0690bf --- /dev/null +++ b/api/app/core/memory/agent/utils/prompt/direct_summary_prompt.jinja2 @@ -0,0 +1,61 @@ +# 角色 +你是一个智能问答助手,基于检索信息和历史对话回答用户问题。 +# 任务 +根据提供的上下文信息回答用户的问题。 +# 输入信息 +- 历史对话:{{history}} +- 检索信息:{{retrieve_info}} +# 用户问题 +{{query}} +# 回答指南 +## 1. 仔细阅读检索信息 +- 答案可能直接或间接地出现在检索信息中 +- 如果检索信息中提到"小曼会使用Python",说明用户名是"小曼" +- 第三人称描述的偏好、行为通常指用户本人 + +## 2. 判断信息相关性 +**情况A:信息匹配问题** +- 直接回答,像自然对话一样 +- 例:检索到"小曼会使用Python" → 问"我叫什么" → 答"你叫小曼" + +**情况B:信息部分相关** +- 先回答已知部分,再自然地询问更多信息 +- 例:检索到"用户去过上海的面包店" → 问"我吃过哪家面包" → 答"我记得你去过上海的面包店,但具体是哪家我不太清楚,是哪家呢?" + +**情况C:信息完全不相关** +- 自然地表达不知道,但可以提及检索到的相关信息,让对话更连贯 +- 使用友好的表达: + - "你好像没和我说过...,但是我知道你[检索到的相关信息]" + - "关于这个我不太清楚,不过我记得你[检索到的相关信息],能告诉我更多吗?" + - "我不记得你提到过...,但你[检索到的相关信息]" +- 即使检索信息不直接回答问题,也可以自然地融入对话中 +- 避免僵硬的"信息不足,无法回答" +## 3. 回答要求 +- 像人类对话一样自然流畅 +- 不要提及"检索信息"、"搜索结果"、"根据资料"等技术术语 +- 不要解释推理过程或引用信息来源 +- 保持友好、乐于助人的语气 +- 使用与问题相同的语言回答 +# 关键示例 +**示例1 - 直接匹配:** +- 检索信息:"小曼会使用Python..." +- 问题:"我叫什么" +- ✓ 正确:"你叫小曼" +- ✗ 错误:"你没有告诉我你的名字" +**示例2 - 间接匹配:** +- 检索信息:"用户很喜欢吃星巴克的甜品" +- 问题:"我喜欢什么" +- ✓ 正确:"你很喜欢吃星巴克的甜品" +- ✗ 错误:"信息不足" +**示例3 - 信息不匹配(推荐做法):** +- 检索信息:"用户只喝拿铁咖啡,认为美式咖啡太苦" +- 问题:"我吃过哪家面包" +- ✓ 最佳:"你好像没和我说过吃过哪家面包,但是我知道你喜欢喝拿铁,能跟我分享一下吗?" +- ✓ 可以:"你好像没和我说过吃过哪家面包,能跟我分享一下吗?" +- ✗ 错误:"用户只喝拿铁咖啡,认为美式咖啡太苦。"(答非所问) +- ✗ 错误:"信息不足,无法回答。"(太僵硬) +# 重要提醒 +- 检索信息中描述用户行为/偏好时提到的名字,就是用户的名字 +- 信息不匹配时,不要强行回答无关内容,但可以自然地提及检索到的信息,让对话更有温度 +- 用对话式语言表达"不知道",而非机械模板 +- 检索信息代表你对用户的了解,即使不直接回答问题,也能体现你对用户的记忆 diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 7c8ee9ac..a24456d2 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -729,7 +729,7 @@ class MemoryAgentService: state=state, history=history, retrieve_info=retrieve_info, - template_name='Retrieve_Summary_prompt.jinja2', + template_name='direct_summary_prompt.jinja2', operation_name='retrieve_summary', response_model=RetrieveSummaryResponse, search_mode="1" From 86812b34d1813373ea04fa50ffc0f79bc1d82c9c Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:57:27 +0800 Subject: [PATCH 047/175] Fix/memory mcp2 1 (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化快速检索的回复内容 * 优化快速检索的回复内容 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 --- .../core/memory/agent/langgraph_graph/nodes/problem_nodes.py | 2 +- .../core/memory/agent/langgraph_graph/nodes/summary_nodes.py | 2 +- .../memory/agent/langgraph_graph/nodes/verification_nodes.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index 697a13bd..2bad650a 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -14,7 +14,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin -template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') +template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') db_session = next(get_db()) logger = get_agent_logger(__name__) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index 44f89c6a..f05a5ae1 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -19,7 +19,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.db import get_db -template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') +template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') logger = get_agent_logger(__name__) db_session = next(get_db()) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index dac7ea14..10ce8db4 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -12,7 +12,7 @@ from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService from app.core.memory.agent.services.optimized_llm_service import LLMServiceMixin -template_root = os.path.join(PROJECT_ROOT_, 'agent', 'utils', 'prompt') +template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') db_session = next(get_db()) logger = get_agent_logger(__name__) From 313f19eba4940c381b36cde243846254c356f19a Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:49:44 +0800 Subject: [PATCH 048/175] Fix/memory mcp2 1 (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化快速检索的回复内容 * 优化快速检索的回复内容 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * LLM生存缺少config_id认证,修复BUG * LLM生存缺少config_id认证,修复BUG * LLM生存缺少config_id认证,修复BUG --- .../controllers/memory_agent_controller.py | 3 +-- api/app/services/memory_agent_service.py | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 8b5a55b9..c54fb02b 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -262,9 +262,7 @@ async def read_server( """ config_id = user_input.config_id workspace_id = current_user.current_workspace_id - api_logger.info(f"Read service: workspace_id={workspace_id}, config_id={config_id}") - # 获取 storage_type,如果为 None 则使用默认值 storage_type = workspace_service.get_workspace_storage_type( db=db, workspace_id=workspace_id, @@ -300,6 +298,7 @@ async def read_server( # 调用 memory_agent_service 的方法生成最终答案 result['answer'] = await memory_agent_service.generate_summary_from_retrieve( + group_id=user_input.group_id, retrieve_info=retrieve_info, history=history, query=query, diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index a24456d2..83b6bdd7 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -410,8 +410,8 @@ class MemoryAgentService: # Resolve config_id if None using end_user's connected config if config_id is None: try: - connected_config = get_end_user_connected_config(group_id, db) - config_id = connected_config.get("memory_config_id") + config_id = get_end_user_connected_config(group_id, db) + config_id=config_id.get('memory_config_id') if config_id is None: raise ValueError(f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") except Exception as e: @@ -670,6 +670,8 @@ class MemoryAgentService: """ logger.info("Classifying message type") + + # Load configuration to get LLM model ID config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config( @@ -683,6 +685,7 @@ class MemoryAgentService: async def generate_summary_from_retrieve( self, + group_id: str, retrieve_info: str, history: List[Dict], query: str, @@ -704,6 +707,18 @@ class MemoryAgentService: Returns: 生成的答案文本 """ + if config_id is None: + try: + config_id = get_end_user_connected_config(group_id, db) + config_id = config_id.get('memory_config_id') + if config_id is None: + raise ValueError( + f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + except Exception as e: + if "No memory configuration found" in str(e): + raise # Re-raise our specific error + logger.error(f"Failed to get connected config for end_user {group_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") logger.info(f"Generating summary from retrieve info for query: {query[:50]}...") try: @@ -713,7 +728,6 @@ class MemoryAgentService: config_id=config_id, service_name="MemoryAgentService" ) - # 导入必要的模块 from app.core.memory.agent.langgraph_graph.nodes.summary_nodes import summary_llm from app.core.memory.agent.models.summary_models import RetrieveSummaryResponse From 1aabaff1f2c696274bd90d513a2e015768d5ef89 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 15:00:09 +0800 Subject: [PATCH 049/175] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_agent_controller.py | 2 +- api/app/services/memory_agent_service.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index dafb522b..1958d6be 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -298,7 +298,7 @@ async def read_server( # 调用 memory_agent_service 的方法生成最终答案 result['answer'] = await memory_agent_service.generate_summary_from_retrieve( - group_id=user_input.group_id, + end_user_id=user_input.group_id, retrieve_info=retrieve_info, history=history, query=query, diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index ae7d5fb5..661c3067 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -682,7 +682,6 @@ class MemoryAgentService: status = await status_typle(message, memory_config.llm_model_id) logger.debug(f"Message type: {status}") return status - async def generate_summary_from_retrieve( self, end_user_id: str, From c44712167f1a57ecaf7b7a9954baf78fee727d7a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 15:03:39 +0800 Subject: [PATCH 050/175] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_agent_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 1958d6be..61b16d9e 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -298,7 +298,7 @@ async def read_server( # 调用 memory_agent_service 的方法生成最终答案 result['answer'] = await memory_agent_service.generate_summary_from_retrieve( - end_user_id=user_input.group_id, + end_user_id=user_input.end_user_id, retrieve_info=retrieve_info, history=history, query=query, From c115bcde545c03f61d9b2c6853b8e017a010e80d Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 23 Jan 2026 16:58:55 +0800 Subject: [PATCH 051/175] feat(home page): version description update --- api/app/core/config.py | 2 +- .../core/tools/builtin/baidu_search_tool.py | 4 +-- api/app/version_info.json | 30 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/api/app/core/config.py b/api/app/core/config.py index 59c6ff5f..3be6f849 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -184,7 +184,7 @@ class Settings: ENABLE_TOOL_MANAGEMENT: bool = os.getenv("ENABLE_TOOL_MANAGEMENT", "true").lower() == "true" # official environment system version - SYSTEM_VERSION: str = os.getenv("SYSTEM_VERSION", "v0.2.0") + SYSTEM_VERSION: str = os.getenv("SYSTEM_VERSION", "v0.2.1") # workflow config WORKFLOW_NODE_TIMEOUT: int = int(os.getenv("WORKFLOW_NODE_TIMEOUT", 600)) diff --git a/api/app/core/tools/builtin/baidu_search_tool.py b/api/app/core/tools/builtin/baidu_search_tool.py index 02431aed..45d4c359 100644 --- a/api/app/core/tools/builtin/baidu_search_tool.py +++ b/api/app/core/tools/builtin/baidu_search_tool.py @@ -16,7 +16,7 @@ class BaiduSearchTool(BuiltinTool): @property def description(self) -> str: - return "百度搜索 - 搜索引擎服务:网页搜索、新闻搜索、图片搜索、实时结果" + return "百度搜索 - 搜索引擎服务:网页搜索、新闻搜索、图片搜索、视频搜索" def get_required_config_parameters(self) -> List[str]: return ["api_key"] @@ -33,7 +33,7 @@ class BaiduSearchTool(BuiltinTool): ToolParameter( name="search_type", type=ParameterType.STRING, - description="搜索类型", + description="搜索类型, web: 网页搜索;news:新闻搜索;image:图片搜索;video视频搜索", required=False, default="web", enum=["web", "news", "image", "video"] diff --git a/api/app/version_info.json b/api/app/version_info.json index 20896845..bee52989 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,34 @@ { + "v0.2.1": { + "introduction": { + "codeName": "启知", + "releaseDate": "2026-1-23", + "upgradePosition": "\uD83D\uDC3B 本次更新主要优化使用体验和修复已知问题,让系统更稳定、更好用。", + "coreUpgrades": [ + "1. 工作流更好用了\n* 界面更清晰,一眼看懂怎么配置\n* 新增节点输出变量展示,方便其他节点引用\n* 修复了几个影响体验的bug", + "2. 智能体配置更简单\n* 提示词和变量联动更顺畅\n* 配置界面重新整理,找功能更方便", + "3. 记忆系统更稳定\n* 优化了情绪记忆和隐性记忆的缓存更新\n* 修复了记忆配置页面的报错问题\n* 现在能自动识别用户和AI的身份了", + "4. 知识库体验提升\n* 修复了文档解析异常的问题\n* 上传文档时能看到处理进度了\n* 取消了操作也不会报错了", + "5. 系统整体更可靠\n* 修复了新用户访问跳转问题\n* 流式接口更稳定,长对话不断线\n* 调整了菜单顺序,操作更顺手\n", + "这次更新虽然不大,但让记忆熊的基础更扎实、体验更流畅。我们继续努力,让AI记忆更好用!", + "记忆熊,记得更牢,用得更好。\uD83D\uDC3B✨" + ] + }, + "introduction_en": { + "codeName": "Qizhi", + "releaseDate": "2026-1-23", + "upgradePosition": "\uD83D\uDC3B This update focuses on improving usability and fixing known issues, making the system more stable and easier to use overall.", + "coreUpgrades": [ + "1. Improved Workflow Experience\nCleaner, more intuitive UI for easier configuration at a glance\nAdded visibility of node output variables, making them easier to reference in downstream nodes\nFixed several usability-related bugs that affected the workflow experience", + "2. Simpler Agent Configuration\nSmoother linkage between prompts and variables\nReorganized configuration layout for easier navigation and better clarity", + "3. More Stable Memory System\nOptimized cache refresh for emotional memory and implicit memory\nFixed error issues on the memory configuration page\nThe system can now automatically distinguish between user and AI roles", + "4. Enhanced Knowledge Base Experience\nFixed issues with document parsing failures\nUpload progress is now displayed during document processing\nCanceling an upload no longer triggers errors", + "5. Overall System Reliability Improvements\nFixed redirect issues affecting new users\nImproved stability of streaming APIs to prevent interruptions during long conversations\nAdjusted menu ordering for a smoother and more intuitive workflow\n", + "Although this is a relatively small update, it strengthens MemoryBear’s foundation and delivers a noticeably smoother experience.\nWe’ll keep refining the system to make AI memory more powerful and easier to use.", + "MemoryBear — remember better, work smarter. \uD83D\uDC3B✨" + ] + } + }, "v0.2.0": { "introduction": { "codeName": "启知", From 191958075922b4c3db665a45c39b2683a93718f9 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:12:21 +0800 Subject: [PATCH 052/175] Fix/memory mcp2 1 (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化快速检索的回复内容 * 优化快速检索的回复内容 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * 路径的BUG修复 * LLM生存缺少config_id认证,修复BUG * LLM生存缺少config_id认证,修复BUG * LLM生存缺少config_id认证,修复BUG * 深度检索优化,搜索不到数据/提问的概念过于蘑菇,以引导的方式继续提问 * 深度检索优化,搜索不到数据/提问的概念过于蘑菇,以引导的方式继续提问 * 深度检索优化,搜索不到数据/提问的概念过于蘑菇,以引导的方式继续提问 --- .../langgraph_graph/nodes/summary_nodes.py | 22 ++++++++-- .../utils/prompt/fail_summary_prompt.jinja2 | 43 +++++++++++++++++++ api/app/services/memory_agent_service.py | 3 +- 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 api/app/core/memory/agent/utils/prompt/fail_summary_prompt.jinja2 diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index f05a5ae1..fb0484d2 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -236,7 +236,7 @@ async def Retrieve_Summary(state: ReadState)-> ReadState: retrieve_info_str='\n'.join(retrieve_info_str) aimessages=await summary_llm(state,history,retrieve_info_str, - 'Retrieve_Summary_prompt.jinja2','retrieve_summary',RetrieveSummaryResponse,"1") + 'direct_summary_prompt.jinja2','retrieve_summary',RetrieveSummaryResponse,"1") if '信息不足,无法回答' not in str(aimessages) or str(aimessages) != "": await summary_redis_save(state, aimessages) if aimessages == '': @@ -276,7 +276,6 @@ async def Summary(state: ReadState)-> ReadState: aimessages=await summary_llm(state,history,data, 'summary_prompt.jinja2','summary',SummaryResponse,0) - if '信息不足,无法回答' not in str(aimessages) or str(aimessages) != "": await summary_redis_save(state, aimessages) if aimessages == '': @@ -295,9 +294,26 @@ async def Summary(state: ReadState)-> ReadState: async def Summary_fails(state: ReadState)-> ReadState: storage_type=state.get("storage_type", '') user_rag_memory_id=state.get("user_rag_memory_id", '') + history = await summary_history(state) + query = state.get("data", '') + verify = state.get("verify", '') + verify_expansion_issue = verify.get("verified_data", '') + retrieve_info_str = '' + for data in verify_expansion_issue: + for key, value in data.items(): + if key == 'answer_small': + for i in value: + retrieve_info_str += i + '\n' + data = { + "query": query, + "history": history, + "retrieve_info": retrieve_info_str + } + aimessages = await summary_llm(state, history, data, + 'fail_summary_prompt.jinja2', 'summary', SummaryResponse, 0) result= { "status": "success", - "summary_result": "没有相关数据", + "summary_result": aimessages, "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id } diff --git a/api/app/core/memory/agent/utils/prompt/fail_summary_prompt.jinja2 b/api/app/core/memory/agent/utils/prompt/fail_summary_prompt.jinja2 new file mode 100644 index 00000000..3744f99b --- /dev/null +++ b/api/app/core/memory/agent/utils/prompt/fail_summary_prompt.jinja2 @@ -0,0 +1,43 @@ +{# 角色定义 #} +你是专业的问题解答专家+引导学者 + +{# 输入数据展示 #} +{% if data %} +## 输入数据 +上下文信息: +{% for item in data.history %} +- {{ item }} +{% endfor %} +检索到的所有信息: +{% for item in data.retrieve_info %} +- {{ item }} +{% endfor %} +{% endif %} + +## User Query +{{ query }} + +{# 问题回答标准 #} +## 问题回答核心标准 +根据上下文信息(history)和检索到的所有信息(retrieve_info)准确回答用户的问题(query)。 +注意,仔细阅读检索信息,答案可能直接或间接地出现在检索信息中或者历史上下文消息中,同时需要 判断信息相关性 +**情况A:信息匹配问题** +- 直接回答,像自然对话一样 +- 例:检索到"小曼会使用Python" → 问"我叫什么" → 答"你叫小曼" + +**情况B:信息部分相关** +- 先回答已知部分,再自然地询问更多信息 +- 例:检索到"用户去过上海的面包店" → 问"我吃过哪家面包" → 答"我记得你去过上海的面包店,但具体是哪家我不太清楚,是哪家呢?" + +**情况C:信息完全不相关** +- 自然地表达不知道,但可以提及检索到的相关信息,让对话更连贯 +- 使用友好的表达: + - "你好像没和我说过...,但是我知道你[检索到的相关信息]" + - "关于这个我不太清楚,不过我记得你[检索到的相关信息],能告诉我更多吗?" + - "我不记得你提到过...,但你[检索到的相关信息]" +- 即使检索信息不直接回答问题,也可以自然地融入对话中 +- 避免僵硬的"信息不足,无法回答" + +{# 重要提醒 #} +当检索以及上下文的历史信息都无法回答的时候,可引导对方进行提问/回答,或者进行其他引导 +当检索或者上下文中出现了,相似的问题,可以委婉,提醒对方,我记得刚刚提过这个问题,但是我自己不记得了,能在描述一次吗~以此为例 diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 83b6bdd7..1e1cde89 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -542,9 +542,8 @@ class MemoryAgentService: if intermediate_type == "search_result": query = intermediate.get('query', '') raw_results = intermediate.get('raw_results', {}) - reranked_results = raw_results.get('reranked_results', []) - try: + reranked_results = raw_results.get('reranked_results', []) statements = [statement['statement'] for statement in reranked_results.get('statements', [])] except Exception: statements = [] From af6e1e2b99272cbeefd49fa4abf06c4be456a2e2 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 17:20:07 +0800 Subject: [PATCH 053/175] =?UTF-8?q?end=5Fuser=5Fid=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B9=B2=E5=87=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/memory_forget_controller.py | 10 +++++----- api/app/controllers/user_memory_controllers.py | 18 +++++++++--------- api/app/core/agent/langchain_agent.py | 4 ++-- api/app/core/memory/agent/utils/get_dialogs.py | 2 +- api/app/core/memory/agent/utils/write_tools.py | 2 +- .../evaluation/longmemeval/qwen_search_eval.py | 2 +- api/app/core/memory/models/message_models.py | 4 ++-- api/app/schemas/memory_storage_schema.py | 12 ------------ api/app/services/memory_agent_service.py | 2 +- api/app/services/memory_storage_service.py | 2 +- 10 files changed, 23 insertions(+), 35 deletions(-) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index 44351f92..a6b6028f 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -107,7 +107,7 @@ async def trigger_forgetting_cycle( # 调用服务层执行遗忘周期 report = await forget_service.trigger_forgetting_cycle( db=db, - group_id=end_user_id, # 服务层方法的参数名是 group_id + end_user_id=end_user_id, # 服务层方法的参数名是 end_user_id max_merge_batch_size=payload.max_merge_batch_size, min_days_since_access=payload.min_days_since_access, config_id=config_id @@ -247,7 +247,7 @@ async def get_forgetting_stats( 返回知识层节点统计、激活值分布等信息。 Args: - group_id: 组ID(即 end_user_id,可选) + end_user_id: 组ID(即 end_user_id,可选) current_user: 当前用户 db: 数据库会话 @@ -261,7 +261,7 @@ async def get_forgetting_stats( api_logger.warning(f"用户 {current_user.username} 尝试获取遗忘引擎统计但未选择工作空间") return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - # 如果提供了 group_id,通过它获取 config_id + # 如果提供了 end_user_id,通过它获取 config_id config_id = None if end_user_id: try: @@ -274,7 +274,7 @@ async def get_forgetting_stats( api_logger.warning(f"终端用户 {end_user_id} 未关联记忆配置") return fail(BizCode.INVALID_PARAMETER, f"终端用户 {end_user_id} 未关联记忆配置", "memory_config_id is None") - api_logger.debug(f"通过 group_id={end_user_id} 获取到 config_id={config_id}") + api_logger.debug(f"通过 end_user_id={end_user_id} 获取到 config_id={config_id}") except ValueError as e: api_logger.warning(f"获取终端用户配置失败: {str(e)}") return fail(BizCode.INVALID_PARAMETER, str(e), "ValueError") @@ -284,7 +284,7 @@ async def get_forgetting_stats( api_logger.info( f"用户 {current_user.username} 在工作空间 {workspace_id} 请求获取遗忘引擎统计: " - f"group_id={end_user_id}, config_id={config_id}" + f"end_user_id={end_user_id}, config_id={config_id}" ) try: diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 6f02f8f9..39cbe523 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -135,27 +135,27 @@ async def generate_cache_api( api_logger.warning(f"用户 {current_user.username} 尝试生成缓存但未选择工作空间") return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - group_id = request.end_user_id + end_user_id = request.end_user_id api_logger.info( f"缓存生成请求: user={current_user.username}, workspace={workspace_id}, " - f"end_user_id={group_id if group_id else '全部用户'}" + f"end_user_id={end_user_id if end_user_id else '全部用户'}" ) try: - if group_id: + if end_user_id: # 为单个用户生成 - api_logger.info(f"开始为单个用户生成缓存: end_user_id={group_id}") + api_logger.info(f"开始为单个用户生成缓存: end_user_id={end_user_id}") # 生成记忆洞察 - insight_result = await user_memory_service.generate_and_cache_insight(db, group_id, workspace_id) + insight_result = await user_memory_service.generate_and_cache_insight(db, end_user_id, workspace_id) # 生成用户摘要 - summary_result = await user_memory_service.generate_and_cache_summary(db, group_id, workspace_id) + summary_result = await user_memory_service.generate_and_cache_summary(db, end_user_id, workspace_id) # 构建响应 result = { - "end_user_id": group_id, + "end_user_id": end_user_id, "insight_success": insight_result["success"], "summary_success": summary_result["success"], "errors": [] @@ -175,9 +175,9 @@ async def generate_cache_api( # 记录结果 if result["insight_success"] and result["summary_success"]: - api_logger.info(f"成功为用户 {group_id} 生成缓存") + api_logger.info(f"成功为用户 {end_user_id} 生成缓存") else: - api_logger.warning(f"用户 {group_id} 的缓存生成部分失败: {result['errors']}") + api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {result['errors']}") return success(data=result, msg="生成完成") diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index 4cff933d..ddacb094 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -155,7 +155,7 @@ class LangChainAgent: # userid=end_user_end, # messages=messages, # apply_id=end_user_end, - # group_id=end_user_end, + # end_user_id=end_user_end, # aimessages=aimessages # ) # store.delete_duplicate_sessions() @@ -228,7 +228,7 @@ class LangChainAgent: # 6. 每个 Chunk 保存到 Neo4j,包含 speaker 字段 logger.info(f"[WRITE] Submitting Celery task - user={actual_end_user_id}, messages={len(structured_messages)}, config={actual_config_id}") write_id = write_message_task.delay( - actual_end_user_id, # group_id: 用户ID + actual_end_user_id, # end_user_id: 用户ID structured_messages, # message: 结构化消息列表 [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}] actual_config_id, # config_id: 配置ID storage_type, # storage_type: "neo4j" diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index a56a32fa..bfb0f675 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -18,7 +18,7 @@ async def get_chunked_dialogs( Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - group_id: Group identifier + end_user_id: Group identifier messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference identifier config_id: Configuration ID for processing diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index d32d152c..446ab86a 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -40,7 +40,7 @@ async def write( Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier memory_config: MemoryConfig object containing all configuration messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference ID, defaults to "wyl20251027" diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py index 8a366a00..8710a504 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py @@ -1307,7 +1307,7 @@ def main(): result = asyncio.run( run_longmemeval_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/models/message_models.py b/api/app/core/memory/models/message_models.py index c660d841..2f8660af 100644 --- a/api/app/core/memory/models/message_models.py +++ b/api/app/core/memory/models/message_models.py @@ -246,9 +246,9 @@ class DialogData(BaseModel): return [] def assign_group_id_to_statements(self) -> None: - """Assign this dialog's group_id to all statements in all chunks. + """Assign this dialog's end_user_id to all statements in all chunks. - This method updates statements that don't have a group_id set. + This method updates statements that don't have a end_user_id set. """ for chunk in self.chunks: for statement in chunk.statements: diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index e5a4cde6..d9c04f8f 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -8,20 +8,8 @@ import uuid from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator -# ============================================================================ -# 原 UserInput 相关 Schema (保留原有功能) -# ============================================================================ -class UserInput(BaseModel): - message: str - history: list[dict] - search_switch: str - group_id: str -class Write_UserInput(BaseModel): - message: str - group_id: str - # ============================================================================ # 从 json_schema.py 迁移的 Schema diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index bf54064c..6e72a53f 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -716,7 +716,7 @@ class MemoryAgentService: if "No memory configuration found" in str(e): raise # Re-raise our specific error logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") logger.info(f"Generating summary from retrieve info for query: {query[:50]}...") try: diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index f3c27c9a..80d8c717 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -572,7 +572,7 @@ async def analytics_hot_memory_tags( # 步骤4: 只调用一次LLM进行筛选 tag_names = [tag for tag, _ in sorted_tags] - # 使用第一个用户的group_id来获取LLM配置 + # 使用第一个用户的end_user_id来获取LLM配置 # 因为同一工作空间下的用户应该使用相同的配置 first_end_user_id = str(end_users[0].id) filtered_tag_names = await filter_tags_with_llm(tag_names, first_end_user_id) From 79cfabb45df32c31e4f660eb8036c70002e28812 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 17:20:32 +0800 Subject: [PATCH 054/175] =?UTF-8?q?end=5Fuser=5Fid=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=B9=B2=E5=87=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/version_info.json | 98 --------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 api/app/version_info.json diff --git a/api/app/version_info.json b/api/app/version_info.json deleted file mode 100644 index bee52989..00000000 --- a/api/app/version_info.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "v0.2.1": { - "introduction": { - "codeName": "启知", - "releaseDate": "2026-1-23", - "upgradePosition": "\uD83D\uDC3B 本次更新主要优化使用体验和修复已知问题,让系统更稳定、更好用。", - "coreUpgrades": [ - "1. 工作流更好用了\n* 界面更清晰,一眼看懂怎么配置\n* 新增节点输出变量展示,方便其他节点引用\n* 修复了几个影响体验的bug", - "2. 智能体配置更简单\n* 提示词和变量联动更顺畅\n* 配置界面重新整理,找功能更方便", - "3. 记忆系统更稳定\n* 优化了情绪记忆和隐性记忆的缓存更新\n* 修复了记忆配置页面的报错问题\n* 现在能自动识别用户和AI的身份了", - "4. 知识库体验提升\n* 修复了文档解析异常的问题\n* 上传文档时能看到处理进度了\n* 取消了操作也不会报错了", - "5. 系统整体更可靠\n* 修复了新用户访问跳转问题\n* 流式接口更稳定,长对话不断线\n* 调整了菜单顺序,操作更顺手\n", - "这次更新虽然不大,但让记忆熊的基础更扎实、体验更流畅。我们继续努力,让AI记忆更好用!", - "记忆熊,记得更牢,用得更好。\uD83D\uDC3B✨" - ] - }, - "introduction_en": { - "codeName": "Qizhi", - "releaseDate": "2026-1-23", - "upgradePosition": "\uD83D\uDC3B This update focuses on improving usability and fixing known issues, making the system more stable and easier to use overall.", - "coreUpgrades": [ - "1. Improved Workflow Experience\nCleaner, more intuitive UI for easier configuration at a glance\nAdded visibility of node output variables, making them easier to reference in downstream nodes\nFixed several usability-related bugs that affected the workflow experience", - "2. Simpler Agent Configuration\nSmoother linkage between prompts and variables\nReorganized configuration layout for easier navigation and better clarity", - "3. More Stable Memory System\nOptimized cache refresh for emotional memory and implicit memory\nFixed error issues on the memory configuration page\nThe system can now automatically distinguish between user and AI roles", - "4. Enhanced Knowledge Base Experience\nFixed issues with document parsing failures\nUpload progress is now displayed during document processing\nCanceling an upload no longer triggers errors", - "5. Overall System Reliability Improvements\nFixed redirect issues affecting new users\nImproved stability of streaming APIs to prevent interruptions during long conversations\nAdjusted menu ordering for a smoother and more intuitive workflow\n", - "Although this is a relatively small update, it strengthens MemoryBear’s foundation and delivers a noticeably smoother experience.\nWe’ll keep refining the system to make AI memory more powerful and easier to use.", - "MemoryBear — remember better, work smarter. \uD83D\uDC3B✨" - ] - } - }, - "v0.2.0": { - "introduction": { - "codeName": "启知", - "releaseDate": "2026-1-16", - "upgradePosition": "本次为架构升级,核心目标是把\"被动存储\"升级为\"主动认知\",让系统具备情绪感知、情景理解与类人记忆机制,为后续多智能体协作与专业场景落地奠定底座。", - "coreUpgrades": [ - "记忆详情:拟人记忆——情绪引擎、情景记忆、短期记忆、工作记忆、感知记忆、显性记忆、隐性记忆,并配套类脑遗忘机制,实现从感知→情绪→情景→长期沉淀的完整人类记忆闭环", - "可视化工作流:拖拽式节点编排(LLM、知识库、逻辑、工具),业务落地周期由天缩至小时。", - "多模态知识处理:PDF、PPT、MP3、MP4 一键解析,时间感知检索准确率 94.3%,问答对数据即插即用。", - "Agent集群内置\"记忆-知识-工具-审核\"四类角色模板,用户一键生成;主控Agent把复杂任务拆为子任务并行分发,再靠情景记忆统一消解冲突、校验一致性,输出完整报告。" - ] - }, - "introduction_en": { - "codeName": "Qizhi", - "releaseDate": "2026-1-16", - "upgradePosition": "This release marks a foundational upgrade to the system’s cognitive architecture. The core objective is to evolve the platform from passive information storage into active cognitive intelligence—enabling emotional awareness, situational understanding, and human-like memory mechanisms. This upgrade lays the groundwork for future multi-agent collaboration and domain-specific, production-grade AI applications.", - "coreUpgrades": [ - "Human-Like Memory Architecture: A comprehensive, human-inspired memory system is introduced, encompassing emotional processing, situational memory, short-term and working memory, perceptual memory, as well as explicit and implicit memory. Combined with brain-inspired forgetting mechanisms, the system now supports a complete cognitive loop—from perception → emotion → context → long-term consolidation, closely mirroring human memory formation.", - "Visual Workflow Orchestration: A fully visual, drag-and-drop workflow enables modular composition of LLMs, knowledge bases, logic, and tools. This dramatically reduces the time required to move from experimentation to production—from days to hours.", - "Multimodal Knowledge Processing: The system now supports one-click parsing and ingestion of PDF, PPT, MP3, and MP4 content. With time-aware retrieval accuracy reaching 94.3%, structured Q&A data becomes instantly usable for downstream reasoning and generation.", - "Built-in Agent Clusters: Predefined role templates across four categories—Memory, Knowledge, Tools, and Review—can be generated with a single click. A Coordinator Agent decomposes complex tasks into parallel subtasks, while situational memory is used to resolve conflicts, validate consistency, and synthesize outputs into a coherent, end-to-end report." - ] - } - }, - "v0.1.0": { - "introduction": { - "codeName": "初心", - "releaseDate": "2025-12-01", - "upgradePosition": "这是一款专注于管理和利用AI记忆的工具,支持RAG和知识图谱两种主流存储方式,旨在为AI应用提供持久化、结构化的\"记忆\"能力。", - "coreUpgrades": [ - "记忆空间:用户可以创建独立的空间来隔离不同记忆,并灵活选择存储方式。", - "记忆配置:简化了配置流程,内置自动提取关键信息的\"记忆萃取\"和管理生命周期的\"遗忘\"引擎。", - "知识检索:提供语义、分词和混合三种检索模式,并支持多种参数微调和结果重排序,以提升召回效果。", - "全局管理:支持统一设置默认检索参数,并可一键应用到所有知识库。", - "测试与调试:内置\"召回测试\"功能,方便用户实时验证检索效果并调整参数,支持通过分享码与他人协作。", - "记忆洞察:可查看详细的对话记录、用户画像和分析报告,帮助理解AI的\"记忆\"内容。", - "集成与管理:提供API Key用于系统集成,并包含基本的用户管理功能。", - "界面与体验:采用现代化的卡片式布局和渐变色设计,注重交互的流畅性和视觉美感。", - "起步与使用:文档中提供了清晰的基础使用流程,引导用户从创建空间、配置记忆到测试检索快速上手。", - "版本说明与限制: 记忆熊 v0.1.0 版本\"初心\"囊括智能记忆管理的核心思路和基础能力,为后续开发奠定了基础。", - "文档资源:用户手册、API文档、FAQ", - "问题反馈:GitHub Issues、邮件支持", - "致谢:感谢所有参与测试和提供反馈的用户!" - ] - }, - "introduction_en": { - "codeName": "Original Intent", - "releaseDate": "2025-12-01", - "upgradePosition": "A tool focused on managing and utilizing AI memory, supporting both RAG and knowledge graph storage methods, aiming to provide persistent and structured 'memory' capabilities for AI applications.", - "coreUpgrades": [ - "Memory Space: Users can create independent spaces to isolate different memories and flexibly choose storage methods.", - "Memory Configuration: Simplified configuration process with built-in 'memory extraction' for automatic key information extraction and 'forgetting' engine for lifecycle management.", - "Knowledge Retrieval: Provides semantic, tokenization, and hybrid retrieval modes with various parameter tuning and result reranking to improve recall.", - "Global Management: Supports unified default retrieval parameter settings with one-click application to all knowledge bases.", - "Testing & Debugging: Built-in 'recall testing' for real-time verification of retrieval effects and parameter adjustment, with sharing code support for collaboration.", - "Memory Insights: View detailed conversation records, user profiles, and analysis reports to understand AI 'memory' content.", - "Integration & Management: Provides API Key for system integration with basic user management features.", - "Interface & Experience: Modern card-based layout with gradient design, focusing on interaction fluidity and visual aesthetics.", - "Getting Started: Documentation provides clear basic usage flow, guiding users from creating spaces, configuring memory to testing retrieval.", - "Version Notes: MemoryBear v0.1.0 'Original Intent' encompasses core concepts and basic capabilities of intelligent memory management, laying foundation for future development.", - "Documentation: User Manual, API Documentation, FAQ", - "Feedback: GitHub Issues, Email Support", - "Acknowledgments: Thanks to all users who participated in testing and provided feedback!" - ] - } - } -} From eccc208229d1dc5a427bc12a79f2d1915b0e90cf Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 18:34:06 +0800 Subject: [PATCH 055/175] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=81=97=E7=95=99?= =?UTF-8?q?=E5=90=88=E5=B9=B6BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/repositories/neo4j/graph_search.py | 38 +++++++++++++------ api/app/services/emotion_analytics_service.py | 2 +- api/app/services/memory_config_service.py | 2 +- api/app/services/memory_perceptual_service.py | 6 +-- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/api/app/repositories/neo4j/graph_search.py b/api/app/repositories/neo4j/graph_search.py index 9660f6cb..e8f52535 100644 --- a/api/app/repositories/neo4j/graph_search.py +++ b/api/app/repositories/neo4j/graph_search.py @@ -305,11 +305,18 @@ async def search_graph( results[key] = _deduplicate_results(results[key]) # 更新知识节点的激活值(Statement, ExtractedEntity, MemorySummary) - results = await _update_search_results_activation( - connector=connector, - results=results, - end_user_id=end_user_id + # Skip activation updates if only searching summaries (optimization) + needs_activation_update = any( + key in include and key in results and results[key] + for key in ['statements', 'entities', 'chunks'] ) + + if needs_activation_update: + results = await _update_search_results_activation( + connector=connector, + results=results, + end_user_id=end_user_id + ) return results @@ -417,14 +424,23 @@ async def search_graph_by_embedding( results[key] = _deduplicate_results(results[key]) # 更新知识节点的激活值(Statement, ExtractedEntity, MemorySummary) - update_start = time.time() - results = await _update_search_results_activation( - connector=connector, - results=results, - end_user_id=end_user_id + # Skip activation updates if only searching summaries (optimization) + needs_activation_update = any( + key in include and key in results and results[key] + for key in ['statements', 'entities', 'chunks'] ) - update_time = time.time() - update_start - print(f"[PERF] Activation value updates took: {update_time:.4f}s") + + if needs_activation_update: + update_start = time.time() + results = await _update_search_results_activation( + connector=connector, + results=results, + end_user_id=end_user_id + ) + update_time = time.time() - update_start + logger.info(f"[PERF] Activation value updates took: {update_time:.4f}s") + else: + logger.info(f"[PERF] Skipping activation updates (only summaries)") return results async def get_dedup_candidates_for_entities( # 适配新版查询:使用全文索引按名称检索候选实体 diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index 19c6cef1..af98fb52 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -505,7 +505,7 @@ class EmotionAnalyticsService: ) config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config( - config_id=int(config_id), + config_id=(config_id), service_name="EmotionAnalyticsService.generate_emotion_suggestions" ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index 692104bb..e901d65d 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -61,7 +61,7 @@ def _validate_config_id(config_id): # Fall back to integer parsing try: - parsed_id = int(config_id_stripped) + parsed_id = config_id_stripped if parsed_id <= 0: raise InvalidConfigError( f"Configuration ID must be positive: {parsed_id}", diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index 29b45474..b65955f2 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -92,17 +92,15 @@ class MemoryPerceptualService: result = { "id": str(memory.id), - "perceptual_type": perceptual_type, "file_name": memory.file_name, "file_path": memory.file_path, - "file_ext": memory.file_ext, - "storage_service": memory.storage_service, - "meta_data": memory.meta_data, + "storage_type": memory.storage_service, "summary": memory.summary, "keywords": content.keywords, "topic": content.topic, "domain": content.domain, "created_time": int(memory.created_time.timestamp()*1000), + **detail } business_logger.info( From a5e44cd229c55a7e81a454466128cbc32d5a68a8 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 18:34:13 +0800 Subject: [PATCH 056/175] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=81=97=E7=95=99?= =?UTF-8?q?=E5=90=88=E5=B9=B6BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/tasks.py b/api/app/tasks.py index cd8f0069..cdd7945e 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1023,7 +1023,7 @@ def workspace_reflection_task(self) -> Dict[str, Any]: end_users = data['end_users'] for base, config, user in zip(releases, data_configs, end_users): - if int(base['config']) == int(config['config_id']) and base['app_id'] == user['app_id']: + if str(base['config']) == str(config['config_id']) and str(base['app_id']) == str(user['app_id']): # 调用反思服务 api_logger.info(f"为用户 {user['id']} 启动反思,config_id: {config['config_id']}") From 9b8ed16e37c63208ac5ee9954609999d57810f1a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 18:35:40 +0800 Subject: [PATCH 057/175] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=81=97=E7=95=99?= =?UTF-8?q?=E5=90=88=E5=B9=B6BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/version_info.json | 98 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 api/version_info.json diff --git a/api/version_info.json b/api/version_info.json new file mode 100644 index 00000000..bee52989 --- /dev/null +++ b/api/version_info.json @@ -0,0 +1,98 @@ +{ + "v0.2.1": { + "introduction": { + "codeName": "启知", + "releaseDate": "2026-1-23", + "upgradePosition": "\uD83D\uDC3B 本次更新主要优化使用体验和修复已知问题,让系统更稳定、更好用。", + "coreUpgrades": [ + "1. 工作流更好用了\n* 界面更清晰,一眼看懂怎么配置\n* 新增节点输出变量展示,方便其他节点引用\n* 修复了几个影响体验的bug", + "2. 智能体配置更简单\n* 提示词和变量联动更顺畅\n* 配置界面重新整理,找功能更方便", + "3. 记忆系统更稳定\n* 优化了情绪记忆和隐性记忆的缓存更新\n* 修复了记忆配置页面的报错问题\n* 现在能自动识别用户和AI的身份了", + "4. 知识库体验提升\n* 修复了文档解析异常的问题\n* 上传文档时能看到处理进度了\n* 取消了操作也不会报错了", + "5. 系统整体更可靠\n* 修复了新用户访问跳转问题\n* 流式接口更稳定,长对话不断线\n* 调整了菜单顺序,操作更顺手\n", + "这次更新虽然不大,但让记忆熊的基础更扎实、体验更流畅。我们继续努力,让AI记忆更好用!", + "记忆熊,记得更牢,用得更好。\uD83D\uDC3B✨" + ] + }, + "introduction_en": { + "codeName": "Qizhi", + "releaseDate": "2026-1-23", + "upgradePosition": "\uD83D\uDC3B This update focuses on improving usability and fixing known issues, making the system more stable and easier to use overall.", + "coreUpgrades": [ + "1. Improved Workflow Experience\nCleaner, more intuitive UI for easier configuration at a glance\nAdded visibility of node output variables, making them easier to reference in downstream nodes\nFixed several usability-related bugs that affected the workflow experience", + "2. Simpler Agent Configuration\nSmoother linkage between prompts and variables\nReorganized configuration layout for easier navigation and better clarity", + "3. More Stable Memory System\nOptimized cache refresh for emotional memory and implicit memory\nFixed error issues on the memory configuration page\nThe system can now automatically distinguish between user and AI roles", + "4. Enhanced Knowledge Base Experience\nFixed issues with document parsing failures\nUpload progress is now displayed during document processing\nCanceling an upload no longer triggers errors", + "5. Overall System Reliability Improvements\nFixed redirect issues affecting new users\nImproved stability of streaming APIs to prevent interruptions during long conversations\nAdjusted menu ordering for a smoother and more intuitive workflow\n", + "Although this is a relatively small update, it strengthens MemoryBear’s foundation and delivers a noticeably smoother experience.\nWe’ll keep refining the system to make AI memory more powerful and easier to use.", + "MemoryBear — remember better, work smarter. \uD83D\uDC3B✨" + ] + } + }, + "v0.2.0": { + "introduction": { + "codeName": "启知", + "releaseDate": "2026-1-16", + "upgradePosition": "本次为架构升级,核心目标是把\"被动存储\"升级为\"主动认知\",让系统具备情绪感知、情景理解与类人记忆机制,为后续多智能体协作与专业场景落地奠定底座。", + "coreUpgrades": [ + "记忆详情:拟人记忆——情绪引擎、情景记忆、短期记忆、工作记忆、感知记忆、显性记忆、隐性记忆,并配套类脑遗忘机制,实现从感知→情绪→情景→长期沉淀的完整人类记忆闭环", + "可视化工作流:拖拽式节点编排(LLM、知识库、逻辑、工具),业务落地周期由天缩至小时。", + "多模态知识处理:PDF、PPT、MP3、MP4 一键解析,时间感知检索准确率 94.3%,问答对数据即插即用。", + "Agent集群内置\"记忆-知识-工具-审核\"四类角色模板,用户一键生成;主控Agent把复杂任务拆为子任务并行分发,再靠情景记忆统一消解冲突、校验一致性,输出完整报告。" + ] + }, + "introduction_en": { + "codeName": "Qizhi", + "releaseDate": "2026-1-16", + "upgradePosition": "This release marks a foundational upgrade to the system’s cognitive architecture. The core objective is to evolve the platform from passive information storage into active cognitive intelligence—enabling emotional awareness, situational understanding, and human-like memory mechanisms. This upgrade lays the groundwork for future multi-agent collaboration and domain-specific, production-grade AI applications.", + "coreUpgrades": [ + "Human-Like Memory Architecture: A comprehensive, human-inspired memory system is introduced, encompassing emotional processing, situational memory, short-term and working memory, perceptual memory, as well as explicit and implicit memory. Combined with brain-inspired forgetting mechanisms, the system now supports a complete cognitive loop—from perception → emotion → context → long-term consolidation, closely mirroring human memory formation.", + "Visual Workflow Orchestration: A fully visual, drag-and-drop workflow enables modular composition of LLMs, knowledge bases, logic, and tools. This dramatically reduces the time required to move from experimentation to production—from days to hours.", + "Multimodal Knowledge Processing: The system now supports one-click parsing and ingestion of PDF, PPT, MP3, and MP4 content. With time-aware retrieval accuracy reaching 94.3%, structured Q&A data becomes instantly usable for downstream reasoning and generation.", + "Built-in Agent Clusters: Predefined role templates across four categories—Memory, Knowledge, Tools, and Review—can be generated with a single click. A Coordinator Agent decomposes complex tasks into parallel subtasks, while situational memory is used to resolve conflicts, validate consistency, and synthesize outputs into a coherent, end-to-end report." + ] + } + }, + "v0.1.0": { + "introduction": { + "codeName": "初心", + "releaseDate": "2025-12-01", + "upgradePosition": "这是一款专注于管理和利用AI记忆的工具,支持RAG和知识图谱两种主流存储方式,旨在为AI应用提供持久化、结构化的\"记忆\"能力。", + "coreUpgrades": [ + "记忆空间:用户可以创建独立的空间来隔离不同记忆,并灵活选择存储方式。", + "记忆配置:简化了配置流程,内置自动提取关键信息的\"记忆萃取\"和管理生命周期的\"遗忘\"引擎。", + "知识检索:提供语义、分词和混合三种检索模式,并支持多种参数微调和结果重排序,以提升召回效果。", + "全局管理:支持统一设置默认检索参数,并可一键应用到所有知识库。", + "测试与调试:内置\"召回测试\"功能,方便用户实时验证检索效果并调整参数,支持通过分享码与他人协作。", + "记忆洞察:可查看详细的对话记录、用户画像和分析报告,帮助理解AI的\"记忆\"内容。", + "集成与管理:提供API Key用于系统集成,并包含基本的用户管理功能。", + "界面与体验:采用现代化的卡片式布局和渐变色设计,注重交互的流畅性和视觉美感。", + "起步与使用:文档中提供了清晰的基础使用流程,引导用户从创建空间、配置记忆到测试检索快速上手。", + "版本说明与限制: 记忆熊 v0.1.0 版本\"初心\"囊括智能记忆管理的核心思路和基础能力,为后续开发奠定了基础。", + "文档资源:用户手册、API文档、FAQ", + "问题反馈:GitHub Issues、邮件支持", + "致谢:感谢所有参与测试和提供反馈的用户!" + ] + }, + "introduction_en": { + "codeName": "Original Intent", + "releaseDate": "2025-12-01", + "upgradePosition": "A tool focused on managing and utilizing AI memory, supporting both RAG and knowledge graph storage methods, aiming to provide persistent and structured 'memory' capabilities for AI applications.", + "coreUpgrades": [ + "Memory Space: Users can create independent spaces to isolate different memories and flexibly choose storage methods.", + "Memory Configuration: Simplified configuration process with built-in 'memory extraction' for automatic key information extraction and 'forgetting' engine for lifecycle management.", + "Knowledge Retrieval: Provides semantic, tokenization, and hybrid retrieval modes with various parameter tuning and result reranking to improve recall.", + "Global Management: Supports unified default retrieval parameter settings with one-click application to all knowledge bases.", + "Testing & Debugging: Built-in 'recall testing' for real-time verification of retrieval effects and parameter adjustment, with sharing code support for collaboration.", + "Memory Insights: View detailed conversation records, user profiles, and analysis reports to understand AI 'memory' content.", + "Integration & Management: Provides API Key for system integration with basic user management features.", + "Interface & Experience: Modern card-based layout with gradient design, focusing on interaction fluidity and visual aesthetics.", + "Getting Started: Documentation provides clear basic usage flow, guiding users from creating spaces, configuring memory to testing retrieval.", + "Version Notes: MemoryBear v0.1.0 'Original Intent' encompasses core concepts and basic capabilities of intelligent memory management, laying foundation for future development.", + "Documentation: User Manual, API Documentation, FAQ", + "Feedback: GitHub Issues, Email Support", + "Acknowledgments: Thanks to all users who participated in testing and provided feedback!" + ] + } + } +} From 94cced832349d6126b278421e8e275c64f244b85 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Fri, 23 Jan 2026 18:36:33 +0800 Subject: [PATCH 058/175] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=81=97=E7=95=99?= =?UTF-8?q?=E5=90=88=E5=B9=B6BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/{ => app}/version_info.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename api/{ => app}/version_info.json (100%) diff --git a/api/version_info.json b/api/app/version_info.json similarity index 100% rename from api/version_info.json rename to api/app/version_info.json From 4f4f55d67fa6ff71bcd2b0272cbacf63c8dbac8c Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 11:04:30 +0800 Subject: [PATCH 059/175] feat(web): memory related interface parameter transfer adjustment --- web/src/api/memory.ts | 48 +- web/src/views/MemoryConversation/index.tsx | 6 +- .../views/MemoryExtractionEngine/constant.ts | 604 +----------------- web/src/views/MemoryManagement/types.ts | 1 - .../components/PerceptualLastInfo.tsx | 11 +- 5 files changed, 36 insertions(+), 634 deletions(-) diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index bbd9f6b0..ff8e0435 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -116,20 +116,20 @@ export const getRagContent = (end_user_id: string) => { return request.get(`/dashboard/rag_content`, { end_user_id, limit: 20 }) } // Emotion distribution analysis -export const getWordCloud = (group_id: string) => { - return request.post(`/memory/emotion-memory/wordcloud`, { group_id, limit: 20 }) +export const getWordCloud = (end_user_id: string) => { + return request.post(`/memory/emotion-memory/wordcloud`, { end_user_id, limit: 20 }) } // High-frequency emotion keywords -export const getEmotionTags = (group_id: string) => { - return request.post(`/memory/emotion-memory/tags`, { group_id, limit: 20 }) +export const getEmotionTags = (end_user_id: string) => { + return request.post(`/memory/emotion-memory/tags`, { end_user_id, limit: 20 }) } // Emotion health index -export const getEmotionHealth = (group_id: string) => { - return request.post(`/memory/emotion-memory/health`, { group_id, limit: 20 }) +export const getEmotionHealth = (end_user_id: string) => { + return request.post(`/memory/emotion-memory/health`, { end_user_id }) } // Personalized suggestions -export const getEmotionSuggestions = (group_id: string) => { - return request.post(`/memory/emotion-memory/suggestions`, { group_id, limit: 20 }) +export const getEmotionSuggestions = (end_user_id: string) => { + return request.post(`/memory/emotion-memory/suggestions`, { end_user_id }) } export const generateSuggestions = (end_user_id: string) => { return request.post(`/memory/emotion-memory/generate_suggestions`, { end_user_id }) @@ -138,8 +138,8 @@ export const analyticsRefresh = (end_user_id: string) => { return request.post('/memory-storage/analytics/generate_cache', { end_user_id }) } // Forgetting stats -export const getForgetStats = (group_id: string) => { - return request.get(`/memory/forget-memory/stats`, { group_id }) +export const getForgetStats = (end_user_id: string) => { + return request.get(`/memory/forget-memory/stats`, { end_user_id }) } // Implicit Memory - Preferences export const getImplicitPreferences = (end_user_id: string) => { @@ -165,20 +165,20 @@ export const getShortTerm = (end_user_id: string) => { return request.get(`/memory/short/short_term`, { end_user_id }) } // Perceptual Memory - Visual memory -export const getPerceptualLastVisual = (end_user: string) => { - return request.get(`/memory/perceptual/${end_user}/last_visual`) +export const getPerceptualLastVisual = (end_user_id: string) => { + return request.get(`/memory/perceptual/${end_user_id}/last_visual`) } // Perceptual Memory - Audio memory -export const getPerceptualLastListen = (end_user: string) => { - return request.get(`/memory/perceptual/${end_user}/last_listen`) +export const getPerceptualLastListen = (end_user_id: string) => { + return request.get(`/memory/perceptual/${end_user_id}/last_listen`) } // Perceptual Memory - Text memory -export const getPerceptualLastText = (end_user: string) => { - return request.get(`/memory/perceptual/${end_user}/last_text`) +export const getPerceptualLastText = (end_user_id: string) => { + return request.get(`/memory/perceptual/${end_user_id}/last_text`) } // Perceptual Memory - Perceptual memory timeline -export const getPerceptualTimeline = (end_user: string) => { - return request.get(`/memory/perceptual/${end_user}/timeline`) +export const getPerceptualTimeline = (end_user_id: string) => { + return request.get(`/memory/perceptual/${end_user_id}/timeline`) } // Episodic Memory - Overview export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => { @@ -201,14 +201,14 @@ export const getExplicitMemory = (end_user_id: string) => { export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => { return request.post(`/memory/explicit-memory/details`, data) } -export const getConversations = (end_user: string) => { - return request.get(`/memory/work/${end_user}/conversations`) +export const getConversations = (end_user_id: string) => { + return request.get(`/memory/work/${end_user_id}/conversations`) } -export const getConversationMessages = (end_user: string, conversation_id: string) => { - return request.get(`/memory/work/${end_user}/messages`, { conversation_id }) +export const getConversationMessages = (end_user_id: string, conversation_id: string) => { + return request.get(`/memory/work/${end_user_id}/messages`, { conversation_id }) } -export const getConversationDetail = (end_user: string, conversation_id: string) => { - return request.get(`/memory/work/${end_user}/detail`, { conversation_id }) +export const getConversationDetail = (end_user_id: string, conversation_id: string) => { + return request.get(`/memory/work/${end_user_id}/detail`, { conversation_id }) } export const forgetTrigger = (data: { max_merge_batch_size: number; min_days_since_access: number; end_user_id: string;}) => { return request.post(`/memory/forget-memory/trigger`, data) diff --git a/web/src/views/MemoryConversation/index.tsx b/web/src/views/MemoryConversation/index.tsx index 424b9878..66a66779 100644 --- a/web/src/views/MemoryConversation/index.tsx +++ b/web/src/views/MemoryConversation/index.tsx @@ -45,7 +45,7 @@ const searchSwitchList = [ ] export interface TestParams { - group_id: string; + end_user_id: string; message: string; search_switch: string; history: { role: string; content: string }[]; @@ -107,7 +107,7 @@ const MemoryConversation: FC = () => { setLoading(true) readService({ message: msg, - group_id: userId, + end_user_id: userId, search_switch: search_switch, history: [], }) @@ -204,7 +204,7 @@ const MemoryConversation: FC = () => { } )} > -
{log.title}
+
{log.title}
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0 ? {log.data.map(vo => ( diff --git a/web/src/views/MemoryExtractionEngine/constant.ts b/web/src/views/MemoryExtractionEngine/constant.ts index d1b7b757..5939a1bc 100644 --- a/web/src/views/MemoryExtractionEngine/constant.ts +++ b/web/src/views/MemoryExtractionEngine/constant.ts @@ -1093,606 +1093,4 @@ export const groupDataByType = (data: any[], groupKey: string) => { }) return grouped -} - -export const mockTestResult = { - "generated_at": "2025-12-12T09:48:43.389893", - "entities": { - "extracted_count": 148 - }, - "dedup": { - "total_merged_count": 39, - "breakdown": { - "exact": 30, - "fuzzy": 0, - "llm": 9 - }, - "impact": [ - { - "name": "记忆熊", - "type": "Person", - "appear_count": 9, - "merge_count": 8 - }, - { - "name": "宋朝", - "type": "Organization", - "appear_count": 5, - "merge_count": 2 - }, - { - "name": "军费", - "type": "EconomicMetric", - "appear_count": 2, - "merge_count": 1 - }, - { - "name": "学生", - "type": "Person", - "appear_count": 6, - "merge_count": 5 - }, - { - "name": "废除丞相制度", - "type": "Event", - "appear_count": 6, - "merge_count": 3 - }, - { - "name": "六部", - "type": "Organization", - "appear_count": 4, - "merge_count": 3 - }, - { - "name": "六部缺乏协调机制", - "type": "Concept", - "appear_count": 2, - "merge_count": 1 - }, - { - "name": "丞相", - "type": "Position", - "appear_count": 4, - "merge_count": 1 - }, - { - "name": "总理", - "type": "Position", - "appear_count": 2, - "merge_count": 1 - }, - { - "name": "各部委", - "type": "Organization", - "appear_count": 2, - "merge_count": 1 - }, - { - "name": "六部直接对皇帝负责", - "type": "AdministrativeStructure", - "appear_count": 2, - "merge_count": 1 - }, - { - "name": "秦国", - "type": "Organization", - "appear_count": 5, - "merge_count": 2 - }, - { - "name": "文官集团", - "type": "Organization", - "appear_count": 2, - "merge_count": 1 - } - ] - }, - "disambiguation": { - "block_count": 1, - "effects": [ - { - "left": { - "name": "节度使", - "type": "Role" - }, - "right": { - "name": "节度使", - "type": "Person" - }, - "result": "成功区分" - } - ] - }, - "memory": { - "chunks": 2 - }, - "triplets": { - "count": 88 - }, - "core_entities": [ - { - "type": "Organization", - "type_cn": "组织", - "count": 16, - "entities": [ - "厂卫机构", - "西厂", - "东厂", - "工部", - "地方军阀" - ] - }, - { - "type": "Event", - "type_cn": "事件", - "count": 12, - "entities": [ - "均田制瓦解", - "无法批阅完所有政务", - "废除丞相制度", - "持续战争", - "政令执行困难" - ] - }, - { - "type": "Condition", - "type_cn": "Condition", - "count": 9, - "entities": [ - "缺乏协作机制", - "作战效率低下", - "厢军装备不足", - "军权分散", - "军事专业化难以提升" - ] - }, - { - "type": "Person", - "type_cn": "人物", - "count": 8, - "entities": [ - "官员", - "宦官", - "节度使", - "皇帝", - "文士" - ] - }, - { - "type": "Concept", - "type_cn": "Concept", - "count": 8, - "entities": [ - "行政紧张", - "军力不足", - "秦国统一六国的原因", - "六部缺乏协调机制", - "专业分工" - ] - }, - { - "type": "Action", - "type_cn": "Action", - "count": 6, - "entities": [ - "再花钱募兵", - "建立军功爵制度", - "裁撤兵员", - "削减装备", - "建立法律制度" - ] - }, - { - "type": "Outcome", - "type_cn": "Outcome", - "count": 5, - "entities": [ - "打仗更吃亏", - "提升国家组织能力", - "降低行政效率", - "士兵效忠个人而非国家", - "政令推行困难" - ] - }, - { - "type": "EconomicMetric", - "type_cn": "EconomicMetric", - "count": 4, - "entities": [ - "财政", - "财政支出", - "支出", - "军费" - ] - }, - { - "type": "Statement", - "type_cn": "Statement", - "count": 3, - "entities": [ - "没有银子", - "禁军由文官控制导致作战效率低下", - "武器没材料" - ] - }, - { - "type": "State", - "type_cn": "State", - "count": 3, - "entities": [ - "军队更弱", - "理解不足", - "不足" - ] - }, - { - "type": "HistoricalPeriod", - "type_cn": "HistoricalPeriod", - "count": 3, - "entities": [ - "春秋战国史", - "唐朝史", - "宋朝" - ] - }, - { - "type": "Attribute", - "type_cn": "Attribute", - "count": 3, - "entities": [ - "资源丰富", - "易守难攻", - "政策连续性强" - ] - }, - { - "type": "Right", - "type_cn": "Right", - "count": 3, - "entities": [ - "军事指挥权", - "财政调度权", - "募兵权" - ] - }, - { - "type": "Policy", - "type_cn": "Policy", - "count": 2, - "entities": [ - "商鞅变法", - "禁军由文官控制" - ] - }, - { - "type": "MilitaryCondition", - "type_cn": "MilitaryCondition", - "count": 2, - "entities": [ - "军力不足", - "缺乏战略纵深" - ] - }, - { - "type": "Role", - "type_cn": "Role", - "count": 2, - "entities": [ - "节度使", - "协调中枢" - ] - }, - { - "type": "Position", - "type_cn": "Position", - "count": 2, - "entities": [ - "总理", - "丞相" - ] - }, - { - "type": "PoliticalCharacteristic", - "type_cn": "PoliticalCharacteristic", - "count": 2, - "entities": [ - "旧贵族势力弱", - "中央集权程度高" - ] - }, - { - "type": "Phenomenon", - "type_cn": "Phenomenon", - "count": 1, - "entities": [ - "宋朝军事弱势" - ] - }, - { - "type": "Factor", - "type_cn": "Factor", - "count": 1, - "entities": [ - "制度性因素" - ] - }, - { - "type": "EconomicFactor", - "type_cn": "EconomicFactor", - "count": 1, - "entities": [ - "财政压力" - ] - }, - { - "type": "EconomicIndicator", - "type_cn": "EconomicIndicator", - "count": 1, - "entities": [ - "财政支出" - ] - }, - { - "type": "MilitaryStrategy", - "type_cn": "MilitaryStrategy", - "count": 1, - "entities": [ - "对外战略被动" - ] - }, - { - "type": "MilitaryCapability", - "type_cn": "MilitaryCapability", - "count": 1, - "entities": [ - "机动能力弱" - ] - }, - { - "type": "PersonGroup", - "type_cn": "PersonGroup", - "count": 1, - "entities": [ - "武将" - ] - }, - { - "type": "EconomicCondition", - "type_cn": "EconomicCondition", - "count": 1, - "entities": [ - "财政压力" - ] - }, - { - "type": "InstitutionalPolicy", - "type_cn": "InstitutionalPolicy", - "count": 1, - "entities": [ - "废除丞相制度" - ] - }, - { - "type": "StateOfAffairs", - "type_cn": "StateOfAffairs", - "count": 1, - "entities": [ - "中央决策高度集中于皇帝" - ] - }, - { - "type": "Institution", - "type_cn": "Institution", - "count": 1, - "entities": [ - "科举" - ] - }, - { - "type": "Function", - "type_cn": "Function", - "count": 1, - "entities": [ - "统筹大事小情" - ] - }, - { - "type": "AdministrativeStructure", - "type_cn": "AdministrativeStructure", - "count": 1, - "entities": [ - "六部直接对皇帝负责" - ] - }, - { - "type": "AdministrativeProblem", - "type_cn": "AdministrativeProblem", - "count": 1, - "entities": [ - "皇帝一人批不完政务" - ] - }, - { - "type": "Behavior", - "type_cn": "Behavior", - "count": 1, - "entities": [ - "互相推诿责任" - ] - }, - { - "type": "Resource", - "type_cn": "Resource", - "count": 1, - "entities": [ - "银子" - ] - }, - { - "type": "Situation", - "type_cn": "Situation", - "count": 1, - "entities": [ - "没人拍板" - ] - }, - { - "type": "HistoricalState", - "type_cn": "HistoricalState", - "count": 1, - "entities": [ - "秦国" - ] - }, - { - "type": "Location", - "type_cn": "地点", - "count": 1, - "entities": [ - "关中" - ] - }, - { - "type": "HistoricalEvent", - "type_cn": "HistoricalEvent", - "count": 1, - "entities": [ - "安史之乱" - ] - }, - { - "type": "PoliticalAction", - "type_cn": "PoliticalAction", - "count": 1, - "entities": [ - "中央整顿" - ] - }, - { - "type": "PoliticalPhenomenon", - "type_cn": "PoliticalPhenomenon", - "count": 1, - "entities": [ - "藩镇割据加剧" - ] - }, - { - "type": "EconomicEntity", - "type_cn": "EconomicEntity", - "count": 1, - "entities": [ - "中央财政" - ] - }, - { - "type": "System", - "type_cn": "System", - "count": 1, - "entities": [ - "募兵制" - ] - }, - { - "type": "WorkRole", - "type_cn": "WorkRole", - "count": 1, - "entities": [ - "掌控禁军" - ] - } - ], - "triplet_samples": [ - { - "subject": "记忆熊", - "predicate": "MENTIONS", - "predicate_cn": "提到", - "object": "宋朝军事弱势" - }, - { - "subject": "宋朝军事弱势", - "predicate": "RESULTED_IN", - "predicate_cn": "resulted in", - "object": "制度性因素" - }, - { - "subject": "记忆熊", - "predicate": "MENTIONS", - "predicate_cn": "提到", - "object": "禁军由文官控制导致作战效率低下" - }, - { - "subject": "禁军由文官控制", - "predicate": "RESULTED_IN", - "predicate_cn": "resulted in", - "object": "作战效率低下" - }, - { - "subject": "记忆熊", - "predicate": "MENTIONS", - "predicate_cn": "提到", - "object": "厢军装备不足" - }, - { - "subject": "记忆熊", - "predicate": "MENTIONS", - "predicate_cn": "提到", - "object": "宋朝" - }, - { - "subject": "记忆熊", - "predicate": "MENTIONS", - "predicate_cn": "提到", - "object": "军费" - } - ], - "self_reflexion": [ - { - "conflict": { - "data": [ - { - "id": "76be6d82d8804beda6baa3d3447d6cbc", - "statement": "学生对\"六部缺乏协调机制\"的具体影响表示理解不足。", - "group_id": "group_123", - "chunk_id": "4a0804127d35456f86d4f06e1fa458f7", - "created_at": "2025-12-12 09:48:00.166068", - "expired_at": null, - "valid_at": null, - "invalid_at": null, - "entity_ids": [] - } - ], - "conflict": true, - "conflict_memory": { - "id": "e268a6fff35543fab471986c188e023e", - "statement": "学生对\"六部缺乏协调机制\"的具体影响表示理解不足。", - "group_id": "group_123", - "chunk_id": "e6cb5f56020e4a8d925d148e1d2fbda0", - "created_at": "2025-12-12 09:48:00.166068", - "expired_at": null, - "valid_at": null, - "invalid_at": null, - "entity_ids": [] - } - }, - "reflexion": { - "reason": "同一学生在不同时间点重复提出对'六部缺乏协调机制'具体影响的理解困难,表明原有解释未能有效解决其认知障碍,存在记忆冗余与教学反馈失效的冲突。", - "solution": "保留后出现的记忆记录(chunk_id为4a0804127d35456f86d4f06e1fa458f7)作为最新学习状态,将其设为有效;将前次相同内容的记忆(id为e268a6fff35543fab471986c188e023e)标记为失效,避免重复干预,并基于后续完整解释优化知识呈现逻辑。" - }, - "resolved": { - "original_memory_id": "e268a6fff35543fab471986c188e023e", - "resolved_memory": { - "id": "e268a6fff35543fab471986c188e023e", - "statement": "学生对\"六部缺乏协调机制\"的具体影响表示理解不足。", - "group_id": "group_123", - "chunk_id": "e6cb5f56020e4a8d925d148e1d2fbda0", - "created_at": "2025-12-12 09:48:00.166068", - "expired_at": null, - "valid_at": null, - "invalid_at": "2025-12-12 09:48:00.166068", - "entity_ids": [] - } - } - } - ] - } \ No newline at end of file +} \ No newline at end of file diff --git a/web/src/views/MemoryManagement/types.ts b/web/src/views/MemoryManagement/types.ts index f926c6c8..55524462 100644 --- a/web/src/views/MemoryManagement/types.ts +++ b/web/src/views/MemoryManagement/types.ts @@ -23,7 +23,6 @@ export interface Memory { include_dialogue_context: boolean; max_context: string; lambda_mem: string; - lambda_mem: string; offset: string; state: boolean; created_at: string; diff --git a/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx index d3788a74..ef547742 100644 --- a/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx +++ b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx @@ -59,6 +59,11 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text' }) } + const handleDownload = () => { + if (!data.file_path) return + window.open(data.file_path, '_blank') + } + return ( // {data.file_name} ) : ( -
{data.file_name}
+
{data.file_name}
) ) : type === 'last_listen' && /\.(mp3|wav|ogg|m4a|aac)$/i.test(data.file_name) ? ( ) : ( -
{data.file_name}
+
{data.file_name}
) ) : ( -
No file
+
{t('empty.tableEmpty')}
)} From 9de6b4f151223888d296fdccff539a433488e2e7 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 26 Jan 2026 11:06:49 +0800 Subject: [PATCH 060/175] =?UTF-8?q?=E6=84=9F=E7=9F=A5meta=5Fdata=E5=AD=97?= =?UTF-8?q?=E6=AE=B5BUG=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/memory_perceptual_schema.py | 2 +- api/app/services/memory_perceptual_service.py | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/api/app/schemas/memory_perceptual_schema.py b/api/app/schemas/memory_perceptual_schema.py index e82d9526..7dfefe01 100644 --- a/api/app/schemas/memory_perceptual_schema.py +++ b/api/app/schemas/memory_perceptual_schema.py @@ -43,7 +43,7 @@ class PerceptualMemoryItem(BaseModel): file_name: str = Field(..., description="File name") file_ext: str = Field(..., description="File extension") summary: Optional[str] = Field(None, description="summary") - meta_data: str = Field(...,description="") + meta_data: Optional[dict] = Field(None, description="Metadata information") created_time: int = Field(..., description="create time") topic: str = Field(..., description="topic") diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index b65955f2..b9d96a0b 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -137,8 +137,19 @@ class MemoryPerceptualService: memory_items = [] for memory in memories: meta_data = memory.meta_data or {} - content = meta_data.get("content") - content = Content(**content) + content = meta_data.get("content", {}) + + # 安全地提取 content 字段,提供默认值 + if content: + content_obj = Content(**content) + topic = content_obj.topic + domain = content_obj.domain + keywords = content_obj.keywords + else: + topic = "Unknown" + domain = "Unknown" + keywords = [] + memory_item = PerceptualMemoryItem( id=memory.id, perceptual_type=PerceptualType(memory.perceptual_type), @@ -146,9 +157,10 @@ class MemoryPerceptualService: file_name=memory.file_name, file_ext=memory.file_ext, summary=memory.summary, - topic=content.topic, - domain=content.domain, - keywords=content.keywords, + meta_data=meta_data, + topic=topic, + domain=domain, + keywords=keywords, created_time=int(memory.created_time.timestamp()*1000), storage_service=FileStorageService(memory.storage_service), ) From 36017378693906f1c89744ef9284671a72821e15 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:53:34 +0800 Subject: [PATCH 061/175] Fix/memory bug fix (#171) --- api/app/__init__.py | 0 .../controllers/emotion_config_controller.py | 7 +- api/app/controllers/emotion_controller.py | 38 +-- .../controllers/implicit_memory_controller.py | 80 +++--- .../controllers/memory_agent_controller.py | 52 ++-- .../controllers/memory_forget_controller.py | 25 +- .../memory_perceptual_controller.py | 66 ++--- .../memory_reflection_controller.py | 41 +-- .../controllers/memory_storage_controller.py | 5 +- .../controllers/memory_working_controller.py | 16 +- .../service/memory_api_controller.py | 2 +- .../controllers/user_memory_controllers.py | 18 +- api/app/core/agent/langchain_agent.py | 18 +- .../langgraph_graph/nodes/problem_nodes.py | 8 +- .../langgraph_graph/nodes/retrieve_nodes.py | 18 +- .../langgraph_graph/nodes/summary_nodes.py | 16 +- .../nodes/verification_nodes.py | 6 +- .../langgraph_graph/nodes/write_nodes.py | 17 +- .../agent/langgraph_graph/read_graph.py | 6 +- .../agent/langgraph_graph/tools/tool.py | 30 +-- .../agent/langgraph_graph/write_graph.py | 23 +- .../agent/services/parameter_builder.py | 6 +- .../memory/agent/services/search_service.py | 8 +- .../memory/agent/services/session_service.py | 18 +- .../core/memory/agent/utils/get_dialogs.py | 32 +-- api/app/core/memory/agent/utils/llm_tools.py | 10 +- api/app/core/memory/agent/utils/redis_tool.py | 26 +- .../core/memory/agent/utils/session_tools.py | 18 +- .../core/memory/agent/utils/write_tools.py | 16 +- .../core/memory/analytics/hot_memory_tags.py | 36 +-- .../analytics/implicit_memory/data_source.py | 4 +- .../memory/evaluation/dialogue_queries.py | 4 +- .../memory/evaluation/extraction_utils.py | 12 +- .../evaluation/locomo/locomo_benchmark.py | 26 +- .../memory/evaluation/locomo/locomo_test.py | 2 +- .../memory/evaluation/locomo/locomo_utils.py | 18 +- .../evaluation/locomo/qwen_search_eval.py | 22 +- .../longmemeval/qwen_search_eval.py | 58 ++--- .../evaluation/longmemeval/test_eval.py | 58 ++--- .../memory/evaluation/memsciqa/evaluate_qa.py | 12 +- .../evaluation/memsciqa/memsciqa-test.py | 12 +- api/app/core/memory/evaluation/run_eval.py | 18 +- .../core/memory/llm_tools/chunker_client.py | 24 +- api/app/core/memory/models/config_models.py | 4 +- api/app/core/memory/models/graph_models.py | 16 +- api/app/core/memory/models/message_models.py | 20 +- api/app/core/memory/src/search.py | 45 ++-- .../data_preprocessing/data_preprocessor.py | 10 +- .../deduplication/deduped_and_disamb.py | 18 +- .../deduplication/entity_dedup_llm.py | 18 +- .../deduplication/second_layer_dedup.py | 8 +- .../deduplication/two_stage_dedup.py | 14 +- .../extraction_orchestrator.py | 68 ++--- .../knowledge_extraction/memory_summary.py | 6 +- .../statement_extraction.py | 16 +- .../temporal_extraction.py | 2 +- .../triplet_extraction.py | 2 +- .../access_history_manager.py | 86 +++---- .../forgetting_engine/config_utils.py | 16 +- .../forgetting_engine/forgetting_scheduler.py | 31 +-- .../forgetting_engine/forgetting_strategy.py | 31 +-- .../storage_services/search/__init__.py | 6 +- .../storage_services/search/hybrid_search.py | 14 +- .../storage_services/search/keyword_search.py | 12 +- .../search/search_strategy.py | 10 +- .../search/semantic_search.py | 12 +- api/app/core/memory/utils/config/get_data.py | 4 +- api/app/core/memory/utils/log/audit_logger.py | 12 +- api/app/core/rag/vdb/field.py | 2 +- .../validators/memory_config_validators.py | 10 +- api/app/core/workflow/nodes/memory/config.py | 5 +- api/app/core/workflow/nodes/memory/node.py | 2 +- api/app/models/__init__.py | 4 +- api/app/models/data_config_model.py | 88 ------- api/app/models/memory_config_model.py | 119 ++++++--- api/app/models/memory_perceptual_model.py | 2 +- ...ository.py => memory_config_repository.py} | 210 ++++++++-------- .../memory_perceptual_repository.py | 4 +- api/app/repositories/neo4j/add_edges.py | 4 +- api/app/repositories/neo4j/add_nodes.py | 22 +- .../neo4j/base_neo4j_repository.py | 2 +- api/app/repositories/neo4j/cypher_queries.py | 171 +++++-------- .../repositories/neo4j/dialog_repository.py | 34 +-- .../repositories/neo4j/emotion_repository.py | 24 +- api/app/repositories/neo4j/graph_saver.py | 12 +- api/app/repositories/neo4j/graph_search.py | 236 +++++++++--------- .../neo4j/memory_summary_repository.py | 48 ++-- api/app/repositories/neo4j/neo4j_connector.py | 48 +--- .../neo4j/statement_repository.py | 2 +- api/app/schemas/app_schema.py | 12 + api/app/schemas/emotion_schema.py | 11 +- api/app/schemas/memory_agent_schema.py | 6 +- api/app/schemas/memory_config_schema.py | 20 +- api/app/schemas/memory_perceptual_schema.py | 8 +- api/app/schemas/memory_reflection_schemas.py | 3 +- api/app/schemas/memory_storage_schema.py | 34 +-- api/app/schemas/model_schema.py | 14 +- api/app/schemas/release_share_schema.py | 14 +- api/app/services/draft_run_service.py | 2 +- api/app/services/emotion_analytics_service.py | 14 +- api/app/services/emotion_config_service.py | 16 +- .../services/emotion_extraction_service.py | 4 +- api/app/services/memory_agent_service.py | 150 +++++------ api/app/services/memory_api_service.py | 21 +- api/app/services/memory_base_service.py | 18 +- api/app/services/memory_config_service.py | 91 ++++--- .../memory_entity_relationship_service.py | 4 +- api/app/services/memory_episodic_service.py | 30 +-- api/app/services/memory_explicit_service.py | 16 +- api/app/services/memory_forget_service.py | 81 +++--- api/app/services/memory_konwledges_server.py | 14 +- api/app/services/memory_perceptual_service.py | 26 +- api/app/services/memory_reflection_service.py | 50 ++-- api/app/services/memory_storage_service.py | 52 ++-- api/app/services/pilot_run_service.py | 2 +- api/app/services/user_memory_service.py | 42 ++-- api/app/tasks.py | 164 +++++++++--- api/app/utils/app_config_utils.py | 23 ++ api/uv.lock | 2 +- 119 files changed, 1711 insertions(+), 1695 deletions(-) create mode 100644 api/app/__init__.py delete mode 100644 api/app/models/data_config_model.py rename api/app/repositories/{data_config_repository.py => memory_config_repository.py} (73%) diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/app/controllers/emotion_config_controller.py b/api/app/controllers/emotion_config_controller.py index 76450d8a..b0015bc2 100644 --- a/api/app/controllers/emotion_config_controller.py +++ b/api/app/controllers/emotion_config_controller.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, Query, HTTPException, status from pydantic import BaseModel, Field from typing import Optional from sqlalchemy.orm import Session +from uuid import UUID from app.core.response_utils import success from app.dependencies import get_current_user @@ -32,11 +33,11 @@ router = APIRouter( class EmotionConfigQuery(BaseModel): """情绪配置查询请求模型""" - config_id: int = Field(..., description="配置ID") + config_id: UUID = Field(..., description="配置ID") class EmotionConfigUpdate(BaseModel): """情绪配置更新请求模型""" - config_id: int = Field(..., description="配置ID") + config_id: UUID = Field(..., description="配置ID") emotion_enabled: bool = Field(..., description="是否启用情绪提取") emotion_model_id: Optional[str] = Field(None, description="情绪分析专用模型ID") emotion_extract_keywords: bool = Field(..., description="是否提取情绪关键词") @@ -45,7 +46,7 @@ class EmotionConfigUpdate(BaseModel): @router.get("/read_config", response_model=ApiResponse) def get_emotion_config( - config_id: int = Query(..., description="配置ID"), + config_id: UUID = Query(..., description="配置ID"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): diff --git a/api/app/controllers/emotion_controller.py b/api/app/controllers/emotion_controller.py index 154a3928..cd199aa7 100644 --- a/api/app/controllers/emotion_controller.py +++ b/api/app/controllers/emotion_controller.py @@ -53,7 +53,7 @@ async def get_emotion_tags( api_logger.info( f"用户 {current_user.username} 请求获取情绪标签统计", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "emotion_type": request.emotion_type, "start_date": request.start_date, "end_date": request.end_date, @@ -63,7 +63,7 @@ async def get_emotion_tags( # 调用服务层 data = await emotion_service.get_emotion_tags( - end_user_id=request.group_id, + end_user_id=request.end_user_id, emotion_type=request.emotion_type, start_date=request.start_date, end_date=request.end_date, @@ -73,7 +73,7 @@ async def get_emotion_tags( api_logger.info( "情绪标签统计获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "total_count": data.get("total_count", 0), "tags_count": len(data.get("tags", [])) } @@ -84,7 +84,7 @@ async def get_emotion_tags( except Exception as e: api_logger.error( f"获取情绪标签统计失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -105,7 +105,7 @@ async def get_emotion_wordcloud( api_logger.info( f"用户 {current_user.username} 请求获取情绪词云数据", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "emotion_type": request.emotion_type, "limit": request.limit } @@ -113,7 +113,7 @@ async def get_emotion_wordcloud( # 调用服务层 data = await emotion_service.get_emotion_wordcloud( - end_user_id=request.group_id, + end_user_id=request.end_user_id, emotion_type=request.emotion_type, limit=request.limit ) @@ -121,7 +121,7 @@ async def get_emotion_wordcloud( api_logger.info( "情绪词云数据获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "total_keywords": data.get("total_keywords", 0) } ) @@ -131,7 +131,7 @@ async def get_emotion_wordcloud( except Exception as e: api_logger.error( f"获取情绪词云数据失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -159,21 +159,21 @@ async def get_emotion_health( api_logger.info( f"用户 {current_user.username} 请求获取情绪健康指数", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "time_range": request.time_range } ) # 调用服务层 data = await emotion_service.calculate_emotion_health_index( - end_user_id=request.group_id, + end_user_id=request.end_user_id, time_range=request.time_range ) api_logger.info( "情绪健康指数获取成功", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "health_score": data.get("health_score", 0), "level": data.get("level", "未知") } @@ -186,7 +186,7 @@ async def get_emotion_health( except Exception as e: api_logger.error( f"获取情绪健康指数失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( @@ -206,7 +206,7 @@ async def get_emotion_suggestions( """获取个性化情绪建议(从缓存读取) Args: - request: 包含 group_id 和可选的 config_id + request: 包含 end_user_id 和可选的 config_id db: 数据库会话 current_user: 当前用户 @@ -217,22 +217,22 @@ async def get_emotion_suggestions( api_logger.info( f"用户 {current_user.username} 请求获取个性化情绪建议(缓存)", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "config_id": request.config_id } ) # 从缓存获取建议 data = await emotion_service.get_cached_suggestions( - end_user_id=request.group_id, + end_user_id=request.end_user_id, db=db ) if data is None: # 缓存不存在或已过期 api_logger.info( - f"用户 {request.group_id} 的建议缓存不存在或已过期", - extra={"group_id": request.group_id} + f"用户 {request.end_user_id} 的建议缓存不存在或已过期", + extra={"end_user_id": request.end_user_id} ) return fail( BizCode.NOT_FOUND, @@ -243,7 +243,7 @@ async def get_emotion_suggestions( api_logger.info( "个性化建议获取成功(缓存)", extra={ - "group_id": request.group_id, + "end_user_id": request.end_user_id, "suggestions_count": len(data.get("suggestions", [])) } ) @@ -253,7 +253,7 @@ async def get_emotion_suggestions( except Exception as e: api_logger.error( f"获取个性化建议失败: {str(e)}", - extra={"group_id": request.group_id}, + extra={"end_user_id": request.end_user_id}, exc_info=True ) raise HTTPException( diff --git a/api/app/controllers/implicit_memory_controller.py b/api/app/controllers/implicit_memory_controller.py index a53290e2..96e437d6 100644 --- a/api/app/controllers/implicit_memory_controller.py +++ b/api/app/controllers/implicit_memory_controller.py @@ -122,10 +122,10 @@ def validate_confidence_threshold(threshold: float) -> None: raise ValueError("confidence_threshold must be between 0.0 and 1.0") -@router.get("/preferences/{user_id}", response_model=ApiResponse) +@router.get("/preferences/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_preference_tags( - user_id: str, + end_user_id: str, confidence_threshold: float = Query(0.5, ge=0.0, le=1.0, description="Minimum confidence threshold"), tag_category: Optional[str] = Query(None, description="Filter by tag category"), start_date: Optional[datetime] = Query(None, description="Filter start date"), @@ -137,7 +137,7 @@ async def get_preference_tags( Get user preference tags from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID confidence_threshold: Minimum confidence score (0.0-1.0) tag_category: Optional category filter start_date: Optional start date filter @@ -146,20 +146,20 @@ async def get_preference_tags( Returns: List of preference tags from cache """ - api_logger.info(f"Preference tags requested for user: {user_id} (from cache)") + api_logger.info(f"Preference tags requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -192,17 +192,17 @@ async def get_preference_tags( filtered_preferences.append(pref) - api_logger.info(f"Retrieved {len(filtered_preferences)} preference tags for user: {user_id} (from cache)") + api_logger.info(f"Retrieved {len(filtered_preferences)} preference tags for user: {end_user_id} (from cache)") return success(data=filtered_preferences, msg="偏好标签获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "偏好标签获取", user_id) + return handle_implicit_memory_error(e, "偏好标签获取", end_user_id) -@router.get("/portrait/{user_id}", response_model=ApiResponse) +@router.get("/portrait/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_dimension_portrait( - user_id: str, + end_user_id: str, include_history: bool = Query(False, description="Include historical trends"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -211,26 +211,26 @@ async def get_dimension_portrait( Get user's four-dimension personality portrait from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID include_history: Whether to include historical trend data (ignored for cached data) Returns: Four-dimension personality portrait from cache """ - api_logger.info(f"Dimension portrait requested for user: {user_id} (from cache)") + api_logger.info(f"Dimension portrait requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -240,17 +240,17 @@ async def get_dimension_portrait( # Extract portrait from cache portrait = cached_profile.get("portrait", {}) - api_logger.info(f"Dimension portrait retrieved for user: {user_id} (from cache)") + api_logger.info(f"Dimension portrait retrieved for user: {end_user_id} (from cache)") return success(data=portrait, msg="四维画像获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "四维画像获取", user_id) + return handle_implicit_memory_error(e, "四维画像获取", end_user_id) -@router.get("/interest-areas/{user_id}", response_model=ApiResponse) +@router.get("/interest-areas/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_interest_area_distribution( - user_id: str, + end_user_id: str, include_trends: bool = Query(False, description="Include trend analysis"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) @@ -259,26 +259,26 @@ async def get_interest_area_distribution( Get user's interest area distribution from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID include_trends: Whether to include trend analysis data (ignored for cached data) Returns: Interest area distribution from cache """ - api_logger.info(f"Interest area distribution requested for user: {user_id} (from cache)") + api_logger.info(f"Interest area distribution requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -288,17 +288,17 @@ async def get_interest_area_distribution( # Extract interest areas from cache interest_areas = cached_profile.get("interest_areas", {}) - api_logger.info(f"Interest area distribution retrieved for user: {user_id} (from cache)") + api_logger.info(f"Interest area distribution retrieved for user: {end_user_id} (from cache)") return success(data=interest_areas, msg="兴趣领域分布获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "兴趣领域分布获取", user_id) + return handle_implicit_memory_error(e, "兴趣领域分布获取", end_user_id) -@router.get("/habits/{user_id}", response_model=ApiResponse) +@router.get("/habits/{end_user_id}", response_model=ApiResponse) @cur_workspace_access_guard() async def get_behavior_habits( - user_id: str, + end_user_id: str, confidence_level: Optional[str] = Query(None, regex="^(high|medium|low)$", description="Filter by confidence level"), frequency_pattern: Optional[str] = Query(None, regex="^(daily|weekly|monthly|seasonal|occasional|event_triggered)$", description="Filter by frequency pattern"), time_period: Optional[str] = Query(None, regex="^(current|past)$", description="Filter by time period"), @@ -309,7 +309,7 @@ async def get_behavior_habits( Get user's behavioral habits from cache. Args: - user_id: Target user ID + end_user_id: Target end user ID confidence_level: Filter by confidence level (high, medium, low) frequency_pattern: Filter by frequency pattern (daily, weekly, monthly, seasonal, occasional, event_triggered) time_period: Filter by time period (current, past) @@ -317,20 +317,20 @@ async def get_behavior_habits( Returns: List of behavioral habits from cache """ - api_logger.info(f"Behavior habits requested for user: {user_id} (from cache)") + api_logger.info(f"Behavior habits requested for user: {end_user_id} (from cache)") try: # Validate inputs - validate_user_id(user_id) + validate_user_id(end_user_id) # Create service with user-specific config - service = ImplicitMemoryService(db=db, end_user_id=user_id) + service = ImplicitMemoryService(db=db, end_user_id=end_user_id) # Get cached profile - cached_profile = await service.get_cached_profile(end_user_id=user_id, db=db) + cached_profile = await service.get_cached_profile(end_user_id=end_user_id, db=db) if cached_profile is None: - api_logger.info(f"用户 {user_id} 的画像缓存不存在或已过期") + api_logger.info(f"用户 {end_user_id} 的画像缓存不存在或已过期") return fail( BizCode.NOT_FOUND, "画像缓存不存在或已过期,请右上角刷新生成新画像", @@ -368,11 +368,11 @@ async def get_behavior_habits( filtered_habits.append(habit) - api_logger.info(f"Retrieved {len(filtered_habits)} behavior habits for user: {user_id} (from cache)") + api_logger.info(f"Retrieved {len(filtered_habits)} behavior habits for user: {end_user_id} (from cache)") return success(data=filtered_habits, msg="行为习惯获取成功(缓存)") except Exception as e: - return handle_implicit_memory_error(e, "行为习惯获取", user_id) + return handle_implicit_memory_error(e, "行为习惯获取", end_user_id) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index c54fb02b..61b16d9e 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -125,7 +125,7 @@ async def write_server( Write service endpoint - processes write operations synchronously Args: - user_input: Write request containing message and group_id + user_input: Write request containing message and end_user_id Returns: Response with write operation status @@ -160,19 +160,18 @@ async def write_server( api_logger.warning("workspace_id 为空,无法使用 rag 存储,将使用 neo4j 存储") storage_type = 'neo4j' - api_logger.info(f"Write service requested for group {user_input.group_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") + api_logger.info(f"Write service requested for group {user_input.end_user_id}, storage_type: {storage_type}, user_rag_memory_id: {user_rag_memory_id}") try: - # 获取标准化的消息列表 messages_list = memory_agent_service.get_messages_list(user_input) - result = await memory_agent_service.write_memory( - user_input.group_id, - messages_list, # 传递结构化消息列表 + user_input.end_user_id, + messages_list, config_id, db, storage_type, user_rag_memory_id ) + return success(data=result, msg="写入成功") except BaseException as e: # Handle ExceptionGroup from TaskGroup (Python 3.11+) or BaseExceptionGroup @@ -196,7 +195,7 @@ async def write_server_async( Async write service endpoint - enqueues write processing to Celery Args: - user_input: Write request containing message and group_id + user_input: Write request containing message and end_user_id Returns: Task ID for tracking async operation @@ -226,10 +225,10 @@ async def write_server_async( try: # 获取标准化的消息列表 messages_list = memory_agent_service.get_messages_list(user_input) - + task = celery_app.send_task( "app.core.memory.agent.write_message", - args=[user_input.group_id, messages_list, config_id, storage_type, user_rag_memory_id] + args=[user_input.end_user_id, messages_list, config_id, storage_type, user_rag_memory_id] ) api_logger.info(f"Write task queued: {task.id}") @@ -255,7 +254,7 @@ async def read_server( - "2": Direct answer based on context Args: - user_input: Read request with message, history, search_switch, and group_id + user_input: Read request with message, history, search_switch, and end_user_id Returns: Response with query answer @@ -277,12 +276,13 @@ async def read_server( name="USER_RAG_MERORY", workspace_id=workspace_id ) - if knowledge: user_rag_memory_id = str(knowledge.id) + if knowledge: + user_rag_memory_id = str(knowledge.id) - api_logger.info(f"Read service: group={user_input.group_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") + api_logger.info(f"Read service: group={user_input.end_user_id}, storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}, workspace_id={workspace_id}") try: result = await memory_agent_service.read_memory( - user_input.group_id, + user_input.end_user_id, user_input.message, user_input.history, user_input.search_switch, @@ -293,12 +293,12 @@ async def read_server( ) if str(user_input.search_switch) == "2": retrieve_info = result['answer'] - history = await SessionService(store).get_history(user_input.group_id, user_input.group_id, user_input.group_id) + history = await SessionService(store).get_history(user_input.end_user_id, user_input.end_user_id, user_input.end_user_id) query = user_input.message - + # 调用 memory_agent_service 的方法生成最终答案 result['answer'] = await memory_agent_service.generate_summary_from_retrieve( - group_id=user_input.group_id, + end_user_id=user_input.end_user_id, retrieve_info=retrieve_info, history=history, query=query, @@ -404,7 +404,7 @@ async def read_server_async( try: task = celery_app.send_task( "app.core.memory.agent.read_message", - args=[user_input.group_id, user_input.message, user_input.history, user_input.search_switch, + args=[user_input.end_user_id, user_input.message, user_input.history, user_input.search_switch, config_id, storage_type, user_rag_memory_id] ) api_logger.info(f"Read task queued: {task.id}") @@ -448,7 +448,7 @@ async def get_read_task_result( return success( data={ "result": task_result.get("result"), - "group_id": task_result.get("group_id"), + "end_user_id": task_result.get("end_user_id"), "elapsed_time": task_result.get("elapsed_time"), "task_id": task_id }, @@ -525,7 +525,7 @@ async def get_write_task_result( return success( data={ "result": task_result.get("result"), - "group_id": task_result.get("group_id"), + "end_user_id": task_result.get("end_user_id"), "elapsed_time": task_result.get("elapsed_time"), "task_id": task_id }, @@ -579,16 +579,16 @@ async def status_type( Determine the type of user message (read or write) Args: - user_input: Request containing user message and group_id + user_input: Request containing user message and end_user_id Returns: Type classification result """ - api_logger.info(f"Status type check requested for group {user_input.group_id}") + api_logger.info(f"Status type check requested for group {user_input.end_user_id}") try: # 获取标准化的消息列表 messages_list = memory_agent_service.get_messages_list(user_input) - + # 将消息列表转换为字符串用于分类 # 只取最后一条用户消息进行分类 last_user_message = "" @@ -596,11 +596,11 @@ async def status_type( if msg.get('role') == 'user': last_user_message = msg.get('content', '') break - + if not last_user_message: # 如果没有用户消息,使用所有消息的内容 last_user_message = " ".join([msg.get('content', '') for msg in messages_list]) - + result = await memory_agent_service.classify_message_type( last_user_message, user_input.config_id, @@ -625,7 +625,7 @@ async def get_knowledge_type_stats_api( 会对缺失类型补 0,返回字典形式。 可选按状态过滤。 - 知识库类型根据当前用户的 current_workspace_id 过滤 - - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (group_id) 过滤 + - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (end_user_id) 过滤 - 如果用户没有当前工作空间或未提供 end_user_id,对应的统计返回 0 """ api_logger.info(f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") @@ -698,7 +698,7 @@ async def get_user_profile_api( current_user: User = Depends(get_current_user) ): """ - 获取工作空间下Popular Memory Tags,包含: + 获取用户详情,包含: - name: 用户名字(直接使用 end_user_id) - tags: 3个用户特征标签(从语句和实体中LLM总结) - hot_tags: 4个热门记忆标签 diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index ca628d0c..a6b6028f 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -11,6 +11,7 @@ """ from typing import Optional +from uuid import UUID from fastapi import APIRouter, Depends from sqlalchemy.orm import Session @@ -106,7 +107,7 @@ async def trigger_forgetting_cycle( # 调用服务层执行遗忘周期 report = await forget_service.trigger_forgetting_cycle( db=db, - group_id=end_user_id, # 服务层方法的参数名是 group_id + end_user_id=end_user_id, # 服务层方法的参数名是 end_user_id max_merge_batch_size=payload.max_merge_batch_size, min_days_since_access=payload.min_days_since_access, config_id=config_id @@ -128,7 +129,7 @@ async def trigger_forgetting_cycle( @router.get("/read_config", response_model=ApiResponse) async def read_forgetting_config( - config_id: int, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -236,7 +237,7 @@ async def update_forgetting_config( @router.get("/stats", response_model=ApiResponse) async def get_forgetting_stats( - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -246,7 +247,7 @@ async def get_forgetting_stats( 返回知识层节点统计、激活值分布等信息。 Args: - group_id: 组ID(即 end_user_id,可选) + end_user_id: 组ID(即 end_user_id,可选) current_user: 当前用户 db: 数据库会话 @@ -260,20 +261,20 @@ async def get_forgetting_stats( api_logger.warning(f"用户 {current_user.username} 尝试获取遗忘引擎统计但未选择工作空间") return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - # 如果提供了 group_id,通过它获取 config_id + # 如果提供了 end_user_id,通过它获取 config_id config_id = None - if group_id: + if end_user_id: try: from app.services.memory_agent_service import get_end_user_connected_config - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if config_id is None: - api_logger.warning(f"终端用户 {group_id} 未关联记忆配置") - return fail(BizCode.INVALID_PARAMETER, f"终端用户 {group_id} 未关联记忆配置", "memory_config_id is None") + api_logger.warning(f"终端用户 {end_user_id} 未关联记忆配置") + return fail(BizCode.INVALID_PARAMETER, f"终端用户 {end_user_id} 未关联记忆配置", "memory_config_id is None") - api_logger.debug(f"通过 group_id={group_id} 获取到 config_id={config_id}") + api_logger.debug(f"通过 end_user_id={end_user_id} 获取到 config_id={config_id}") except ValueError as e: api_logger.warning(f"获取终端用户配置失败: {str(e)}") return fail(BizCode.INVALID_PARAMETER, str(e), "ValueError") @@ -283,14 +284,14 @@ async def get_forgetting_stats( api_logger.info( f"用户 {current_user.username} 在工作空间 {workspace_id} 请求获取遗忘引擎统计: " - f"group_id={group_id}, config_id={config_id}" + f"end_user_id={end_user_id}, config_id={config_id}" ) try: # 调用服务层获取统计信息 stats = await forget_service.get_forgetting_stats( db=db, - group_id=group_id, + end_user_id=end_user_id, config_id=config_id ) diff --git a/api/app/controllers/memory_perceptual_controller.py b/api/app/controllers/memory_perceptual_controller.py index 5154c763..44750808 100644 --- a/api/app/controllers/memory_perceptual_controller.py +++ b/api/app/controllers/memory_perceptual_controller.py @@ -27,27 +27,27 @@ router = APIRouter( ) -@router.get("/{group_id}/count", response_model=ApiResponse) +@router.get("/{end_user_id}/count", response_model=ApiResponse) def get_memory_count( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve perceptual memory statistics for a user group. Args: - group_id: ID of the user group (usually end_user_id in this context) + end_user_id: ID of the user group (usually end_user_id in this context) current_user: Current authenticated user db: Database session Returns: ApiResponse: Response containing memory count statistics """ - api_logger.info(f"Fetching perceptual memory statistics: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching perceptual memory statistics: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - count_stats = service.get_memory_count(group_id) + count_stats = service.get_memory_count(end_user_id) api_logger.info(f"Memory statistics fetched successfully: total={count_stats.get('total', 0)}") @@ -57,37 +57,37 @@ def get_memory_count( ) except Exception as e: - api_logger.error(f"Failed to fetch memory statistics: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch memory statistics: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch memory statistics", ) -@router.get("/{group_id}/last_visual", response_model=ApiResponse) +@router.get("/{end_user_id}/last_visual", response_model=ApiResponse) def get_last_visual_memory( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent VISION-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest visual memory """ - api_logger.info(f"Fetching latest visual memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest visual memory: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - visual_memory = service.get_latest_visual_memory(group_id) + visual_memory = service.get_latest_visual_memory(end_user_id) if visual_memory is None: - api_logger.info(f"No visual memory found: group_id={group_id}") + api_logger.info(f"No visual memory found: end_user_id={end_user_id}") return success( data=None, msg="No visual memory available" @@ -101,37 +101,37 @@ def get_last_visual_memory( ) except Exception as e: - api_logger.error(f"Failed to fetch latest visual memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest visual memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest visual memory", ) -@router.get("/{group_id}/last_listen", response_model=ApiResponse) +@router.get("/{end_user_id}/last_listen", response_model=ApiResponse) def get_last_memory_listen( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent AUDIO-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest audio memory """ - api_logger.info(f"Fetching latest audio memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest audio memory: user={current_user.username}, end_user_id={end_user_id}") try: service = MemoryPerceptualService(db) - audio_memory = service.get_latest_audio_memory(group_id) + audio_memory = service.get_latest_audio_memory(end_user_id) if audio_memory is None: - api_logger.info(f"No audio memory found: group_id={group_id}") + api_logger.info(f"No audio memory found: end_user_id={end_user_id}") return success( data=None, msg="No audio memory available" @@ -145,38 +145,38 @@ def get_last_memory_listen( ) except Exception as e: - api_logger.error(f"Failed to fetch latest audio memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest audio memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest audio memory", ) -@router.get("/{group_id}/last_text", response_model=ApiResponse) +@router.get("/{end_user_id}/last_text", response_model=ApiResponse) def get_last_text_memory( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """Retrieve the most recent TEXT-type memory for a user. Args: - group_id: ID of the user group + end_user_id: ID of the user group current_user: Current authenticated user db: Database session Returns: ApiResponse: Metadata of the latest text memory """ - api_logger.info(f"Fetching latest text memory: user={current_user.username}, group_id={group_id}") + api_logger.info(f"Fetching latest text memory: user={current_user.username}, end_user_id={end_user_id}") try: # 调用服务层获取最近的文本记忆 service = MemoryPerceptualService(db) - text_memory = service.get_latest_text_memory(group_id) + text_memory = service.get_latest_text_memory(end_user_id) if text_memory is None: - api_logger.info(f"No text memory found: group_id={group_id}") + api_logger.info(f"No text memory found: end_user_id={end_user_id}") return success( data=None, msg="No text memory available" @@ -190,16 +190,16 @@ def get_last_text_memory( ) except Exception as e: - api_logger.error(f"Failed to fetch latest text memory: group_id={group_id}, error={str(e)}") + api_logger.error(f"Failed to fetch latest text memory: end_user_id={end_user_id}, error={str(e)}") return fail( code=BizCode.INTERNAL_ERROR, msg="Failed to fetch latest text memory", ) -@router.get("/{group_id}/timeline", response_model=ApiResponse) +@router.get("/{end_user_id}/timeline", response_model=ApiResponse) def get_memory_time_line( - group_id: uuid.UUID, + end_user_id: uuid.UUID, perceptual_type: Optional[PerceptualType] = Query(None, description="感知类型过滤"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(10, ge=1, le=100, description="每页大小"), @@ -209,7 +209,7 @@ def get_memory_time_line( """Retrieve a timeline of perceptual memories for a user group. Args: - group_id: ID of the user group + end_user_id: ID of the user group perceptual_type: Optional filter for perceptual type page: Page number for pagination page_size: Number of items per page @@ -221,7 +221,7 @@ def get_memory_time_line( """ api_logger.info( f"Fetching perceptual memory timeline: user={current_user.username}, " - f"group_id={group_id}, type={perceptual_type}, page={page}" + f"end_user_id={end_user_id}, type={perceptual_type}, page={page}" ) try: @@ -232,7 +232,7 @@ def get_memory_time_line( ) service = MemoryPerceptualService(db) - timeline_data = service.get_time_line(group_id, query) + timeline_data = service.get_time_line(end_user_id, query) api_logger.info( f"Perceptual memory timeline retrieved successfully: total={timeline_data.total}, " @@ -246,7 +246,7 @@ def get_memory_time_line( except Exception as e: api_logger.error( - f"Failed to fetch perceptual memory timeline: group_id={group_id}, " + f"Failed to fetch perceptual memory timeline: end_user_id={end_user_id}, " f"error={str(e)}" ) return fail( diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index abd50a33..ccf9485f 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -1,6 +1,7 @@ import asyncio import time import uuid +from uuid import UUID from app.core.logging_config import get_api_logger from app.core.memory.storage_services.reflection_engine.self_reflexion import ( @@ -11,7 +12,7 @@ from app.core.response_utils import success from app.db import get_db from app.dependencies import get_current_user from app.models.user_model import User -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_reflection_schemas import Memory_Reflection from app.services.memory_reflection_service import ( @@ -50,7 +51,7 @@ async def save_reflection_config( api_logger.info(f"用户 {current_user.username} 保存反思配置,config_id: {config_id}") - data_config = DataConfigRepository.update_reflection_config( + memory_config = MemoryConfigRepository.update_reflection_config( db, config_id=config_id, enable_self_reflexion=request.reflection_enabled, @@ -63,17 +64,17 @@ async def save_reflection_config( ) db.commit() - db.refresh(data_config) + db.refresh(memory_config) reflection_result={ - "config_id": data_config.config_id, - "enable_self_reflexion": data_config.enable_self_reflexion, - "iteration_period": data_config.iteration_period, - "reflexion_range": data_config.reflexion_range, - "baseline": data_config.baseline, - "reflection_model_id": data_config.reflection_model_id, - "memory_verify": data_config.memory_verify, - "quality_assessment": data_config.quality_assessment} + "config_id": memory_config.config_id, + "enable_self_reflexion": memory_config.enable_self_reflexion, + "iteration_period": memory_config.iteration_period, + "reflexion_range": memory_config.reflexion_range, + "baseline": memory_config.baseline, + "reflection_model_id": memory_config.reflection_model_id, + "memory_verify": memory_config.memory_verify, + "quality_assessment": memory_config.quality_assessment} return success(data=reflection_result, msg="反思配置成功") @@ -111,14 +112,14 @@ async def start_workspace_reflection( reflection_results = [] for data in result['apps_detailed_info']: - if data['data_configs'] == []: + if data['memory_configs'] == []: continue releases = data['releases'] - data_configs = data['data_configs'] + memory_configs = data['memory_configs'] end_users = data['end_users'] - for base, config, user in zip(releases, data_configs, end_users): + for base, config, user in zip(releases, memory_configs, end_users): # 安全地转换为整数,处理空字符串和None的情况 print(base['config']) try: @@ -156,14 +157,14 @@ async def start_workspace_reflection( @router.get("/reflection/configs") async def start_reflection_configs( - config_id: int, + config_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: - """通过config_id查询data_config表中的反思配置信息""" + """通过config_id查询memory_config表中的反思配置信息""" try: api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - result = DataConfigRepository.query_reflection_config_by_id(db, config_id) + result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) # 构建返回数据 reflection_config = { "config_id": result.config_id, @@ -191,7 +192,7 @@ async def start_reflection_configs( @router.get("/reflection/run") async def reflection_run( - config_id: int, + config_id: UUID, language_type: str = Header(default="zh", alias="X-Language-Type"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -200,8 +201,8 @@ async def reflection_run( api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - # 使用DataConfigRepository查询反思配置 - result = DataConfigRepository.query_reflection_config_by_id(db, config_id) + # 使用MemoryConfigRepository查询反思配置 + result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) if not result: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index 3722be3d..fb0ebc14 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -1,5 +1,6 @@ import os from typing import Optional +from uuid import UUID from app.core.error_codes import BizCode from app.core.logging_config import get_api_logger @@ -160,7 +161,7 @@ def create_config( @router.delete("/delete_config", response_model=ApiResponse) # 删除数据库中的内容(按配置名称) def delete_config( - config_id: str, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: @@ -232,7 +233,7 @@ def update_config_extracted( @router.get("/read_config_extracted", response_model=ApiResponse) # 通过查询参数读取某条配置(固定路径) 没有意义的话就删除 def read_config_extracted( - config_id: str, + config_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: diff --git a/api/app/controllers/memory_working_controller.py b/api/app/controllers/memory_working_controller.py index dfd64044..e5de3c04 100644 --- a/api/app/controllers/memory_working_controller.py +++ b/api/app/controllers/memory_working_controller.py @@ -20,18 +20,18 @@ router = APIRouter( ) -@router.get("/{group_id}/count", response_model=ApiResponse) +@router.get("/{end_user_id}/count", response_model=ApiResponse) def get_memory_count( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): pass -@router.get("/{group_id}/conversations", response_model=ApiResponse) +@router.get("/{end_user_id}/conversations", response_model=ApiResponse) def get_conversations( - group_id: uuid.UUID, + end_user_id: uuid.UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -39,7 +39,7 @@ def get_conversations( Retrieve all conversations for the current user in a specific group. Args: - group_id (UUID): The group identifier. + end_user_id (UUID): The group identifier. current_user (User, optional): The authenticated user. db (Session, optional): SQLAlchemy session. @@ -53,7 +53,7 @@ def get_conversations( """ conversation_service = ConversationService(db) conversations = conversation_service.get_user_conversations( - group_id + end_user_id ) return success(data=[ { @@ -63,7 +63,7 @@ def get_conversations( ], msg="get conversations success") -@router.get("/{group_id}/messages", response_model=ApiResponse) +@router.get("/{end_user_id}/messages", response_model=ApiResponse) def get_messages( conversation_id: uuid.UUID, current_user: User = Depends(get_current_user), @@ -100,7 +100,7 @@ def get_messages( return success(data=messages, msg="get conversation history success") -@router.get("/{group_id}/detail", response_model=ApiResponse) +@router.get("/{end_user_id}/detail", response_model=ApiResponse) async def get_conversation_detail( conversation_id: uuid.UUID, current_user: User = Depends(get_current_user), diff --git a/api/app/controllers/service/memory_api_controller.py b/api/app/controllers/service/memory_api_controller.py index 30ca1306..accd749e 100644 --- a/api/app/controllers/service/memory_api_controller.py +++ b/api/app/controllers/service/memory_api_controller.py @@ -39,7 +39,7 @@ async def write_memory_api_service( Stores memory content for the specified end user using the Memory API Service. """ - logger.info(f"Memory write request - end_user_id: {payload.end_user_id}") + logger.info(f"Memory write request - end_user_id: {payload.end_user_id}, tenant_id: {api_key_auth.tenant_id}") memory_api_service = MemoryAPIService(db) diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 6f02f8f9..39cbe523 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -135,27 +135,27 @@ async def generate_cache_api( api_logger.warning(f"用户 {current_user.username} 尝试生成缓存但未选择工作空间") return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - group_id = request.end_user_id + end_user_id = request.end_user_id api_logger.info( f"缓存生成请求: user={current_user.username}, workspace={workspace_id}, " - f"end_user_id={group_id if group_id else '全部用户'}" + f"end_user_id={end_user_id if end_user_id else '全部用户'}" ) try: - if group_id: + if end_user_id: # 为单个用户生成 - api_logger.info(f"开始为单个用户生成缓存: end_user_id={group_id}") + api_logger.info(f"开始为单个用户生成缓存: end_user_id={end_user_id}") # 生成记忆洞察 - insight_result = await user_memory_service.generate_and_cache_insight(db, group_id, workspace_id) + insight_result = await user_memory_service.generate_and_cache_insight(db, end_user_id, workspace_id) # 生成用户摘要 - summary_result = await user_memory_service.generate_and_cache_summary(db, group_id, workspace_id) + summary_result = await user_memory_service.generate_and_cache_summary(db, end_user_id, workspace_id) # 构建响应 result = { - "end_user_id": group_id, + "end_user_id": end_user_id, "insight_success": insight_result["success"], "summary_success": summary_result["success"], "errors": [] @@ -175,9 +175,9 @@ async def generate_cache_api( # 记录结果 if result["insight_success"] and result["summary_success"]: - api_logger.info(f"成功为用户 {group_id} 生成缓存") + api_logger.info(f"成功为用户 {end_user_id} 生成缓存") else: - api_logger.warning(f"用户 {group_id} 的缓存生成部分失败: {result['errors']}") + api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {result['errors']}") return success(data=result, msg="生成完成") diff --git a/api/app/core/agent/langchain_agent.py b/api/app/core/agent/langchain_agent.py index 87b46e6f..ddacb094 100644 --- a/api/app/core/agent/langchain_agent.py +++ b/api/app/core/agent/langchain_agent.py @@ -155,13 +155,13 @@ class LangChainAgent: # userid=end_user_end, # messages=messages, # apply_id=end_user_end, - # group_id=end_user_end, + # end_user_id=end_user_end, # aimessages=aimessages # ) # store.delete_duplicate_sessions() # # logger.info(f'Redis_Agent:{end_user_end};{session_id}') # return session_id - + # TODO 乐力齐 - 累积多组对话批量写入功能已禁用 # async def term_memory_redis_read(self,end_user_end): # end_user_end = f"Term_{end_user_end}" @@ -179,7 +179,7 @@ class LangChainAgent: async def write(self, storage_type, end_user_id, user_message, ai_message, user_rag_memory_id, actual_end_user_id, actual_config_id): """ 写入记忆(支持结构化消息) - + Args: storage_type: 存储类型 (neo4j/rag) end_user_id: 终端用户ID @@ -188,7 +188,7 @@ class LangChainAgent: user_rag_memory_id: RAG 记忆ID actual_end_user_id: 实际用户ID actual_config_id: 配置ID - + 逻辑说明: - RAG 模式:组合 user_message 和 ai_message 为字符串格式,保持原有逻辑不变 - Neo4j 模式:使用结构化消息列表 @@ -204,20 +204,20 @@ class LangChainAgent: else: # Neo4j 模式:使用结构化消息列表 structured_messages = [] - + # 始终添加用户消息(如果不为空) if user_message: structured_messages.append({"role": "user", "content": user_message}) - + # 只有当 AI 回复不为空时才添加 assistant 消息 if ai_message: structured_messages.append({"role": "assistant", "content": ai_message}) - + # 如果没有消息,直接返回 if not structured_messages: logger.warning(f"No messages to write for user {actual_end_user_id}") return - + # 调用 Celery 任务,传递结构化消息列表 # 数据流: # 1. structured_messages 传递给 write_message_task @@ -228,7 +228,7 @@ class LangChainAgent: # 6. 每个 Chunk 保存到 Neo4j,包含 speaker 字段 logger.info(f"[WRITE] Submitting Celery task - user={actual_end_user_id}, messages={len(structured_messages)}, config={actual_config_id}") write_id = write_message_task.delay( - actual_end_user_id, # group_id: 用户ID + actual_end_user_id, # end_user_id: 用户ID structured_messages, # message: 结构化消息列表 [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}] actual_config_id, # config_id: 配置ID storage_type, # storage_type: "neo4j" diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py index 2bad650a..ac1fb9a6 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/problem_nodes.py @@ -35,10 +35,10 @@ async def Split_The_Problem(state: ReadState) -> ReadState: """问题分解节点""" # 从状态中获取数据 content = state.get('data', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) # 生成 JSON schema 以指导 LLM 输出正确格式 json_schema = ProblemExtensionResponse.model_json_schema() @@ -140,7 +140,7 @@ async def Problem_Extension(state: ReadState) -> ReadState: start = time.time() content = state.get('data', '') data = state.get('spit_data', '')['context'] - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') memory_config = state.get('memory_config', None) @@ -156,7 +156,7 @@ async def Problem_Extension(state: ReadState) -> ReadState: databasets = {} data = [] - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) # 生成 JSON schema 以指导 LLM 输出正确格式 json_schema = ProblemExtensionResponse.model_json_schema() diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py index 14f8fa8b..1880357c 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/retrieve_nodes.py @@ -52,9 +52,9 @@ async def rag_config(state): return kb_config async def rag_knowledge(state,question): kb_config = await rag_config(state) - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') user_rag_memory_id=state.get("user_rag_memory_id",'') - retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(group_id)]) + retrieve_chunks_result = knowledge_retrieval(question, kb_config, [str(end_user_id)]) try: retrieval_knowledge = [i.page_content for i in retrieve_chunks_result] clean_content = '\n\n'.join(retrieval_knowledge) @@ -159,7 +159,7 @@ async def retrieve_nodes(state: ReadState) -> ReadState: problem_extension=state.get('problem_extension', '')['context'] storage_type=state.get('storage_type', '') user_rag_memory_id=state.get('user_rag_memory_id', '') - group_id=state.get('group_id', '') + end_user_id=state.get('end_user_id', '') memory_config = state.get('memory_config', None) original=state.get('data', '') problem_list=[] @@ -172,7 +172,7 @@ async def retrieve_nodes(state: ReadState) -> ReadState: try: # Prepare search parameters based on storage type search_params = { - "group_id": group_id, + "end_user_id": end_user_id, "question": question, "return_raw_results": True } @@ -263,13 +263,13 @@ async def retrieve_nodes(state: ReadState) -> ReadState: async def retrieve(state: ReadState) -> ReadState: - # 从state中获取group_id + # 从state中获取end_user_id import time start=time.time() problem_extension = state.get('problem_extension', '')['context'] storage_type = state.get('storage_type', '') user_rag_memory_id = state.get('user_rag_memory_id', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) original = state.get('data', '') problem_list = [] @@ -295,13 +295,13 @@ async def retrieve(state: ReadState) -> ReadState: temperature=0.2, ) - time_retrieval_tool = create_time_retrieval_tool(group_id) - search_params = { "group_id": group_id, "return_raw_results": True } + time_retrieval_tool = create_time_retrieval_tool(end_user_id) + search_params = { "end_user_id": end_user_id, "return_raw_results": True } hybrid_retrieval=create_hybrid_retrieval_tool_sync(memory_config, **search_params) agent = create_agent( llm, tools=[time_retrieval_tool,hybrid_retrieval], - system_prompt=f"我是检索专家,可以根据适合的工具进行检索。当前使用的group_id是: {group_id}" + system_prompt=f"我是检索专家,可以根据适合的工具进行检索。当前使用的end_user_id是: {end_user_id}" ) # 创建异步任务处理单个问题 diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py index fb0484d2..0144c0e9 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/summary_nodes.py @@ -34,8 +34,8 @@ class SummaryNodeService(LLMServiceMixin): summary_service = SummaryNodeService() async def summary_history(state: ReadState) -> ReadState: - group_id = state.get("group_id", '') - history = await SessionService(store).get_history(group_id, group_id, group_id) + end_user_id = state.get("end_user_id", '') + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) return history async def summary_llm(state: ReadState, history, retrieve_info, template_name, operation_name, response_model,search_mode) -> str: @@ -122,12 +122,12 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o async def summary_redis_save(state: ReadState,aimessages) -> ReadState: data = state.get("data", '') - group_id = state.get("group_id", '') + end_user_id = state.get("end_user_id", '') await SessionService(store).save_session( - user_id=group_id, + user_id=end_user_id, query=data, - apply_id=group_id, - group_id=group_id, + apply_id=end_user_id, + end_user_id=end_user_id, ai_response=aimessages ) await SessionService(store).cleanup_duplicates() @@ -175,11 +175,11 @@ async def Input_Summary(state: ReadState) -> ReadState: memory_config = state.get('memory_config', None) user_rag_memory_id=state.get("user_rag_memory_id",'') data=state.get("data", '') - group_id=state.get("group_id", '') + end_user_id=state.get("end_user_id", '') logger.info(f"Input_Summary: storage_type={storage_type}, user_rag_memory_id={user_rag_memory_id}") history = await summary_history( state) search_params = { - "group_id": group_id, + "end_user_id": end_user_id, "question": data, "return_raw_results": True, "include": ["summaries"] # Only search summary nodes for faster performance diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py index 10ce8db4..b809faf2 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/verification_nodes.py @@ -62,12 +62,12 @@ async def Verify(state: ReadState): logger.info("=== Verify 节点开始执行 ===") try: content = state.get('data', '') - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', None) - logger.info(f"Verify: content={content[:50] if content else 'empty'}..., group_id={group_id}") + logger.info(f"Verify: content={content[:50] if content else 'empty'}..., end_user_id={end_user_id}") - history = await SessionService(store).get_history(group_id, group_id, group_id) + history = await SessionService(store).get_history(end_user_id, end_user_id, end_user_id) logger.info(f"Verify: 获取历史记录完成,history length={len(history)}") retrieve = state.get("retrieve", {}) diff --git a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py index 6af313c3..b85130ad 100644 --- a/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py +++ b/api/app/core/memory/agent/langgraph_graph/nodes/write_nodes.py @@ -1,23 +1,24 @@ - -from app.core.memory.agent.utils.llm_tools import WriteState +from app.core.memory.agent.utils.llm_tools import WriteState from app.core.memory.agent.utils.write_tools import write from app.core.logging_config import get_agent_logger logger = get_agent_logger(__name__) + + async def write_node(state: WriteState) -> WriteState: """ Write data to the database/file system. Args: - state: WriteState containing messages, group_id, and memory_config + state: WriteState containing messages, end_user_id, and memory_config Returns: dict: Contains 'write_result' with status and data fields """ messages = state.get('messages', []) - group_id = state.get('group_id', '') + end_user_id = state.get('end_user_id', '') memory_config = state.get('memory_config', '') - + # Convert LangChain messages to structured format expected by write() structured_messages = [] for msg in messages: @@ -28,13 +29,11 @@ async def write_node(state: WriteState) -> WriteState: "role": role, "content": msg.content # content is now guaranteed to be a string }) - + try: result = await write( messages=structured_messages, - user_id=group_id, - apply_id=group_id, - group_id=group_id, + end_user_id=end_user_id, memory_config=memory_config, ) logger.info(f"Write completed successfully! Config: {memory_config.config_name}") diff --git a/api/app/core/memory/agent/langgraph_graph/read_graph.py b/api/app/core/memory/agent/langgraph_graph/read_graph.py index 19011a5f..3476d0ec 100644 --- a/api/app/core/memory/agent/langgraph_graph/read_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/read_graph.py @@ -79,7 +79,7 @@ async def make_read_graph(): async def main(): """主函数 - 运行工作流""" message = "昨天有什么好看的电影" - group_id = '88a459f5_text09' # 组ID + end_user_id = '88a459f5_text09' # 组ID storage_type = 'neo4j' # 存储类型 search_switch = '1' # 搜索开关 user_rag_memory_id = 'wwwwwwww' # 用户RAG记忆ID @@ -95,9 +95,9 @@ async def main(): start=time.time() try: async with make_read_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 - initial_state = {"messages": [HumanMessage(content=message)] ,"search_switch":search_switch,"group_id":group_id + initial_state = {"messages": [HumanMessage(content=message)] ,"search_switch":search_switch,"end_user_id":end_user_id ,"storage_type":storage_type,"user_rag_memory_id":user_rag_memory_id,"memory_config":memory_config} # 获取节点更新信息 _intermediate_outputs = [] diff --git a/api/app/core/memory/agent/langgraph_graph/tools/tool.py b/api/app/core/memory/agent/langgraph_graph/tools/tool.py index ce6d5dd4..c4814de1 100644 --- a/api/app/core/memory/agent/langgraph_graph/tools/tool.py +++ b/api/app/core/memory/agent/langgraph_graph/tools/tool.py @@ -48,11 +48,11 @@ def extract_tool_message_content(response): class TimeRetrievalInput(BaseModel): """时间检索工具的输入模式""" context: str = Field(description="用户输入的查询内容") - group_id: str = Field(default="88a459f5_text09", description="组ID,用于过滤搜索结果") + end_user_id: str = Field(default="88a459f5_text09", description="组ID,用于过滤搜索结果") -def create_time_retrieval_tool(group_id: str): +def create_time_retrieval_tool(end_user_id: str): """ - 创建一个带有特定group_id的TimeRetrieval工具(同步版本),用于按时间范围搜索语句(Statements) + 创建一个带有特定end_user_id的TimeRetrieval工具(同步版本),用于按时间范围搜索语句(Statements) """ def clean_temporal_result_fields(data): @@ -93,26 +93,26 @@ def create_time_retrieval_tool(group_id: str): return data @tool - def TimeRetrievalWithGroupId(context: str, start_date: str = None, end_date: str = None, group_id_param: str = None, clean_output: bool = True) -> str: + def TimeRetrievalWithGroupId(context: str, start_date: str = None, end_date: str = None, end_user_id_param: str = None, clean_output: bool = True) -> str: """ 优化的时间检索工具,只结合时间范围搜索(同步版本),自动过滤不需要的元数据字段 显式接收参数: - context: 查询上下文内容 - start_date: 开始时间(可选,格式:YYYY-MM-DD) - end_date: 结束时间(可选,格式:YYYY-MM-DD) - - group_id_param: 组ID(可选,用于覆盖默认组ID) + - end_user_id_param: 组ID(可选,用于覆盖默认组ID) - clean_output: 是否清理输出中的元数据字段 -end_date 需要根据用户的描述获取结束的时间,输出格式用strftime("%Y-%m-%d") """ async def _async_search(): # 使用传入的参数或默认值 - actual_group_id = group_id_param or group_id + actual_end_user_id = end_user_id_param or end_user_id actual_end_date = end_date or datetime.now().strftime("%Y-%m-%d") actual_start_date = start_date or (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") # 基本时间搜索 results = await search_by_temporal( - group_id=actual_group_id, + end_user_id=actual_end_user_id, start_date=actual_start_date, end_date=actual_end_date, limit=10 @@ -147,7 +147,7 @@ def create_time_retrieval_tool(group_id: str): # 关键词时间搜索 results = await search_by_keyword_temporal( query_text=context, - group_id=group_id, + end_user_id=end_user_id, start_date=actual_start_date, end_date=actual_end_date, limit=15 @@ -172,7 +172,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): Args: memory_config: 内存配置对象 - **search_params: 搜索参数,包含group_id, limit, include等 + **search_params: 搜索参数,包含end_user_id, limit, include等 """ def clean_result_fields(data): @@ -211,7 +211,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): context: str, search_type: str = "hybrid", limit: int = 10, - group_id: str = None, + end_user_id: str = None, rerank_alpha: float = 0.6, use_forgetting_rerank: bool = False, use_llm_rerank: bool = False, @@ -224,7 +224,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): context: 查询内容 search_type: 搜索类型 ('keyword', 'embedding', 'hybrid') limit: 结果数量限制 - group_id: 组ID,用于过滤搜索结果 + end_user_id: 组ID,用于过滤搜索结果 rerank_alpha: 重排序权重参数 use_forgetting_rerank: 是否使用遗忘重排序 use_llm_rerank: 是否使用LLM重排序 @@ -238,7 +238,7 @@ def create_hybrid_retrieval_tool_async(memory_config, **search_params): final_params = { "query_text": context, "search_type": search_type, - "group_id": group_id or search_params.get("group_id"), + "end_user_id": end_user_id or search_params.get("end_user_id"), "limit": limit or search_params.get("limit", 10), "include": search_params.get("include", ["summaries", "statements", "chunks", "entities"]), "output_path": None, # 不保存到文件 @@ -291,7 +291,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): context: str, search_type: str = "hybrid", limit: int = 10, - group_id: str = None, + end_user_id: str = None, clean_output: bool = True ) -> str: """ @@ -301,7 +301,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): context: 查询内容 search_type: 搜索类型 ('keyword', 'embedding', 'hybrid') limit: 结果数量限制 - group_id: 组ID,用于过滤搜索结果 + end_user_id: 组ID,用于过滤搜索结果 clean_output: 是否清理输出中的元数据字段 """ async def _async_search(): @@ -311,7 +311,7 @@ def create_hybrid_retrieval_tool_sync(memory_config, **search_params): "context": context, "search_type": search_type, "limit": limit, - "group_id": group_id, + "end_user_id": end_user_id, "clean_output": clean_output }) diff --git a/api/app/core/memory/agent/langgraph_graph/write_graph.py b/api/app/core/memory/agent/langgraph_graph/write_graph.py index fe281a23..8b5de444 100644 --- a/api/app/core/memory/agent/langgraph_graph/write_graph.py +++ b/api/app/core/memory/agent/langgraph_graph/write_graph.py @@ -14,6 +14,7 @@ from app.db import get_db from app.core.logging_config import get_agent_logger from app.core.memory.agent.utils.llm_tools import WriteState from app.core.memory.agent.langgraph_graph.nodes.write_nodes import write_node +from app.core.memory.agent.langgraph_graph.nodes.data_nodes import content_input_write from app.services.memory_config_service import MemoryConfigService warnings.filterwarnings("ignore", category=RuntimeWarning) @@ -26,9 +27,21 @@ async def make_write_graph(): """ Create a write graph workflow for memory operations. - The workflow directly processes messages from the initial state - and saves them to Neo4j storage. + Args: + user_id: User identifier + tools: MCP tools loaded from session + apply_id: Application identifier + end_user_id: Group identifier + memory_config: MemoryConfig object containing all configuration """ + # workflow = StateGraph(WriteState) + # workflow.add_node("content_input", content_input_write) + # workflow.add_node("save_neo4j", write_node) + # workflow.add_edge(START, "content_input") + # workflow.add_edge("content_input", "save_neo4j") + # workflow.add_edge("save_neo4j", END) + # + # graph = workflow.compile() workflow = StateGraph(WriteState) workflow.add_node("save_neo4j", write_node) workflow.add_edge(START, "save_neo4j") @@ -42,7 +55,7 @@ async def make_write_graph(): async def main(): """主函数 - 运行工作流""" message = "今天周一" - group_id = 'new_2025test1103' # 组ID + end_user_id = 'new_2025test1103' # 组ID # 获取数据库会话 @@ -54,9 +67,9 @@ async def main(): ) try: async with make_write_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 - initial_state = {"messages": [HumanMessage(content=message)], "group_id": group_id, "memory_config": memory_config} + initial_state = {"messages": [HumanMessage(content=message)], "end_user_id": end_user_id, "memory_config": memory_config} # 获取节点更新信息 async for update_event in graph.astream( diff --git a/api/app/core/memory/agent/services/parameter_builder.py b/api/app/core/memory/agent/services/parameter_builder.py index a58fcf1a..74382ade 100644 --- a/api/app/core/memory/agent/services/parameter_builder.py +++ b/api/app/core/memory/agent/services/parameter_builder.py @@ -24,7 +24,7 @@ class ParameterBuilder: tool_call_id: str, search_switch: str, apply_id: str, - group_id: str, + end_user_id: str, storage_type: Optional[str] = None, user_rag_memory_id: Optional[str] = None ) -> Dict[str, Any]: @@ -44,7 +44,7 @@ class ParameterBuilder: tool_call_id: Extracted tool call identifier search_switch: Search routing parameter apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier storage_type: Storage type for the workspace (optional) user_rag_memory_id: User RAG memory ID for knowledge base retrieval (optional) @@ -55,7 +55,7 @@ class ParameterBuilder: base_args = { "usermessages": tool_call_id, "apply_id": apply_id, - "group_id": group_id + "end_user_id": end_user_id } # Always add storage_type and user_rag_memory_id (with defaults if None) diff --git a/api/app/core/memory/agent/services/search_service.py b/api/app/core/memory/agent/services/search_service.py index 8a2e7cfe..4fc4256e 100644 --- a/api/app/core/memory/agent/services/search_service.py +++ b/api/app/core/memory/agent/services/search_service.py @@ -91,7 +91,7 @@ class SearchService: async def execute_hybrid_search( self, - group_id: str, + end_user_id: str, question: str, limit: int = 5, search_type: str = "hybrid", @@ -105,7 +105,7 @@ class SearchService: Execute hybrid search and return clean content. Args: - group_id: Group identifier for filtering results + end_user_id: Group identifier for filtering results question: Search query text limit: Maximum number of results to return (default: 5) search_type: Type of search - "hybrid", "keyword", or "embedding" (default: "hybrid") @@ -130,7 +130,7 @@ class SearchService: answer = await run_hybrid_search( query_text=cleaned_query, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, output_path=output_path, @@ -186,7 +186,7 @@ class SearchService: except Exception as e: logger.error( - f"Search failed for query '{question}' in group '{group_id}': {e}", + f"Search failed for query '{question}' in group '{end_user_id}': {e}", exc_info=True ) # Return empty results on failure diff --git a/api/app/core/memory/agent/services/session_service.py b/api/app/core/memory/agent/services/session_service.py index b2d4f0ff..f7389984 100644 --- a/api/app/core/memory/agent/services/session_service.py +++ b/api/app/core/memory/agent/services/session_service.py @@ -59,7 +59,7 @@ class SessionService: self, user_id: str, apply_id: str, - group_id: str + end_user_id: str ) -> List[dict]: """ Retrieve conversation history from Redis. @@ -67,20 +67,20 @@ class SessionService: Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier Returns: List of conversation history items with Query and Answer keys Returns empty list if no history found or on error """ try: - history = self.store.find_user_apply_group(user_id, apply_id, group_id) + history = self.store.find_user_apply_group(user_id, apply_id, end_user_id) # Validate history structure if not isinstance(history, list): logger.warning( f"Invalid history format for user {user_id}, " - f"apply {apply_id}, group {group_id}: expected list, got {type(history)}" + f"apply {apply_id}, group {end_user_id}: expected list, got {type(history)}" ) return [] @@ -89,7 +89,7 @@ class SessionService: except Exception as e: logger.error( f"Failed to retrieve history for user {user_id}, " - f"apply {apply_id}, group {group_id}: {e}", + f"apply {apply_id}, group {end_user_id}: {e}", exc_info=True ) # Return empty list on error to allow execution to continue @@ -100,7 +100,7 @@ class SessionService: user_id: str, query: str, apply_id: str, - group_id: str, + end_user_id: str, ai_response: str ) -> Optional[str]: """ @@ -110,7 +110,7 @@ class SessionService: user_id: User identifier query: User query/message apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier ai_response: AI response/answer Returns: @@ -131,7 +131,7 @@ class SessionService: userid=user_id, messages=query, apply_id=apply_id, - group_id=group_id, + end_user_id=end_user_id, aimessages=ai_response ) @@ -152,7 +152,7 @@ class SessionService: Duplicates are identified by matching: - sessionid - user_id (id field) - - group_id + - end_user_id - messages - aimessages diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index 82a41773..bfb0f675 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -9,9 +9,7 @@ from app.core.memory.models.message_models import DialogData, ConversationContex async def get_chunked_dialogs( chunker_strategy: str = "RecursiveChunker", - group_id: str = "group_1", - user_id: str = "user1", - apply_id: str = "applyid", + end_user_id: str = "group_1", messages: list = None, ref_id: str = "wyl_20251027", config_id: str = None @@ -20,9 +18,7 @@ async def get_chunked_dialogs( Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - group_id: Group identifier - user_id: User identifier - apply_id: Application identifier + end_user_id: Group identifier messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference identifier config_id: Configuration ID for processing @@ -32,42 +28,40 @@ async def get_chunked_dialogs( """ from app.core.logging_config import get_agent_logger logger = get_agent_logger(__name__) - + if not messages or not isinstance(messages, list) or len(messages) == 0: raise ValueError("messages parameter must be a non-empty list") - + conversation_messages = [] - + for idx, msg in enumerate(messages): if not isinstance(msg, dict) or 'role' not in msg or 'content' not in msg: raise ValueError(f"Message {idx} format error: must contain 'role' and 'content' fields") - + role = msg['role'] content = msg['content'] - + if role not in ['user', 'assistant']: raise ValueError(f"Message {idx} role must be 'user' or 'assistant', got: {role}") - + if content.strip(): conversation_messages.append(ConversationMessage(role=role, msg=content.strip())) - + if not conversation_messages: raise ValueError("Message list cannot be empty after filtering") - + conversation_context = ConversationContext(msgs=conversation_messages) dialog_data = DialogData( context=conversation_context, ref_id=ref_id, - group_id=group_id, - user_id=user_id, - apply_id=apply_id, + end_user_id=end_user_id, config_id=config_id ) - + chunker = DialogueChunker(chunker_strategy) extracted_chunks = await chunker.process_dialogue(dialog_data) dialog_data.chunks = extracted_chunks - + logger.info(f"DialogData created with {len(extracted_chunks)} chunks") return [dialog_data] diff --git a/api/app/core/memory/agent/utils/llm_tools.py b/api/app/core/memory/agent/utils/llm_tools.py index e73d5653..7f1041cb 100644 --- a/api/app/core/memory/agent/utils/llm_tools.py +++ b/api/app/core/memory/agent/utils/llm_tools.py @@ -13,13 +13,11 @@ class WriteState(TypedDict): Langgrapg Writing TypedDict ''' messages: Annotated[list[AnyMessage], add_messages] - user_id:str - apply_id:str - group_id:str + end_user_id: str errors: list[dict] # Track errors: [{"tool": "tool_name", "error": "message"}] memory_config: object write_result: dict - data:str + data: str class ReadState(TypedDict): """ @@ -29,7 +27,7 @@ class ReadState(TypedDict): messages: 消息列表,支持自动追加 loop_count: 遍历次数 search_switch: 搜索类型开关 - group_id: 组标识 + end_user_id: 组标识 config_id: 配置ID,用于过滤结果 data: 从content_input_node传递的内容数据 spit_data: 从Split_The_Problem传递的分解结果 @@ -40,7 +38,7 @@ class ReadState(TypedDict): messages: Annotated[list[AnyMessage], add_messages] # 消息追加模式 loop_count: int search_switch: str - group_id: str + end_user_id: str config_id: str data: str # 新增字段用于传递内容 spit_data: dict # 新增字段用于传递问题分解结果 diff --git a/api/app/core/memory/agent/utils/redis_tool.py b/api/app/core/memory/agent/utils/redis_tool.py index 31a76a11..505545b3 100644 --- a/api/app/core/memory/agent/utils/redis_tool.py +++ b/api/app/core/memory/agent/utils/redis_tool.py @@ -28,7 +28,7 @@ class RedisSessionStore: return text # 修改后的 save_session 方法 - def save_session(self, userid, messages, aimessages, apply_id, group_id): + def save_session(self, userid, messages, aimessages, apply_id, end_user_id): """ 写入一条会话数据,返回 session_id 优化版本:确保写入时间不超过1秒 @@ -46,7 +46,7 @@ class RedisSessionStore: "id": self.uudi, "sessionid": userid, "apply_id": apply_id, - "group_id": group_id, + "end_user_id": end_user_id, "messages": messages, "aimessages": aimessages, "starttime": starttime @@ -67,7 +67,7 @@ class RedisSessionStore: def save_sessions_batch(self, sessions_data): """ 批量写入多条会话数据,返回 session_id 列表 - sessions_data: list of dict, 每个 dict 包含 userid, messages, aimessages, apply_id, group_id + sessions_data: list of dict, 每个 dict 包含 userid, messages, aimessages, apply_id, end_user_id 优化版本:批量操作,大幅提升性能 """ try: @@ -83,7 +83,7 @@ class RedisSessionStore: "id": self.uudi, "sessionid": session.get('userid'), "apply_id": session.get('apply_id'), - "group_id": session.get('group_id'), + "end_user_id": session.get('end_user_id'), "messages": session.get('messages'), "aimessages": session.get('aimessages'), "starttime": starttime @@ -108,9 +108,9 @@ class RedisSessionStore: data = self.r.hgetall(key) return data if data else None - def get_session_apply_group(self, sessionid, apply_id, group_id): + def get_session_apply_group(self, sessionid, apply_id, end_user_id): """ - 根据 sessionid、apply_id 和 group_id 三个条件查询会话数据 + 根据 sessionid、apply_id 和 end_user_id 三个条件查询会话数据 """ result_items = [] @@ -124,7 +124,7 @@ class RedisSessionStore: # 检查三个条件是否都匹配 if (data.get('sessionid') == sessionid and data.get('apply_id') == apply_id and - data.get('group_id') == group_id): + data.get('end_user_id') == end_user_id): result_items.append(data) return result_items @@ -172,7 +172,7 @@ class RedisSessionStore: def delete_duplicate_sessions(self): """ 删除重复会话数据,条件: - "sessionid"、"user_id"、"group_id"、"messages"、"aimessages" 五个字段都相同的只保留一个,其他删除 + "sessionid"、"user_id"、"end_user_id"、"messages"、"aimessages" 五个字段都相同的只保留一个,其他删除 优化版本:使用 pipeline 批量操作,确保在1秒内完成 """ import time @@ -202,12 +202,12 @@ class RedisSessionStore: # 获取五个字段的值 sessionid = data.get('sessionid', '') user_id = data.get('id', '') - group_id = data.get('group_id', '') + end_user_id = data.get('end_user_id', '') messages = data.get('messages', '') aimessages = data.get('aimessages', '') # 用五元组作为唯一标识 - identifier = (sessionid, user_id, group_id, messages, aimessages) + identifier = (sessionid, user_id, end_user_id, messages, aimessages) if identifier in seen: # 重复,标记为待删除 @@ -248,9 +248,9 @@ class RedisSessionStore: result_items = [] return (result_items) - def find_user_apply_group(self, sessionid, apply_id, group_id): + def find_user_apply_group(self, sessionid, apply_id, end_user_id): """ - 根据 sessionid、apply_id 和 group_id 三个条件查询会话数据,返回最新的6条 + 根据 sessionid、apply_id 和 end_user_id 三个条件查询会话数据,返回最新的6条 """ import time start_time = time.time() @@ -276,7 +276,7 @@ class RedisSessionStore: # 检查是否符合三个条件 if (data.get('apply_id') == apply_id and - data.get('group_id') == group_id): + data.get('end_user_id') == end_user_id): # 支持模糊匹配 sessionid 或者完全匹配 if sessionid in data.get('sessionid', '') or data.get('sessionid') == sessionid: matched_items.append({ diff --git a/api/app/core/memory/agent/utils/session_tools.py b/api/app/core/memory/agent/utils/session_tools.py index b2d4f0ff..f7389984 100644 --- a/api/app/core/memory/agent/utils/session_tools.py +++ b/api/app/core/memory/agent/utils/session_tools.py @@ -59,7 +59,7 @@ class SessionService: self, user_id: str, apply_id: str, - group_id: str + end_user_id: str ) -> List[dict]: """ Retrieve conversation history from Redis. @@ -67,20 +67,20 @@ class SessionService: Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier Returns: List of conversation history items with Query and Answer keys Returns empty list if no history found or on error """ try: - history = self.store.find_user_apply_group(user_id, apply_id, group_id) + history = self.store.find_user_apply_group(user_id, apply_id, end_user_id) # Validate history structure if not isinstance(history, list): logger.warning( f"Invalid history format for user {user_id}, " - f"apply {apply_id}, group {group_id}: expected list, got {type(history)}" + f"apply {apply_id}, group {end_user_id}: expected list, got {type(history)}" ) return [] @@ -89,7 +89,7 @@ class SessionService: except Exception as e: logger.error( f"Failed to retrieve history for user {user_id}, " - f"apply {apply_id}, group {group_id}: {e}", + f"apply {apply_id}, group {end_user_id}: {e}", exc_info=True ) # Return empty list on error to allow execution to continue @@ -100,7 +100,7 @@ class SessionService: user_id: str, query: str, apply_id: str, - group_id: str, + end_user_id: str, ai_response: str ) -> Optional[str]: """ @@ -110,7 +110,7 @@ class SessionService: user_id: User identifier query: User query/message apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier ai_response: AI response/answer Returns: @@ -131,7 +131,7 @@ class SessionService: userid=user_id, messages=query, apply_id=apply_id, - group_id=group_id, + end_user_id=end_user_id, aimessages=ai_response ) @@ -152,7 +152,7 @@ class SessionService: Duplicates are identified by matching: - sessionid - user_id (id field) - - group_id + - end_user_id - messages - aimessages diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 1df0b336..446ab86a 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -29,20 +29,18 @@ logger = get_agent_logger(__name__) async def write( - user_id: str, - apply_id: str, - group_id: str, + end_user_id: str, memory_config: MemoryConfig, messages: list, ref_id: str = "wyl20251027", ) -> None: """ Execute the complete knowledge extraction pipeline. - + Args: user_id: User identifier apply_id: Application identifier - group_id: Group identifier + end_user_id: Group identifier memory_config: MemoryConfig object containing all configuration messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference ID, defaults to "wyl20251027" @@ -51,14 +49,14 @@ async def write( embedding_model_id = str(memory_config.embedding_model_id) chunker_strategy = memory_config.chunker_strategy config_id = str(memory_config.config_id) - + logger.info("=== MemSci Knowledge Extraction Pipeline ===") logger.info(f"Config: {memory_config.config_name} (ID: {config_id})") logger.info(f"Workspace: {memory_config.workspace_name}") logger.info(f"LLM model: {memory_config.llm_model_name}") logger.info(f"Embedding model: {memory_config.embedding_model_name}") logger.info(f"Chunker strategy: {chunker_strategy}") - logger.info(f"Group ID: {group_id}") + logger.info(f"end_user_id ID: {end_user_id}") # Construct clients from memory_config using factory pattern with db session with get_db_context() as db: @@ -83,9 +81,7 @@ async def write( step_start = time.time() chunked_dialogs = await get_chunked_dialogs( chunker_strategy=chunker_strategy, - group_id=group_id, - user_id=user_id, - apply_id=apply_id, + end_user_id=end_user_id, messages=messages, ref_id=ref_id, config_id=config_id, diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index cab6cacd..95302726 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -16,13 +16,13 @@ class FilteredTags(BaseModel): """用于接收LLM筛选后的核心标签列表的模型。""" meaningful_tags: List[str] = Field(..., description="从原始列表中筛选出的具有核心代表意义的名词列表。") -async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: +async def filter_tags_with_llm(tags: List[str], end_user_id: str) -> List[str]: """ 使用LLM筛选标签列表,仅保留具有代表性的核心名词。 Args: tags: 原始标签列表 - group_id: 用户组ID,用于获取配置 + end_user_id: 用户组ID,用于获取配置 Returns: 筛选后的标签列表 @@ -37,12 +37,12 @@ async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: get_end_user_connected_config, ) - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if not config_id: raise ValueError( - f"No memory_config_id found for group_id: {group_id}. " + f"No memory_config_id found for end_user_id: {end_user_id}. " "Please ensure the user has a valid memory configuration." ) @@ -87,7 +87,7 @@ async def filter_tags_with_llm(tags: List[str], group_id: str) -> List[str]: async def get_raw_tags_from_db( connector: Neo4jConnector, - group_id: str, + end_user_id: str, limit: int, by_user: bool = False ) -> List[Tuple[str, int]]: @@ -99,9 +99,9 @@ async def get_raw_tags_from_db( Args: connector: Neo4j连接器实例 - group_id: 如果by_user=False,则为group_id;如果by_user=True,则为user_id + end_user_id: 如果by_user=False,则为end_user_id;如果by_user=True,则为user_id limit: 返回的标签数量限制 - by_user: 是否按user_id查询(默认False,按group_id查询) + by_user: 是否按user_id查询(默认False,按end_user_id查询) Returns: List[Tuple[str, int]]: 标签名称和频率的元组列表 @@ -119,7 +119,7 @@ async def get_raw_tags_from_db( else: query = ( "MATCH (e:ExtractedEntity) " - "WHERE e.group_id = $id AND e.entity_type <> '人物' AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " + "WHERE e.end_user_id = $id AND e.entity_type <> '人物' AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC " "LIMIT $limit" @@ -128,44 +128,44 @@ async def get_raw_tags_from_db( # 使用项目的Neo4jConnector执行查询 results = await connector.execute_query( query, - id=group_id, + id=end_user_id, limit=limit, names_to_exclude=names_to_exclude ) return [(record["name"], record["frequency"]) for record in results] -async def get_hot_memory_tags(group_id: str, limit: int = 40, by_user: bool = False) -> List[Tuple[str, int]]: +async def get_hot_memory_tags(end_user_id: str, limit: int = 40, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 查询更多的标签(limit=40)给LLM提供更丰富的上下文进行筛选。 Args: - group_id: 必需参数。如果by_user=False,则为group_id;如果by_user=True,则为user_id + end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id limit: 返回的标签数量限制 - by_user: 是否按user_id查询(默认False,按group_id查询) + by_user: 是否按user_id查询(默认False,按end_user_id查询) Raises: - ValueError: 如果group_id未提供或为空 + ValueError: 如果end_user_id未提供或为空 """ - # 验证group_id必须提供且不为空 - if not group_id or not group_id.strip(): + # 验证end_user_id必须提供且不为空 + if not end_user_id or not end_user_id.strip(): raise ValueError( - "group_id is required. Please provide a valid group_id or user_id." + "end_user_id is required. Please provide a valid end_user_id or user_id." ) # 使用项目的Neo4jConnector connector = Neo4jConnector() try: # 1. 从数据库获取原始排名靠前的标签 - raw_tags_with_freq = await get_raw_tags_from_db(connector, group_id, limit, by_user=by_user) + raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, limit, by_user=by_user) if not raw_tags_with_freq: return [] raw_tag_names = [tag for tag, freq in raw_tags_with_freq] # 2. 初始化LLM客户端并使用LLM筛选出有意义的标签 - meaningful_tag_names = await filter_tags_with_llm(raw_tag_names, group_id) + meaningful_tag_names = await filter_tags_with_llm(raw_tag_names, end_user_id) # 3. 根据LLM的筛选结果,构建最终的标签列表(保留原始频率和顺序) final_tags = [] diff --git a/api/app/core/memory/analytics/implicit_memory/data_source.py b/api/app/core/memory/analytics/implicit_memory/data_source.py index d277a05e..18678a55 100644 --- a/api/app/core/memory/analytics/implicit_memory/data_source.py +++ b/api/app/core/memory/analytics/implicit_memory/data_source.py @@ -75,8 +75,8 @@ class MemoryDataSource: start_date = time_range.start_date if time_range else None end_date = time_range.end_date if time_range else None - summary_dicts = await self.memory_summary_repo.find_by_group_id( - group_id=user_id, + summary_dicts = await self.memory_summary_repo.find_by_end_user_id( + end_user_id=user_id, limit=limit, start_date=start_date, end_date=end_date diff --git a/api/app/core/memory/evaluation/dialogue_queries.py b/api/app/core/memory/evaluation/dialogue_queries.py index fd7fa671..25abe64e 100644 --- a/api/app/core/memory/evaluation/dialogue_queries.py +++ b/api/app/core/memory/evaluation/dialogue_queries.py @@ -41,7 +41,7 @@ DIALOGUE_EMBEDDING_SEARCH = """ WITH $embedding AS q MATCH (d:Dialogue) WHERE d.dialog_embedding IS NOT NULL - AND ($group_id IS NULL OR d.group_id = $group_id) + AND ($end_user_id IS NULL OR d.end_user_id = $end_user_id) WITH d, q, d.dialog_embedding AS v WITH d, reduce(dot = 0.0, i IN range(0, size(q)-1) | dot + toFloat(q[i]) * toFloat(v[i])) AS dot, @@ -50,7 +50,7 @@ WITH d, WITH d, CASE WHEN qnorm = 0 OR vnorm = 0 THEN 0.0 ELSE dot / (qnorm * vnorm) END AS score WHERE score > $threshold RETURN d.id AS dialog_id, - d.group_id AS group_id, + d.end_user_id AS end_user_id, d.content AS content, d.created_at AS created_at, d.expired_at AS expired_at, diff --git a/api/app/core/memory/evaluation/extraction_utils.py b/api/app/core/memory/evaluation/extraction_utils.py index 9afa228c..9e70bc28 100644 --- a/api/app/core/memory/evaluation/extraction_utils.py +++ b/api/app/core/memory/evaluation/extraction_utils.py @@ -36,7 +36,7 @@ from app.repositories.neo4j.neo4j_connector import Neo4jConnector async def ingest_contexts_via_full_pipeline( contexts: List[str], - group_id: str, + end_user_id: str, chunker_strategy: str | None = None, embedding_name: str | None = None, save_chunk_output: bool = False, @@ -48,7 +48,7 @@ async def ingest_contexts_via_full_pipeline( This function mirrors the steps in main(), but starts from raw text contexts. Args: contexts: List of dialogue texts, each containing lines like "role: message". - group_id: Group ID to assign to generated DialogData and graph nodes. + end_user_id: Group ID to assign to generated DialogData and graph nodes. chunker_strategy: Optional chunker strategy; defaults to SELECTED_CHUNKER_STRATEGY. embedding_name: Optional embedding model ID; defaults to SELECTED_EMBEDDING_ID. save_chunk_output: If True, write chunked DialogData list to a JSON file for debugging. @@ -109,7 +109,7 @@ async def ingest_contexts_via_full_pipeline( dialog = DialogData( context=context_model, ref_id=f"pipeline_item_{idx}", - group_id=group_id, + end_user_id=end_user_id, user_id="default_user", apply_id="default_application", ) @@ -318,16 +318,16 @@ async def handle_context_processing(args): print("No contexts provided for processing.") return False - return await main_from_contexts(contexts, args.context_group_id) + return await main_from_contexts(contexts, args.context_end_user_id) -async def main_from_contexts(contexts: List[str], group_id: str): +async def main_from_contexts(contexts: List[str], end_user_id: str): """Run the pipeline from provided dialogue contexts instead of test data.""" print("=== Running pipeline from provided contexts ===") success = await ingest_contexts_via_full_pipeline( contexts=contexts, - group_id=group_id, + end_user_id=end_user_id, chunker_strategy=SELECTED_CHUNKER_STRATEGY, embedding_name=SELECTED_EMBEDDING_ID, save_chunk_output=True diff --git a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py index b7d988c5..1c70c28e 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py +++ b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py @@ -47,7 +47,7 @@ from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient from app.core.memory.utils.definitions import ( PROJECT_ROOT, SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, + SELECTED_end_user_id, SELECTED_LLM_ID, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -59,7 +59,7 @@ from app.services.memory_config_service import MemoryConfigService async def run_locomo_benchmark( sample_size: int = 20, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, search_type: str = "hybrid", search_limit: int = 12, context_char_budget: int = 8000, @@ -85,7 +85,7 @@ async def run_locomo_benchmark( Args: sample_size: Number of QA pairs to evaluate (from first conversation) - group_id: Database group ID for retrieval (uses default if None) + end_user_id: Database group ID for retrieval (uses default if None) search_type: "keyword", "embedding", or "hybrid" search_limit: Max documents to retrieve per query context_char_budget: Max characters for context @@ -96,8 +96,8 @@ async def run_locomo_benchmark( Returns: Dictionary with evaluation results including metrics, timing, and samples """ - # Use default group_id if not provided - group_id = group_id or SELECTED_GROUP_ID + # Use default end_user_id if not provided + end_user_id = end_user_id or SELECTED_end_user_id # Determine data path data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") @@ -110,7 +110,7 @@ async def run_locomo_benchmark( print(f"{'='*60}") print("📊 Configuration:") print(f" Sample size: {sample_size}") - print(f" Group ID: {group_id}") + print(f" Group ID: {end_user_id}") print(f" Search type: {search_type}") print(f" Search limit: {search_limit}") print(f" Context budget: {context_char_budget} chars") @@ -134,7 +134,7 @@ async def run_locomo_benchmark( # Step 2: Extract conversations and ingest if needed if skip_ingest: print("⏭️ Skipping data ingestion (using existing data in Neo4j)") - print(f" Group ID: {group_id}\n") + print(f" Group ID: {end_user_id}\n") else: print("💾 Checking database ingestion...") try: @@ -142,10 +142,10 @@ async def run_locomo_benchmark( print(f"📝 Extracted {len(conversations)} conversations") # Always ingest for now (ingestion check not implemented) - print(f"🔄 Ingesting conversations into group '{group_id}'...") + print(f"🔄 Ingesting conversations into group '{end_user_id}'...") success = await ingest_conversations_if_needed( conversations=conversations, - group_id=group_id, + end_user_id=end_user_id, reset=reset_group ) @@ -224,7 +224,7 @@ async def run_locomo_benchmark( try: retrieved_info = await retrieve_relevant_information( question=question, - group_id=group_id, + end_user_id=end_user_id, search_type=search_type, search_limit=search_limit, connector=connector, @@ -409,7 +409,7 @@ async def run_locomo_benchmark( "sample_size": len(qa_items), "timestamp": datetime.now().isoformat(), "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_type": search_type, "search_limit": search_limit, "context_char_budget": context_char_budget, @@ -467,7 +467,7 @@ def main(): help="Number of QA pairs to evaluate" ) parser.add_argument( - "--group_id", + "--end_user_id", type=str, default=None, help="Database group ID for retrieval (uses default if not specified)" @@ -516,7 +516,7 @@ def main(): # Run benchmark result = asyncio.run(run_locomo_benchmark( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_type=args.search_type, search_limit=args.search_limit, context_char_budget=args.context_char_budget, diff --git a/api/app/core/memory/evaluation/locomo/locomo_test.py b/api/app/core/memory/evaluation/locomo/locomo_test.py index affedd0f..01c45123 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_test.py +++ b/api/app/core/memory/evaluation/locomo/locomo_test.py @@ -556,7 +556,7 @@ async def run_enhanced_evaluation(): search_results = await run_hybrid_search( query_text=q, search_type="hybrid", - group_id="locomo_sk", + end_user_id="locomo_sk", limit=20, include=["statements", "chunks", "entities", "summaries"], alpha=0.6, # BM25权重 diff --git a/api/app/core/memory/evaluation/locomo/locomo_utils.py b/api/app/core/memory/evaluation/locomo/locomo_utils.py index 69be5da9..d3b74947 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_utils.py +++ b/api/app/core/memory/evaluation/locomo/locomo_utils.py @@ -348,7 +348,7 @@ def select_and_format_information( async def retrieve_relevant_information( question: str, - group_id: str, + end_user_id: str, search_type: str, search_limit: int, connector: Any, @@ -368,7 +368,7 @@ async def retrieve_relevant_information( Args: question: Question to search for - group_id: Database group ID (identifies which conversation memory to search) + end_user_id: Database group ID (identifies which conversation memory to search) search_type: "keyword", "embedding", or "hybrid" search_limit: Max memory pieces to retrieve connector: Neo4j connector instance @@ -396,7 +396,7 @@ async def retrieve_relevant_information( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -455,7 +455,7 @@ async def retrieve_relevant_information( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit ) @@ -491,7 +491,7 @@ async def retrieve_relevant_information( search_results = await run_hybrid_search( query_text=question, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], output_path=None, @@ -524,7 +524,7 @@ async def retrieve_relevant_information( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -584,7 +584,7 @@ async def retrieve_relevant_information( async def ingest_conversations_if_needed( conversations: List[str], - group_id: str, + end_user_id: str, reset: bool = False ) -> bool: """ @@ -603,7 +603,7 @@ async def ingest_conversations_if_needed( Args: conversations: List of raw conversation texts from LoCoMo dataset Example: ["User: I went to Paris. AI: When was that?", ...] - group_id: Target group ID for database storage + end_user_id: Target group ID for database storage reset: Whether to clear existing data first (not implemented in wrapper) Returns: @@ -617,7 +617,7 @@ async def ingest_conversations_if_needed( try: success = await ingest_contexts_via_full_pipeline( contexts=conversations, - group_id=group_id, + end_user_id=end_user_id, save_chunk_output=True ) return success diff --git a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py index 87a70a29..6a5caa0c 100644 --- a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py @@ -249,7 +249,7 @@ def get_search_params_by_category(category: str): async def run_locomo_eval( sample_size: int = 1, - group_id: str | None = None, + end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, # 保持默认值不变 llm_temperature: float = 0.0, @@ -262,7 +262,7 @@ async def run_locomo_eval( ) -> Dict[str, Any]: # 函数内部使用三路检索逻辑,但保持参数签名不变 - group_id = group_id or SELECTED_GROUP_ID + end_user_id = end_user_id or SELECTED_end_user_id data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") if not os.path.exists(data_path): data_path = os.path.join(os.getcwd(), "data", "locomo10.json") @@ -340,7 +340,7 @@ async def run_locomo_eval( # 关键修复:强制重新摄入纯净的对话数据 print("🔄 强制重新摄入纯净的对话数据...") - await ingest_contexts_via_full_pipeline(contents, group_id, save_chunk_output=True) + await ingest_contexts_via_full_pipeline(contents, end_user_id, save_chunk_output=True) # 使用异步LLM客户端 with get_db_context() as db: @@ -405,7 +405,7 @@ async def run_locomo_eval( connector=connector, embedder_client=embedder, query_text=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], # 修复:使用正确的类型 ) @@ -456,7 +456,7 @@ async def run_locomo_eval( search_results = await search_graph( connector=connector, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit ) dialogs = search_results.get("dialogues", []) @@ -486,7 +486,7 @@ async def run_locomo_eval( search_results = await run_hybrid_search( query_text=q, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], output_path=None, @@ -524,7 +524,7 @@ async def run_locomo_eval( connector=connector, embedder_client=embedder, query_text=q, - group_id=group_id, + end_user_id=end_user_id, limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -597,7 +597,7 @@ async def run_locomo_eval( "dialogues": [ { "uuid": d.get("uuid", ""), - "group_id": d.get("group_id", ""), + "end_user_id": d.get("end_user_id", ""), "content": d.get("content", "")[:200] + "..." if len(d.get("content", "")) > 200 else d.get("content", ""), "score": d.get("score", 0.0) } @@ -795,7 +795,7 @@ async def run_locomo_eval( }, "samples": samples, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, @@ -825,7 +825,7 @@ async def run_locomo_eval( def main(): parser = argparse.ArgumentParser(description="Run LoCoMo evaluation with Qwen search") parser.add_argument("--sample_size", type=int, default=1, help="Number of samples to evaluate") - parser.add_argument("--group_id", type=str, default=None, help="Group ID for retrieval") + parser.add_argument("--end_user_id", type=str, default=None, help="Group ID for retrieval") parser.add_argument("--search_limit", type=int, default=8, help="Search limit per query") parser.add_argument("--context_char_budget", type=int, default=12000, help="Max characters for context") parser.add_argument("--llm_temperature", type=float, default=0.0, help="LLM temperature") @@ -841,7 +841,7 @@ def main(): result = asyncio.run(run_locomo_eval( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py index 292e7288..8710a504 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py @@ -524,11 +524,11 @@ def generate_query_keywords_cn(question: str) -> List[str]: # 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], group_id: str | None, limit: int) -> List[Dict[str, Any]]: +async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] try: for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, group_id=group_id, limit=limit) + rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) if rows: results.extend(rows) except Exception: @@ -548,15 +548,15 @@ async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[st # 通过对话/陈述中的entity_ids反查实体名称 _FETCH_ENTITIES_BY_IDS = """ MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type """ -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], group_id: str | None) -> List[Dict[str, Any]]: +async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: if not ids: return [] try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), group_id=group_id) + rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) return rows or [] except Exception: return [] @@ -566,18 +566,18 @@ async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], grou _TIME_ENTITY_SEARCH = """ MATCH (e:ExtractedEntity) WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type LIMIT $limit """ -async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: +async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: """专门搜索时间相关的实体""" try: date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" rows = await connector.execute_query(_TIME_ENTITY_SEARCH, date_pattern=date_pattern, - group_id=group_id, + end_user_id=end_user_id, limit=limit) return rows or [] except Exception: @@ -624,7 +624,7 @@ def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: async def run_longmemeval_test( sample_size: int = 3, - group_id: str = "longmemeval_zh_bak_3", + end_user_id: str = "longmemeval_zh_bak_3", search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -678,13 +678,13 @@ async def run_longmemeval_test( contexts.extend(selected) print(f"📥 摄入 {len(contexts)} 个上下文到数据库") - if reset_group_before_ingest and group_id: + if reset_group_before_ingest and end_user_id: try: _tmp_conn = Neo4jConnector() - await _tmp_conn.delete_group(group_id) - print(f"🧹 已清空组 {group_id} 的历史图数据") + await _tmp_conn.delete_group(end_user_id) + print(f"🧹 已清空组 {end_user_id} 的历史图数据") except Exception as _e: - print(f"⚠️ 清空组数据失败(忽略继续): {group_id} - {_e}") + print(f"⚠️ 清空组数据失败(忽略继续): {end_user_id} - {_e}") finally: try: await _tmp_conn.close() @@ -696,7 +696,7 @@ async def run_longmemeval_test( else: await _ingest_fn( contexts, - group_id, + end_user_id, save_chunk_output=save_chunk_output, save_chunk_output_path=save_chunk_output_path, ) @@ -751,7 +751,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -796,7 +796,7 @@ async def run_longmemeval_test( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) chunks = search_results.get("chunks", []) @@ -831,7 +831,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], ) @@ -849,7 +849,7 @@ async def run_longmemeval_test( kw_res = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) if isinstance(kw_res, dict): @@ -860,7 +860,7 @@ async def run_longmemeval_test( # 时间推理问题的特殊处理 if is_temporal: # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, group_id, search_limit//2) + time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) if time_entities: kw_entities.extend(time_entities) # 添加时间相关关键词检索 @@ -870,7 +870,7 @@ async def run_longmemeval_test( time_res = await search_graph( connector=connector, q=tk, - group_id=group_id, + end_user_id=end_user_id, limit=2, ) if isinstance(time_res, dict): @@ -881,7 +881,7 @@ async def run_longmemeval_test( # 中文关键词拆分后做别名匹配 cn_tokens = _extract_cn_tokens(question) - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, group_id, search_limit) + alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) if alias_entities: kw_entities.extend(alias_entities) @@ -895,7 +895,7 @@ async def run_longmemeval_test( except Exception: pass if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, group_id) + id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) if id_entities: kw_entities.extend(id_entities) @@ -909,7 +909,7 @@ async def run_longmemeval_test( sub_res = await search_graph( connector=connector, q=str(kw), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(sub_res, dict): @@ -928,7 +928,7 @@ async def run_longmemeval_test( opt_res = await search_graph( connector=connector, q=str(opt), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(opt_res, dict): @@ -1010,7 +1010,7 @@ async def run_longmemeval_test( kw_fallback = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=max(search_limit, 5), ) fb_dialogs = kw_fallback.get("dialogues", []) or [] @@ -1224,7 +1224,7 @@ async def run_longmemeval_test( "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, }, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, @@ -1307,7 +1307,7 @@ def main(): result = asyncio.run( run_longmemeval_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/longmemeval/test_eval.py b/api/app/core/memory/evaluation/longmemeval/test_eval.py index 08a763e3..67bd6ec2 100644 --- a/api/app/core/memory/evaluation/longmemeval/test_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/test_eval.py @@ -498,11 +498,11 @@ def smart_context_selection(contexts: List[str], question: str, max_chars: int = # 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], group_id: str | None, limit: int) -> List[Dict[str, Any]]: +async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] try: for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, group_id=group_id, limit=limit) + rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) if rows: results.extend(rows) except Exception: @@ -522,15 +522,15 @@ async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[st # 通过对话/陈述中的entity_ids反查实体名称 _FETCH_ENTITIES_BY_IDS = """ MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type """ -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], group_id: str | None) -> List[Dict[str, Any]]: +async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: if not ids: return [] try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), group_id=group_id) + rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) return rows or [] except Exception: return [] @@ -540,18 +540,18 @@ async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], grou _TIME_ENTITY_SEARCH = """ MATCH (e:ExtractedEntity) WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($group_id IS NULL OR e.group_id = $group_id) -RETURN e.id AS id, e.name AS name, e.group_id AS group_id, e.entity_type AS entity_type +AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) +RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type LIMIT $limit """ -async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: +async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: """专门搜索时间相关的实体""" try: date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" rows = await connector.execute_query(_TIME_ENTITY_SEARCH, date_pattern=date_pattern, - group_id=group_id, + end_user_id=end_user_id, limit=limit) return rows or [] except Exception: @@ -559,25 +559,25 @@ async def _search_time_entities(connector: Neo4jConnector, group_id: str | None, # 技术术语专门检索 -async def _search_tech_terms(connector: Neo4jConnector, question: str, group_id: str | None, limit: int = 3) -> List[Dict[str, Any]]: +async def _search_tech_terms(connector: Neo4jConnector, question: str, end_user_id: str | None, limit: int = 3) -> List[Dict[str, Any]]: """专门搜索技术术语相关的实体""" tech_entities = [] try: # GPS相关 if any(term in question for term in ["GPS", "导航", "定位系统"]): - gps_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="GPS", group_id=group_id, limit=limit) + gps_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="GPS", end_user_id=end_user_id, limit=limit) if gps_rows: tech_entities.extend(gps_rows) # 活动相关 if any(term in question for term in ["工作坊", "研讨会", "网络研讨会"]): - workshop_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="工作坊", group_id=group_id, limit=limit) + workshop_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="工作坊", end_user_id=end_user_id, limit=limit) if workshop_rows: tech_entities.extend(workshop_rows) # 时间顺序相关 if any(term in question for term in ["先", "后", "第一个"]): - time_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="第一次", group_id=group_id, limit=limit) + time_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="第一次", end_user_id=end_user_id, limit=limit) if time_rows: tech_entities.extend(time_rows) @@ -627,7 +627,7 @@ def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: async def run_longmemeval_test( sample_size: int = 3, - group_id: str = "longmemeval_zh_bak_2", + end_user_id: str = "longmemeval_zh_bak_2", search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -707,7 +707,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], ) @@ -746,7 +746,7 @@ async def run_longmemeval_test( search_results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) dialogs = search_results.get("dialogues", []) @@ -776,7 +776,7 @@ async def run_longmemeval_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], ) @@ -792,7 +792,7 @@ async def run_longmemeval_test( kw_res = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, ) if isinstance(kw_res, dict): @@ -801,14 +801,14 @@ async def run_longmemeval_test( kw_entities = kw_res.get("entities", []) or [] # 技术术语专门检索 - tech_entities = await _search_tech_terms(connector, question, group_id, search_limit//2) + tech_entities = await _search_tech_terms(connector, question, end_user_id, search_limit//2) if tech_entities: kw_entities.extend(tech_entities) # 时间推理问题的特殊处理 if is_temporal: # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, group_id, search_limit//2) + time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) if time_entities: kw_entities.extend(time_entities) # 添加时间相关关键词检索 @@ -818,7 +818,7 @@ async def run_longmemeval_test( time_res = await search_graph( connector=connector, q=tk, - group_id=group_id, + end_user_id=end_user_id, limit=2, ) if isinstance(time_res, dict): @@ -829,7 +829,7 @@ async def run_longmemeval_test( # 中文关键词拆分后做别名匹配 cn_tokens = generate_query_keywords_cn(question) # 使用增强版关键词提取 - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, group_id, search_limit) + alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) if alias_entities: kw_entities.extend(alias_entities) @@ -843,7 +843,7 @@ async def run_longmemeval_test( except Exception: pass if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, group_id) + id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) if id_entities: kw_entities.extend(id_entities) @@ -857,7 +857,7 @@ async def run_longmemeval_test( sub_res = await search_graph( connector=connector, q=str(kw), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(sub_res, dict): @@ -876,7 +876,7 @@ async def run_longmemeval_test( opt_res = await search_graph( connector=connector, q=str(opt), - group_id=group_id, + end_user_id=end_user_id, limit=max(3, search_limit // 2), ) if isinstance(opt_res, dict): @@ -971,7 +971,7 @@ async def run_longmemeval_test( kw_fallback = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=max(search_limit, 5), ) fb_dialogs = kw_fallback.get("dialogues", []) or [] @@ -1199,7 +1199,7 @@ async def run_longmemeval_test( "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, }, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, @@ -1278,7 +1278,7 @@ def main(): result = asyncio.run( run_longmemeval_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py index 6efb66ff..869fdb60 100644 --- a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py +++ b/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py @@ -135,8 +135,8 @@ def _combine_dialogues_for_hybrid(results: Dict[str, Any]) -> List[Dict[str, Any return merged -async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: - group_id = group_id or SELECTED_GROUP_ID +async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: + end_user_id = end_user_id or SELECTED_GROUP_ID # Load data data_path = os.path.join(PROJECT_ROOT, "data", "msc_self_instruct.jsonl") if not os.path.exists(data_path): @@ -147,7 +147,7 @@ async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, s # 改为:每条样本仅摄入一个上下文(完整对话转录),避免多上下文摄入 # 说明:memsciqa 数据集的每个样本天然只有一个对话,保持按样本一上下文的策略 contexts: List[str] = [build_context_from_dialog(item) for item in items] - await ingest_contexts_via_full_pipeline(contexts, group_id) + await ingest_contexts_via_full_pipeline(contexts, end_user_id) # LLM client (使用异步调用) with get_db_context() as db: @@ -173,7 +173,7 @@ async def run_memsciqa_eval(sample_size: int = 1, group_id: str | None = None, s results = await run_hybrid_search( query_text=question, search_type=search_type, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["dialogues", "statements", "entities"], output_path=None, @@ -298,7 +298,7 @@ def main(): load_dotenv() parser = argparse.ArgumentParser(description="Evaluate DMR (memsciqa) with graph search and Qwen") parser.add_argument("--sample-size", type=int, default=1, help="评测样本数量") - parser.add_argument("--group-id", type=str, default=None, help="可选 group_id,默认取 runtime.json") + parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") parser.add_argument("--search-limit", type=int, default=8, help="每类检索最大返回数") parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") @@ -309,7 +309,7 @@ def main(): result = asyncio.run( run_memsciqa_eval( sample_size=args.sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py index 900cda9d..3023020a 100644 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py @@ -199,7 +199,7 @@ def load_dataset_memsciqa(data_path: str) -> List[Dict[str, Any]]: async def run_memsciqa_test( sample_size: int = 3, - group_id: str | None = None, + end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -217,7 +217,7 @@ async def run_memsciqa_test( """ # 默认使用指定的 memsci 组 ID - group_id = group_id or "group_memsci" + end_user_id = end_user_id or "group_memsci" # 数据路径解析(项目根与当前工作目录兜底) if not data_path: @@ -283,7 +283,7 @@ async def run_memsciqa_test( connector=connector, embedder_client=embedder, query_text=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues ) @@ -292,7 +292,7 @@ async def run_memsciqa_test( results = await search_graph( connector=connector, q=question, - group_id=group_id, + end_user_id=end_user_id, limit=search_limit, include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues ) @@ -500,7 +500,7 @@ async def run_memsciqa_test( }, "samples": samples, "params": { - "group_id": group_id, + "end_user_id": end_user_id, "search_limit": search_limit, "context_char_budget": context_char_budget, "llm_temperature": llm_temperature, @@ -543,7 +543,7 @@ def main(): result = asyncio.run( run_memsciqa_test( sample_size=sample_size, - group_id=args.group_id, + end_user_id=args.end_user_id, search_limit=args.search_limit, context_char_budget=args.context_char_budget, llm_temperature=args.llm_temperature, diff --git a/api/app/core/memory/evaluation/run_eval.py b/api/app/core/memory/evaluation/run_eval.py index 1de3de89..c5aacb2f 100644 --- a/api/app/core/memory/evaluation/run_eval.py +++ b/api/app/core/memory/evaluation/run_eval.py @@ -26,7 +26,7 @@ async def run( dataset: str, sample_size: int, reset_group: bool, - group_id: str | None, + end_user_id: str | None, judge_model: str | None = None, search_limit: int | None = None, context_char_budget: int | None = None, @@ -37,17 +37,17 @@ async def run( max_contexts_per_item: int | None = None, ) -> Dict[str, Any]: # 恢复原始风格:统一入口做路由,并沿用各数据集既有默认 - group_id = group_id or SELECTED_GROUP_ID + end_user_id = end_user_id or SELECTED_GROUP_ID if reset_group: connector = Neo4jConnector() try: - await connector.delete_group(group_id) + await connector.delete_group(end_user_id) finally: await connector.close() if dataset == "locomo": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -61,7 +61,7 @@ async def run( return await run_locomo_eval(**kwargs) if dataset == "memsciqa": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -75,7 +75,7 @@ async def run( return await run_memsciqa_eval(**kwargs) if dataset == "longmemeval": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "group_id": group_id} + kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} if search_limit is not None: kwargs["search_limit"] = search_limit if context_char_budget is not None: @@ -99,8 +99,8 @@ def main(): parser = argparse.ArgumentParser(description="统一评估入口:memsciqa / longmemeval / locomo") parser.add_argument("--dataset", choices=["memsciqa", "longmemeval", "locomo"], required=True) parser.add_argument("--sample-size", type=int, default=1, help="先用一条数据跑通") - parser.add_argument("--reset-group", action="store_true", help="运行前清空当前 group_id 的图数据") - parser.add_argument("--group-id", type=str, default=None, help="可选 group_id,默认取 runtime.json") + parser.add_argument("--reset-group", action="store_true", help="运行前清空当前 end_user_id 的图数据") + parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") parser.add_argument("--judge-model", type=str, default=None, help="可选:longmemeval 判别式评测模型名") parser.add_argument("--search-limit", type=int, default=None, help="检索返回的对话节点数量上限(不提供则使用各脚本默认)") parser.add_argument("--context-char-budget", type=int, default=None, help="上下文字符预算(不提供则使用各脚本默认)") @@ -117,7 +117,7 @@ def main(): args.dataset, args.sample_size, args.reset_group, - args.group_id, + args.end_user_id, args.judge_model, args.search_limit, args.context_char_budget, diff --git a/api/app/core/memory/llm_tools/chunker_client.py b/api/app/core/memory/llm_tools/chunker_client.py index 87cdb9f4..93a2df82 100644 --- a/api/app/core/memory/llm_tools/chunker_client.py +++ b/api/app/core/memory/llm_tools/chunker_client.py @@ -187,11 +187,11 @@ class ChunkerClient: async def generate_chunks(self, dialogue: DialogData): """ Generate chunks following 1 Message = 1 Chunk strategy. - + Each message creates one chunk, directly inheriting role information. If a message is too long, it will be split into multiple sub-chunks, each maintaining the same speaker. - + Raises: ValueError: If dialogue has no messages or chunking fails """ @@ -201,9 +201,9 @@ class ChunkerClient: f"Dialogue {dialogue.ref_id} has no messages. " f"Cannot generate chunks from empty dialogue." ) - + dialogue.chunks = [] - + # 按消息分块:每个消息创建一个或多个 chunk,直接继承角色 for msg_idx, msg in enumerate(dialogue.context.msgs): # Validate message has required attributes @@ -212,13 +212,13 @@ class ChunkerClient: f"Message {msg_idx} in dialogue {dialogue.ref_id} " f"missing 'role' or 'msg' attribute" ) - + msg_content = msg.msg.strip() - + # Skip empty messages if not msg_content: continue - + # 如果消息太长,可以进一步分块 if len(msg_content) > self.chunk_size: # 对单个消息的内容进行分块 @@ -228,14 +228,14 @@ class ChunkerClient: raise ValueError( f"Failed to chunk long message {msg_idx} in dialogue {dialogue.ref_id}: {e}" ) - + for idx, sub_chunk in enumerate(sub_chunks): sub_chunk_text = sub_chunk.text if hasattr(sub_chunk, 'text') else str(sub_chunk) sub_chunk_text = sub_chunk_text.strip() - + if len(sub_chunk_text) < (self.min_characters_per_chunk or 50): continue - + chunk = Chunk( content=f"{msg.role}: {sub_chunk_text}", speaker=msg.role, # 直接继承角色 @@ -260,7 +260,7 @@ class ChunkerClient: }, ) dialogue.chunks.append(chunk) - + # Validate we generated at least one chunk if not dialogue.chunks: raise ValueError( @@ -268,7 +268,7 @@ class ChunkerClient: f"All messages were either empty or too short. " f"Messages count: {len(dialogue.context.msgs)}" ) - + return dialogue def evaluate_chunking(self, dialogue: DialogData) -> dict: diff --git a/api/app/core/memory/models/config_models.py b/api/app/core/memory/models/config_models.py index f3341cc5..ca1780aa 100644 --- a/api/app/core/memory/models/config_models.py +++ b/api/app/core/memory/models/config_models.py @@ -72,7 +72,7 @@ class TemporalSearchParams(BaseModel): """Parameters for temporal search queries in the knowledge graph. Attributes: - group_id: Group ID to filter search results (default: 'test') + end_user_id: Group ID to filter search results (default: 'test') apply_id: Application ID to filter search results user_id: User ID to filter search results start_date: Start date for temporal filtering (format: 'YYYY-MM-DD') @@ -81,7 +81,7 @@ class TemporalSearchParams(BaseModel): invalid_date: Date when memory should be invalid (format: 'YYYY-MM-DD') limit: Maximum number of results to return (default: 3) """ - group_id: Optional[str] = Field("test", description="The group ID to filter the search.") + end_user_id: Optional[str] = Field("test", description="The group ID to filter the search.") apply_id: Optional[str] = Field(None, description="The apply ID to filter the search.") user_id: Optional[str] = Field(None, description="The user ID to filter the search.") start_date: Optional[str] = Field(None, description="The start date for the search.") diff --git a/api/app/core/memory/models/graph_models.py b/api/app/core/memory/models/graph_models.py index 7a48d6cb..79b88fdc 100644 --- a/api/app/core/memory/models/graph_models.py +++ b/api/app/core/memory/models/graph_models.py @@ -103,9 +103,7 @@ class Edge(BaseModel): id: Unique identifier for the edge source: ID of the source node target: ID of the target node - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy run_id: Unique identifier for the pipeline run that created this edge created_at: Timestamp when the edge was created (system perspective) expired_at: Optional timestamp when the edge expires (system perspective) @@ -113,9 +111,7 @@ class Edge(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex, description="A unique identifier for the edge.") source: str = Field(..., description="The ID of the source node.") target: str = Field(..., description="The ID of the target node.") - group_id: str = Field(..., description="The group ID of the edge.") - user_id: str = Field(..., description="The user ID of the edge.") - apply_id: str = Field(..., description="The apply ID of the edge.") + end_user_id: str = Field(..., description="The end user ID of the edge.") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(..., description="The valid time of the edge from system perspective.") expired_at: Optional[datetime] = Field(None, description="The expired time of the edge from system perspective.") @@ -185,18 +181,14 @@ class Node(BaseModel): Attributes: id: Unique identifier for the node name: Name of the node - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy run_id: Unique identifier for the pipeline run that created this node created_at: Timestamp when the node was created (system perspective) expired_at: Optional timestamp when the node expires (system perspective) """ id: str = Field(..., description="The unique identifier for the node.") name: str = Field(..., description="The name of the node.") - group_id: str = Field(..., description="The group ID of the node.") - user_id: str = Field(..., description="The user ID of the edge.") - apply_id: str = Field(..., description="The apply ID of the edge.") + end_user_id: str = Field(..., description="The end user ID of the node.") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(..., description="The valid time of the node from system perspective.") expired_at: Optional[datetime] = Field(None, description="The expired time of the node from system perspective.") diff --git a/api/app/core/memory/models/message_models.py b/api/app/core/memory/models/message_models.py index bcf08999..2f8660af 100644 --- a/api/app/core/memory/models/message_models.py +++ b/api/app/core/memory/models/message_models.py @@ -55,7 +55,7 @@ class Statement(BaseModel): Attributes: id: Unique identifier for the statement chunk_id: ID of the parent chunk this statement belongs to - group_id: Optional group ID for multi-tenancy + end_user_id: Optional group ID for multi-tenancy statement: The actual statement text content speaker: Optional speaker identifier ('用户' for user, 'AI' for AI responses) statement_embedding: Optional embedding vector for the statement @@ -73,7 +73,7 @@ class Statement(BaseModel): """ id: str = Field(default_factory=lambda: uuid4().hex, description="A unique identifier for the statement.") chunk_id: str = Field(..., description="ID of the parent chunk this statement belongs to.") - group_id: Optional[str] = Field(None, description="ID of the group this statement belongs to.") + end_user_id: Optional[str] = Field(None, description="ID of the group this statement belongs to.") statement: str = Field(..., description="The text content of the statement.") speaker: Optional[str] = Field(None, description="Speaker identifier: 'user' for user messages, 'assistant' for AI responses") statement_embedding: Optional[List[float]] = Field(None, description="The embedding vector of the statement.") @@ -159,9 +159,7 @@ class DialogData(BaseModel): context: Full conversation context dialog_embedding: Optional embedding vector for the entire dialog ref_id: Reference ID linking to external dialog system - group_id: Group ID for multi-tenancy - user_id: User ID for user-specific data - apply_id: Application ID for application-specific data + end_user_id: End user ID for multi-tenancy created_at: Timestamp when the dialog was created expired_at: Timestamp when the dialog expires (default: far future) metadata: Additional metadata as key-value pairs @@ -175,9 +173,7 @@ class DialogData(BaseModel): context: ConversationContext = Field(..., description="The full conversation context as a single string.") dialog_embedding: Optional[List[float]] = Field(None, description="The embedding vector of the dialog.") ref_id: str = Field(..., description="Refer to external dialog id. This is used to link to the original dialog.") - group_id: str = Field(default=..., description="Group ID of dialogue data") - user_id: str = Field(..., description="USER ID of dialogue data") - apply_id: str = Field(..., description="APPLY ID of dialogue data") + end_user_id: str = Field(default=..., description="End user ID of dialogue data") run_id: str = Field(default_factory=lambda: uuid4().hex, description="Unique identifier for this pipeline run.") created_at: datetime = Field(default_factory=datetime.now, description="The timestamp when the dialog was created.") expired_at: datetime = Field(default_factory=lambda: datetime(9999, 12, 31), description="The timestamp when the dialog expires.") @@ -250,11 +246,11 @@ class DialogData(BaseModel): return [] def assign_group_id_to_statements(self) -> None: - """Assign this dialog's group_id to all statements in all chunks. + """Assign this dialog's end_user_id to all statements in all chunks. - This method updates statements that don't have a group_id set. + This method updates statements that don't have a end_user_id set. """ for chunk in self.chunks: for statement in chunk.statements: - if statement.group_id is None: - statement.group_id = self.group_id + if statement.end_user_id is None: + statement.end_user_id = self.end_user_id diff --git a/api/app/core/memory/src/search.py b/api/app/core/memory/src/search.py index 91e47eae..0e1d8424 100644 --- a/api/app/core/memory/src/search.py +++ b/api/app/core/memory/src/search.py @@ -6,6 +6,7 @@ import os import time from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional +from uuid import UUID if TYPE_CHECKING: from app.schemas.memory_config_schema import MemoryConfig @@ -396,13 +397,13 @@ def rerank_with_activation( return reranked -def log_search_query(query_text: str, search_type: str, group_id: str | None, limit: int, include: List[str], log_file: str = None): +def log_search_query(query_text: str, search_type: str, end_user_id: str | None, limit: int, include: List[str], log_file: str = None): """Log search query information using the logger. Args: query_text: The search query text search_type: Type of search (keyword, embedding, hybrid) - group_id: Group identifier for filtering + end_user_id: Group identifier for filtering limit: Maximum number of results include: List of result types to include log_file: Deprecated parameter, kept for backward compatibility @@ -413,7 +414,7 @@ def log_search_query(query_text: str, search_type: str, group_id: str | None, li # Log using the standard logger logger.info( f"Search query: query='{cleaned_query}', type={search_type}, " - f"group_id={group_id}, limit={limit}, include={include}" + f"end_user_id={end_user_id}, limit={limit}, include={include}" ) @@ -672,7 +673,7 @@ def apply_reranker_placeholder( async def run_hybrid_search( query_text: str, search_type: str, - group_id: str | None, + end_user_id: str | None, limit: int, include: List[str], output_path: str | None, @@ -715,7 +716,7 @@ async def run_hybrid_search( } # Log the search query - log_search_query(query_text, search_type, group_id, limit, include) + log_search_query(query_text, search_type, end_user_id, limit, include) connector = Neo4jConnector() results = {} @@ -732,7 +733,7 @@ async def run_hybrid_search( search_graph( connector=connector, q=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include ) @@ -769,7 +770,7 @@ async def run_hybrid_search( connector=connector, embedder_client=embedder, query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, ) @@ -916,9 +917,7 @@ async def run_hybrid_search( async def search_by_temporal( - group_id: Optional[str] = "test", - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = "test", start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -929,7 +928,7 @@ async def search_by_temporal( Temporal search across Statements. - Matches statements created between start_date and end_date - - Optionally filters by group_id + - Optionally filters by end_user_id - Returns up to 'limit' statements """ connector = Neo4jConnector() @@ -939,9 +938,7 @@ async def search_by_temporal( end_date = normalize_date_safe(end_date) params = TemporalSearchParams.model_validate({ - "group_id": group_id, - "apply_id": apply_id, - "user_id": user_id, + "end_user_id": end_user_id, "start_date": start_date, "end_date": end_date, "valid_date": valid_date, @@ -950,9 +947,7 @@ async def search_by_temporal( }) statements = await search_graph_by_temporal( connector=connector, - group_id=params.group_id, - apply_id=params.apply_id, - user_id=params.user_id, + end_user_id=params.end_user_id, start_date=params.start_date, end_date=params.end_date, valid_date=params.valid_date, @@ -964,9 +959,7 @@ async def search_by_temporal( async def search_by_keyword_temporal( query_text: str, - group_id: Optional[str] = "test", - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = "test", start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -987,9 +980,7 @@ async def search_by_keyword_temporal( invalid_date = normalize_date_safe(invalid_date) params = TemporalSearchParams.model_validate({ - "group_id": group_id, - "apply_id": apply_id, - "user_id": user_id, + "end_user_id": end_user_id, "start_date": start_date, "end_date": end_date, "valid_date": valid_date, @@ -999,9 +990,7 @@ async def search_by_keyword_temporal( statements = await search_graph_by_keyword_temporal( connector=connector, query_text=query_text, - group_id=params.group_id, - apply_id=params.apply_id, - user_id=params.user_id, + end_user_id=params.end_user_id, start_date=params.start_date, end_date=params.end_date, valid_date=params.valid_date, @@ -1013,7 +1002,7 @@ async def search_by_keyword_temporal( async def search_chunk_by_chunk_id( chunk_id: str, - group_id: Optional[str] = "test", + end_user_id: Optional[str] = "test", limit: int = 1, ): """ @@ -1023,7 +1012,7 @@ async def search_chunk_by_chunk_id( chunks = await search_graph_by_chunk_id( connector=connector, chunk_id=chunk_id, - group_id=group_id, + end_user_id=end_user_id, limit=limit ) return {"chunks": chunks} diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py index f5e72517..4dafd3ed 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_preprocessor.py @@ -555,8 +555,8 @@ class DataPreprocessor: dialog_id = item.get('dialog_id', item.get('ref_id', item.get('id', f'dialog_{i}'))) - # 获取group_id,如果不存在则生成默认值 - group_id = item.get('group_id', f'group_default_{i}') + # 获取end_user_id,如果不存在则生成默认值 + end_user_id = item.get('end_user_id', f'group_default_{i}') user_id = item.get('user_id', f'user_default_{i}') apply_id = item.get('apply_id', f'apply_default_{i}') @@ -574,7 +574,7 @@ class DataPreprocessor: dialog_data = DialogData( context=context, ref_id=dialog_id, - group_id=group_id, + end_user_id=end_user_id, user_id=user_id, apply_id=apply_id, metadata=metadata @@ -644,7 +644,7 @@ class DataPreprocessor: context = ConversationContext(msgs=messages) dialog_id = item.get('dialog_id', item.get('ref_id', item.get('id', f'dialog_{i}'))) - group_id = item.get('group_id', f'group_default_{i}') + end_user_id = item.get('end_user_id', f'group_default_{i}') user_id = item.get('user_id', f'user_default_{i}') apply_id = item.get('apply_id', f'apply_default_{i}') @@ -657,7 +657,7 @@ class DataPreprocessor: dialog_data = DialogData( context=context, ref_id=dialog_id, - group_id=group_id, + end_user_id=end_user_id, user_id=user_id, apply_id=apply_id, metadata=metadata diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 62b656b0..a425e0ed 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -199,7 +199,7 @@ def accurate_match( entity_nodes: List[ExtractedEntityNode] ) -> Tuple[List[ExtractedEntityNode], Dict[str, str], Dict[str, Dict]]: """ - 精确匹配:按 (group_id, name, entity_type) 合并实体并建立重定向与合并记录。 + 精确匹配:按 (end_user_id, name, entity_type) 合并实体并建立重定向与合并记录。 返回: (deduped_entities, id_redirect, exact_merge_map) """ exact_merge_map: Dict[str, Dict] = {} @@ -210,8 +210,8 @@ def accurate_match( for ent in entity_nodes: name_norm = (getattr(ent, "name", "") or "").strip() type_norm = (getattr(ent, "entity_type", "") or "").strip() - key = f"{getattr(ent, 'group_id', None)}|{name_norm}|{type_norm}" - # 为避免跨业务组误并,明确以 group_id 为范围边界 + key = f"{getattr(ent, 'end_user_id', None)}|{name_norm}|{type_norm}" + # 为避免跨业务组误并,明确以 end_user_id 为范围边界 if key not in canonical_map: canonical_map[key] = ent id_redirect[ent.id] = ent.id @@ -223,11 +223,11 @@ def accurate_match( id_redirect[ent.id] = canonical.id # 记录精确匹配的合并项(使用规范化键,避免外层变量误用) try: - k = f"{canonical.group_id}|{(canonical.name or '').strip()}|{(canonical.entity_type or '').strip()}" + k = f"{canonical.end_user_id}|{(canonical.name or '').strip()}|{(canonical.entity_type or '').strip()}" if k not in exact_merge_map: exact_merge_map[k] = { "canonical_id": canonical.id, - "group_id": canonical.group_id, + "end_user_id": canonical.end_user_id, "name": canonical.name, "entity_type": canonical.entity_type, "merged_ids": set(), @@ -596,7 +596,7 @@ def fuzzy_match( b = deduped_entities[j] # 跳过不同业务组的实体 - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): j += 1 continue @@ -671,7 +671,7 @@ def fuzzy_match( merge_reason = "[别名匹配]" if alias_match_merge else "[模糊]" merge_reason = "[别名匹配]" if alias_match_merge else "[模糊]" fuzzy_merge_records.append( - f"{merge_reason} 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type}) | " + f"{merge_reason} 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type}) | " f"s_name={s_name:.3f}, s_type={s_type:.3f}, overall={overall:.3f}, exact_alias={has_exact_match}" ) except Exception: @@ -779,7 +779,7 @@ async def LLM_decision( # 决策中包含去重和消歧的功能 # 记录 LLM 融合日志 try: llm_records.append( - f"[LLM融合] 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type})" + f"[LLM融合] 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type})" ) # 详细的“同类名称相似”记录改由 LLM 去重模块统一生成以携带 conf/reason except Exception: @@ -847,7 +847,7 @@ async def LLM_disamb_decision( id_redirect[k] = a.id try: disamb_records.append( - f"[DISAMB合并应用] 规范实体 {a.id} ({a.group_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.group_id}|{b.name}|{b.entity_type})" + f"[DISAMB合并应用] 规范实体 {a.id} ({a.end_user_id}|{a.name}|{a.entity_type}) <- 合并实体 {b.id} ({b.end_user_id}|{b.name}|{b.entity_type})" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py index 734f7b69..0249ac1f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/entity_dedup_llm.py @@ -174,7 +174,7 @@ async def _judge_pair( pass # 3. 构建LLM判断的“上下文信息”(规则层计算的所有特征) 判断上下文特征有助于实体消歧首先判断的类型关系 ctx = { - "same_group": getattr(a, "group_id", None) == getattr(b, "group_id", None), + "same_group": getattr(a, "end_user_id", None) == getattr(b, "end_user_id", None), "type_ok": _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "type_similarity": _type_similarity(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "name_text_sim": name_text_sim, @@ -235,7 +235,7 @@ async def _judge_pair_disamb( except Exception: pass ctx = { - "same_group": getattr(a, "group_id", None) == getattr(b, "group_id", None), + "same_group": getattr(a, "end_user_id", None) == getattr(b, "end_user_id", None), "type_ok": _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)), "name_text_sim": name_text_sim, "name_embed_sim": name_embed_sim, @@ -317,8 +317,8 @@ async def llm_dedup_entities( # 保留对偶判断作为子流程,是为了 a = entity_nodes[i] for j in range(i + 1, len(entity_nodes)): b = entity_nodes[j] - # 规则1:必须属于同一组(group_id相同,不同组的实体不重复) - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + # 规则1:必须属于同一组(end_user_id相同,不同组的实体不重复) + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): continue # 规则2:类型必须兼容(调用_simple_type_ok判断) if not _simple_type_ok(getattr(a, "entity_type", None), getattr(b, "entity_type", None)): @@ -474,7 +474,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 - max_rounds: upper bound for iterative passes (default 3) - auto_merge_threshold: decision confidence for auto-merge when no co-occurrence (default 0.90) - co_ctx_threshold: lower threshold when co-occurrence is detected (default 0.83) - - shuffle_each_round: whether to shuffle entities within group_id each round to vary block composition + - shuffle_each_round: whether to shuffle entities within end_user_id each round to vary block composition Returns: - global_redirect: dict losing_id -> canonical_id accumulated across rounds @@ -509,7 +509,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 def _partition_blocks(nodes: List[ExtractedEntityNode]) -> List[List[ExtractedEntityNode]]: """ - 按 group_id 分块,避免跨组实体在同一块,减少无效候选对 + 按 end_user_id 分块,避免跨组实体在同一块,减少无效候选对 Args: nodes: 实体节点列表 @@ -519,7 +519,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 """ groups: Dict[str, List[ExtractedEntityNode]] = {} for e in nodes: - gid = getattr(e, "group_id", None) + gid = getattr(e, "end_user_id", None) groups.setdefault(str(gid), []).append(e) blocks: List[List[ExtractedEntityNode]] = [] for gid, arr in groups.items(): @@ -559,7 +559,7 @@ async def llm_dedup_entities_iterative_blocks( # 迭代分块并发 LLM 去重 # Collapse nodes to canonical reps before each round to avoid redundant comparisons # 步骤1:折叠实体(合并已确定的重复实体,减少后续计算量) current_nodes = _collapse_nodes(current_nodes) - # 步骤2:分块(按group_id分块,避免跨组处理) + # 步骤2:分块(按end_user_id分块,避免跨组处理) blocks = _partition_blocks(current_nodes) if not blocks: # 无块可处理(实体已全部折叠),退出循环 break @@ -645,7 +645,7 @@ async def llm_disambiguate_pairs_iterative( a = entity_nodes[i] b = entity_nodes[j] # 必须同组 - if getattr(a, "group_id", None) != getattr(b, "group_id", None): + if getattr(a, "end_user_id", None) != getattr(b, "end_user_id", None): continue ta = getattr(a, "entity_type", None) tb = getattr(b, "entity_type", None) diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py index b41f35a4..dbc697d9 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/second_layer_dedup.py @@ -61,7 +61,7 @@ def _row_to_entity(row: Dict[str, Any]) -> ExtractedEntityNode: return ExtractedEntityNode( id=row.get("id"), name=row.get("name") or "", - group_id=row.get("group_id") or "", + end_user_id=row.get("end_user_id") or "", user_id=row.get("user_id") or "", apply_id=row.get("apply_id") or "", created_at=_parse_dt(row.get("created_at")), @@ -79,7 +79,7 @@ def _row_to_entity(row: Dict[str, Any]) -> ExtractedEntityNode: async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑,与 Neo4j 中同组实体联合去重 connector: Neo4jConnector, - group_id: str, # 用于定位neo4j中同一组的实体,确保只在同组内去重 + end_user_id: str, # 用于定位neo4j中同一组的实体,确保只在同组内去重 entity_nodes: List[ExtractedEntityNode], # 输入的实体节点列表,包含待去重的实体 statement_entity_edges: List[StatementEntityEdge], # 输入的语句实体边列表,用于处理实体之间的关系 entity_entity_edges: List[EntityEntityEdge], # 输入的实体实体边列表,用于处理实体之间的关系 @@ -88,7 +88,7 @@ async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑 ) -> Tuple[List[ExtractedEntityNode], List[StatementEntityEdge], List[EntityEntityEdge]]: """ 第二层去重消歧: - - 以第一层结果为索引,检索相同 group_id 下的 DB 候选实体 + - 以第一层结果为索引,检索相同 end_user_id 下的 DB 候选实体 - 将 DB 候选与当前实体集合联合,按既有精确/模糊/LLM 决策进行融合 - 返回融合后的实体与重定向后的边(边已指向规范 ID,优先 DB ID) """ @@ -102,7 +102,7 @@ async def second_layer_dedup_and_merge_with_neo4j( # 二层去重的核心逻辑 ] candidates_map = await get_dedup_candidates_for_entities( # 从 Neo4j 中查询候选实体,并将结果赋值给candidates_map(等待异步操作完成)。 - connector=connector, group_id=group_id, + connector=connector, end_user_id=end_user_id, entities=incoming_rows, # 传入参数:第一层实体的核心信息(作为查询索引) use_contains_fallback=True # 传入参数:启用 “包含关系” 作为匹配失败的降级策略(若精确匹配无结果,用包含关系召回候选),与src\database\cypher_queries.py的307产生联动 ) diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py index 11845d7d..f28b8a5f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/two_stage_dedup.py @@ -57,11 +57,11 @@ async def dedup_layers_and_merge_and_return( if pipeline_config is None: raise ValueError("pipeline_config is required for dedup_layers_and_merge_and_return") - # 先探测 group_id,决定报告写入策略 - group_id: Optional[str] = None + # 先探测 end_user_id,决定报告写入策略 + end_user_id: Optional[str] = None for dd in dialog_data_list: - group_id = getattr(dd, "group_id", None) - if group_id: + end_user_id = getattr(dd, "end_user_id", None) + if end_user_id: break # 第一层去重消歧 @@ -82,11 +82,11 @@ async def dedup_layers_and_merge_and_return( # 第二层去重消歧:与 Neo4j 中同组实体联合融合 try: - if group_id: + if end_user_id: if connector: fused_entity_nodes, fused_statement_entity_edges, fused_entity_entity_edges = await second_layer_dedup_and_merge_with_neo4j( connector=connector, - group_id=group_id, + end_user_id=end_user_id, entity_nodes=dedup_entity_nodes, statement_entity_edges=dedup_statement_entity_edges, entity_entity_edges=dedup_entity_entity_edges, @@ -96,7 +96,7 @@ async def dedup_layers_and_merge_and_return( else: print("Skip second-layer dedup: missing connector") else: - print("Skip second-layer dedup: missing group_id") + print("Skip second-layer dedup: missing end_user_id") except Exception as e: print(f"Second-layer dedup failed: {e}") diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 46ba1dde..8c69c7cf 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -287,7 +287,7 @@ class ExtractionOrchestrator: for d_idx, dialog in enumerate(dialog_data_list): dialogue_content = dialog.content if self.config.statement_extraction.include_dialogue_context else None for c_idx, chunk in enumerate(dialog.chunks): - all_chunks.append((chunk, dialog.group_id, dialogue_content)) + all_chunks.append((chunk, dialog.end_user_id, dialogue_content)) chunk_metadata.append((d_idx, c_idx)) logger.info(f"收集到 {len(all_chunks)} 个分块,开始全局并行提取") @@ -299,9 +299,9 @@ class ExtractionOrchestrator: # 全局并行处理所有分块 async def extract_for_chunk(chunk_data, chunk_index): nonlocal completed_chunks - chunk, group_id, dialogue_content = chunk_data + chunk, end_user_id, dialogue_content = chunk_data try: - statements = await self.statement_extractor._extract_statements(chunk, group_id, dialogue_content) + statements = await self.statement_extractor._extract_statements(chunk, end_user_id, dialogue_content) # 流式输出:每提取完一个分块的陈述句,立即发送进度 # 注意:只在试运行模式下发送陈述句详情,正式模式不发送 @@ -569,32 +569,32 @@ class ExtractionOrchestrator: if dialog_data_list and hasattr(dialog_data_list[0], 'config_id'): config_id = dialog_data_list[0].config_id - # 加载DataConfig - data_config = None + # 加载MemoryConfig + memory_config = None if config_id: try: from app.db import SessionLocal - from app.repositories.data_config_repository import DataConfigRepository + from app.repositories.memory_config_repository import MemoryConfigRepository db = SessionLocal() try: - data_config = DataConfigRepository.get_by_id(db, config_id) + memory_config = MemoryConfigRepository.get_by_id(db, config_id) finally: db.close() - if data_config and not data_config.emotion_enabled: + if memory_config and not memory_config.emotion_enabled: logger.info("情绪提取已在配置中禁用,跳过情绪提取") return [{} for _ in dialog_data_list] except Exception as e: - logger.warning(f"加载DataConfig失败: {e},将跳过情绪提取") + logger.warning(f"加载MemoryConfig失败: {e},将跳过情绪提取") return [{} for _ in dialog_data_list] else: logger.info("未找到config_id,跳过情绪提取") return [{} for _ in dialog_data_list] # 如果配置未启用情绪提取,直接返回空映射 - if not data_config or not data_config.emotion_enabled: + if not memory_config or not memory_config.emotion_enabled: logger.info("情绪提取未启用,跳过") return [{} for _ in dialog_data_list] @@ -608,7 +608,7 @@ class ExtractionOrchestrator: total_statements += 1 # 只处理用户的陈述句 (role 为 "user") if hasattr(statement, 'speaker') and statement.speaker == "user": - all_statements.append((statement, data_config)) + all_statements.append((statement, memory_config)) statement_metadata.append((d_idx, statement.id)) filtered_statements += 1 @@ -617,7 +617,7 @@ class ExtractionOrchestrator: # 初始化情绪提取服务 from app.services.emotion_extraction_service import EmotionExtractionService emotion_service = EmotionExtractionService( - llm_id=data_config.emotion_model_id if data_config.emotion_model_id else None + llm_id=memory_config.emotion_model_id if memory_config.emotion_model_id else None ) # 全局并行处理所有陈述句 @@ -992,9 +992,7 @@ class ExtractionOrchestrator: id=dialog_data.id, name=f"Dialog_{dialog_data.id}", # 添加必需的 name 字段 ref_id=dialog_data.ref_id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id content=dialog_data.context.content if dialog_data.context else "", dialog_embedding=dialog_data.dialog_embedding if hasattr(dialog_data, 'dialog_embedding') else None, @@ -1012,9 +1010,7 @@ class ExtractionOrchestrator: id=chunk.id, name=f"Chunk_{chunk.id}", # 添加必需的 name 字段 dialog_id=dialog_data.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id content=chunk.content, chunk_embedding=chunk.chunk_embedding, @@ -1035,9 +1031,7 @@ class ExtractionOrchestrator: stmt_type=getattr(statement, 'stmt_type', 'general'), # 添加必需的 stmt_type 字段 temporal_info=getattr(statement, 'temporal_info', TemporalInfo.ATEMPORAL), # 添加必需的 temporal_info 字段 connect_strength=statement.connect_strength if statement.connect_strength is not None else 'Strong', # 添加必需的 connect_strength 字段 - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id statement=statement.statement, speaker=getattr(statement, 'speaker', None), # 添加 speaker 字段 @@ -1060,9 +1054,7 @@ class ExtractionOrchestrator: statement_chunk_edge = StatementChunkEdge( source=statement.id, target=chunk.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, ) @@ -1095,9 +1087,7 @@ class ExtractionOrchestrator: aliases=getattr(entity, 'aliases', []) or [], # 传递从三元组提取阶段获取的aliases name_embedding=getattr(entity, 'name_embedding', None), is_explicit_memory=getattr(entity, 'is_explicit_memory', False), # 新增:传递语义记忆标记 - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, expired_at=dialog_data.expired_at, @@ -1112,9 +1102,7 @@ class ExtractionOrchestrator: source=statement.id, target=entity.id, connect_strength=entity_connect_strength if entity_connect_strength is not None else 'Strong', - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, ) @@ -1134,9 +1122,7 @@ class ExtractionOrchestrator: relation_type=triplet.predicate, statement=statement.statement, source_statement_id=statement.id, - group_id=dialog_data.group_id, - user_id=dialog_data.user_id, - apply_id=dialog_data.apply_id, + end_user_id=dialog_data.end_user_id, run_id=dialog_data.run_id, # 使用 dialog_data 的 run_id created_at=dialog_data.created_at, expired_at=dialog_data.expired_at, @@ -1763,14 +1749,14 @@ class ExtractionOrchestrator: async def get_chunked_dialogs( chunker_strategy: str = "RecursiveChunker", - group_id: str = "group_1", + end_user_id: str = "group_1", indices: Optional[List[int]] = None, ) -> List[DialogData]: """从测试数据生成分块对话 Args: chunker_strategy: 分块策略(默认: RecursiveChunker) - group_id: 组ID + end_user_id: 组ID indices: 要处理的数据索引列表(可选) Returns: @@ -1834,7 +1820,7 @@ async def get_chunked_dialogs( dialog_data = DialogData( context=conversation_context, ref_id=data['id'], - group_id=group_id, + end_user_id=end_user_id, metadata=dialog_metadata, ) @@ -1936,7 +1922,7 @@ async def get_chunked_dialogs_from_preprocessed( async def get_chunked_dialogs_with_preprocessing( chunker_strategy: str = "RecursiveChunker", - group_id: str = "default", + end_user_id: str = "default", user_id: str = "default", apply_id: str = "default", indices: Optional[List[int]] = None, @@ -1948,7 +1934,7 @@ async def get_chunked_dialogs_with_preprocessing( Args: chunker_strategy: 分块策略 - group_id: 组ID + end_user_id: 组ID user_id: 用户ID apply_id: 应用ID indices: 要处理的数据索引列表 @@ -1976,11 +1962,9 @@ async def get_chunked_dialogs_with_preprocessing( indices=indices, ) - # 设置 group_id, user_id, apply_id + # 设置 end_user_id for dd in preprocessed_data: - dd.group_id = group_id - dd.user_id = user_id - dd.apply_id = apply_id + dd.end_user_id = end_user_id # 步骤2: 语义剪枝 try: diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py index 7e75fd2d..f39313a8 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/memory_summary.py @@ -193,9 +193,9 @@ async def _process_chunk_summary( node = MemorySummaryNode( id=uuid4().hex, name=title if title else f"MemorySummaryChunk_{chunk.id}", - group_id=dialog.group_id, - user_id=dialog.user_id, - apply_id=dialog.apply_id, + end_user_id=dialog.end_user_id, + user_id=dialog.end_user_id, + apply_id=dialog.end_user_id, run_id=dialog.run_id, # 使用 dialog 的 run_id created_at=datetime.now(), expired_at=datetime(9999, 12, 31), diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py index fb1b539a..b06bd70f 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/statement_extraction.py @@ -82,12 +82,12 @@ class StatementExtractor: logger.warning(f"Chunk {getattr(chunk, 'id', 'unknown')} has no speaker field or is empty") return None - async def _extract_statements(self, chunk, group_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]: + async def _extract_statements(self, chunk, end_user_id: Optional[str] = None, dialogue_content: str = None) -> List[Statement]: """Process a single chunk and return extracted statements Args: chunk: Chunk object to process - group_id: Group ID to assign to all statements in this chunk + end_user_id: Group ID to assign to all statements in this chunk dialogue_content: Full dialogue content to provide as context Returns: @@ -158,7 +158,7 @@ class StatementExtractor: temporal_info=temporal_type, relevence_info=relevence_info, chunk_id=chunk.id, - group_id=group_id, + end_user_id=end_user_id, speaker=chunk_speaker, ) @@ -184,10 +184,10 @@ class StatementExtractor: logger.info(f"Processing {len(chunks_to_process)} chunks for statement extraction") - # Process all chunks concurrently, passing the group_id and dialogue content from dialog_data + # Process all chunks concurrently, passing the end_user_id and dialogue content from dialog_data dialogue_content = dialog_data.content if self.config.include_dialogue_context else None results = await asyncio.gather( - *[self._extract_statements(chunk, dialog_data.group_id, dialogue_content) for chunk in chunks_to_process], + *[self._extract_statements(chunk, dialog_data.end_user_id, dialogue_content) for chunk in chunks_to_process], return_exceptions=True ) @@ -225,7 +225,7 @@ class StatementExtractor: for i, statement in enumerate(statements, 1): f.write(f"Statement {i}:\n") f.write(f"Id: {statement.id}\n") - f.write(f"Group Id: {statement.group_id}\n") + f.write(f"Group Id: {statement.end_user_id}\n") f.write(f"Content: {statement.statement}\n") f.write(f"Type: {statement.stmt_type.value}\n") f.write(f"Temporal Info: {statement.temporal_info.value}\n") @@ -298,7 +298,7 @@ class StatementExtractor: dialog_sections.append({ "dialog_id": dialog.ref_id, - "group_id": dialog.group_id, + "end_user_id": dialog.end_user_id, "content": dialog.content if getattr(dialog, "content", None) else "", "strong": strong_relations, "weak": weak_relations, @@ -312,7 +312,7 @@ class StatementExtractor: for idx, section in enumerate(dialog_sections, 1): f.write(f"Dialog {idx}:\n") f.write(f"Dialog ID: {section.get('dialog_id', '')}\n") - f.write(f"Group ID: {section.get('group_id', '')}\n") + f.write(f"Group ID: {section.get('end_user_id', '')}\n") f.write("Content:\n") f.write(f"{section.get('content', '')}\n") f.write("-" * 40 + "\n\n") diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py index 9528e638..499027a4 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/temporal_extraction.py @@ -132,7 +132,7 @@ class TemporalExtractor: prompt_logger.info("") prompt_logger.info("=== TEMPORAL EXTRACTION RESULTS ===") prompt_logger.info( - f"[Temporal] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, group_id={getattr(dialog_data, 'group_id', None)}" + f"[Temporal] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, end_user_id={getattr(dialog_data, 'end_user_id', None)}" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py index d3d059b0..bfc0bc88 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/triplet_extraction.py @@ -116,7 +116,7 @@ class TripletExtractor: logger.info(f"Processing {len(all_statements)} statements for triplet extraction...") try: prompt_logger.info( - f"[Triplet] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, group_id={getattr(dialog_data, 'group_id', None)}, statements_to_process={len(all_statements)}" + f"[Triplet] Dialog ref_id={getattr(dialog_data, 'ref_id', None)}, end_user_id={getattr(dialog_data, 'end_user_id', None)}, statements_to_process={len(all_statements)}" ) except Exception: pass diff --git a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py index 5722769a..a71c0957 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py +++ b/api/app/core/memory/storage_services/forgetting_engine/access_history_manager.py @@ -75,7 +75,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_time: Optional[datetime] = None ) -> Dict[str, Any]: """ @@ -91,7 +91,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签(Statement, ExtractedEntity, MemorySummary) - group_id: 组ID(可选,用于过滤) + end_user_id: 组ID(可选,用于过滤) current_time: 当前时间(可选,默认使用系统时间) Returns: @@ -123,7 +123,7 @@ class AccessHistoryManager: for attempt in range(self.max_retries): try: # 步骤1:读取当前节点状态 - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: raise ValueError( @@ -142,7 +142,7 @@ class AccessHistoryManager: node_id=node_id, node_label=node_label, update_data=update_data, - group_id=group_id + end_user_id=end_user_id ) logger.info( @@ -172,7 +172,7 @@ class AccessHistoryManager: self, node_ids: List[str], node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, current_time: Optional[datetime] = None ) -> List[Dict[str, Any]]: """ @@ -184,7 +184,7 @@ class AccessHistoryManager: Args: node_ids: 节点ID列表 node_label: 节点标签(所有节点必须是同一类型) - group_id: 组ID(可选) + end_user_id: 组ID(可选) current_time: 当前时间(可选) Returns: @@ -202,7 +202,7 @@ class AccessHistoryManager: task = self.record_access( node_id=node_id, node_label=node_label, - group_id=group_id, + end_user_id=end_user_id, current_time=current_time ) tasks.append(task) @@ -235,7 +235,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Tuple[ConsistencyCheckResult, Optional[str]]: """ 检查节点数据的一致性 @@ -249,14 +249,14 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Tuple[ConsistencyCheckResult, Optional[str]]: - 一致性检查结果枚举 - 错误描述(如果不一致) """ - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: return ConsistencyCheckResult.CONSISTENT, None @@ -305,7 +305,7 @@ class AccessHistoryManager: async def check_batch_consistency( self, node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1000 ) -> Dict[str, Any]: """ @@ -313,7 +313,7 @@ class AccessHistoryManager: Args: node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) limit: 检查的最大节点数 Returns: @@ -329,16 +329,16 @@ class AccessHistoryManager: MATCH (n:{node_label}) WHERE n.access_history IS NOT NULL """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ RETURN n.id as id LIMIT $limit """ params = {"limit": limit} - if group_id: - params["group_id"] = group_id + if end_user_id: + params["end_user_id"] = end_user_id results = await self.connector.execute_query(query, **params) node_ids = [r['id'] for r in results] @@ -351,7 +351,7 @@ class AccessHistoryManager: result, message = await self.check_consistency( node_id=node_id, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) if result == ConsistencyCheckResult.CONSISTENT: @@ -387,7 +387,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> bool: """ 自动修复节点的数据不一致问题 @@ -401,7 +401,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: bool: 修复成功返回True,否则返回False @@ -411,7 +411,7 @@ class AccessHistoryManager: result, message = await self.check_consistency( node_id=node_id, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) if result == ConsistencyCheckResult.CONSISTENT: @@ -419,7 +419,7 @@ class AccessHistoryManager: return True # 获取节点数据 - node_data = await self._fetch_node(node_id, node_label, group_id) + node_data = await self._fetch_node(node_id, node_label, end_user_id) if not node_data: logger.error(f"节点不存在,无法修复: {node_label}[{node_id}]") return False @@ -457,8 +457,8 @@ class AccessHistoryManager: query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - query += " WHERE n.group_id = $group_id" + if end_user_id: + query += " WHERE n.end_user_id = $end_user_id" query += """ SET n += $repair_data RETURN n @@ -468,8 +468,8 @@ class AccessHistoryManager: 'node_id': node_id, 'repair_data': repair_data } - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id await self.connector.execute_query(query, **params) @@ -491,7 +491,7 @@ class AccessHistoryManager: self, node_id: str, node_label: str, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ 获取节点数据 @@ -499,7 +499,7 @@ class AccessHistoryManager: Args: node_id: 节点ID node_label: 节点标签 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Optional[Dict[str, Any]]: 节点数据,如果不存在返回None @@ -507,8 +507,8 @@ class AccessHistoryManager: query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - query += " WHERE n.group_id = $group_id" + if end_user_id: + query += " WHERE n.end_user_id = $end_user_id" query += """ RETURN n.id as id, n.importance_score as importance_score, @@ -519,8 +519,8 @@ class AccessHistoryManager: """ params = {'node_id': node_id} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) @@ -585,7 +585,7 @@ class AccessHistoryManager: node_id: str, node_label: str, update_data: Dict[str, Any], - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Dict[str, Any]: """ 原子性更新节点(使用乐观锁) @@ -597,7 +597,7 @@ class AccessHistoryManager: node_id: 节点ID node_label: 节点标签 update_data: 更新数据 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Dict[str, Any]: 更新后的节点数据 @@ -606,13 +606,13 @@ class AccessHistoryManager: RuntimeError: 如果更新失败或发生版本冲突 """ # 定义事务函数 - async def update_transaction(tx, node_id, node_label, update_data, group_id): + async def update_transaction(tx, node_id, node_label, update_data, end_user_id): # 步骤1:读取当前节点并获取版本号 read_query = f""" MATCH (n:{node_label} {{id: $node_id}}) """ - if group_id: - read_query += " WHERE n.group_id = $group_id" + if end_user_id: + read_query += " WHERE n.end_user_id = $end_user_id" read_query += """ RETURN n.id as id, n.version as version, @@ -624,8 +624,8 @@ class AccessHistoryManager: """ read_params = {'node_id': node_id} - if group_id: - read_params['group_id'] = group_id + if end_user_id: + read_params['end_user_id'] = end_user_id read_result = await tx.run(read_query, **read_params) current_node = await read_result.single() @@ -656,8 +656,8 @@ class AccessHistoryManager: # 构建 WHERE 子句 where_conditions = [] - if group_id: - where_conditions.append("n.group_id = $group_id") + if end_user_id: + where_conditions.append("n.end_user_id = $end_user_id") # 添加版本检查 if current_version > 0: @@ -695,8 +695,8 @@ class AccessHistoryManager: 'last_access_time': update_data['last_access_time'], 'access_count': update_data['access_count'] } - if group_id: - update_params['group_id'] = group_id + if end_user_id: + update_params['end_user_id'] = end_user_id update_result = await tx.run(update_query, **update_params) updated_node = await update_result.single() @@ -720,7 +720,7 @@ class AccessHistoryManager: node_id=node_id, node_label=node_label, update_data=update_data, - group_id=group_id + end_user_id=end_user_id ) return result except Exception as e: diff --git a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py index ea9a6358..25daa968 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/config_utils.py +++ b/api/app/core/memory/storage_services/forgetting_engine/config_utils.py @@ -11,9 +11,10 @@ Functions: import logging from typing import Optional, Dict, Any +from uuid import UUID from sqlalchemy.orm import Session -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.core.memory.storage_services.forgetting_engine.actr_calculator import ACTRCalculator @@ -61,12 +62,12 @@ def calculate_forgetting_rate(lambda_time: float, lambda_mem: float) -> float: def load_actr_config_from_db( db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 从数据库加载 ACT-R 配置参数 - 从 PostgreSQL 的 data_config 表读取配置参数, + 从 PostgreSQL 的 memory_config 表读取配置参数, 并计算派生参数(如 forgetting_rate)。 Args: @@ -99,7 +100,7 @@ def load_actr_config_from_db( # 从数据库加载配置 try: - repository = DataConfigRepository() + repository = MemoryConfigRepository() db_config = repository.get_by_id(db, config_id) if db_config is None: @@ -150,7 +151,7 @@ def load_actr_config_from_db( def create_actr_calculator_from_config( db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> ACTRCalculator: """ 从数据库配置创建 ACTRCalculator 实例 @@ -168,11 +169,6 @@ def create_actr_calculator_from_config( ValueError: 如果指定的 config_id 不存在 Examples: - >>> from sqlalchemy.orm import Session - >>> db = Session() - >>> calculator = create_actr_calculator_from_config(db, config_id=1) - >>> # 使用计算器 - >>> activation = calculator.calculate_memory_activation(...) """ # 加载配置 config = load_actr_config_from_db(db, config_id) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py index 6d42af53..5a178fc2 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py @@ -16,6 +16,7 @@ Classes: import logging from typing import Dict, Any, Optional +from uuid import UUID from datetime import datetime from app.core.memory.storage_services.forgetting_engine.forgetting_strategy import ForgettingStrategy @@ -66,10 +67,10 @@ class ForgettingScheduler: async def run_forgetting_cycle( self, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, max_merge_batch_size: int = 100, min_days_since_access: int = 30, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> Dict[str, Any]: """ @@ -77,7 +78,7 @@ class ForgettingScheduler: Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) max_merge_batch_size: 单次最大融合节点对数(默认 100) min_days_since_access: 最小未访问天数(默认 30 天) config_id: 配置ID(可选,用于获取 llm_id) @@ -107,19 +108,19 @@ class ForgettingScheduler: start_time_iso = start_time.isoformat() logger.info( - f"开始遗忘周期: group_id={group_id}, " + f"开始遗忘周期: end_user_id={end_user_id}, " f"max_batch={max_merge_batch_size}, " f"min_days={min_days_since_access}" ) try: # 步骤1:统计遗忘前的节点数量 - nodes_before = await self._count_knowledge_nodes(group_id) + nodes_before = await self._count_knowledge_nodes(end_user_id) logger.info(f"遗忘前节点总数: {nodes_before}") # 步骤2:识别可遗忘的节点对 forgettable_pairs = await self.forgetting_strategy.find_forgettable_nodes( - group_id=group_id, + end_user_id=end_user_id, min_days_since_access=min_days_since_access ) @@ -213,7 +214,7 @@ class ForgettingScheduler: 'statement_text': pair['statement_text'], 'statement_activation': pair['statement_activation'], 'statement_importance': pair['statement_importance'], - 'group_id': group_id + 'end_user_id': end_user_id } entity_node = { @@ -222,7 +223,7 @@ class ForgettingScheduler: 'entity_type': pair['entity_type'], 'entity_activation': pair['entity_activation'], 'entity_importance': pair['entity_importance'], - 'group_id': group_id + 'end_user_id': end_user_id } # 融合节点 @@ -262,7 +263,7 @@ class ForgettingScheduler: continue # 步骤6:统计遗忘后的节点数量 - nodes_after = await self._count_knowledge_nodes(group_id) + nodes_after = await self._count_knowledge_nodes(end_user_id) logger.info(f"遗忘后节点总数: {nodes_after}") # 步骤7:生成遗忘报告 @@ -315,7 +316,7 @@ class ForgettingScheduler: async def _count_knowledge_nodes( self, - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> int: """ 统计知识层节点总数 @@ -323,7 +324,7 @@ class ForgettingScheduler: 统计 Statement、ExtractedEntity 和 MemorySummary 节点的总数。 Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) Returns: int: 知识层节点总数 @@ -333,16 +334,16 @@ class ForgettingScheduler: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ RETURN count(n) as total """ params = {} - if group_id: - params['group_id'] = group_id + if end_user_id: + end_user_id['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py index ccd8d2ca..a8c62dd4 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_strategy.py @@ -13,6 +13,7 @@ Classes: import logging from typing import List, Dict, Any, Optional +from uuid import UUID from datetime import datetime, timedelta from app.repositories.neo4j.neo4j_connector import Neo4jConnector @@ -90,7 +91,7 @@ class ForgettingStrategy: async def find_forgettable_nodes( self, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, min_days_since_access: int = 30 ) -> List[Dict[str, Any]]: """ @@ -102,7 +103,7 @@ class ForgettingStrategy: 3. Statement 和 Entity 之间存在关系边 Args: - group_id: 组 ID(可选,用于过滤特定组的节点) + end_user_id: 组 ID(可选,用于过滤特定组的节点) min_days_since_access: 最小未访问天数(默认 30 天) Returns: @@ -136,8 +137,8 @@ class ForgettingStrategy: AND (e.entity_type IS NULL OR e.entity_type <> 'Person') """ - if group_id: - query += " AND s.group_id = $group_id AND e.group_id = $group_id" + if end_user_id: + query += " AND s.end_user_id = $end_user_id AND e.end_user_id = $end_user_id" query += """ RETURN s.id as statement_id, @@ -159,8 +160,8 @@ class ForgettingStrategy: 'threshold': self.forgetting_threshold, 'cutoff_time': cutoff_time_iso } - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) @@ -176,7 +177,7 @@ class ForgettingStrategy: self, statement_node: Dict[str, Any], entity_node: Dict[str, Any], - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> str: """ @@ -247,8 +248,8 @@ class ForgettingStrategy: entity_activation = entity_node['entity_activation'] entity_importance = entity_node['entity_importance'] - # 获取 group_id(从 statement 或 entity 节点) - group_id = statement_node.get('group_id') or entity_node.get('group_id') + # 获取 end_user_id(从 statement 或 entity 节点) + end_user_id = statement_node.get('end_user_id') or entity_node.get('end_user_id') # 生成摘要内容 summary_text = await self._generate_summary( @@ -325,7 +326,7 @@ class ForgettingStrategy: last_access_time: $current_time, access_count: 1, version: 1, - group_id: $group_id, + end_user_id: $end_user_id, created_at: datetime($current_time), merged_at: datetime($current_time) }) @@ -423,7 +424,7 @@ class ForgettingStrategy: 'inherited_activation': inherited_activation, 'inherited_importance': inherited_importance, 'current_time': current_time_iso, - 'group_id': group_id + 'end_user_id': end_user_id } try: @@ -462,7 +463,7 @@ class ForgettingStrategy: statement_text: str, entity_name: str, entity_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, db = None ) -> str: """ @@ -527,7 +528,7 @@ class ForgettingStrategy: statement_text, entity_name, entity_type ) - async def _get_llm_client(self, db, config_id: int): + async def _get_llm_client(self, db, config_id: UUID): """ 从数据库获取 LLM 客户端 @@ -539,11 +540,11 @@ class ForgettingStrategy: LLM 客户端实例,如果无法获取则返回 None """ try: - from app.repositories.data_config_repository import DataConfigRepository + from app.repositories.memory_config_repository import MemoryConfigRepository from app.core.memory.utils.llm.llm_utils import MemoryClientFactory # 从数据库读取配置 - repository = DataConfigRepository() + repository = MemoryConfigRepository() db_config = repository.get_by_id(db, config_id) if db_config is None or db_config.llm_id is None: diff --git a/api/app/core/memory/storage_services/search/__init__.py b/api/app/core/memory/storage_services/search/__init__.py index 2bec5bf1..c12c39b0 100644 --- a/api/app/core/memory/storage_services/search/__init__.py +++ b/api/app/core/memory/storage_services/search/__init__.py @@ -37,7 +37,7 @@ __all__ = [ async def run_hybrid_search( query_text: str, search_type: str = "hybrid", - group_id: str | None = None, + end_user_id: str | None = None, apply_id: str | None = None, user_id: str | None = None, limit: int = 50, @@ -54,7 +54,7 @@ async def run_hybrid_search( Args: query_text: 查询文本 search_type: 搜索类型("hybrid", "keyword", "semantic") - group_id: 组ID过滤 + end_user_id: 组ID过滤 apply_id: 应用ID过滤 user_id: 用户ID过滤 limit: 每个类别的最大结果数 @@ -104,7 +104,7 @@ async def run_hybrid_search( # 执行搜索 result = await strategy.search( query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include, alpha=alpha, diff --git a/api/app/core/memory/storage_services/search/hybrid_search.py b/api/app/core/memory/storage_services/search/hybrid_search.py index 43215df5..4111b09c 100644 --- a/api/app/core/memory/storage_services/search/hybrid_search.py +++ b/api/app/core/memory/storage_services/search/hybrid_search.py @@ -77,7 +77,7 @@ # async def search( # self, # query_text: str, -# group_id: Optional[str] = None, +# end_user_id: Optional[str] = None, # limit: int = 50, # include: Optional[List[str]] = None, # **kwargs @@ -86,7 +86,7 @@ # Args: # query_text: 查询文本 -# group_id: 可选的组ID过滤 +# end_user_id: 可选的组ID过滤 # limit: 每个类别的最大结果数 # include: 要包含的搜索类别列表 # **kwargs: 其他搜索参数(如alpha, use_forgetting_curve) @@ -94,7 +94,7 @@ # Returns: # SearchResult: 搜索结果对象 # """ -# logger.info(f"执行混合搜索: query='{query_text}', group_id={group_id}, limit={limit}") +# logger.info(f"执行混合搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # # 从kwargs中获取参数 # alpha = kwargs.get("alpha", self.alpha) @@ -107,14 +107,14 @@ # # 并行执行关键词搜索和语义搜索 # keyword_result = await self.keyword_strategy.search( # query_text=query_text, -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list # ) # semantic_result = await self.semantic_strategy.search( # query_text=query_text, -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list # ) @@ -139,7 +139,7 @@ # metadata = self._create_metadata( # query_text=query_text, # search_type="hybrid", -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # include=include_list, # alpha=alpha, @@ -165,7 +165,7 @@ # metadata=self._create_metadata( # query_text=query_text, # search_type="hybrid", -# group_id=group_id, +# end_user_id=end_user_id, # limit=limit, # error=str(e) # ) diff --git a/api/app/core/memory/storage_services/search/keyword_search.py b/api/app/core/memory/storage_services/search/keyword_search.py index 95dd0581..d2591945 100644 --- a/api/app/core/memory/storage_services/search/keyword_search.py +++ b/api/app/core/memory/storage_services/search/keyword_search.py @@ -44,7 +44,7 @@ class KeywordSearchStrategy(SearchStrategy): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -53,7 +53,7 @@ class KeywordSearchStrategy(SearchStrategy): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表 **kwargs: 其他搜索参数 @@ -61,7 +61,7 @@ class KeywordSearchStrategy(SearchStrategy): Returns: SearchResult: 搜索结果对象 """ - logger.info(f"执行关键词搜索: query='{query_text}', group_id={group_id}, limit={limit}") + logger.info(f"执行关键词搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # 获取有效的搜索类别 include_list = self._get_include_list(include) @@ -75,7 +75,7 @@ class KeywordSearchStrategy(SearchStrategy): results_dict = await search_graph( connector=self.connector, q=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -84,7 +84,7 @@ class KeywordSearchStrategy(SearchStrategy): metadata = self._create_metadata( query_text=query_text, search_type="keyword", - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -115,7 +115,7 @@ class KeywordSearchStrategy(SearchStrategy): metadata=self._create_metadata( query_text=query_text, search_type="keyword", - group_id=group_id, + end_user_id=end_user_id, limit=limit, error=str(e) ) diff --git a/api/app/core/memory/storage_services/search/search_strategy.py b/api/app/core/memory/storage_services/search/search_strategy.py index 27c02c89..3a670dd6 100644 --- a/api/app/core/memory/storage_services/search/search_strategy.py +++ b/api/app/core/memory/storage_services/search/search_strategy.py @@ -58,7 +58,7 @@ class SearchStrategy(ABC): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -67,7 +67,7 @@ class SearchStrategy(ABC): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表(statements, chunks, entities, summaries) **kwargs: 其他搜索参数 @@ -81,7 +81,7 @@ class SearchStrategy(ABC): self, query_text: str, search_type: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, **kwargs ) -> Dict[str, Any]: @@ -90,7 +90,7 @@ class SearchStrategy(ABC): Args: query_text: 查询文本 search_type: 搜索类型 - group_id: 组ID + end_user_id: 组ID limit: 结果限制 **kwargs: 其他元数据 @@ -100,7 +100,7 @@ class SearchStrategy(ABC): metadata = { "query": query_text, "search_type": search_type, - "group_id": group_id, + "end_user_id": end_user_id, "limit": limit, "timestamp": datetime.now().isoformat() } diff --git a/api/app/core/memory/storage_services/search/semantic_search.py b/api/app/core/memory/storage_services/search/semantic_search.py index b20f90a5..8d4eb05f 100644 --- a/api/app/core/memory/storage_services/search/semantic_search.py +++ b/api/app/core/memory/storage_services/search/semantic_search.py @@ -85,7 +85,7 @@ class SemanticSearchStrategy(SearchStrategy): async def search( self, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: Optional[List[str]] = None, **kwargs @@ -94,7 +94,7 @@ class SemanticSearchStrategy(SearchStrategy): Args: query_text: 查询文本 - group_id: 可选的组ID过滤 + end_user_id: 可选的组ID过滤 limit: 每个类别的最大结果数 include: 要包含的搜索类别列表 **kwargs: 其他搜索参数 @@ -102,7 +102,7 @@ class SemanticSearchStrategy(SearchStrategy): Returns: SearchResult: 搜索结果对象 """ - logger.info(f"执行语义搜索: query='{query_text}', group_id={group_id}, limit={limit}") + logger.info(f"执行语义搜索: query='{query_text}', end_user_id={end_user_id}, limit={limit}") # 获取有效的搜索类别 include_list = self._get_include_list(include) @@ -119,7 +119,7 @@ class SemanticSearchStrategy(SearchStrategy): connector=self.connector, embedder_client=self.embedder_client, query_text=query_text, - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -128,7 +128,7 @@ class SemanticSearchStrategy(SearchStrategy): metadata = self._create_metadata( query_text=query_text, search_type="semantic", - group_id=group_id, + end_user_id=end_user_id, limit=limit, include=include_list ) @@ -159,7 +159,7 @@ class SemanticSearchStrategy(SearchStrategy): metadata=self._create_metadata( query_text=query_text, search_type="semantic", - group_id=group_id, + end_user_id=end_user_id, limit=limit, error=str(e) ) diff --git a/api/app/core/memory/utils/config/get_data.py b/api/app/core/memory/utils/config/get_data.py index 1de6f6aa..e37ad723 100644 --- a/api/app/core/memory/utils/config/get_data.py +++ b/api/app/core/memory/utils/config/get_data.py @@ -23,7 +23,7 @@ async def _load_(data: List[Any]) -> List[Dict]: target_keys = [ "id", "statement", - "group_id", + "end_user_id", "chunk_id", "created_at", "expired_at", @@ -75,7 +75,7 @@ async def get_data(result): """ EXCLUDE_FIELDS = { "user_id", - "group_id", + "end_user_id", "entity_type", "connect_strength", "relationship_type", diff --git a/api/app/core/memory/utils/log/audit_logger.py b/api/app/core/memory/utils/log/audit_logger.py index 9010aad5..f80ad4d5 100644 --- a/api/app/core/memory/utils/log/audit_logger.py +++ b/api/app/core/memory/utils/log/audit_logger.py @@ -62,7 +62,7 @@ class ConfigAuditLogger: self, config_id: str, user_id: Optional[str] = None, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, success: bool = True, details: Optional[Dict[str, Any]] = None ): @@ -72,14 +72,14 @@ class ConfigAuditLogger: Args: config_id: 配置 ID user_id: 用户 ID(可选) - group_id: 组 ID(可选) + end_user_id: 组 ID(可选) success: 是否成功 details: 详细信息(可选) """ result = "SUCCESS" if success else "FAILED" msg = ( f"CONFIG_LOAD config_id={config_id} " - f"user={user_id or 'N/A'} group={group_id or 'N/A'} " + f"user={user_id or 'N/A'} group={end_user_id or 'N/A'} " f"result={result}" ) if details: @@ -121,7 +121,7 @@ class ConfigAuditLogger: self, operation: str, config_id: str, - group_id: str, + end_user_id: str, success: bool = True, duration: Optional[float] = None, error: Optional[str] = None, @@ -133,7 +133,7 @@ class ConfigAuditLogger: Args: operation: 操作类型(WRITE, READ 等) config_id: 配置 ID - group_id: 组 ID + end_user_id: 组 ID success: 是否成功 duration: 操作耗时(秒) error: 错误信息(可选) @@ -142,7 +142,7 @@ class ConfigAuditLogger: result = "SUCCESS" if success else "FAILED" msg = ( f"{operation.upper()} config_id={config_id} " - f"group={group_id} result={result}" + f"group={end_user_id} result={result}" ) if duration is not None: msg += f" duration={duration:.2f}s" diff --git a/api/app/core/rag/vdb/field.py b/api/app/core/rag/vdb/field.py index 86d39060..99d872c2 100644 --- a/api/app/core/rag/vdb/field.py +++ b/api/app/core/rag/vdb/field.py @@ -4,7 +4,7 @@ from enum import StrEnum, auto class Field(StrEnum): CONTENT_KEY = "page_content" METADATA_KEY = "metadata" - GROUP_KEY = "group_id" + GROUP_KEY = "end_user_id" VECTOR = auto() # Sparse Vector aims to support full text search SPARSE_VECTOR = auto() diff --git a/api/app/core/validators/memory_config_validators.py b/api/app/core/validators/memory_config_validators.py index 333572e6..ba26c5f2 100644 --- a/api/app/core/validators/memory_config_validators.py +++ b/api/app/core/validators/memory_config_validators.py @@ -26,7 +26,7 @@ logger = get_config_logger() def _parse_model_id(model_id: Union[str, UUID, None], model_type: str, - config_id: Optional[int] = None, workspace_id: Optional[UUID] = None) -> Optional[UUID]: + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None) -> Optional[UUID]: """Parse model ID from string or UUID.""" if model_id is None: return None @@ -59,7 +59,7 @@ def validate_model_exists_and_active( model_type: str, db: Session, tenant_id: Optional[UUID] = None, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None ) -> tuple[str, bool]: """Validate that a model exists and is active. @@ -166,7 +166,7 @@ def validate_and_resolve_model_id( db: Session, tenant_id: Optional[UUID] = None, required: bool = False, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None ) -> tuple[Optional[UUID], Optional[str]]: """Validate and resolve a model ID, checking existence and active status. @@ -204,7 +204,7 @@ def validate_and_resolve_model_id( def validate_embedding_model( - config_id: int, + config_id: UUID, embedding_id: Union[str, UUID, None], db: Session, tenant_id: Optional[UUID] = None, @@ -256,7 +256,7 @@ def validate_embedding_model( def validate_llm_model( - config_id: int, + config_id: UUID, llm_id: Union[str, UUID, None], db: Session, tenant_id: Optional[UUID] = None, diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 987230c1..4c8c43eb 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -1,4 +1,5 @@ import uuid +from uuid import UUID from pydantic import Field from typing import Literal @@ -11,7 +12,7 @@ class MemoryReadNodeConfig(BaseNodeConfig): ... ) - config_id: int = Field( + config_id: UUID = Field( ... ) @@ -26,6 +27,6 @@ class MemoryWriteNodeConfig(BaseNodeConfig): ... ) - config_id: int = Field( + config_id: UUID = Field( ... ) diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index 08a2b280..0589cc82 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -22,7 +22,7 @@ class MemoryReadNode(BaseNode): raise RuntimeError("End user id is required") return await MemoryAgentService().read_memory( - group_id=end_user_id, + end_user_id=end_user_id, message=self._render_template(self.typed_config.message, state), config_id=str(self.typed_config.config_id), search_switch=self.typed_config.search_switch, diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index bf3a1b3d..e069b40d 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -18,7 +18,7 @@ from .appshare_model import AppShare from .release_share_model import ReleaseShare from .conversation_model import Conversation, Message from .api_key_model import ApiKey, ApiKeyLog, ApiKeyType -from .data_config_model import DataConfig +from .memory_config_model import MemoryConfig from .multi_agent_model import MultiAgentConfig, AgentInvocation from .workflow_model import WorkflowConfig, WorkflowExecution, WorkflowNodeExecution from .retrieval_info import RetrievalInfo @@ -57,7 +57,7 @@ __all__ = [ "ApiKey", "ApiKeyLog", "ApiKeyType", - "DataConfig", + "MemoryConfig", "MultiAgentConfig", "AgentInvocation", "WorkflowConfig", diff --git a/api/app/models/data_config_model.py b/api/app/models/data_config_model.py deleted file mode 100644 index 06f87cb2..00000000 --- a/api/app/models/data_config_model.py +++ /dev/null @@ -1,88 +0,0 @@ -import datetime -from sqlalchemy import Column, String, Boolean, DateTime, Integer, Float -from sqlalchemy.dialects.postgresql import UUID -from app.db import Base - - -class DataConfig(Base): - """数据配置表 - 用于存储记忆系统的配置参数""" - __tablename__ = "data_config" - - # 主键 - config_id = Column(Integer, primary_key=True, autoincrement=True, comment="配置ID") - - # 基本信息 - config_name = Column(String, nullable=False, comment="配置名称") - config_desc = Column(String, nullable=True, comment="配置描述") - - # 组织信息 - workspace_id = Column(UUID(as_uuid=True), nullable=True, comment="工作空间ID") - group_id = Column(String, nullable=True, comment="组ID") - user_id = Column(String, nullable=True, comment="用户ID") - apply_id = Column(String, nullable=True, comment="应用ID") - - # 模型选择(从workspace继承) - llm_id = Column(String, nullable=True, comment="LLM模型配置ID") - embedding_id = Column(String, nullable=True, comment="嵌入模型配置ID") - rerank_id = Column(String, nullable=True, comment="重排序模型配置ID") - - # 记忆萃取引擎配置 - enable_llm_dedup_blockwise = Column(Boolean, default=True, comment="启用LLM决策去重") - enable_llm_disambiguation = Column(Boolean, default=True, comment="启用LLM决策消歧") - deep_retrieval = Column(Boolean, default=True, comment="深度检索开关") - - # 阈值配置 (0-1 之间的浮点数) - t_type_strict = Column(Float, default=0.8, comment="类型严格阈值") - t_name_strict = Column(Float, default=0.8, comment="名称严格阈值") - t_overall = Column(Float, default=0.8, comment="综合阈值") - - # 状态配置 - state = Column(Boolean, default=False, comment="配置使用状态") - - # 分块策略 - chunker_strategy = Column(String, default="RecursiveChunker", comment="分块策略") - - # 剪枝配置 - pruning_enabled = Column(Boolean, default=False, comment="是否启动智能语义剪枝") - pruning_scene = Column(String, nullable=True, comment="智能剪枝场景:education/online_service/outbound") - pruning_threshold = Column(Float, nullable=True, comment="智能语义剪枝阈值(0-0.9)") - - # 自我反思配置 - enable_self_reflexion = Column(Boolean, default=False, comment="是否启用自我反思") - iteration_period = Column(String, default="3", comment="反思迭代周期") - reflexion_range = Column(String, default="partial", comment="反思范围:部分/全部") - baseline = Column(String, default="TIME", comment="基线:时间/事实/时间和事实") - reflection_model_id = Column(String, nullable=True, comment="反思模型ID") - memory_verify = Column(Boolean, default=True, comment="记忆验证") - quality_assessment = Column(Boolean, default=True, comment="质量评估") - - # 遗忘引擎配置 - statement_granularity = Column(Integer, default=2, comment="陈述提取颗粒度,挡位 1/2/3") - include_dialogue_context = Column(Boolean, default=False, comment="是否包含对话上下文") - max_context = Column(Integer, default=1000, comment="对话语境中包含字符的最大数量") - lambda_time = Column("lambda_time", Float, default=0.5, comment="最低保持度,0-1 小数") - lambda_mem = Column("lambda_mem", Float, default=0.5, comment="遗忘率,0-1 小数") - offset = Column("offset", Float, default=0.0, comment="偏移度,0-1 小数") - - # ACT-R 遗忘引擎配置 - decay_constant = Column(Float, default=0.5, comment="ACT-R衰减常数d,默认0.5") - forgetting_threshold = Column(Float, default=0.3, comment="遗忘阈值,默认0.3") - forgetting_interval_hours = Column(Integer, default=24, comment="遗忘周期间隔(小时),默认24") - enable_llm_summary = Column(Boolean, default=True, comment="是否使用LLM生成摘要,默认True") - max_merge_batch_size = Column(Integer, default=100, comment="单次最大融合节点对数,默认100") - max_history_length = Column(Integer, default=100, comment="访问历史最大长度,默认100") - min_days_since_access = Column(Integer, default=30, comment="最小未访问天数,默认30") - - # 情绪引擎配置 - emotion_enabled = Column(Boolean, default=True, comment="是否启用情绪提取") - emotion_model_id = Column(String, nullable=True, comment="情绪分析专用模型ID") - emotion_extract_keywords = Column(Boolean, default=True, comment="是否提取情绪关键词") - emotion_min_intensity = Column(Float, default=0.1, comment="最小情绪强度阈值") - emotion_enable_subject = Column(Boolean, default=True, comment="是否启用主体分类") - - # 时间戳 - created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间") - updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间") - - def __repr__(self): - return f"" diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index d47c3b52..b468e2a2 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -1,39 +1,88 @@ -# -*- coding: utf-8 -*- -"""Memory Configuration Model - Backward Compatibility +import datetime +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Float +from sqlalchemy.dialects.postgresql import UUID +from app.db import Base -This module provides backward compatibility for imports. -All classes have been moved to app.schemas.memory_config_schema. -DEPRECATED: Import from app.schemas.memory_config_schema instead. -""" +class MemoryConfig(Base): + """记忆配置表 - 用于存储记忆系统的配置参数""" + __tablename__ = "memory_config" -# Re-export for backward compatibility -from app.schemas.memory_config_schema import ( - ConfigurationError, - InvalidConfigError, - MemoryConfig, - MemoryConfigValidation, - ModelInactiveError, - ModelNotFoundError, - ModelValidation, - WorkspaceNotFoundError, - WorkspaceValidation, - validate_memory_config_data, - validate_model_data, - validate_workspace_data, -) + # 主键 + config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") -__all__ = [ - "ConfigurationError", - "InvalidConfigError", - "MemoryConfig", - "MemoryConfigValidation", - "ModelInactiveError", - "ModelNotFoundError", - "ModelValidation", - "WorkspaceNotFoundError", - "WorkspaceValidation", - "validate_memory_config_data", - "validate_model_data", - "validate_workspace_data", -] + # 基本信息 + config_name = Column(String, nullable=False, comment="配置名称") + config_desc = Column(String, nullable=True, comment="配置描述") + + # 组织信息 + workspace_id = Column(UUID(as_uuid=True), nullable=True, comment="工作空间ID") + end_user_id = Column(String, nullable=True, comment="组ID") + user_id = Column(String, nullable=True, comment="用户ID") + apply_id = Column(String, nullable=True, comment="应用ID") + + # 模型选择(从workspace继承) + llm_id = Column(String, nullable=True, comment="LLM模型配置ID") + embedding_id = Column(String, nullable=True, comment="嵌入模型配置ID") + rerank_id = Column(String, nullable=True, comment="重排序模型配置ID") + + # 记忆萃取引擎配置 + enable_llm_dedup_blockwise = Column(Boolean, default=True, comment="启用LLM决策去重") + enable_llm_disambiguation = Column(Boolean, default=True, comment="启用LLM决策消歧") + deep_retrieval = Column(Boolean, default=True, comment="深度检索开关") + + # 阈值配置 (0-1 之间的浮点数) + t_type_strict = Column(Float, default=0.8, comment="类型严格阈值") + t_name_strict = Column(Float, default=0.8, comment="名称严格阈值") + t_overall = Column(Float, default=0.8, comment="综合阈值") + + # 状态配置 + state = Column(Boolean, default=False, comment="配置使用状态") + + # 分块策略 + chunker_strategy = Column(String, default="RecursiveChunker", comment="分块策略") + + # 剪枝配置 + pruning_enabled = Column(Boolean, default=False, comment="是否启动智能语义剪枝") + pruning_scene = Column(String, nullable=True, comment="智能剪枝场景:education/online_service/outbound") + pruning_threshold = Column(Float, nullable=True, comment="智能语义剪枝阈值(0-0.9)") + + # 自我反思配置 + enable_self_reflexion = Column(Boolean, default=False, comment="是否启用自我反思") + iteration_period = Column(String, default="3", comment="反思迭代周期") + reflexion_range = Column(String, default="partial", comment="反思范围:部分/全部") + baseline = Column(String, default="TIME", comment="基线:时间/事实/时间和事实") + reflection_model_id = Column(String, nullable=True, comment="反思模型ID") + memory_verify = Column(Boolean, default=True, comment="记忆验证") + quality_assessment = Column(Boolean, default=True, comment="质量评估") + + # 遗忘引擎配置 + statement_granularity = Column(Integer, default=2, comment="陈述提取颗粒度,挡位 1/2/3") + include_dialogue_context = Column(Boolean, default=False, comment="是否包含对话上下文") + max_context = Column(Integer, default=1000, comment="对话语境中包含字符的最大数量") + lambda_time = Column("lambda_time", Float, default=0.5, comment="最低保持度,0-1 小数") + lambda_mem = Column("lambda_mem", Float, default=0.5, comment="遗忘率,0-1 小数") + offset = Column("offset", Float, default=0.0, comment="偏移度,0-1 小数") + + # ACT-R 遗忘引擎配置 + decay_constant = Column(Float, default=0.5, comment="ACT-R衰减常数d,默认0.5") + forgetting_threshold = Column(Float, default=0.3, comment="遗忘阈值,默认0.3") + forgetting_interval_hours = Column(Integer, default=24, comment="遗忘周期间隔(小时),默认24") + enable_llm_summary = Column(Boolean, default=True, comment="是否使用LLM生成摘要,默认True") + max_merge_batch_size = Column(Integer, default=100, comment="单次最大融合节点对数,默认100") + max_history_length = Column(Integer, default=100, comment="访问历史最大长度,默认100") + min_days_since_access = Column(Integer, default=30, comment="最小未访问天数,默认30") + + # 情绪引擎配置 + emotion_enabled = Column(Boolean, default=True, comment="是否启用情绪提取") + emotion_model_id = Column(String, nullable=True, comment="情绪分析专用模型ID") + emotion_extract_keywords = Column(Boolean, default=True, comment="是否提取情绪关键词") + emotion_min_intensity = Column(Float, default=0.1, comment="最小情绪强度阈值") + emotion_enable_subject = Column(Boolean, default=True, comment="是否启用主体分类") + + # 时间戳 + created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间") + + def __repr__(self): + return f"" diff --git a/api/app/models/memory_perceptual_model.py b/api/app/models/memory_perceptual_model.py index 59eb0222..cafb18d4 100644 --- a/api/app/models/memory_perceptual_model.py +++ b/api/app/models/memory_perceptual_model.py @@ -16,7 +16,7 @@ class PerceptualType(IntEnum): CONVERSATION = 4 -class FileStorageType(IntEnum): +class FileStorageService(IntEnum): LOCAL = 1 REMOTE = 2 diff --git a/api/app/repositories/data_config_repository.py b/api/app/repositories/memory_config_repository.py similarity index 73% rename from api/app/repositories/data_config_repository.py rename to api/app/repositories/memory_config_repository.py index 3df7f800..12e564e2 100644 --- a/api/app/repositories/data_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -1,18 +1,19 @@ # -*- coding: utf-8 -*- -"""数据配置Repository模块 +"""记忆配置Repository模块 -本模块提供data_config表的数据访问层,使用SQLAlchemy ORM进行数据库操作。 +本模块提供memory_config表的数据访问层,使用SQLAlchemy ORM进行数据库操作。 包括CRUD操作和Neo4j Cypher查询常量。 Classes: - DataConfigRepository: 数据配置仓储类,提供CRUD操作 + MemoryConfigRepository: 记忆配置仓储类,提供CRUD操作 """ import uuid +from uuid import UUID from typing import Dict, List, Optional, Tuple from app.core.exceptions import BusinessException from app.core.logging_config import get_config_logger, get_db_logger -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig from app.schemas.memory_storage_schema import ( ConfigKey, ConfigParamsCreate, @@ -28,11 +29,11 @@ db_logger = get_db_logger() # 获取配置专用日志器 config_logger = get_config_logger() -TABLE_NAME = "data_config" -class DataConfigRepository: - """数据配置Repository +TABLE_NAME = "memory_config" +class MemoryConfigRepository: + """记忆配置Repository - 提供data_config表的数据访问方法,包括: + 提供memory_config表的数据访问方法,包括: - SQLAlchemy ORM 数据库操作 - Neo4j Cypher查询常量 """ @@ -41,48 +42,48 @@ class DataConfigRepository: # Dialogue count by group SEARCH_FOR_DIALOGUE = """ - MATCH (n:Dialogue) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Dialogue) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # Chunk count by group SEARCH_FOR_CHUNK = """ - MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # Statement count by group SEARCH_FOR_STATEMENT = """ - MATCH (n:Statement) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:Statement) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # ExtractedEntity count by group SEARCH_FOR_ENTITY = """ - MATCH (n:ExtractedEntity) WHERE n.group_id = $group_id RETURN COUNT(n) AS num + MATCH (n:ExtractedEntity) WHERE n.end_user_id = $end_user_id RETURN COUNT(n) AS num """ # All counts by label and total SEARCH_FOR_ALL = """ - OPTIONAL MATCH (n:Dialogue) WHERE n.group_id = $group_id RETURN 'Dialogue' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Dialogue) WHERE n.end_user_id = $end_user_id RETURN 'Dialogue' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN 'Chunk' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN 'Chunk' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:Statement) WHERE n.group_id = $group_id RETURN 'Statement' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:Statement) WHERE n.end_user_id = $end_user_id RETURN 'Statement' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n:ExtractedEntity) WHERE n.group_id = $group_id RETURN 'ExtractedEntity' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n:ExtractedEntity) WHERE n.end_user_id = $end_user_id RETURN 'ExtractedEntity' AS Label, COUNT(n) AS Count UNION ALL - OPTIONAL MATCH (n) WHERE n.group_id = $group_id RETURN 'ALL' AS Label, COUNT(n) AS Count + OPTIONAL MATCH (n) WHERE n.end_user_id = $end_user_id RETURN 'ALL' AS Label, COUNT(n) AS Count """ # Extracted entity details within group/app/user SEARCH_FOR_DETIALS = """ MATCH (n:ExtractedEntity) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN n.entity_idx AS entity_idx, n.connect_strength AS connect_strength, n.description AS description, n.entity_type AS entity_type, n.name AS name, COALESCE(n.fact_summary, '') AS fact_summary, - n.group_id AS group_id, + n.end_user_id AS end_user_id, n.apply_id AS apply_id, n.user_id AS user_id, n.id AS id @@ -91,9 +92,9 @@ class DataConfigRepository: # Edges between extracted entities within group/app/user SEARCH_FOR_EDGES = """ MATCH (n:ExtractedEntity)-[r]->(m:ExtractedEntity) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN - r.group_id AS group_id, + r.end_user_id AS end_user_id, r.apply_id AS apply_id, r.user_id AS user_id, elementId(r) AS rel_id, @@ -107,7 +108,7 @@ class DataConfigRepository: @staticmethod def update_reflection_config( db: Session, - config_id: int, + config_id: uuid.UUID, enable_self_reflexion: bool, iteration_period: str, reflexion_range: str, @@ -115,7 +116,7 @@ class DataConfigRepository: reflection_model_id: str, memory_verify: bool, quality_assessment: bool - ) -> DataConfig: + ) -> MemoryConfig: """构建反思配置更新语句(SQLAlchemy text() 命名参数) Args: @@ -130,28 +131,28 @@ class DataConfigRepository: config_id: 配置ID Returns: - Data + MemoryConfig Raises: ValueError: 没有字段需要更新时抛出 """ db_logger.debug(f"构建反思配置更新语句: config_id={config_id}") - stmt = select(DataConfig).where(DataConfig.config_id == config_id) - data_config_obj = db.scalars(stmt).first() - if not data_config_obj: + stmt = select(MemoryConfig).where(MemoryConfig.config_id == config_id) + memory_config_obj = db.scalars(stmt).first() + if not memory_config_obj: raise BusinessException - data_config_obj.enable_self_reflexion = enable_self_reflexion - data_config_obj.iteration_period = iteration_period - data_config_obj.reflexion_range = reflexion_range - data_config_obj.baseline = baseline - data_config_obj.reflection_model_id = reflection_model_id - data_config_obj.memory_verify = memory_verify - data_config_obj.quality_assessment = quality_assessment + memory_config_obj.enable_self_reflexion = enable_self_reflexion + memory_config_obj.iteration_period = iteration_period + memory_config_obj.reflexion_range = reflexion_range + memory_config_obj.baseline = baseline + memory_config_obj.reflection_model_id = reflection_model_id + memory_config_obj.memory_verify = memory_verify + memory_config_obj.quality_assessment = quality_assessment - return data_config_obj + return memory_config_obj @staticmethod - def query_reflection_config_by_id(db: Session, config_id: int) -> DataConfig: + def query_reflection_config_by_id(db: Session, config_id: uuid.UUID) -> MemoryConfig: """构建反思配置查询语句,通过config_id查询反思配置(SQLAlchemy text() 命名参数) Args: @@ -162,13 +163,13 @@ class DataConfigRepository: Tuple[str, Dict]: (SQL查询字符串, 参数字典) """ db_logger.debug(f"构建反思配置查询语句: config_id={config_id}") - stmt = select(DataConfig).where(DataConfig.config_id == config_id) - data_config = db.scalars(stmt).first() - if not data_config: + stmt = select(MemoryConfig).where(MemoryConfig.config_id == config_id) + memory_config = db.scalars(stmt).first() + if not memory_config: raise RuntimeError("reflection config not found") - return data_config + return memory_config @staticmethod - def query_reflection_config_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> DataConfig: + def query_reflection_config_by_workspace_id(db: Session, workspace_id: uuid.UUID) -> MemoryConfig: """构建查询所有配置的语句(SQLAlchemy text() 命名参数) Args: @@ -180,11 +181,11 @@ class DataConfigRepository: """ db_logger.debug(f"构建查询所有配置语句: workspace_id={workspace_id}") - stmt = select(DataConfig).where(DataConfig.workspace_id == workspace_id) - data_config = db.scalars(stmt).first() - if not data_config: + stmt = select(MemoryConfig).where(MemoryConfig.workspace_id == workspace_id) + memory_config = db.scalars(stmt).first() + if not memory_config: raise RuntimeError("reflection config not found") - return data_config + return memory_config @staticmethod @@ -208,20 +209,21 @@ class DataConfigRepository: return query, params @staticmethod - def create(db: Session, params: ConfigParamsCreate) -> DataConfig: - """创建数据配置 + def create(db: Session, params: ConfigParamsCreate) -> MemoryConfig: + """创建记忆配置 Args: db: 数据库会话 params: 配置参数创建模型 Returns: - DataConfig: 创建的配置对象 + MemoryConfig: 创建的配置对象 """ - db_logger.debug(f"创建数据配置: config_name={params.config_name}, workspace_id={params.workspace_id}") + db_logger.debug(f"创建记忆配置: config_name={params.config_name}, workspace_id={params.workspace_id}") try: - db_config = DataConfig( + db_config = MemoryConfig( + config_id=uuid.uuid4(), config_name=params.config_name, config_desc=params.config_desc, workspace_id=params.workspace_id, @@ -232,16 +234,16 @@ class DataConfigRepository: db.add(db_config) db.flush() # 获取自增ID但不提交事务 - db_logger.info(f"数据配置已添加到会话: {db_config.config_name} (ID: {db_config.config_id})") + db_logger.info(f"记忆配置已添加到会话: {db_config.config_name} (ID: {db_config.config_id})") return db_config except Exception as e: db.rollback() - db_logger.error(f"创建数据配置失败: {params.config_name} - {str(e)}") + db_logger.error(f"创建记忆配置失败: {params.config_name} - {str(e)}") raise @staticmethod - def update(db: Session, update: ConfigUpdate) -> Optional[DataConfig]: + def update(db: Session, update: ConfigUpdate) -> Optional[MemoryConfig]: """更新基础配置 Args: @@ -249,17 +251,17 @@ class DataConfigRepository: update: 配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 """ - db_logger.debug(f"更新数据配置: config_id={update.config_id}") + db_logger.debug(f"更新记忆配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段 @@ -277,17 +279,17 @@ class DataConfigRepository: db.commit() db.refresh(db_config) - db_logger.info(f"数据配置更新成功: {db_config.config_name} (ID: {update.config_id})") + db_logger.info(f"记忆配置更新成功: {db_config.config_name} (ID: {update.config_id})") return db_config except Exception as e: db.rollback() - db_logger.error(f"更新数据配置失败: config_id={update.config_id} - {str(e)}") + db_logger.error(f"更新记忆配置失败: config_id={update.config_id} - {str(e)}") raise @staticmethod - def update_extracted(db: Session, update: ConfigUpdateExtracted) -> Optional[DataConfig]: + def update_extracted(db: Session, update: ConfigUpdateExtracted) -> Optional[MemoryConfig]: """更新记忆萃取引擎配置 Args: @@ -295,7 +297,7 @@ class DataConfigRepository: update: 萃取配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 @@ -303,9 +305,9 @@ class DataConfigRepository: db_logger.debug(f"更新萃取配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段映射 @@ -360,7 +362,7 @@ class DataConfigRepository: raise @staticmethod - def update_forget(db: Session, update: ConfigUpdateForget) -> Optional[DataConfig]: + def update_forget(db: Session, update: ConfigUpdateForget) -> Optional[MemoryConfig]: """更新遗忘引擎配置 Args: @@ -368,7 +370,7 @@ class DataConfigRepository: update: 遗忘配置更新模型 Returns: - Optional[DataConfig]: 更新后的配置对象,不存在则返回None + Optional[MemoryConfig]: 更新后的配置对象,不存在则返回None Raises: ValueError: 没有字段需要更新时抛出 @@ -376,9 +378,9 @@ class DataConfigRepository: db_logger.debug(f"更新遗忘配置: config_id={update.config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == update.config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == update.config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={update.config_id}") + db_logger.warning(f"记忆配置不存在: config_id={update.config_id}") return None # 更新字段 @@ -408,7 +410,7 @@ class DataConfigRepository: raise @staticmethod - def get_extracted_config(db: Session, config_id: int) -> Optional[Dict]: + def get_extracted_config(db: Session, config_id: UUID) -> Optional[Dict]: """获取萃取配置,通过主键查询某条配置 Args: @@ -421,7 +423,7 @@ class DataConfigRepository: db_logger.debug(f"查询萃取配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: db_logger.debug(f"萃取配置不存在: config_id={config_id}") return None @@ -457,7 +459,7 @@ class DataConfigRepository: raise @staticmethod - def get_forget_config(db: Session, config_id: int) -> Optional[Dict]: + def get_forget_config(db: Session, config_id: UUID) -> Optional[Dict]: """获取遗忘配置,通过主键查询某条配置 Args: @@ -470,7 +472,7 @@ class DataConfigRepository: db_logger.debug(f"查询遗忘配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: db_logger.debug(f"遗忘配置不存在: config_id={config_id}") return None @@ -489,39 +491,39 @@ class DataConfigRepository: raise @staticmethod - def get_by_id(db: Session, config_id: int) -> Optional[DataConfig]: - """根据ID获取数据配置 + def get_by_id(db: Session, config_id: uuid.UUID) -> Optional[MemoryConfig]: + """根据ID获取记忆配置 Args: db: 数据库会话 config_id: 配置ID Returns: - Optional[DataConfig]: 配置对象,不存在则返回None + Optional[MemoryConfig]: 配置对象,不存在则返回None """ - db_logger.debug(f"根据ID查询数据配置: config_id={config_id}") + db_logger.debug(f"根据ID查询记忆配置: config_id={config_id}") try: - config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if config: - db_logger.debug(f"数据配置查询成功: {config.config_name} (ID: {config_id})") + db_logger.debug(f"记忆配置查询成功: {config.config_name} (ID: {config_id})") else: - db_logger.debug(f"数据配置不存在: config_id={config_id}") + db_logger.debug(f"记忆配置不存在: config_id={config_id}") return config except Exception as e: - db_logger.error(f"根据ID查询数据配置失败: config_id={config_id} - {str(e)}") + db_logger.error(f"根据ID查询记忆配置失败: config_id={config_id} - {str(e)}") raise @staticmethod - def get_config_with_workspace(db: Session, config_id: int) -> Optional[tuple]: - """Get data config and its associated workspace information + def get_config_with_workspace(db: Session, config_id: uuid.UUID) -> Optional[tuple]: + """Get memory config and its associated workspace information Args: db: Database session config_id: Configuration ID Returns: - Optional[tuple]: (DataConfig, Workspace) tuple, None if not found + Optional[tuple]: (MemoryConfig, Workspace) tuple, None if not found Raises: ValueError: Raised when config exists but workspace doesn't @@ -541,19 +543,19 @@ class DataConfigRepository: } ) - db_logger.debug(f"Querying data config and workspace: config_id={config_id}") + db_logger.debug(f"Querying memory config and workspace: config_id={config_id}") try: # Use join query to get both config and workspace - result = db.query(DataConfig, Workspace).join( - Workspace, DataConfig.workspace_id == Workspace.id - ).filter(DataConfig.config_id == config_id).first() + result = db.query(MemoryConfig, Workspace).join( + Workspace, MemoryConfig.workspace_id == Workspace.id + ).filter(MemoryConfig.config_id == config_id).first() elapsed_ms = (time.time() - start_time) * 1000 if not result: # Check if config exists but workspace is missing - config_only = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + config_only = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if config_only: if config_only.workspace_id is None: config_logger.error( @@ -566,7 +568,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.error(f"Data config {config_id} has no associated workspace ID") + db_logger.error(f"Memory config {config_id} has no associated workspace ID") raise ValueError(f"Configuration {config_id} has no associated workspace") else: config_logger.error( @@ -579,7 +581,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.error(f"Data config {config_id} references non-existent workspace {config_only.workspace_id}") + db_logger.error(f"Memory config {config_id} references non-existent workspace {config_only.workspace_id}") raise ValueError(f"Workspace {config_only.workspace_id} not found for configuration {config_id}") config_logger.debug( @@ -591,7 +593,7 @@ class DataConfigRepository: "elapsed_ms": elapsed_ms } ) - db_logger.debug(f"Data config not found: config_id={config_id}") + db_logger.debug(f"Memory config not found: config_id={config_id}") return None config, workspace = result @@ -611,7 +613,7 @@ class DataConfigRepository: } ) - db_logger.debug(f"Data config and workspace query successful: config={config.config_name}, workspace={workspace.name}") + db_logger.debug(f"Memory config and workspace query successful: config={config.config_name}, workspace={workspace.name}") return (config, workspace) except ValueError: @@ -633,10 +635,10 @@ class DataConfigRepository: exc_info=True ) - db_logger.error(f"Failed to query data config and workspace: config_id={config_id} - {str(e)}") + db_logger.error(f"Failed to query memory config and workspace: config_id={config_id} - {str(e)}") raise @staticmethod - def get_all(db: Session, workspace_id: Optional[uuid.UUID] = None) -> List[DataConfig]: + def get_all(db: Session, workspace_id: Optional[uuid.UUID] = None) -> List[MemoryConfig]: """获取所有配置参数 Args: @@ -644,17 +646,17 @@ class DataConfigRepository: workspace_id: 工作空间ID,用于过滤查询结果 Returns: - List[DataConfig]: 配置列表 + List[MemoryConfig]: 配置列表 """ db_logger.debug(f"查询所有配置: workspace_id={workspace_id}") try: - query = db.query(DataConfig) + query = db.query(MemoryConfig) if workspace_id: - query = query.filter(DataConfig.workspace_id == workspace_id) + query = query.filter(MemoryConfig.workspace_id == workspace_id) - configs = query.order_by(desc(DataConfig.updated_at)).all() + configs = query.order_by(desc(MemoryConfig.updated_at)).all() db_logger.debug(f"配置列表查询成功: 数量={len(configs)}") return configs @@ -664,8 +666,8 @@ class DataConfigRepository: raise @staticmethod - def delete(db: Session, config_id: int) -> bool: - """删除数据配置 + def delete(db: Session, config_id: uuid.UUID) -> bool: + """删除记忆配置 Args: db: 数据库会话 @@ -674,22 +676,22 @@ class DataConfigRepository: Returns: bool: 删除成功返回True,配置不存在返回False """ - db_logger.debug(f"删除数据配置: config_id={config_id}") + db_logger.debug(f"删除记忆配置: config_id={config_id}") try: - db_config = db.query(DataConfig).filter(DataConfig.config_id == config_id).first() + db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: - db_logger.warning(f"数据配置不存在: config_id={config_id}") + db_logger.warning(f"记忆配置不存在: config_id={config_id}") return False db.delete(db_config) db.commit() - db_logger.info(f"数据配置删除成功: config_id={config_id}") + db_logger.info(f"记忆配置删除成功: config_id={config_id}") return True except Exception as e: db.rollback() - db_logger.error(f"删除数据配置失败: config_id={config_id} - {str(e)}") + db_logger.error(f"删除记忆配置失败: config_id={config_id} - {str(e)}") raise diff --git a/api/app/repositories/memory_perceptual_repository.py b/api/app/repositories/memory_perceptual_repository.py index 8415c2d0..9fa9536e 100644 --- a/api/app/repositories/memory_perceptual_repository.py +++ b/api/app/repositories/memory_perceptual_repository.py @@ -6,7 +6,7 @@ from sqlalchemy import and_, desc from sqlalchemy.orm import Session from app.core.logging_config import get_db_logger -from app.models.memory_perceptual_model import MemoryPerceptualModel, PerceptualType, FileStorageType +from app.models.memory_perceptual_model import MemoryPerceptualModel, PerceptualType, FileStorageService from app.schemas.memory_perceptual_schema import PerceptualQuerySchema db_logger = get_db_logger() @@ -28,7 +28,7 @@ class MemoryPerceptualRepository: file_ext: str, summary: Optional[str] = None, meta_data: Optional[dict] = None, - storage_service: FileStorageType = FileStorageType.LOCAL + storage_service: FileStorageService = FileStorageService.LOCAL ) -> MemoryPerceptualModel: diff --git a/api/app/repositories/neo4j/add_edges.py b/api/app/repositories/neo4j/add_edges.py index 3b45867e..162bf411 100644 --- a/api/app/repositories/neo4j/add_edges.py +++ b/api/app/repositories/neo4j/add_edges.py @@ -32,7 +32,7 @@ async def add_chunk_statement_edges(chunks: List[Chunk], connector: Neo4jConnect "id": stable_edge_id, "source": chunk.id, "target": stmt.id, - "group_id": getattr(stmt, 'group_id', None), + "end_user_id": getattr(stmt, 'end_user_id', None), "user_id":getattr(stmt, 'user_id', None), "apply_id": getattr(stmt, 'apply_id', None), "run_id": getattr(stmt, 'run_id', None) or getattr(chunk, 'run_id', None), @@ -83,7 +83,7 @@ async def add_memory_summary_statement_edges(summaries: List[MemorySummaryNode], edges.append({ "summary_id": s.id, "chunk_id": chunk_id, - "group_id": s.group_id, + "end_user_id": s.end_user_id, "run_id": s.run_id, "created_at": s.created_at.isoformat() if s.created_at else None, "expired_at": s.expired_at.isoformat() if s.expired_at else None, diff --git a/api/app/repositories/neo4j/add_nodes.py b/api/app/repositories/neo4j/add_nodes.py index cf60a773..fcf700b5 100644 --- a/api/app/repositories/neo4j/add_nodes.py +++ b/api/app/repositories/neo4j/add_nodes.py @@ -6,10 +6,10 @@ from app.core.memory.models.graph_models import DialogueNode, StatementNode, Chu from app.repositories.neo4j.neo4j_connector import Neo4jConnector -async def delete_all_nodes(group_id: str, connector: Neo4jConnector): +async def delete_all_nodes(end_user_id: str, connector: Neo4jConnector): """Delete all nodes in the database.""" - result = await connector.execute_query(f"MATCH (n {{group_id: '{group_id}'}}) DETACH DELETE n") - print(f"All group_id: {group_id} node and edge deleted successfully") + result = await connector.execute_query(f"MATCH (n {{end_user_id: '{end_user_id}'}}) DETACH DELETE n") + print(f"All end_user_id: {end_user_id} node and edge deleted successfully") return result async def add_dialogue_nodes(dialogues: List[DialogueNode], connector: Neo4jConnector) -> Optional[List[str]]: @@ -32,9 +32,7 @@ async def add_dialogue_nodes(dialogues: List[DialogueNode], connector: Neo4jConn for dialogue in dialogues: flattened_dialogues.append({ "id": dialogue.id, - "group_id": dialogue.group_id, - "user_id": dialogue.user_id, - "apply_id": dialogue.apply_id, + "end_user_id": dialogue.end_user_id, "run_id": dialogue.run_id, "ref_id": dialogue.ref_id, "name": dialogue.name, @@ -79,9 +77,7 @@ async def add_statement_nodes(statements: List[StatementNode], connector: Neo4jC flattened_statement = { "id": statement.id, "name": statement.name, - "group_id": statement.group_id, - "user_id": statement.user_id, - "apply_id": statement.apply_id, + "end_user_id": statement.end_user_id, "run_id": statement.run_id, "chunk_id": statement.chunk_id, # "created_at": statement.created_at.isoformat(), @@ -154,9 +150,7 @@ async def add_chunk_nodes(chunks: List[ChunkNode], connector: Neo4jConnector) -> flattened_chunk = { "id": chunk.id, "name": chunk.name, - "group_id": chunk.group_id, - "user_id": chunk.user_id, - "apply_id": chunk.apply_id, + "end_user_id": chunk.end_user_id, "run_id": chunk.run_id, "created_at": chunk.created_at.isoformat() if chunk.created_at else None, "expired_at": chunk.expired_at.isoformat() if chunk.expired_at else None, @@ -206,9 +200,7 @@ async def add_memory_summary_nodes(summaries: List[MemorySummaryNode], connector flattened.append({ "id": s.id, "name": s.name, - "group_id": s.group_id, - "user_id": s.user_id, - "apply_id": s.apply_id, + "end_user_id": s.end_user_id, "run_id": s.run_id, "created_at": s.created_at.isoformat() if s.created_at else None, "expired_at": s.expired_at.isoformat() if s.expired_at else None, diff --git a/api/app/repositories/neo4j/base_neo4j_repository.py b/api/app/repositories/neo4j/base_neo4j_repository.py index 959a1e68..df953eb9 100644 --- a/api/app/repositories/neo4j/base_neo4j_repository.py +++ b/api/app/repositories/neo4j/base_neo4j_repository.py @@ -152,7 +152,7 @@ class BaseNeo4jRepository(BaseRepository[T]): Example: >>> results = await repository.find( - ... {"group_id": "group_123", "user_id": "user_456"}, + ... {"end_user_id": "group_123", "user_id": "user_456"}, ... limit=50 ... ) """ diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index cd3cbed7..c93e75b3 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -3,9 +3,7 @@ DIALOGUE_NODE_SAVE = """ UNWIND $dialogues AS dialogue MERGE (n:Dialogue {id: dialogue.id}) SET n.uuid = coalesce(n.uuid, dialogue.id), - n.group_id = dialogue.group_id, - n.user_id = dialogue.user_id, - n.apply_id = dialogue.apply_id, + n.end_user_id = dialogue.end_user_id, n.run_id = dialogue.run_id, n.ref_id = dialogue.ref_id, n.created_at = dialogue.created_at, @@ -22,9 +20,7 @@ SET s += { id: statement.id, run_id: statement.run_id, chunk_id: statement.chunk_id, - group_id: statement.group_id, - user_id: statement.user_id, - apply_id: statement.apply_id, + end_user_id: statement.end_user_id, stmt_type: statement.stmt_type, statement: statement.statement, emotion_intensity: statement.emotion_intensity, @@ -54,9 +50,7 @@ MERGE (c:Chunk {id: chunk.id}) SET c += { id: chunk.id, name: chunk.name, - group_id: chunk.group_id, - user_id: chunk.user_id, - apply_id: chunk.apply_id, + end_user_id: chunk.end_user_id, run_id: chunk.run_id, created_at: chunk.created_at, expired_at: chunk.expired_at, @@ -76,9 +70,7 @@ EXTRACTED_ENTITY_NODE_SAVE = """ UNWIND $entities AS entity MERGE (e:ExtractedEntity {id: entity.id}) SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity.name ELSE e.name END, - e.group_id = CASE WHEN entity.group_id IS NOT NULL AND entity.group_id <> '' THEN entity.group_id ELSE e.group_id END, - e.user_id = CASE WHEN entity.user_id IS NOT NULL AND entity.user_id <> '' THEN entity.user_id ELSE e.user_id END, - e.apply_id = CASE WHEN entity.apply_id IS NOT NULL AND entity.apply_id <> '' THEN entity.apply_id ELSE e.apply_id END, + e.end_user_id = CASE WHEN entity.end_user_id IS NOT NULL AND entity.end_user_id <> '' THEN entity.end_user_id ELSE e.end_user_id END, e.run_id = CASE WHEN entity.run_id IS NOT NULL AND entity.run_id <> '' THEN entity.run_id ELSE e.run_id END, e.created_at = CASE WHEN entity.created_at IS NOT NULL AND (e.created_at IS NULL OR entity.created_at < e.created_at) @@ -134,9 +126,9 @@ RETURN e.id AS uuid # Add back ENTITY_RELATIONSHIP_SAVE to be used by graph_saver.save_entities_and_relationships ENTITY_RELATIONSHIP_SAVE = """ UNWIND $relationships AS rel -// Match entities by stable id within group, do not constrain by run_id -MATCH (subject:ExtractedEntity {id: rel.source_id, group_id: rel.group_id}) -MATCH (object:ExtractedEntity {id: rel.target_id, group_id: rel.group_id}) +// Match entities by stable id within end_user_id, do not constrain by run_id +MATCH (subject:ExtractedEntity {id: rel.source_id, end_user_id: rel.end_user_id}) +MATCH (object:ExtractedEntity {id: rel.target_id, end_user_id: rel.end_user_id}) // Avoid duplicate edges across runs for the same endpoints MERGE (subject)-[r:EXTRACTED_RELATIONSHIP]->(object) SET r.predicate = rel.predicate, @@ -148,7 +140,7 @@ SET r.predicate = rel.predicate, r.created_at = rel.created_at, r.expired_at = rel.expired_at, r.run_id = rel.run_id, - r.group_id = rel.group_id + r.end_user_id = rel.end_user_id RETURN elementId(r) AS uuid """ @@ -160,7 +152,7 @@ UNWIND $weak_entities AS entity MERGE (e:ExtractedEntity {id: entity.id, run_id: entity.run_id}) SET e += { name: entity.name, - group_id: entity.group_id, + end_user_id: entity.end_user_id, run_id: entity.run_id, description: entity.description, chunk_id: entity.chunk_id, @@ -175,11 +167,11 @@ RETURN e.id AS id SAVE_STRONG_TRIPLE_ENTITIES = """ UNWIND $items AS item MERGE (s:ExtractedEntity {id: item.source_id, run_id: item.run_id}) -SET s += {name: item.subject, group_id: item.group_id, run_id: item.run_id} +SET s += {name: item.subject, end_user_id: item.end_user_id, run_id: item.run_id} // Independent strong flag SET s.is_strong = true MERGE (o:ExtractedEntity {id: item.target_id, run_id: item.run_id}) -SET o += {name: item.object, group_id: item.group_id, run_id: item.run_id} +SET o += {name: item.object, end_user_id: item.end_user_id, run_id: item.run_id} // Independent strong flag SET o.is_strong = true """ @@ -194,7 +186,7 @@ DIALOGUE_STATEMENT_EDGE_SAVE = """ // 仅按端点去重,关系属性可更新 MERGE (dialogue)-[e:MENTIONS]->(statement) SET e.uuid = edge.id, - e.group_id = edge.group_id, + e.end_user_id = edge.end_user_id, e.created_at = edge.created_at, e.expired_at = edge.expired_at RETURN e.uuid AS uuid @@ -208,7 +200,7 @@ CHUNK_STATEMENT_EDGE_SAVE = """ MATCH (statement:Statement {id: edge.source, run_id: edge.run_id}) MATCH (chunk:Chunk {id: edge.target, run_id: edge.run_id}) MERGE (chunk)-[e:CONTAINS {id: edge.id}]->(statement) - SET e.group_id = edge.group_id, + SET e.end_user_id = edge.end_user_id, e.run_id = edge.run_id, e.created_at = edge.created_at, e.expired_at = edge.expired_at @@ -218,13 +210,12 @@ CHUNK_STATEMENT_EDGE_SAVE = """ STATEMENT_ENTITY_EDGE_SAVE = """ UNWIND $relationships AS rel // Statement nodes are per-run; keep run_id constraint on statements -// Statement nodes are per-run; keep run_id constraint on statements MATCH (statement:Statement {id: rel.source, run_id: rel.run_id}) -// Entities are shared across runs within a group; do not constrain by run_id -MATCH (entity:ExtractedEntity {id: rel.target, group_id: rel.group_id}) +// Entities are shared across runs within end_user_id; do not constrain by run_id +MATCH (entity:ExtractedEntity {id: rel.target, end_user_id: rel.end_user_id}) // Avoid duplicate edges across runs for same endpoints MERGE (statement)-[r:REFERENCES_ENTITY]->(entity) -SET r.group_id = rel.group_id, +SET r.end_user_id = rel.end_user_id, r.run_id = rel.run_id, r.created_at = rel.created_at, r.expired_at = rel.expired_at, @@ -236,10 +227,10 @@ ENTITY_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('entity_embedding_index', $limit * 100, $embedding) YIELD node AS e, score WHERE e.name_embedding IS NOT NULL - AND ($group_id IS NULL OR e.group_id = $group_id) + AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) RETURN e.id AS id, e.name AS name, - e.group_id AS group_id, + e.end_user_id AS end_user_id, e.entity_type AS entity_type, COALESCE(e.activation_value, e.importance_score, 0.5) AS activation_value, COALESCE(e.importance_score, 0.5) AS importance_score, @@ -254,10 +245,10 @@ STATEMENT_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('statement_embedding_index', $limit * 100, $embedding) YIELD node AS s, score WHERE s.statement_embedding IS NOT NULL - AND ($group_id IS NULL OR s.group_id = $group_id) + AND ($end_user_id IS NULL OR s.end_user_id = $end_user_id) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.expired_at AS expired_at, @@ -277,9 +268,9 @@ CHUNK_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('chunk_embedding_index', $limit * 100, $embedding) YIELD node AS c, score WHERE c.chunk_embedding IS NOT NULL - AND ($group_id IS NULL OR c.group_id = $group_id) + AND ($end_user_id IS NULL OR c.end_user_id = $end_user_id) RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, COALESCE(c.activation_value, 0.5) AS activation_value, @@ -292,12 +283,12 @@ LIMIT $limit SEARCH_STATEMENTS_BY_KEYWORD = """ CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score -WHERE ($group_id IS NULL OR s.group_id = $group_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.expired_at AS expired_at, @@ -316,15 +307,13 @@ LIMIT $limit # 查询实体名称包含指定字符串的实体 SEARCH_ENTITIES_BY_NAME = """ CALL db.index.fulltext.queryNodes("entitiesFulltext", $q) YIELD node AS e, score -WHERE ($group_id IS NULL OR e.group_id = $group_id) +WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) OPTIONAL MATCH (s:Statement)-[:REFERENCES_ENTITY]->(e) OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) RETURN e.id AS id, e.name AS name, - e.group_id AS group_id, + e.end_user_id AS end_user_id, e.entity_type AS entity_type, - e.apply_id AS apply_id, - e.user_id AS user_id, e.created_at AS created_at, e.expired_at AS expired_at, e.entity_idx AS entity_idx, @@ -347,11 +336,11 @@ LIMIT $limit SEARCH_CHUNKS_BY_CONTENT = """ CALL db.index.fulltext.queryNodes("chunksFulltext", $q) YIELD node AS c, score -WHERE ($group_id IS NULL OR c.group_id = $group_id) +WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) OPTIONAL MATCH (c)-[:CONTAINS]->(s:Statement) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, c.sequence_number AS sequence_number, @@ -413,10 +402,10 @@ LIMIT $limit SEARCH_DIALOGUE_BY_DIALOG_ID = """ MATCH (d:Dialogue) -WHERE ($group_id IS NULL OR d.group_id = $group_id) +WHERE ($end_user_id IS NULL OR d.end_user_id = $end_user_id) AND d.id = $dialog_id RETURN d.id AS dialog_id, - d.group_id AS group_id, + d.end_user_id AS end_user_id, d.content AS content, d.created_at AS created_at, d.expired_at AS expired_at @@ -426,10 +415,10 @@ LIMIT $limit SEARCH_CHUNK_BY_CHUNK_ID = """ MATCH (c:Chunk) -WHERE ($group_id IS NULL OR c.group_id = $group_id) +WHERE ($end_user_id IS NULL OR c.end_user_id = $end_user_id) AND c.id = $chunk_id RETURN c.id AS chunk_id, - c.group_id AS group_id, + c.end_user_id AS end_user_id, c.content AS content, c.dialog_id AS dialog_id, c.created_at AS created_at, @@ -441,18 +430,14 @@ LIMIT $limit SEARCH_STATEMENTS_BY_TEMPORAL = """ MATCH (s:Statement) -WHERE ($group_id IS NULL OR s.group_id = $group_id) - AND ($apply_id IS NULL OR s.apply_id = $apply_id) - AND ($user_id IS NULL OR s.user_id = $user_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND ((($start_date IS NULL OR datetime(s.created_at) >= datetime($start_date)) AND ($end_date IS NULL OR datetime(s.created_at) <= datetime($end_date))) OR (($valid_date IS NULL OR (s.valid_at IS NOT NULL AND datetime(s.valid_at) >= datetime($valid_date))) AND ($invalid_date IS NULL OR (s.invalid_at IS NOT NULL AND datetime(s.invalid_at) <= datetime($invalid_date))))) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, - s.apply_id AS apply_id, - s.user_id AS user_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.valid_at AS valid_at, @@ -468,9 +453,7 @@ LIMIT $limit SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL = """ CALL db.index.fulltext.queryNodes("statementsFulltext", $q) YIELD node AS s, score -WHERE ($group_id IS NULL OR s.group_id = $group_id) - AND ($apply_id IS NULL OR s.apply_id = $apply_id) - AND ($user_id IS NULL OR s.user_id = $user_id) +WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND ((($start_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) >= datetime($start_date))) AND ($end_date IS NULL OR (s.created_at IS NOT NULL AND datetime(s.created_at) <= datetime($end_date)))) OR (($valid_date IS NULL OR (s.valid_at IS NOT NULL AND datetime(s.valid_at) >= datetime($valid_date))) @@ -479,9 +462,7 @@ OPTIONAL MATCH (c:Chunk)-[:CONTAINS]->(s) OPTIONAL MATCH (s)-[:REFERENCES_ENTITY]->(e:ExtractedEntity) RETURN s.id AS id, s.statement AS statement, - s.group_id AS group_id, - s.apply_id AS apply_id, - s.user_id AS user_id, + s.end_user_id AS end_user_id, s.chunk_id AS chunk_id, s.created_at AS created_at, s.valid_at AS valid_at, @@ -499,15 +480,11 @@ LIMIT $limit SEARCH_STATEMENTS_BY_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 10)) = date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -519,15 +496,11 @@ LIMIT $limit SEARCH_STATEMENTS_BY_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) = date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -539,15 +512,11 @@ LIMIT $limit SEARCH_STATEMENTS_G_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 19)) = date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -559,15 +528,11 @@ LIMIT $limit SEARCH_STATEMENTS_L_CREATED_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($created_at IS NOT NULL AND date(substring(n.created_at, 0, 19)) < date($created_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -579,15 +544,11 @@ LIMIT $limit SEARCH_STATEMENTS_G_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) > date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -599,15 +560,11 @@ LIMIT $limit SEARCH_STATEMENTS_L_VALID_AT = """ MATCH (n:Statement) -WHERE ($group_id IS NULL OR n.group_id = $group_id) - AND ($apply_id IS NULL OR n.apply_id = $apply_id) - AND ($user_id IS NULL OR n.user_id = $user_id) +WHERE ($end_user_id IS NULL OR n.end_user_id = $end_user_id) AND ($valid_at IS NOT NULL AND date(substring(n.valid_at, 0, 10)) < date($valid_at)) RETURN n.id AS id, n.statement AS statement, - n.group_id AS group_id, - n.apply_id AS apply_id, - n.user_id AS user_id, + n.end_user_id AS end_user_id, n.chunk_id AS chunk_id, n.created_at AS created_at, n.valid_at AS valid_at, @@ -665,18 +622,18 @@ LIMIT $limit # 根据id修改句子的invalid_at的值 UPDATE_STATEMENT_INVALID_AT = """ -MATCH (n:Statement {group_id: $group_id, id: $id}) +MATCH (n:Statement {end_user_id: $end_user_id, id: $id}) SET n.invalid_at = $new_invalid_at """ # MemorySummary keyword search using fulltext index SEARCH_MEMORY_SUMMARIES_BY_KEYWORD = """ CALL db.index.fulltext.queryNodes("summariesFulltext", $q) YIELD node AS m, score -WHERE ($group_id IS NULL OR m.group_id = $group_id) +WHERE ($end_user_id IS NULL OR m.end_user_id = $end_user_id) OPTIONAL MATCH (m)-[:DERIVED_FROM_STATEMENT]->(s:Statement) RETURN m.id AS id, m.name AS name, - m.group_id AS group_id, + m.end_user_id AS end_user_id, m.dialog_id AS dialog_id, m.chunk_ids AS chunk_ids, m.content AS content, @@ -695,10 +652,10 @@ MEMORY_SUMMARY_EMBEDDING_SEARCH = """ CALL db.index.vector.queryNodes('summary_embedding_index', $limit * 100, $embedding) YIELD node AS m, score WHERE m.summary_embedding IS NOT NULL - AND ($group_id IS NULL OR m.group_id = $group_id) + AND ($end_user_id IS NULL OR m.end_user_id = $end_user_id) RETURN m.id AS id, m.name AS name, - m.group_id AS group_id, + m.end_user_id AS end_user_id, m.dialog_id AS dialog_id, m.chunk_ids AS chunk_ids, m.content AS content, @@ -718,9 +675,7 @@ MERGE (m:MemorySummary {id: summary.id}) SET m += { id: summary.id, name: summary.name, - group_id: summary.group_id, - user_id: summary.user_id, - apply_id: summary.apply_id, + end_user_id: summary.end_user_id, run_id: summary.run_id, created_at: summary.created_at, expired_at: summary.expired_at, @@ -745,7 +700,7 @@ MATCH (ms:MemorySummary {id: e.summary_id, run_id: e.run_id}) MATCH (c:Chunk {id: e.chunk_id, run_id: e.run_id}) MATCH (c)-[:CONTAINS]->(s:Statement {run_id: e.run_id}) MERGE (ms)-[r:DERIVED_FROM_STATEMENT]->(s) -SET r.group_id = e.group_id, +SET r.end_user_id = e.end_user_id, r.run_id = e.run_id, r.created_at = e.created_at, r.expired_at = e.expired_at @@ -774,7 +729,7 @@ FOREACH (rel IN CASE WHEN r IS NOT NULL THEN [r] ELSE [] END | source_statement_id: rel.source_statement_id, valid_at: rel.valid_at, invalid_at: rel.invalid_at, - group_id: rel.group_id, + end_user_id: rel.end_user_id, user_id: rel.user_id, apply_id: rel.apply_id, run_id: rel.run_id, @@ -796,7 +751,7 @@ FOREACH (rel IN CASE WHEN r IS NOT NULL THEN [r] ELSE [] END | source_statement_id: rel.source_statement_id, valid_at: rel.valid_at, invalid_at: rel.invalid_at, - group_id: rel.group_id, + end_user_id: rel.end_user_id, user_id: rel.user_id, apply_id: rel.apply_id, run_id: rel.run_id, @@ -814,7 +769,7 @@ RETURN count(losing) as deleted neo4j_statement_part = ''' MATCH (n:Statement) -WHERE n.group_id = "{}" +WHERE n.end_user_id = "{}" AND datetime(n.created_at) >= datetime() - duration('P3D') RETURN n.statement as statement_name, @@ -824,7 +779,7 @@ RETURN ''' neo4j_statement_all = ''' MATCH (n:Statement) -WHERE n.group_id = "{}" +WHERE n.end_user_id = "{}" RETURN n.statement as statement_name, n.id as statement_id @@ -832,7 +787,7 @@ RETURN ''' neo4j_query_part = """ MATCH (n)-[r]-(m:ExtractedEntity) - WHERE n.group_id = "{}" + WHERE n.end_user_id = "{}" AND datetime(n.created_at) >= datetime() - duration('P3D') WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) @@ -853,7 +808,7 @@ neo4j_query_part = """ """ neo4j_query_all = """ MATCH (n)-[r]-(m:ExtractedEntity) - WHERE n.group_id = "{}" + WHERE n.end_user_id = "{}" WITH DISTINCT m OPTIONAL MATCH (m)-[rel]-(other:ExtractedEntity) RETURN @@ -1027,14 +982,14 @@ RETURN DISTINCT Memory_Space_User=""" MATCH (n)-[r]->(m) -WHERE n.group_id = $group_id AND m.name="用户" +WHERE n.end_user_id = $end_user_id AND m.name="用户" return DISTINCT elementId(m) as id """ Memory_Space_Entity=""" MATCH (n)-[]-(m) WHERE elementId(m) = $id AND m.entity_type = "Person" RETURN -DISTINCT m.name as name,m.group_id as group_id +DISTINCT m.name as name,m.end_user_id as end_user_id """ Memory_Space_Associative=""" MATCH (u)-[]-(x)-[]-(h) diff --git a/api/app/repositories/neo4j/dialog_repository.py b/api/app/repositories/neo4j/dialog_repository.py index ccb3d94c..020e7346 100644 --- a/api/app/repositories/neo4j/dialog_repository.py +++ b/api/app/repositories/neo4j/dialog_repository.py @@ -19,7 +19,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """对话仓储 管理对话节点的创建、查询、更新和删除操作。 - 提供按group_id、user_id、ref_id等条件查询对话的方法。 + 提供按end_user_id、user_id、ref_id等条件查询对话的方法。 Attributes: connector: Neo4j连接器实例 @@ -54,17 +54,17 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): return DialogueNode(**n) - async def find_by_group_id(self, group_id: str, limit: int = 100) -> List[DialogueNode]: - """根据group_id查询对话 + async def find_by_end_user_id(self, end_user_id: str, limit: int = 100) -> List[DialogueNode]: + """根据end_user_id查询对话 Args: - group_id: 组ID + end_user_id: 组ID limit: 返回结果的最大数量 Returns: List[DialogueNode]: 对话列表 """ - return await self.find({"group_id": group_id}, limit=limit) + return await self.find({"end_user_id": end_user_id}, limit=limit) async def find_by_user_id(self, user_id: str, limit: int = 100) -> List[DialogueNode]: """根据user_id查询对话 @@ -94,14 +94,14 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): async def find_by_group_and_user( self, - group_id: str, + end_user_id: str, user_id: str, limit: int = 100 ) -> List[DialogueNode]: - """根据group_id和user_id查询对话 + """根据end_user_id和user_id查询对话 Args: - group_id: 组ID + end_user_id: 组ID user_id: 用户ID limit: 返回结果的最大数量 @@ -109,20 +109,20 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): List[DialogueNode]: 对话列表 """ return await self.find( - {"group_id": group_id, "user_id": user_id}, + {"end_user_id": end_user_id, "user_id": user_id}, limit=limit ) async def find_recent_dialogs( self, - group_id: str, + end_user_id: str, days: int = 7, limit: int = 100 ) -> List[DialogueNode]: """查询最近的对话 Args: - group_id: 组ID + end_user_id: 组ID days: 查询最近多少天的对话 limit: 返回结果的最大数量 @@ -131,7 +131,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND n.created_at >= datetime() - duration({{days: $days}}) RETURN n ORDER BY n.created_at DESC @@ -139,7 +139,7 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): """ results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, days=days, limit=limit ) @@ -164,22 +164,22 @@ class DialogRepository(BaseNeo4jRepository[DialogueNode]): async def find_by_config_and_group( self, config_id: str, - group_id: str, + end_user_id: str, limit: int = 100 ) -> List[DialogueNode]: - """根据config_id和group_id查询对话 + """根据config_id和end_user_id查询对话 支持按配置ID和组ID同时过滤,确保只返回使用特定配置处理的对话。 Args: config_id: 配置ID - group_id: 组ID + end_user_id: 组ID limit: 返回结果的最大数量 Returns: List[DialogueNode]: 对话列表 """ return await self.find( - {"config_id": config_id, "group_id": group_id}, + {"config_id": config_id, "end_user_id": end_user_id}, limit=limit ) diff --git a/api/app/repositories/neo4j/emotion_repository.py b/api/app/repositories/neo4j/emotion_repository.py index d445c8d4..e39968ac 100644 --- a/api/app/repositories/neo4j/emotion_repository.py +++ b/api/app/repositories/neo4j/emotion_repository.py @@ -40,7 +40,7 @@ class EmotionRepository: async def get_emotion_tags( self, - group_id: str, + end_user_id: str, emotion_type: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, @@ -51,7 +51,7 @@ class EmotionRepository: 查询指定用户的情绪类型分布,包括计数、百分比和平均强度。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) emotion_type: 可选的情绪类型过滤(joy/sadness/anger/fear/surprise/neutral) start_date: 可选的开始日期(ISO格式字符串) end_date: 可选的结束日期(ISO格式字符串) @@ -65,8 +65,8 @@ class EmotionRepository: - avg_intensity: 平均强度 """ # 构建查询条件 - where_clauses = ["s.group_id = $group_id", "s.emotion_type IS NOT NULL"] - params = {"group_id": group_id, "limit": limit} + where_clauses = ["s.end_user_id = $end_user_id", "s.emotion_type IS NOT NULL"] + params = {"end_user_id": end_user_id, "limit": limit} if emotion_type: where_clauses.append("s.emotion_type = $emotion_type") @@ -119,7 +119,7 @@ class EmotionRepository: async def get_emotion_wordcloud( self, - group_id: str, + end_user_id: str, emotion_type: Optional[str] = None, limit: int = 50 ) -> List[Dict[str, Any]]: @@ -128,7 +128,7 @@ class EmotionRepository: 查询情绪关键词及其频率,用于生成词云可视化。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) emotion_type: 可选的情绪类型过滤 limit: 返回关键词的最大数量 @@ -140,8 +140,8 @@ class EmotionRepository: - avg_intensity: 平均强度 """ # 构建查询条件 - where_clauses = ["s.group_id = $group_id", "s.emotion_keywords IS NOT NULL"] - params = {"group_id": group_id, "limit": limit} + where_clauses = ["s.end_user_id = $end_user_id", "s.emotion_keywords IS NOT NULL"] + params = {"end_user_id": end_user_id, "limit": limit} if emotion_type: where_clauses.append("s.emotion_type = $emotion_type") @@ -186,7 +186,7 @@ class EmotionRepository: async def get_emotions_in_range( self, - group_id: str, + end_user_id: str, time_range: str = "30d" ) -> List[Dict[str, Any]]: """获取时间范围内的情绪数据 @@ -194,7 +194,7 @@ class EmotionRepository: 查询指定时间范围内的所有情绪数据,用于健康指数计算。 Args: - group_id: 用户组ID(宿主ID) + end_user_id: 用户组ID(宿主ID) time_range: 时间范围(7d/30d/90d) Returns: @@ -214,7 +214,7 @@ class EmotionRepository: # 优化的 Cypher 查询:使用字符串比较避免时区问题 query = """ MATCH (s:Statement) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id AND s.emotion_type IS NOT NULL AND s.created_at >= $start_date RETURN s.id as statement_id, @@ -227,7 +227,7 @@ class EmotionRepository: try: results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, start_date=start_date ) formatted_results = [ diff --git a/api/app/repositories/neo4j/graph_saver.py b/api/app/repositories/neo4j/graph_saver.py index 13215e0f..1575315f 100644 --- a/api/app/repositories/neo4j/graph_saver.py +++ b/api/app/repositories/neo4j/graph_saver.py @@ -44,9 +44,7 @@ async def save_entities_and_relationships( 'created_at': edge.created_at.isoformat(), 'expired_at': edge.expired_at.isoformat(), 'run_id': edge.run_id, - 'group_id': edge.group_id, - 'user_id': edge.user_id, - 'apply_id': edge.apply_id, + 'end_user_id': edge.end_user_id, } all_relationships.append(relationship) @@ -101,9 +99,7 @@ async def save_statement_chunk_edges( "id": edge.id, "source": edge.source, "target": edge.target, - "group_id": edge.group_id, - "user_id": edge.user_id, - "apply_id": edge.apply_id, + "end_user_id": edge.end_user_id, "run_id": edge.run_id, "created_at": edge.created_at.isoformat() if edge.created_at else None, "expired_at": edge.expired_at.isoformat() if edge.expired_at else None, @@ -132,9 +128,7 @@ async def save_statement_entity_edges( edge_data = { "source": edge.source, "target": edge.target, - "group_id": edge.group_id, - "user_id": edge.user_id, - "apply_id": edge.apply_id, + "end_user_id": edge.end_user_id, "run_id": edge.run_id, "connect_strength": edge.connect_strength, "created_at": edge.created_at.isoformat() if edge.created_at else None, diff --git a/api/app/repositories/neo4j/graph_search.py b/api/app/repositories/neo4j/graph_search.py index 6f5764b4..e8f52535 100644 --- a/api/app/repositories/neo4j/graph_search.py +++ b/api/app/repositories/neo4j/graph_search.py @@ -33,7 +33,7 @@ async def _update_activation_values_batch( connector: Neo4jConnector, nodes: List[Dict[str, Any]], node_label: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, max_retries: int = 3 ) -> List[Dict[str, Any]]: """ @@ -46,7 +46,7 @@ async def _update_activation_values_batch( connector: Neo4j连接器 nodes: 节点列表,每个节点必须包含 'id' 字段 node_label: 节点标签(Statement, ExtractedEntity, MemorySummary) - group_id: 组ID(可选) + end_user_id: 组ID(可选) max_retries: 最大重试次数 Returns: @@ -97,7 +97,7 @@ async def _update_activation_values_batch( updated_nodes = await access_manager.record_batch_access( node_ids=unique_node_ids, node_label=node_label, - group_id=group_id + end_user_id=end_user_id ) logger.info( @@ -118,7 +118,7 @@ async def _update_activation_values_batch( async def _update_search_results_activation( connector: Neo4jConnector, results: Dict[str, List[Dict[str, Any]]], - group_id: Optional[str] = None + end_user_id: Optional[str] = None ) -> Dict[str, List[Dict[str, Any]]]: """ 更新搜索结果中所有知识节点的激活值 @@ -129,7 +129,7 @@ async def _update_search_results_activation( Args: connector: Neo4j连接器 results: 搜索结果字典,包含不同类型节点的列表 - group_id: 组ID(可选) + end_user_id: 组ID(可选) Returns: Dict[str, List[Dict[str, Any]]]: 更新后的搜索结果 @@ -152,7 +152,7 @@ async def _update_search_results_activation( connector=connector, nodes=results[key], node_label=label, - group_id=group_id + end_user_id=end_user_id ) ) update_keys.append(key) @@ -218,7 +218,7 @@ async def _update_search_results_activation( async def search_graph( connector: Neo4jConnector, q: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: List[str] = None, ) -> Dict[str, List[Dict[str, Any]]]: @@ -236,7 +236,7 @@ async def search_graph( Args: connector: Neo4j connector q: Query text - group_id: Optional group filter + end_user_id: Optional group filter limit: Max results per category include: List of categories to search (default: all) @@ -254,7 +254,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_STATEMENTS_BY_KEYWORD, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("statements") @@ -263,7 +263,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("entities") @@ -272,7 +272,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_CHUNKS_BY_CONTENT, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("chunks") @@ -281,7 +281,7 @@ async def search_graph( tasks.append(connector.execute_query( SEARCH_MEMORY_SUMMARIES_BY_KEYWORD, q=q, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("summaries") @@ -310,12 +310,12 @@ async def search_graph( key in include and key in results and results[key] for key in ['statements', 'entities', 'chunks'] ) - + if needs_activation_update: results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results @@ -325,7 +325,7 @@ async def search_graph_by_embedding( connector: Neo4jConnector, embedder_client, query_text: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 50, include: List[str] = ["statements", "chunks", "entities","summaries"], ) -> Dict[str, List[Dict[str, Any]]]: @@ -337,7 +337,7 @@ async def search_graph_by_embedding( - Computes query embedding with the provided embedder_client - Ranks by cosine similarity in Cypher - - Filters by group_id if provided + - Filters by end_user_id if provided - Returns up to 'limit' per included type """ import time @@ -346,7 +346,7 @@ async def search_graph_by_embedding( embed_start = time.time() embeddings = await embedder_client.response([query_text]) embed_time = time.time() - embed_start - logger.info(f"[PERF] Embedding generation took: {embed_time:.4f}s") + print(f"[PERF] Embedding generation took: {embed_time:.4f}s") if not embeddings or not embeddings[0]: return {"statements": [], "chunks": [], "entities": [], "summaries": []} @@ -361,7 +361,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( STATEMENT_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("statements") @@ -371,7 +371,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( CHUNK_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("chunks") @@ -381,7 +381,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( ENTITY_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("entities") @@ -391,7 +391,7 @@ async def search_graph_by_embedding( tasks.append(connector.execute_query( MEMORY_SUMMARY_EMBEDDING_SEARCH, embedding=embedding, - group_id=group_id, + end_user_id=end_user_id, limit=limit, )) task_keys.append("summaries") @@ -400,7 +400,7 @@ async def search_graph_by_embedding( query_start = time.time() task_results = await asyncio.gather(*tasks, return_exceptions=True) query_time = time.time() - query_start - logger.info(f"[PERF] Neo4j queries (parallel) took: {query_time:.4f}s") + print(f"[PERF] Neo4j queries (parallel) took: {query_time:.4f}s") # Build results dictionary results: Dict[str, List[Dict[str, Any]]] = { @@ -429,13 +429,13 @@ async def search_graph_by_embedding( key in include and key in results and results[key] for key in ['statements', 'entities', 'chunks'] ) - + if needs_activation_update: update_start = time.time() results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) update_time = time.time() - update_start logger.info(f"[PERF] Activation value updates took: {update_time:.4f}s") @@ -445,7 +445,7 @@ async def search_graph_by_embedding( return results async def get_dedup_candidates_for_entities( # 适配新版查询:使用全文索引按名称检索候选实体 connector: Neo4jConnector, - group_id: str, + end_user_id: str, entities: List[Dict[str, Any]], use_contains_fallback: bool = True, batch_size: int = 500, @@ -453,7 +453,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 ) -> Dict[str, List[Dict[str, Any]]]: """ 为第二层去重消歧批量检索候选实体(适配新版 cypher_queries): - - 使用全文索引查询 `SEARCH_ENTITIES_BY_NAME` 按 (group_id, name) 检索候选; + - 使用全文索引查询 `SEARCH_ENTITIES_BY_NAME` 按 (end_user_id, name) 检索候选; - 保留并发控制与返回结构(incoming_id -> [db_entity_props...]); - 若提供 `entity_type`,在本地对返回结果做类型过滤; - `use_contains_fallback` 保留形参以兼容,必要时可扩展二次查询策略。 @@ -477,7 +477,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 rows = await connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=name, - group_id=group_id, + end_user_id=end_user_id, limit=100, ) except Exception: @@ -501,7 +501,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 rows = await connector.execute_query( SEARCH_ENTITIES_BY_NAME, q=name.lower(), - group_id=group_id, + end_user_id=end_user_id, limit=100, ) for r in rows: @@ -532,9 +532,7 @@ async def get_dedup_candidates_for_entities( # 适配新版查询:使用全 async def search_graph_by_keyword_temporal( connector: Neo4jConnector, query_text: str, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -547,32 +545,30 @@ async def search_graph_by_keyword_temporal( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements containing query_text created between start_date and end_date - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ if not query_text: - logger.warning(f"query_text cannot be empty") + print(f"query_text不能为空") return {"statements": []} statements = await connector.execute_query( SEARCH_STATEMENTS_BY_KEYWORD_TEMPORAL, q=query_text, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, start_date=start_date, end_date=end_date, valid_date=valid_date, invalid_date=invalid_date, limit=limit, ) - logger.debug(f"Temporal keyword search results: {len(statements)} statements found") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results @@ -580,9 +576,7 @@ async def search_graph_by_keyword_temporal( async def search_graph_by_temporal( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, valid_date: Optional[str] = None, @@ -595,14 +589,12 @@ async def search_graph_by_temporal( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created between start_date and end_date - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_TEMPORAL, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, start_date=start_date, end_date=end_date, valid_date=valid_date, @@ -610,16 +602,16 @@ async def search_graph_by_temporal( limit=limit, ) - logger.debug(f"Temporal search query: {SEARCH_STATEMENTS_BY_TEMPORAL}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, start_date={start_date}, end_date={end_date}, valid_date={valid_date}, invalid_date={invalid_date}, limit={limit}") - logger.debug(f"Temporal search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_TEMPORAL}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, start_date: {start_date}, end_date: {end_date}, valid_date: {valid_date}, invalid_date: {invalid_date}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results @@ -628,23 +620,23 @@ async def search_graph_by_temporal( async def search_graph_by_dialog_id( connector: Neo4jConnector, dialog_id: str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: """ Temporal search across Dialogues. - Matches dialogues with dialog_id - - Optionally filters by group_id + - Optionally filters by end_user_id - Returns up to 'limit' dialogues """ if not dialog_id: - logger.warning(f"dialog_id cannot be empty") + print(f"dialog_id不能为空") return {"dialogues": []} dialogues = await connector.execute_query( SEARCH_DIALOGUE_BY_DIALOG_ID, - group_id=group_id, + end_user_id=end_user_id, dialog_id=dialog_id, limit=limit, ) @@ -654,15 +646,15 @@ async def search_graph_by_dialog_id( async def search_graph_by_chunk_id( connector: Neo4jConnector, chunk_id : str, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: if not chunk_id: - logger.warning(f"chunk_id cannot be empty") + print(f"chunk_id不能为空") return {"chunks": []} chunks = await connector.execute_query( SEARCH_CHUNK_BY_CHUNK_ID, - group_id=group_id, + end_user_id=end_user_id, chunk_id=chunk_id, limit=limit, ) @@ -671,9 +663,9 @@ async def search_graph_by_chunk_id( async def search_graph_by_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -683,37 +675,37 @@ async def search_graph_by_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search by created_at query: {SEARCH_STATEMENTS_BY_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id} created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_by_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -723,37 +715,37 @@ async def search_graph_by_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_BY_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search by valid_at query: {SEARCH_STATEMENTS_BY_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_BY_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_g_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -763,37 +755,37 @@ async def search_graph_g_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_G_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search greater than created_at query: {SEARCH_STATEMENTS_G_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_G_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_g_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -803,37 +795,37 @@ async def search_graph_g_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_G_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search greater than valid_at query: {SEARCH_STATEMENTS_G_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_G_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_l_created_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + created_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -843,37 +835,37 @@ async def search_graph_l_created_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements created at created_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_L_CREATED_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + created_at=created_at, limit=limit, ) - logger.debug(f"Search less than created_at query: {SEARCH_STATEMENTS_L_CREATED_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, created_at={created_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_L_CREATED_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, created_at: {created_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results async def search_graph_l_valid_at( connector: Neo4jConnector, - group_id: Optional[str] = None, - apply_id: Optional[str] = None, - user_id: Optional[str] = None, + end_user_id: Optional[str] = None, + + valid_at: Optional[str] = None, limit: int = 1, ) -> Dict[str, List[Dict[str, Any]]]: @@ -883,28 +875,28 @@ async def search_graph_l_valid_at( INTEGRATED: Updates activation values for Statement nodes before returning results - Matches statements valid at valid_at - - Optionally filters by group_id, apply_id, user_id + - Optionally filters by end_user_id, apply_id, user_id - Returns up to 'limit' statements """ statements = await connector.execute_query( SEARCH_STATEMENTS_L_VALID_AT, - group_id=group_id, - apply_id=apply_id, - user_id=user_id, + end_user_id=end_user_id, + + valid_at=valid_at, limit=limit, ) - logger.debug(f"Search less than valid_at query: {SEARCH_STATEMENTS_L_VALID_AT}") - logger.debug(f"Query params: group_id={group_id}, apply_id={apply_id}, user_id={user_id}, valid_at={valid_at}, limit={limit}") - logger.debug(f"Search results: {len(statements)} statements found") + print(f"查询语句为:\n{SEARCH_STATEMENTS_L_VALID_AT}") + print(f"查询参数为:\n{{end_user_id: {end_user_id}, valid_at: {valid_at}, limit: {limit}}}") + print(f"查询结果为:\n{statements}") # 更新 Statement 节点的激活值 results = {"statements": statements} results = await _update_search_results_activation( connector=connector, results=results, - group_id=group_id + end_user_id=end_user_id ) return results diff --git a/api/app/repositories/neo4j/memory_summary_repository.py b/api/app/repositories/neo4j/memory_summary_repository.py index fc743f33..d7cd4fd4 100644 --- a/api/app/repositories/neo4j/memory_summary_repository.py +++ b/api/app/repositories/neo4j/memory_summary_repository.py @@ -18,7 +18,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """Memory Summary Repository Manages CRUD operations for MemorySummary nodes. - Provides methods to query summaries by group_id, user_id, and time ranges. + Provides methods to query summaries by end_user_id, user_id, and time ranges. Attributes: connector: Neo4j connector instance @@ -51,17 +51,17 @@ class MemorySummaryRepository(BaseNeo4jRepository): return dict(n) - async def find_by_group_id( + async def find_by_end_user_id( self, - group_id: str, + end_user_id: str, limit: int = 1000, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: - """Query memory summaries by group_id + """Query memory summaries by end_user_id Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by limit: Maximum number of results to return start_date: Optional start date filter end_date: Optional end date filter @@ -71,10 +71,10 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id """ - params = {"group_id": group_id, "limit": limit} + params = {"end_user_id": end_user_id, "limit": limit} # Add date range filters if provided if start_date: @@ -139,16 +139,16 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_by_group_and_user( self, - group_id: str, + end_user_id: str, user_id: str, limit: int = 1000, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> List[Dict[str, Any]]: - """Query memory summaries by both group_id and user_id + """Query memory summaries by both end_user_id and user_id Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by user_id: User ID to filter by limit: Maximum number of results to return start_date: Optional start date filter @@ -159,10 +159,10 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id AND n.user_id = $user_id + WHERE n.end_user_id = $end_user_id AND n.user_id = $user_id """ - params = {"group_id": group_id, "user_id": user_id, "limit": limit} + params = {"end_user_id": end_user_id, "user_id": user_id, "limit": limit} # Add date range filters if provided if start_date: @@ -184,14 +184,14 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_recent_summaries( self, - group_id: str, + end_user_id: str, days: int = 7, limit: int = 1000 ) -> List[Dict[str, Any]]: """Query recent memory summaries Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by days: Number of recent days to query limit: Maximum number of results to return @@ -200,7 +200,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND n.created_at >= datetime() - duration({{days: $days}}) RETURN n ORDER BY n.created_at DESC @@ -209,7 +209,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): results = await self.connector.execute_query( query, - group_id=group_id, + end_user_id=end_user_id, days=days, limit=limit ) @@ -217,14 +217,14 @@ class MemorySummaryRepository(BaseNeo4jRepository): async def find_by_content_keywords( self, - group_id: str, + end_user_id: str, keywords: List[str], limit: int = 100 ) -> List[Dict[str, Any]]: """Query memory summaries by content keywords Args: - group_id: Group ID to filter by + end_user_id: Group ID to filter by keywords: List of keywords to search for in content limit: Maximum number of results to return @@ -233,7 +233,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): """ # Build keyword search conditions keyword_conditions = [] - params = {"group_id": group_id, "limit": limit} + params = {"end_user_id": end_user_id, "limit": limit} for i, keyword in enumerate(keywords): keyword_conditions.append(f"toLower(n.content) CONTAINS toLower($keyword_{i})") @@ -243,7 +243,7 @@ class MemorySummaryRepository(BaseNeo4jRepository): query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND ({keyword_filter}) RETURN n ORDER BY n.created_at DESC @@ -253,21 +253,21 @@ class MemorySummaryRepository(BaseNeo4jRepository): results = await self.connector.execute_query(query, **params) return [self._map_to_dict(r) for r in results] - async def get_summary_count_by_group(self, group_id: str) -> int: + async def get_summary_count_by_group(self, end_user_id: str) -> int: """Get count of memory summaries for a group Args: - group_id: Group ID to count summaries for + end_user_id: Group ID to count summaries for Returns: int: Number of memory summaries """ query = f""" MATCH (n:{self.node_label}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - results = await self.connector.execute_query(query, group_id=group_id) + results = await self.connector.execute_query(query, end_user_id=end_user_id) return results[0]['count'] if results else 0 \ No newline at end of file diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index 7c4b43b5..d96e4431 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -70,11 +70,7 @@ class Neo4jConnector: List[Dict[str, Any]]: 查询结果列表,每个元素是一个字典 Example: - >>> connector = Neo4jConnector() - >>> results = await connector.execute_query( - ... "MATCH (n:Person {name: $name}) RETURN n", - ... name="Alice" - ... ) + """ result = await self.driver.execute_query( query, @@ -98,17 +94,7 @@ class Neo4jConnector: Any: 事务函数的返回值 Example: - >>> async def create_node(tx, name): - ... result = await tx.run( - ... "CREATE (n:Person {name: $name}) RETURN n", - ... name=name - ... ) - ... return await result.single() - >>> - >>> connector = Neo4jConnector() - >>> result = await connector.execute_write_transaction( - ... create_node, name="Alice" - ... ) + """ async with self.driver.session(database="neo4j") as session: return await session.execute_write(transaction_func, **kwargs) @@ -126,45 +112,33 @@ class Neo4jConnector: Any: 事务函数的返回值 Example: - >>> async def get_node(tx, name): - ... result = await tx.run( - ... "MATCH (n:Person {name: $name}) RETURN n", - ... name=name - ... ) - ... return await result.single() - >>> - >>> connector = Neo4jConnector() - >>> result = await connector.execute_read_transaction( - ... get_node, name="Alice" - ... ) + """ async with self.driver.session(database="neo4j") as session: return await session.execute_read(transaction_func, **kwargs) - async def delete_group(self, group_id: str): + async def delete_group(self, end_user_id: str): """删除指定组的所有数据 - 删除所有属于指定group_id的节点和边。 + 删除所有属于指定end_user_id的节点和边。 这是一个危险操作,会永久删除数据。 Args: - group_id: 要删除的组ID + end_user_id: 要删除的组ID Example: - >>> connector = Neo4jConnector() - >>> await connector.delete_group("group_123") Group group_123 deleted. """ # 删除节点(DETACH DELETE会同时删除相关的边) await self.driver.execute_query( - "MATCH (n) WHERE n.group_id = $group_id DETACH DELETE n", + "MATCH (n) WHERE n.end_user_id = $end_user_id DETACH DELETE n", database="neo4j", - group_id=group_id + end_user_id=end_user_id ) # 删除独立的边(如果有的话) await self.driver.execute_query( - "MATCH ()-[r]->() WHERE r.group_id = $group_id DELETE r", + "MATCH ()-[r]->() WHERE r.end_user_id = $end_user_id DELETE r", database="neo4j", - group_id=group_id + end_user_id=end_user_id ) - print(f"Group {group_id} deleted.") + print(f"Group {end_user_id} deleted.") diff --git a/api/app/repositories/neo4j/statement_repository.py b/api/app/repositories/neo4j/statement_repository.py index cd9f2fac..4f12af83 100644 --- a/api/app/repositories/neo4j/statement_repository.py +++ b/api/app/repositories/neo4j/statement_repository.py @@ -20,7 +20,7 @@ class StatementRepository(BaseNeo4jRepository[StatementNode]): """陈述句仓储 管理陈述句节点的创建、查询、更新和删除操作。 - 提供按chunk_id、group_id、向量相似度等条件查询陈述句的方法。 + 提供按chunk_id、end_user_id、向量相似度等条件查询陈述句的方法。 Attributes: connector: Neo4j连接器实例 diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 35d2e424..09410091 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -299,6 +299,18 @@ class AppRelease(BaseModel): created_at: datetime.datetime updated_at: datetime.datetime + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v if v is not None else {} + @field_serializer("created_at", when_used="json") def _serialize_created_at(self, dt: datetime.datetime): return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/schemas/emotion_schema.py b/api/app/schemas/emotion_schema.py index c48fbd41..13c802b5 100644 --- a/api/app/schemas/emotion_schema.py +++ b/api/app/schemas/emotion_schema.py @@ -1,11 +1,12 @@ """情绪分析相关的请求和响应模型""" from typing import Optional +from uuid import UUID from pydantic import BaseModel, Field class EmotionTagsRequest(BaseModel): """获取情绪标签统计请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") emotion_type: Optional[str] = Field(None, description="情绪类型过滤(joy/sadness/anger/fear/surprise/neutral)") start_date: Optional[str] = Field(None, description="开始日期(ISO格式,如:2024-01-01)") end_date: Optional[str] = Field(None, description="结束日期(ISO格式,如:2024-12-31)") @@ -14,14 +15,14 @@ class EmotionTagsRequest(BaseModel): class EmotionWordcloudRequest(BaseModel): """获取情绪词云数据请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") emotion_type: Optional[str] = Field(None, description="情绪类型过滤(joy/sadness/anger/fear/surprise/neutral)") limit: int = Field(50, ge=1, le=200, description="返回词语数量") class EmotionHealthRequest(BaseModel): """获取情绪健康指数请求""" - group_id: str = Field(..., description="组ID") + end_user_id: str = Field(..., description="组ID") time_range: str = Field("30d", description="时间范围(7d/30d/90d)") @@ -29,8 +30,8 @@ class EmotionHealthRequest(BaseModel): class EmotionSuggestionsRequest(BaseModel): """获取个性化情绪建议请求""" - group_id: str = Field(..., description="组ID") - config_id: Optional[int] = Field(None, description="配置ID(用于指定LLM模型)") + end_user_id: str = Field(..., description="组ID") + config_id: Optional[UUID] = Field(None, description="配置ID(用于指定LLM模型)") class EmotionGenerateSuggestionsRequest(BaseModel): diff --git a/api/app/schemas/memory_agent_schema.py b/api/app/schemas/memory_agent_schema.py index d4354c40..b6f50dd7 100644 --- a/api/app/schemas/memory_agent_schema.py +++ b/api/app/schemas/memory_agent_schema.py @@ -7,11 +7,11 @@ class UserInput(BaseModel): message: str history: list[dict] search_switch: str - group_id: str + end_user_id: str config_id: Optional[str] = None class Write_UserInput(BaseModel): messages: list[dict] - group_id: str - config_id: Optional[str] = None + end_user_id: str + config_id: Optional[str] = None \ No newline at end of file diff --git a/api/app/schemas/memory_config_schema.py b/api/app/schemas/memory_config_schema.py index 0443dcc4..76acee5c 100644 --- a/api/app/schemas/memory_config_schema.py +++ b/api/app/schemas/memory_config_schema.py @@ -35,7 +35,7 @@ class ConfigurationError(Exception): def __init__( self, message: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, context: Optional[Dict[str, Any]] = None, ): @@ -72,7 +72,7 @@ class WorkspaceNotFoundError(ConfigurationError): def __init__( self, workspace_id: UUID, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, message: Optional[str] = None, ): if message is None: @@ -89,7 +89,7 @@ class ModelNotFoundError(ConfigurationError): self, model_id: Union[str, UUID], model_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, message: Optional[str] = None, ): @@ -112,7 +112,7 @@ class ModelInactiveError(ConfigurationError): model_id: Union[str, UUID], model_name: str, model_type: str, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, message: Optional[str] = None, ): @@ -136,7 +136,7 @@ class InvalidConfigError(ConfigurationError): message: str, field_name: Optional[str] = None, invalid_value: Optional[Any] = None, - config_id: Optional[int] = None, + config_id: Optional[UUID] = None, workspace_id: Optional[UUID] = None, ): context = {} @@ -155,7 +155,7 @@ class InvalidConfigError(ConfigurationError): class MemoryConfigValidation(BaseModel): """Pydantic model for validating memory configuration data from database.""" - config_id: int = Field(..., gt=0, description="Configuration ID must be positive") + config_id: UUID = Field(..., description="Configuration ID (UUID)") config_name: str = Field(..., min_length=1, max_length=255) workspace_id: UUID = Field(..., description="Workspace UUID") workspace_name: str = Field(..., min_length=1, max_length=255) @@ -275,7 +275,7 @@ class ModelValidation(BaseModel): def validate_memory_config_data( - config_data: Dict[str, Any], config_id: Optional[int] = None + config_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> MemoryConfigValidation: """Validate memory configuration data using Pydantic model.""" try: @@ -302,7 +302,7 @@ def validate_memory_config_data( def validate_workspace_data( - workspace_data: Dict[str, Any], config_id: Optional[int] = None + workspace_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> WorkspaceValidation: """Validate workspace data using Pydantic model.""" try: @@ -331,7 +331,7 @@ def validate_workspace_data( def validate_model_data( - model_data: Dict[str, Any], config_id: Optional[int] = None + model_data: Dict[str, Any], config_id: Optional[UUID] = None ) -> ModelValidation: """Validate model data using Pydantic model.""" try: @@ -364,7 +364,7 @@ def validate_model_data( class MemoryConfig: """Immutable memory configuration loaded from database.""" - config_id: int + config_id: UUID config_name: str workspace_id: UUID workspace_name: str diff --git a/api/app/schemas/memory_perceptual_schema.py b/api/app/schemas/memory_perceptual_schema.py index 05e01d2a..7dfefe01 100644 --- a/api/app/schemas/memory_perceptual_schema.py +++ b/api/app/schemas/memory_perceptual_schema.py @@ -4,7 +4,7 @@ from typing import Optional from pydantic import BaseModel, Field -from app.models.memory_perceptual_model import PerceptualType, FileStorageType +from app.models.memory_perceptual_model import PerceptualType, FileStorageService class PerceptualFilter(BaseModel): @@ -38,12 +38,14 @@ class PerceptualMemoryItem(BaseModel): """感知记忆项""" id: uuid.UUID = Field(..., description="Unique memory ID") perceptual_type: PerceptualType = Field(..., description="Type of perception, e.g., text, audio, or video") + storage_service: FileStorageService = Field(..., description="Storage service for file") file_path: str = Field(..., description="File path in the storage service") - file_ext: str = Field(..., description="File extension") file_name: str = Field(..., description="File name") + file_ext: str = Field(..., description="File extension") summary: Optional[str] = Field(None, description="summary") - storage_type: FileStorageType = Field(..., description="Storage type for file") + meta_data: Optional[dict] = Field(None, description="Metadata information") created_time: int = Field(..., description="create time") + topic: str = Field(..., description="topic") domain: str = Field(..., description="domain") keywords: list[str] = Field(..., description="keywords") diff --git a/api/app/schemas/memory_reflection_schemas.py b/api/app/schemas/memory_reflection_schemas.py index 860f1ef1..df841fb1 100644 --- a/api/app/schemas/memory_reflection_schemas.py +++ b/api/app/schemas/memory_reflection_schemas.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field from typing import Optional +from uuid import UUID from enum import Enum @@ -9,7 +10,7 @@ class OptimizationStrategy(str, Enum): ACCURACY_FIRST = "accuracy_first" BALANCED = "balanced" class Memory_Reflection(BaseModel): - config_id: Optional[int] = None + config_id: Optional[UUID] = None reflection_enabled: bool reflection_period_in_hours: str reflexion_range: Optional[str] = "partial" diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index d17a9f2c..d9c04f8f 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -1,5 +1,5 @@ """ -所有的内容是放错误地方了,应该放在models + """ from typing import Any, Optional, List, Dict, Literal, Union @@ -8,20 +8,8 @@ import uuid from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator -# ============================================================================ -# 原 UserInput 相关 Schema (保留原有功能) -# ============================================================================ -class UserInput(BaseModel): - message: str - history: list[dict] - search_switch: str - group_id: str -class Write_UserInput(BaseModel): - message: str - group_id: str - # ============================================================================ # 从 json_schema.py 迁移的 Schema @@ -159,7 +147,7 @@ class ReflexionResultSchema(BaseModel): # Composite key identifying a config row class ConfigKey(BaseModel): # 配置参数键模型 model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field("config_id", description="配置唯一标识(字符串)") + config_id: uuid.UUID = Field("config_id", description="配置唯一标识(UUID)") user_id: str = Field("user_id", description="用户标识(字符串)") apply_id: str = Field("apply_id", description="应用或场景标识(字符串)") @@ -250,17 +238,17 @@ class ConfigParamsCreate(BaseModel): # 创建配置参数模型(仅 body, class ConfigParamsDelete(BaseModel): # 删除配置参数模型(请求体) model_config = ConfigDict(populate_by_name=True, extra="forbid") # config_name: str = Field("配置名称", description="配置名称(字符串)") - config_id: int = Field("配置ID", description="配置ID(字符串)") + config_id: uuid.UUID = Field("配置ID", description="配置ID(UUID)") class ConfigUpdate(BaseModel): # 更新记忆萃取引擎配置参数时使用的模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None config_name: str = Field("配置名称", description="配置名称(字符串)") config_desc: str = Field("配置描述", description="配置描述(字符串)") class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数时使用的模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None llm_id: Optional[str] = Field(None, description="LLM模型配置ID") embedding_id: Optional[str] = Field(None, description="嵌入模型配置ID") rerank_id: Optional[str] = Field(None, description="重排序模型配置ID") @@ -327,14 +315,14 @@ class ConfigUpdateExtracted(BaseModel): # 更新记忆萃取引擎配置参数 class ConfigUpdateForget(BaseModel): # 更新遗忘引擎配置参数时使用的模型 # 遗忘引擎配置参数更新模型 - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None lambda_time: Optional[float] = Field(0.5, ge=0.0, le=1.0, description="最低保持度,0-1 小数;默认 0.5") lambda_mem: Optional[float] = Field(0.5, ge=0.0, le=1.0, description="遗忘率,0-1 小数;默认 0.5") offset: Optional[float] = Field(0.0, ge=0.0, le=1.0, description="偏移度,0-1 小数;默认 0.0") class ConfigPilotRun(BaseModel): # 试运行触发请求模型 - config_id: int = Field(..., description="配置ID(唯一)") + config_id: uuid.UUID = Field(..., description="配置ID(唯一)") dialogue_text: str = Field(..., description="前端传入的对话文本,格式如 '用户: ...\nAI: ...' 可多行,试运行必填") model_config = ConfigDict(populate_by_name=True, extra="forbid") @@ -342,7 +330,7 @@ class ConfigPilotRun(BaseModel): # 试运行触发请求模型 class ConfigFilter(BaseModel): # 查询配置参数时使用的模型 model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: Optional[int] = None + config_id: Optional[uuid.UUID] = None user_id: Optional[str] = None apply_id: Optional[str] = None @@ -418,7 +406,7 @@ class ForgettingConfigResponse(BaseModel): """遗忘引擎配置响应模型""" model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field(..., description="配置ID") + config_id: uuid.UUID = Field(..., description="配置ID") decay_constant: float = Field(..., description="衰减常数 d") lambda_time: float = Field(..., description="时间衰减参数") lambda_mem: float = Field(..., description="记忆衰减参数") @@ -436,7 +424,7 @@ class ForgettingConfigUpdateRequest(BaseModel): """遗忘引擎配置更新请求模型""" model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: int = Field(..., description="配置ID") + config_id: uuid.UUID = Field(..., description="配置ID") decay_constant: Optional[float] = Field(None, ge=0.0, le=1.0, description="衰减常数 d") lambda_time: Optional[float] = Field(None, ge=0.0, le=1.0, description="时间衰减参数") lambda_mem: Optional[float] = Field(None, ge=0.0, le=1.0, description="记忆衰减参数") @@ -511,7 +499,7 @@ class ForgettingCurveRequest(BaseModel): importance_score: float = Field(0.5, ge=0.0, le=1.0, description="重要性分数(0-1)") days: int = Field(60, ge=1, le=365, description="模拟天数(默认60天)") - config_id: Optional[int] = Field(None, description="配置ID(可选,如果为None则使用默认配置)") + config_id: Optional[uuid.UUID] = Field(None, description="配置ID(可选,如果为None则使用默认配置)") class ForgettingCurveResponse(BaseModel): diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index 5b1fe6d9..68f15115 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, field_serializer, ConfigDict +from pydantic import BaseModel, Field, field_serializer, field_validator, ConfigDict from typing import Optional, List, Dict, Any import datetime import uuid @@ -91,6 +91,18 @@ class ModelApiKey(ModelApiKeyBase): created_at: datetime.datetime updated_at: datetime.datetime + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v + @field_serializer("created_at", when_used="json") def _serialize_created_at(self, dt: datetime.datetime): return int(dt.timestamp() * 1000) if dt else None diff --git a/api/app/schemas/release_share_schema.py b/api/app/schemas/release_share_schema.py index 069b78a9..47897847 100644 --- a/api/app/schemas/release_share_schema.py +++ b/api/app/schemas/release_share_schema.py @@ -1,7 +1,7 @@ import uuid import datetime from typing import Optional, List, Dict, Any -from pydantic import BaseModel, Field, ConfigDict, field_serializer +from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator # ---------- Input Schemas ---------- @@ -88,6 +88,18 @@ class SharedReleaseInfo(BaseModel): # 嵌入配置 allow_embed: bool + @field_validator("config", mode="before") + @classmethod + def parse_config(cls, v): + """处理 config 字段,如果是字符串则解析为字典""" + if isinstance(v, str): + import json + try: + return json.loads(v) + except json.JSONDecodeError: + return {} + return v if v is not None else {} + class EmbedCode(BaseModel): """嵌入代码""" diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 4f20f6d9..9766eec0 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -92,7 +92,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str try: memory_content = asyncio.run( MemoryAgentService().read_memory( - group_id=end_user_id, + end_user_id=end_user_id, message=question, history=[], search_switch="2", diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index 601d2921..af98fb52 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -75,7 +75,7 @@ class EmotionAnalyticsService: # 调用仓储层查询 tags = await self.emotion_repo.get_emotion_tags( - group_id=end_user_id, + end_user_id=end_user_id, emotion_type=emotion_type, start_date=start_date, end_date=end_date, @@ -157,7 +157,7 @@ class EmotionAnalyticsService: # 调用仓储层查询 keywords = await self.emotion_repo.get_emotion_wordcloud( - group_id=end_user_id, + end_user_id=end_user_id, emotion_type=emotion_type, limit=limit ) @@ -339,7 +339,7 @@ class EmotionAnalyticsService: # 获取时间范围内的情绪数据 emotions = await self.emotion_repo.get_emotions_in_range( - group_id=end_user_id, + end_user_id=end_user_id, time_range=time_range ) @@ -505,7 +505,7 @@ class EmotionAnalyticsService: ) config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config( - config_id=int(config_id), + config_id=(config_id), service_name="EmotionAnalyticsService.generate_emotion_suggestions" ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory @@ -519,7 +519,7 @@ class EmotionAnalyticsService: # 3. 获取情绪数据用于模式分析 emotions = await self.emotion_repo.get_emotions_in_range( - group_id=end_user_id, + end_user_id=end_user_id, time_range="30d" ) @@ -598,13 +598,13 @@ class EmotionAnalyticsService: # 查询用户的实体和标签 query = """ MATCH (e:Entity) - WHERE e.group_id = $group_id + WHERE e.end_user_id = $end_user_id RETURN e.name as name, e.type as type ORDER BY e.created_at DESC LIMIT 20 """ - entities = await connector.execute_query(query, group_id=end_user_id) + entities = await connector.execute_query(query, end_user_id=end_user_id) # 提取兴趣标签 interests = [e["name"] for e in entities if e.get("type") in ["INTEREST", "HOBBY"]][:5] diff --git a/api/app/services/emotion_config_service.py b/api/app/services/emotion_config_service.py index 37171640..9880d4e1 100644 --- a/api/app/services/emotion_config_service.py +++ b/api/app/services/emotion_config_service.py @@ -8,9 +8,11 @@ Classes: """ from typing import Dict, Any +from uuid import UUID + from sqlalchemy.orm import Session -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig from app.core.logging_config import get_business_logger logger = get_business_logger() @@ -37,7 +39,7 @@ class EmotionConfigService: self.db = db logger.info("情绪配置服务初始化完成") - def get_emotion_config(self, config_id: int) -> Dict[str, Any]: + def get_emotion_config(self, config_id: UUID) -> Dict[str, Any]: """获取情绪引擎配置 查询指定配置ID的情绪相关配置字段。 @@ -61,8 +63,8 @@ class EmotionConfigService: logger.info(f"获取情绪配置: config_id={config_id}") # 查询配置 - config = self.db.query(DataConfig).filter( - DataConfig.config_id == config_id + config = self.db.query(MemoryConfig).filter( + MemoryConfig.config_id == config_id ).first() if not config: @@ -144,7 +146,7 @@ class EmotionConfigService: def update_emotion_config( self, - config_id: int, + config_id: UUID, config_data: Dict[str, Any] ) -> Dict[str, Any]: """更新情绪引擎配置 @@ -173,8 +175,8 @@ class EmotionConfigService: self.validate_emotion_config(config_data) # 查询配置 - config = self.db.query(DataConfig).filter( - DataConfig.config_id == config_id + config = self.db.query(MemoryConfig).filter( + MemoryConfig.config_id == config_id ).first() if not config: diff --git a/api/app/services/emotion_extraction_service.py b/api/app/services/emotion_extraction_service.py index d134251d..6b596a80 100644 --- a/api/app/services/emotion_extraction_service.py +++ b/api/app/services/emotion_extraction_service.py @@ -14,7 +14,7 @@ from app.core.memory.llm_tools.llm_client import LLMClientException from app.core.memory.models.emotion_models import EmotionExtraction from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context -from app.models.data_config_model import DataConfig +from app.models.memory_config_model import MemoryConfig logger = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class EmotionExtractionService: async def extract_emotion( self, statement: str, - config: DataConfig + config: MemoryConfig ) -> Optional[EmotionExtraction]: """Extract emotion information from a statement. diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 1e1cde89..6e72a53f 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -9,6 +9,7 @@ import os import re import time import uuid +from uuid import UUID from typing import Any, AsyncGenerator, Dict, List, Optional import redis @@ -27,6 +28,7 @@ from app.core.memory.analytics.hot_memory_tags import get_hot_memory_tags from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.models.knowledge_model import Knowledge, KnowledgeType +from app.repositories.memory_short_repository import ShortTermMemoryRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_agent_schema import Write_UserInput from app.schemas.memory_config_schema import ConfigurationError @@ -35,6 +37,7 @@ from app.services.memory_config_service import MemoryConfigService from app.services.memory_konwledges_server import ( write_rag, ) +from langchain_core.messages import AIMessage from langchain_core.messages import HumanMessage from pydantic import BaseModel, Field from sqlalchemy import func @@ -54,25 +57,24 @@ _neo4j_connector = Neo4jConnector() class MemoryAgentService: """Service for memory agent operations""" - def writer_messages_deal(self, messages, start_time, group_id, config_id, message, context): + def writer_messages_deal(self, messages, start_time, end_user_id, config_id, message, context): duration = time.time() - start_time - if str(messages) == 'success': - logger.info(f"Write operation successful for group {group_id} with config_id {config_id}") + logger.info(f"Write operation successful for group {end_user_id} with config_id {config_id}") # 记录成功的操作 if audit_logger: - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=True, + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=True, duration=duration, details={"message_length": len(message)}) return context else: - logger.warning(f"Write operation failed for group {group_id}") + logger.warning(f"Write operation failed for group {end_user_id}") # 记录失败的操作 if audit_logger: audit_logger.log_operation( operation="WRITE", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=f"写入失败: {messages[:100]}" @@ -263,13 +265,13 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, group_id: str, messages: list[dict], config_id: Optional[str], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID], db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id Args: - group_id: Group identifier (also used as end_user_id) - messages: Structured message list [{"role": "user", "content": "..."}, ...] + end_user_id: Group identifier (also used as end_user_id) + message: Message to write config_id: Configuration ID from database db: SQLAlchemy database session storage_type: Storage type (neo4j or rag) @@ -284,15 +286,15 @@ class MemoryAgentService: # Resolve config_id if None using end_user's connected config if config_id is None: try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") if config_id is None: - raise ValueError(f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): - raise - logger.error(f"Failed to get connected config for end_user {group_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + raise # Re-raise our specific error + logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") import time start_time = time.time() @@ -312,7 +314,7 @@ class MemoryAgentService: # Log failed operation if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) raise ValueError(error_msg) @@ -320,24 +322,23 @@ class MemoryAgentService: if storage_type == "rag": # For RAG storage, convert messages to single string message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - result = await write_rag(group_id, message_text, user_rag_memory_id) + result = await write_rag(end_user_id, message_text, user_rag_memory_id) return result else: async with make_write_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # Convert structured messages to LangChain messages langchain_messages = [] for msg in messages: if msg['role'] == 'user': langchain_messages.append(HumanMessage(content=msg['content'])) elif msg['role'] == 'assistant': - from langchain_core.messages import AIMessage langchain_messages.append(AIMessage(content=msg['content'])) - + # 初始状态 - 包含所有必要字段 initial_state = { "messages": langchain_messages, - "group_id": group_id, + "end_user_id": end_user_id, "memory_config": memory_config } @@ -354,14 +355,14 @@ class MemoryAgentService: contents = massages.get('write_result') # Convert messages back to string for logging message_text = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) - return self.writer_messages_deal(massagesstatus, start_time, group_id, config_id, message_text, contents) + return self.writer_messages_deal(massagesstatus, start_time, end_user_id, config_id, message_text, contents) except Exception as e: # Ensure proper error handling and logging error_msg = f"Write operation failed: {str(e)}" logger.error(error_msg) if audit_logger: duration = time.time() - start_time - audit_logger.log_operation(operation="WRITE", config_id=config_id, group_id=group_id, success=False, duration=duration, error=error_msg) + audit_logger.log_operation(operation="WRITE", config_id=config_id, end_user_id=end_user_id, success=False, duration=duration, error=error_msg) raise ValueError(error_msg) @@ -369,15 +370,14 @@ class MemoryAgentService: async def read_memory( self, - group_id: str, + end_user_id: str, message: str, history: List[Dict], search_switch: str, - config_id: Optional[str], + config_id: Optional[UUID], db: Session, storage_type: str, - user_rag_memory_id: str - ) -> Dict: + user_rag_memory_id: str) -> Dict: """ Process read operation with config_id @@ -387,7 +387,7 @@ class MemoryAgentService: - "2": Direct answer based on context Args: - group_id: Group identifier (also used as end_user_id) + end_user_id: Group identifier (also used as end_user_id) message: User message history: Conversation history search_switch: Search mode switch @@ -405,22 +405,22 @@ class MemoryAgentService: import time start_time = time.time() - logger.info(f"[PERF] read_memory started for group_id={group_id}, search_switch={search_switch}") + ori_message= message # Resolve config_id if None using end_user's connected config if config_id is None: try: - config_id = get_end_user_connected_config(group_id, db) - config_id=config_id.get('memory_config_id') + connected_config = get_end_user_connected_config(end_user_id, db) + config_id = connected_config.get("memory_config_id") if config_id is None: - raise ValueError(f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + raise ValueError(f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): raise # Re-raise our specific error - logger.error(f"Failed to get connected config for end_user {group_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") - logger.info(f"Read operation for group {group_id} with config_id {config_id}") + logger.info(f"Read operation for group {end_user_id} with config_id {config_id}") # 导入审计日志记录器 try: @@ -448,7 +448,7 @@ class MemoryAgentService: audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=error_msg @@ -458,16 +458,16 @@ class MemoryAgentService: # Step 2: Prepare history history.append({"role": "user", "content": message}) - logger.debug(f"Group ID:{group_id}, Message:{message}, History:{history}, Config ID:{config_id}") + logger.debug(f"Group ID:{end_user_id}, Message:{message}, History:{history}, Config ID:{config_id}") # Step 3: Initialize MCP client and execute read workflow graph_exec_start = time.time() try: async with make_read_graph() as graph: - config = {"configurable": {"thread_id": group_id}} + config = {"configurable": {"thread_id": end_user_id}} # 初始状态 - 包含所有必要字段 initial_state = {"messages": [HumanMessage(content=message)], "search_switch": search_switch, - "group_id": group_id + "end_user_id": end_user_id , "storage_type": storage_type, "user_rag_memory_id": user_rag_memory_id, "memory_config": memory_config} # 获取节点更新信息 @@ -562,13 +562,13 @@ class MemoryAgentService: if '信息不足,无法回答。' != str(summary) and str(search_switch).strip() != "2": # 使用 upsert 方法 repo.upsert( - end_user_id=group_id, - messages=message, + end_user_id=end_user_id, + messages=ori_message, aimessages=summary, retrieved_content=retrieved_content, search_switch=str(search_switch) ) - logger.info(f"成功保存短期记忆: group_id={group_id}, search_switch={search_switch}") + logger.info(f"成功保存短期记忆: end_user_id={end_user_id}, search_switch={search_switch}") else: logger.debug(f"跳过保存短期记忆: summary={summary[:50] if summary else 'None'}, search_switch={search_switch}") @@ -584,7 +584,7 @@ class MemoryAgentService: audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=True, duration=duration ) @@ -596,20 +596,20 @@ class MemoryAgentService: except Exception as e: # Ensure proper error handling and logging error_msg = f"Read operation failed: {str(e)}" - total_time = time.time() - start_time - logger.error(f"[PERF] read_memory failed after {total_time:.4f}s: {error_msg}") + logger.error(error_msg) if audit_logger: duration = time.time() - start_time audit_logger.log_operation( operation="READ", config_id=config_id, - group_id=group_id, + end_user_id=end_user_id, success=False, duration=duration, error=error_msg ) raise ValueError(error_msg) + def get_messages_list(self, user_input: Write_UserInput) -> list[dict]: """ Get standardized message list from user input. @@ -654,7 +654,7 @@ class MemoryAgentService: logger.info(f"Validation successful: Structured message list, count: {len(user_input.messages)}") return user_input.messages - async def classify_message_type(self, message: str, config_id: int, db: Session) -> Dict: + async def classify_message_type(self, message: str, config_id: UUID, db: Session) -> Dict: """ Determine the type of user message (read or write) Updated to eliminate global variables in favor of explicit parameters. @@ -681,10 +681,9 @@ class MemoryAgentService: status = await status_typle(message, memory_config.llm_model_id) logger.debug(f"Message type: {status}") return status - async def generate_summary_from_retrieve( self, - group_id: str, + end_user_id: str, retrieve_info: str, history: List[Dict], query: str, @@ -708,16 +707,16 @@ class MemoryAgentService: """ if config_id is None: try: - config_id = get_end_user_connected_config(group_id, db) + config_id = get_end_user_connected_config(end_user_id, db) config_id = config_id.get('memory_config_id') if config_id is None: raise ValueError( - f"No memory configuration found for end_user {group_id}. Please ensure the user has a connected memory configuration.") + f"No memory configuration found for end_user {end_user_id}. Please ensure the user has a connected memory configuration.") except Exception as e: if "No memory configuration found" in str(e): raise # Re-raise our specific error - logger.error(f"Failed to get connected config for end_user {group_id}: {e}") - raise ValueError(f"Unable to determine memory configuration for end_user {group_id}: {e}") + logger.error(f"Failed to get connected config for end_user {end_user_id}: {e}") + raise ValueError(f"Unable to determine memory configuration for end_user {end_user_id}: {e}") logger.info(f"Generating summary from retrieve info for query: {query[:50]}...") try: @@ -727,6 +726,7 @@ class MemoryAgentService: config_id=config_id, service_name="MemoryAgentService" ) + # 导入必要的模块 from app.core.memory.agent.langgraph_graph.nodes.summary_nodes import summary_llm from app.core.memory.agent.models.summary_models import RetrieveSummaryResponse @@ -766,7 +766,7 @@ class MemoryAgentService: """ 统计知识库类型分布,包含: 1. PostgreSQL 中的知识库类型:General, Web, Third-party, Folder(根据 workspace_id 过滤) - 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/group_id 过滤) + 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) 3. total: 所有类型的总和 参数: @@ -852,11 +852,11 @@ class MemoryAgentService: for end_user in end_users: end_user_id_str = str(end_user.id) memory_query = """ - MATCH (n:Chunk) WHERE n.group_id = $group_id RETURN count(n) AS Count + MATCH (n:Chunk) WHERE n.end_user_id = $end_user_id RETURN count(n) AS Count """ neo4j_result = await _neo4j_connector.execute_query( memory_query, - group_id=end_user_id_str, + end_user_id=end_user_id_str, ) chunk_count = neo4j_result[0]["Count"] if neo4j_result else 0 total_chunks += chunk_count @@ -896,7 +896,7 @@ class MemoryAgentService: 获取指定用户的热门记忆标签 参数: - - end_user_id: 用户ID(可选),对应Neo4j中的group_id字段 + - end_user_id: 用户ID(可选),对应Neo4j中的end_user_id字段 - limit: 返回标签数量限制 返回格式: @@ -906,7 +906,7 @@ class MemoryAgentService: ] """ try: - # by_user=False 表示按 group_id 查询(在Neo4j中,group_id就是用户维度) + # by_user=False 表示按 end_user_id 查询(在Neo4j中,end_user_id就是用户维度) tags = await get_hot_memory_tags(end_user_id, limit=limit, by_user=False) payload=[] for tag, freq in tags: @@ -981,21 +981,21 @@ class MemoryAgentService: # 查询该用户的语句 query = ( "MATCH (s:Statement) " - "WHERE ($group_id IS NULL OR s.group_id = $group_id) AND s.statement IS NOT NULL " + "WHERE ($end_user_id IS NULL OR s.end_user_id = $end_user_id) AND s.statement IS NOT NULL " "RETURN s.statement AS statement " "ORDER BY s.created_at DESC LIMIT 100" ) - rows = await connector.execute_query(query, group_id=end_user_id) + rows = await connector.execute_query(query, end_user_id=end_user_id) statements = [r.get("statement", "") for r in rows if r.get("statement")] # 查询该用户的热门实体 entity_query = ( "MATCH (e:ExtractedEntity) " - "WHERE ($group_id IS NULL OR e.group_id = $group_id) AND e.entity_type <> '人物' AND e.name IS NOT NULL " + "WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) AND e.entity_type <> '人物' AND e.name IS NOT NULL " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC LIMIT 20" ) - entity_rows = await connector.execute_query(entity_query, group_id=end_user_id) + entity_rows = await connector.execute_query(entity_query, end_user_id=end_user_id) entities = [f"{r['name']} ({r['frequency']})" for r in entity_rows] await connector.close() @@ -1048,14 +1048,14 @@ class MemoryAgentService: names_to_exclude = ['AI', 'Caroline', 'Melanie', 'Jon', 'Gina', '用户', 'AI助手', 'John', 'Maria'] hot_tag_query = ( "MATCH (e:ExtractedEntity) " - "WHERE ($group_id IS NULL OR e.group_id = $group_id) AND e.entity_type <> '人物' " + "WHERE ($end_user_id IS NULL OR e.end_user_id = $end_user_id) AND e.entity_type <> '人物' " "AND e.name IS NOT NULL AND NOT e.name IN $names_to_exclude " "RETURN e.name AS name, count(e) AS frequency " "ORDER BY frequency DESC LIMIT 4" ) hot_tag_rows = await connector.execute_query( hot_tag_query, - group_id=end_user_id, + end_user_id=end_user_id, names_to_exclude=names_to_exclude ) await connector.close() @@ -1189,6 +1189,16 @@ def get_end_user_connected_config(end_user_id: str, db: Session) -> Dict[str, An # 3. 从 config 中提取 memory_config_id config = latest_release.config or {} + + # 如果 config 是字符串,解析为字典 + if isinstance(config, str): + import json + try: + config = json.loads(config) + except json.JSONDecodeError: + logger.warning(f"Failed to parse config JSON for release {latest_release.id}") + config = {} + memory_obj = config.get('memory', {}) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None @@ -1227,7 +1237,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) """ from app.models.app_release_model import AppRelease from app.models.end_user_model import EndUser - from app.models.data_config_model import DataConfig + from app.models.memory_config_model import MemoryConfig from sqlalchemy import select logger.info(f"Batch getting connected configs for {len(end_user_ids)} end_users") @@ -1240,10 +1250,10 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 1. 批量查询所有 end_user 及其 app_id end_users = db.query(EndUser).filter(EndUser.id.in_(end_user_ids)).all() - + # 创建 end_user_id -> app_id 的映射 user_to_app = {str(eu.id): eu.app_id for eu in end_users} - + # 记录未找到的用户 found_user_ids = set(user_to_app.keys()) missing_user_ids = set(end_user_ids) - found_user_ids @@ -1285,13 +1295,13 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 批量查询 memory_config_name config_id_to_name = {} if memory_config_ids: - memory_configs = db.query(DataConfig).filter(DataConfig.config_id.in_(memory_config_ids)).all() - config_id_to_name = {str(mc.config_id): mc.config_name for mc in memory_configs} + memory_configs = db.query(MemoryConfig).filter(MemoryConfig.id.in_(memory_config_ids)).all() + config_id_to_name = {str(mc.id): mc.config_name for mc in memory_configs} # 4. 构建最终结果 for end_user_id, app_id in user_to_app.items(): release = app_to_release.get(app_id) - + if not release: logger.warning(f"No active release found for app: {app_id} (end_user: {end_user_id})") result[end_user_id] = {"memory_config_id": None, "memory_config_name": None} @@ -1303,7 +1313,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None # 获取配置名称 - memory_config_name = config_id_to_name.get(str(memory_config_id)) if memory_config_id else None + memory_config_name = config_id_to_name.get(memory_config_id) if memory_config_id else None result[end_user_id] = { "memory_config_id": memory_config_id, diff --git a/api/app/services/memory_api_service.py b/api/app/services/memory_api_service.py index 2d3d047e..a8c39a5a 100644 --- a/api/app/services/memory_api_service.py +++ b/api/app/services/memory_api_service.py @@ -25,7 +25,7 @@ class MemoryAPIService: This service provides a thin layer that: 1. Validates end_user exists and belongs to the authorized workspace - 2. Maps end_user_id to group_id for memory operations + 2. Maps end_user_id to end_user_id for memory operations 3. Delegates to MemoryAgentService for actual memory read/write operations """ @@ -68,7 +68,7 @@ class MemoryAPIService: ) end_user = self.db.query(EndUser).filter(EndUser.id == end_user_uuid).first() - + if not end_user: logger.warning(f"End user not found: {end_user_id}") raise ResourceNotFoundException( @@ -118,7 +118,7 @@ class MemoryAPIService: Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as group_id) + end_user_id: End user identifier (used as end_user_id) message: Message content to store config_id: Optional memory configuration ID storage_type: Storage backend (neo4j or rag) @@ -136,14 +136,13 @@ class MemoryAPIService: # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) - # Use end_user_id as group_id for memory operations - group_id = end_user_id + # Use end_user_id as end_user_id for memory operations try: # Delegate to MemoryAgentService result = await MemoryAgentService().write_memory( - group_id=group_id, - message=message, + end_user_id=end_user_id, + messages=message, config_id=config_id, db=self.db, storage_type=storage_type, @@ -189,7 +188,7 @@ class MemoryAPIService: Args: workspace_id: Workspace ID for resource validation - end_user_id: End user identifier (used as group_id) + end_user_id: End user identifier (used as end_user_id) message: Query message search_switch: Search mode (0=deep search with verification, 1=deep search, 2=fast search) config_id: Optional memory configuration ID @@ -208,13 +207,13 @@ class MemoryAPIService: # Validate end_user exists and belongs to workspace self.validate_end_user(end_user_id, workspace_id) - # Use end_user_id as group_id for memory operations - group_id = end_user_id + # Use end_user_id as end_user_id for memory operations + try: # Delegate to MemoryAgentService result = await MemoryAgentService().read_memory( - group_id=group_id, + end_user_id=end_user_id, message=message, history=[], search_switch=search_switch, diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index 25a8281d..bc647752 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -326,7 +326,7 @@ class MemoryBaseService: Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 最大emotion_intensity对应的emotion_type,如果没有则返回None @@ -334,7 +334,7 @@ class MemoryBaseService: try: query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) WHERE stmt.emotion_type IS NOT NULL AND stmt.emotion_intensity IS NOT NULL @@ -347,7 +347,7 @@ class MemoryBaseService: result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) if result and len(result) > 0: @@ -381,10 +381,10 @@ class MemoryBaseService: if end_user_id: query = """ MATCH (n:MemorySummary) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await self.neo4j_connector.execute_query(query, group_id=end_user_id) + result = await self.neo4j_connector.execute_query(query, end_user_id=end_user_id) else: query = """ MATCH (n:MemorySummary) @@ -423,12 +423,12 @@ class MemoryBaseService: if end_user_id: semantic_query = """ MATCH (e:ExtractedEntity) - WHERE e.group_id = $group_id AND e.is_explicit_memory = true + WHERE e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN count(e) as count """ semantic_result = await self.neo4j_connector.execute_query( semantic_query, - group_id=end_user_id + end_user_id=end_user_id ) else: semantic_query = """ @@ -519,7 +519,7 @@ class MemoryBaseService: """ if end_user_id: - query += " AND n.group_id = $group_id" + query += " AND n.end_user_id = $end_user_id" query += """ RETURN sum(CASE WHEN n.activation_value IS NOT NULL AND n.activation_value < $threshold THEN 1 ELSE 0 END) as low_activation_nodes @@ -528,7 +528,7 @@ class MemoryBaseService: # 设置查询参数 params = {'threshold': forgetting_threshold} if end_user_id: - params['group_id'] = end_user_id + params['end_user_id'] = end_user_id # 执行查询 result = await self.neo4j_connector.execute_query(query, **params) diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index 0099eb18..e901d65d 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -14,7 +14,7 @@ from app.core.validators.memory_config_validators import ( validate_embedding_model, validate_model_exists_and_active, ) -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.schemas.memory_config_schema import ( ConfigurationError, InvalidConfigError, @@ -23,20 +23,24 @@ from app.schemas.memory_config_schema import ( ModelNotFoundError, ) from sqlalchemy.orm import Session +from uuid import UUID logger = get_logger(__name__) config_logger = get_config_logger() - +import uuid def _validate_config_id(config_id): - """Validate configuration ID format.""" + """Validate configuration ID format (supports both UUID and integer).""" + if isinstance(config_id, uuid.UUID): + return config_id + if config_id is None: raise InvalidConfigError( "Configuration ID cannot be None", field_name="config_id", invalid_value=config_id, ) - + if isinstance(config_id, int): if config_id <= 0: raise InvalidConfigError( @@ -45,10 +49,19 @@ def _validate_config_id(config_id): invalid_value=config_id, ) return config_id - + if isinstance(config_id, str): + config_id_stripped = config_id.strip() + + # Try parsing as UUID first try: - parsed_id = int(config_id.strip()) + return uuid.UUID(config_id_stripped) + except ValueError: + pass + + # Fall back to integer parsing + try: + parsed_id = config_id_stripped if parsed_id <= 0: raise InvalidConfigError( f"Configuration ID must be positive: {parsed_id}", @@ -58,13 +71,13 @@ def _validate_config_id(config_id): return parsed_id except ValueError: raise InvalidConfigError( - f"Invalid configuration ID format: '{config_id}'", + f"Invalid configuration ID format: '{config_id}' (must be UUID or positive integer)", field_name="config_id", invalid_value=config_id, ) - + raise InvalidConfigError( - f"Invalid type for configuration ID: expected int or str, got {type(config_id).__name__}", + f"Invalid type for configuration ID: expected UUID, int or str, got {type(config_id).__name__}", field_name="config_id", invalid_value=config_id, ) @@ -73,61 +86,61 @@ def _validate_config_id(config_id): class MemoryConfigService: """ Centralized service for memory configuration loading and validation. - + This class provides a single implementation of configuration loading logic that can be shared across multiple services, eliminating code duplication. - + Usage: config_service = MemoryConfigService(db) memory_config = config_service.load_memory_config(config_id) model_config = config_service.get_model_config(model_id) """ - + def __init__(self, db: Session): """Initialize the service with a database session. - + Args: db: SQLAlchemy database session """ self.db = db - + def load_memory_config( self, - config_id: int, + config_id: UUID, service_name: str = "MemoryConfigService", ) -> MemoryConfig: """ Load memory configuration from database by config_id. - + Args: - config_id: Configuration ID from database + config_id: Configuration ID (UUID) from database service_name: Name of the calling service (for logging purposes) - + Returns: MemoryConfig: Immutable configuration object - + Raises: ConfigurationError: If validation fails """ start_time = time.time() - + config_logger.info( "Starting memory configuration loading", extra={ "operation": "load_memory_config", "service": service_name, - "config_id": config_id, + "config_id": str(config_id), }, ) - + logger.info(f"Loading memory configuration from database: config_id={config_id}") - + try: validated_config_id = _validate_config_id(config_id) - + # Step 1: Get config and workspace db_query_start = time.time() - result = DataConfigRepository.get_config_with_workspace(self.db, validated_config_id) + result = MemoryConfigRepository.get_config_with_workspace(self.db, validated_config_id) db_query_time = time.time() - db_query_start logger.info(f"[PERF] Config+Workspace query: {db_query_time:.4f}s") if not result: @@ -136,18 +149,18 @@ class MemoryConfigService: "Configuration not found in database", extra={ "operation": "load_memory_config", - "config_id": validated_config_id, + "config_id": str(config_id), "load_result": "not_found", "elapsed_ms": elapsed_ms, "service": service_name, }, ) raise ConfigurationError( - f"Configuration {validated_config_id} not found in database" + f"Configuration {config_id} not found in database" ) - + memory_config, workspace = result - + # Step 2: Validate embedding model (returns both UUID and name) embed_start = time.time() embedding_uuid, embedding_name = validate_embedding_model( @@ -159,7 +172,7 @@ class MemoryConfigService: ) embed_time = time.time() - embed_start logger.info(f"[PERF] Embedding validation: {embed_time:.4f}s") - + # Step 3: Resolve LLM model llm_start = time.time() llm_uuid, llm_name = validate_and_resolve_model_id( @@ -173,7 +186,7 @@ class MemoryConfigService: ) llm_time = time.time() - llm_start logger.info(f"[PERF] LLM validation: {llm_time:.4f}s") - + # Step 4: Resolve optional rerank model rerank_start = time.time() rerank_uuid = None @@ -191,10 +204,10 @@ class MemoryConfigService: rerank_time = time.time() - rerank_start if memory_config.rerank_id: logger.info(f"[PERF] Rerank validation: {rerank_time:.4f}s") - + # Note: embedding_name is now returned from validate_embedding_model above # No need for redundant query! - + # Create immutable MemoryConfig object config = MemoryConfig( config_id=memory_config.config_id, @@ -235,9 +248,9 @@ class MemoryConfigService: pruning_scene=memory_config.pruning_scene or "education", pruning_threshold=float(memory_config.pruning_threshold) if memory_config.pruning_threshold is not None else 0.5, ) - + elapsed_ms = (time.time() - start_time) * 1000 - + config_logger.info( "Memory configuration loaded successfully", extra={ @@ -250,13 +263,13 @@ class MemoryConfigService: "elapsed_ms": elapsed_ms, }, ) - + logger.info(f"Memory configuration loaded successfully: {config.config_name}") return config - + except Exception as e: elapsed_ms = (time.time() - start_time) * 1000 - + config_logger.error( "Failed to load memory configuration", extra={ @@ -270,7 +283,7 @@ class MemoryConfigService: }, exc_info=True, ) - + logger.error(f"Failed to load memory configuration {config_id}: {e}") if isinstance(e, (ConfigurationError, ValueError)): raise diff --git a/api/app/services/memory_entity_relationship_service.py b/api/app/services/memory_entity_relationship_service.py index 9b5f3c99..7081d28b 100644 --- a/api/app/services/memory_entity_relationship_service.py +++ b/api/app/services/memory_entity_relationship_service.py @@ -717,8 +717,8 @@ class MemoryInteraction: ori_data= await self.connector.execute_query(Memory_Space_Entity, id=self.id) if ori_data!=[]: # name = ori_data[0]['name'] - group_id = [i['group_id'] for i in ori_data][0] - Space_User = await self.connector.execute_query(Memory_Space_User, group_id=group_id) + end_user_id = [i['end_user_id'] for i in ori_data][0] + Space_User = await self.connector.execute_query(Memory_Space_User, end_user_id=end_user_id) if not Space_User: return [] user_id=Space_User[0]['id'] diff --git a/api/app/services/memory_episodic_service.py b/api/app/services/memory_episodic_service.py index 12eeff6e..08751fd1 100644 --- a/api/app/services/memory_episodic_service.py +++ b/api/app/services/memory_episodic_service.py @@ -34,7 +34,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: (标题, 类型)元组,如果不存在则返回默认值 @@ -43,14 +43,14 @@ class MemoryEpisodicService(MemoryBaseService): # 查询Summary节点的name(作为title)和memory_type(作为type) query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id RETURN s.name AS title, s.memory_type AS type """ result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) if not result or len(result) == 0: @@ -77,7 +77,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 前3个实体的name属性列表 @@ -87,7 +87,7 @@ class MemoryEpisodicService(MemoryBaseService): # 按activation_value降序排序,返回前3个 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) MATCH (stmt)-[:REFERENCES_ENTITY]->(entity:ExtractedEntity) WHERE entity.activation_value IS NOT NULL @@ -99,7 +99,7 @@ class MemoryEpisodicService(MemoryBaseService): result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 提取实体名称 @@ -123,7 +123,7 @@ class MemoryEpisodicService(MemoryBaseService): Args: summary_id: Summary节点的ID - end_user_id: 终端用户ID (group_id) + end_user_id: 终端用户ID (end_user_id) Returns: 所有Statement节点的statement属性内容列表 @@ -132,7 +132,7 @@ class MemoryEpisodicService(MemoryBaseService): # 查询Summary节点指向的所有Statement节点 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id MATCH (s)-[:DERIVED_FROM_STATEMENT]->(stmt:Statement) WHERE stmt.statement IS NOT NULL AND stmt.statement <> '' RETURN stmt.statement AS statement @@ -141,7 +141,7 @@ class MemoryEpisodicService(MemoryBaseService): result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 提取statement内容 @@ -214,12 +214,12 @@ class MemoryEpisodicService(MemoryBaseService): # 1. 先查询所有情景记忆的总数(不受筛选条件限制) total_all_query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id RETURN count(s) AS total_all """ total_all_result = await self.neo4j_connector.execute_query( total_all_query, - group_id=end_user_id + end_user_id=end_user_id ) total_all = total_all_result[0]["total_all"] if total_all_result else 0 @@ -229,7 +229,7 @@ class MemoryEpisodicService(MemoryBaseService): # 3. 构建Cypher查询 query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id """ # 添加时间范围过滤 @@ -248,7 +248,7 @@ class MemoryEpisodicService(MemoryBaseService): ORDER BY s.created_at DESC """ - params = {"group_id": end_user_id} + params = {"end_user_id": end_user_id} if time_filter: params["time_filter"] = time_filter if title_keyword: @@ -333,14 +333,14 @@ class MemoryEpisodicService(MemoryBaseService): # 1. 查询指定的MemorySummary节点 query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $summary_id AND s.group_id = $group_id + WHERE elementId(s) = $summary_id AND s.end_user_id = $end_user_id RETURN elementId(s) AS id, s.created_at AS created_at """ result = await self.neo4j_connector.execute_query( query, summary_id=summary_id, - group_id=end_user_id + end_user_id=end_user_id ) # 2. 如果节点不存在,返回错误 diff --git a/api/app/services/memory_explicit_service.py b/api/app/services/memory_explicit_service.py index 713215c3..f8d39ae8 100644 --- a/api/app/services/memory_explicit_service.py +++ b/api/app/services/memory_explicit_service.py @@ -60,7 +60,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 1. 查询情景记忆(MemorySummary节点) ========== episodic_query = """ MATCH (s:MemorySummary) - WHERE s.group_id = $group_id + WHERE s.end_user_id = $end_user_id RETURN elementId(s) AS id, s.name AS title, s.content AS content, @@ -70,7 +70,7 @@ class MemoryExplicitService(MemoryBaseService): episodic_result = await self.neo4j_connector.execute_query( episodic_query, - group_id=end_user_id + end_user_id=end_user_id ) # 处理情景记忆数据 @@ -96,7 +96,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 2. 查询语义记忆(ExtractedEntity节点) ========== semantic_query = """ MATCH (e:ExtractedEntity) - WHERE e.group_id = $group_id + WHERE e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN elementId(e) AS id, e.name AS name, @@ -107,7 +107,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_result = await self.neo4j_connector.execute_query( semantic_query, - group_id=end_user_id + end_user_id=end_user_id ) # 处理语义记忆数据 @@ -189,7 +189,7 @@ class MemoryExplicitService(MemoryBaseService): # ========== 1. 先尝试查询情景记忆 ========== episodic_query = """ MATCH (s:MemorySummary) - WHERE elementId(s) = $memory_id AND s.group_id = $group_id + WHERE elementId(s) = $memory_id AND s.end_user_id = $end_user_id RETURN s.name AS title, s.content AS content, s.created_at AS created_at @@ -198,7 +198,7 @@ class MemoryExplicitService(MemoryBaseService): episodic_result = await self.neo4j_connector.execute_query( episodic_query, memory_id=memory_id, - group_id=end_user_id + end_user_id=end_user_id ) if episodic_result and len(episodic_result) > 0: @@ -229,7 +229,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_query = """ MATCH (e:ExtractedEntity) WHERE elementId(e) = $memory_id - AND e.group_id = $group_id + AND e.end_user_id = $end_user_id AND e.is_explicit_memory = true RETURN e.name AS name, e.description AS core_definition, @@ -240,7 +240,7 @@ class MemoryExplicitService(MemoryBaseService): semantic_result = await self.neo4j_connector.execute_query( semantic_query, memory_id=memory_id, - group_id=end_user_id + end_user_id=end_user_id ) if semantic_result and len(semantic_result) > 0: diff --git a/api/app/services/memory_forget_service.py b/api/app/services/memory_forget_service.py index 2db4cdc7..e1030b24 100644 --- a/api/app/services/memory_forget_service.py +++ b/api/app/services/memory_forget_service.py @@ -12,6 +12,7 @@ from typing import Optional, Dict, Any, Tuple from datetime import datetime, timezone +from uuid import UUID from sqlalchemy.orm import Session @@ -23,7 +24,7 @@ from app.core.memory.storage_services.forgetting_engine.config_utils import ( load_actr_config_from_db, ) from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.forgetting_cycle_history_repository import ForgettingCycleHistoryRepository @@ -70,7 +71,7 @@ class MemoryForgetService: def __init__(self): """初始化服务""" - self.config_repository = DataConfigRepository() + self.config_repository = MemoryConfigRepository() self.history_repository = ForgettingCycleHistoryRepository() def _get_neo4j_connector(self) -> Neo4jConnector: @@ -87,7 +88,7 @@ class MemoryForgetService: async def _get_forgetting_components( self, db: Session, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Tuple[ACTRCalculator, ForgettingStrategy, ForgettingScheduler, Dict[str, Any]]: """ 获取遗忘引擎组件(计算器、策略、调度器) @@ -132,7 +133,7 @@ class MemoryForgetService: async def _get_knowledge_stats( self, connector: Neo4jConnector, - group_id: Optional[str] = None, + end_user_id: Optional[str] = None, forgetting_threshold: float = 0.3 ) -> Dict[str, Any]: """ @@ -140,7 +141,7 @@ class MemoryForgetService: Args: connector: Neo4j 连接器 - group_id: 组ID(可选) + end_user_id: 组ID(可选) forgetting_threshold: 遗忘阈值 Returns: @@ -152,8 +153,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) """ - if group_id: - query += " AND n.group_id = $group_id" + if end_user_id: + query += " AND n.end_user_id = $end_user_id" query += """ WITH n, @@ -172,8 +173,8 @@ class MemoryForgetService: """ params = {'threshold': forgetting_threshold} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id results = await connector.execute_query(query, **params) @@ -200,7 +201,7 @@ class MemoryForgetService: async def _get_pending_forgetting_nodes( self, connector: Neo4jConnector, - group_id: str, + end_user_id: str, forgetting_threshold: float, min_days_since_access: int, limit: int = 20 @@ -212,7 +213,7 @@ class MemoryForgetService: Args: connector: Neo4j 连接器 - group_id: 组ID + end_user_id: 组ID forgetting_threshold: 遗忘阈值 min_days_since_access: 最小未访问天数 limit: 返回节点数量限制 @@ -229,7 +230,7 @@ class MemoryForgetService: query = """ MATCH (n) WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary) - AND n.group_id = $group_id + AND n.end_user_id = $end_user_id AND n.activation_value IS NOT NULL AND n.activation_value < $threshold AND n.last_access_time IS NOT NULL @@ -250,7 +251,7 @@ class MemoryForgetService: """ params = { - 'group_id': group_id, + 'end_user_id': end_user_id, 'threshold': forgetting_threshold, 'min_access_time_str': min_access_time_str, 'limit': limit @@ -291,10 +292,10 @@ class MemoryForgetService: async def trigger_forgetting_cycle( self, db: Session, - group_id: str, + end_user_id: str, max_merge_batch_size: Optional[int] = None, min_days_since_access: Optional[int] = None, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 手动触发遗忘周期 @@ -303,10 +304,10 @@ class MemoryForgetService: Args: db: 数据库会话 - group_id: 组ID(即终端用户ID,必填) + end_user_id: 组ID(即终端用户ID,必填) max_merge_batch_size: 最大融合批次大小(可选) min_days_since_access: 最小未访问天数(可选) - config_id: 配置ID(必填,由控制器层通过 group_id 获取) + config_id: 配置ID(必填,由控制器层通过 end_user_id 获取) Returns: dict: 遗忘报告 @@ -319,7 +320,7 @@ class MemoryForgetService: # 运行遗忘周期(LLM 客户端将在需要时由 forgetting_strategy 内部获取) report = await forgetting_scheduler.run_forgetting_cycle( - group_id=group_id, + end_user_id=end_user_id, max_merge_batch_size=max_merge_batch_size, min_days_since_access=min_days_since_access, config_id=config_id, @@ -338,7 +339,7 @@ class MemoryForgetService: stats_query = """ MATCH (n) WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) - AND n.group_id = $group_id + AND n.end_user_id = $end_user_id RETURN count(n) as total_nodes, avg(n.activation_value) as average_activation, @@ -347,7 +348,7 @@ class MemoryForgetService: stats_results = await connector.execute_query( stats_query, - group_id=group_id, + end_user_id=end_user_id, threshold=config['forgetting_threshold'] ) @@ -364,7 +365,7 @@ class MemoryForgetService: # 保存历史记录到数据库 self.history_repository.create( db=db, - end_user_id=group_id, + end_user_id=end_user_id, execution_time=execution_time, merged_count=report['merged_count'], failed_count=report['failed_count'], @@ -376,7 +377,7 @@ class MemoryForgetService: ) api_logger.info( - f"已保存遗忘周期历史记录: end_user_id={group_id}, " + f"已保存遗忘周期历史记录: end_user_id={end_user_id}, " f"merged_count={report['merged_count']}" ) @@ -389,7 +390,7 @@ class MemoryForgetService: def read_forgetting_config( self, db: Session, - config_id: int + config_id: UUID ) -> Dict[str, Any]: """ 获取遗忘引擎配置 @@ -416,7 +417,7 @@ class MemoryForgetService: def update_forgetting_config( self, db: Session, - config_id: int, + config_id: UUID, update_fields: Dict[str, Any] ) -> Dict[str, Any]: """ @@ -465,8 +466,8 @@ class MemoryForgetService: async def get_forgetting_stats( self, db: Session, - group_id: Optional[str] = None, - config_id: Optional[int] = None + end_user_id: Optional[str] = None, + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 获取遗忘引擎统计信息 @@ -475,7 +476,7 @@ class MemoryForgetService: Args: db: 数据库会话 - group_id: 组ID(可选) + end_user_id: 组ID(可选) config_id: 配置ID(可选,用于获取遗忘阈值) Returns: @@ -493,8 +494,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) """ - if group_id: - activation_query += " AND n.group_id = $group_id" + if end_user_id: + activation_query += " AND n.end_user_id = $end_user_id" activation_query += """ RETURN @@ -506,8 +507,8 @@ class MemoryForgetService: """ params = {'threshold': forgetting_threshold} - if group_id: - params['group_id'] = group_id + if end_user_id: + params['end_user_id'] = end_user_id activation_results = await connector.execute_query(activation_query, **params) @@ -539,8 +540,8 @@ class MemoryForgetService: WHERE (n:Statement OR n:ExtractedEntity OR n:MemorySummary OR n:Chunk) """ - if group_id: - distribution_query += " AND n.group_id = $group_id" + if end_user_id: + distribution_query += " AND n.end_user_id = $end_user_id" distribution_query += """ WITH n, @@ -558,8 +559,8 @@ class MemoryForgetService: """ dist_params = {} - if group_id: - dist_params['group_id'] = group_id + if end_user_id: + dist_params['end_user_id'] = end_user_id distribution_results = await connector.execute_query(distribution_query, **dist_params) @@ -582,11 +583,11 @@ class MemoryForgetService: # 获取最近7个日期的历史趋势数据(每天取最后一次执行) recent_trends = [] try: - if group_id: + if end_user_id: # 查询所有历史记录 history_records = self.history_repository.get_recent_by_end_user( db=db, - end_user_id=group_id + end_user_id=end_user_id ) # 按日期分组(一天可能有多次执行,取最后一次) @@ -632,7 +633,7 @@ class MemoryForgetService: # 获取待遗忘节点列表(前20个满足遗忘条件的节点) pending_nodes = [] try: - if group_id: + if end_user_id: # 验证 min_days_since_access 配置值 min_days = config.get('min_days_since_access') if min_days is None or not isinstance(min_days, (int, float)) or min_days < 0: @@ -643,7 +644,7 @@ class MemoryForgetService: pending_nodes = await self._get_pending_forgetting_nodes( connector=connector, - group_id=group_id, + end_user_id=end_user_id, forgetting_threshold=forgetting_threshold, min_days_since_access=int(min_days), limit=20 @@ -677,7 +678,7 @@ class MemoryForgetService: db: Session, importance_score: float, days: int, - config_id: Optional[int] = None + config_id: Optional[UUID] = None ) -> Dict[str, Any]: """ 获取遗忘曲线数据 diff --git a/api/app/services/memory_konwledges_server.py b/api/app/services/memory_konwledges_server.py index c6297e12..420f7ca1 100644 --- a/api/app/services/memory_konwledges_server.py +++ b/api/app/services/memory_konwledges_server.py @@ -450,12 +450,12 @@ async def create_document_chunk( return success(data=chunk, msg="文档块创建成功") -async def write_rag(group_id, message, user_rag_memory_id): +async def write_rag(end_user_id, message, user_rag_memory_id): """ 将消息写入 RAG 知识库 Args: - group_id: 组ID,用作文件标题 + end_user_id: 组ID,用作文件标题 message: 消息内容 user_rag_memory_id: 知识库ID(必须是有效的UUID) @@ -487,10 +487,10 @@ async def write_rag(group_id, message, user_rag_memory_id): db = next(db_gen) try: - create_data = CustomTextFileCreate(title=group_id, content=message) + create_data = CustomTextFileCreate(title=end_user_id, content=message) current_user = SimpleUser(user_rag_memory_id) # 检查文档是否已存在 - document = find_document_id_by_kb_and_filename(db=db, kb_id=user_rag_memory_id, file_name=f"{group_id}.txt") + document = find_document_id_by_kb_and_filename(db=db, kb_id=user_rag_memory_id, file_name=f"{end_user_id}.txt") print('======',document) api_logger.info(f"查找文档结果: document_id={document}") if document is not None: @@ -508,7 +508,7 @@ async def write_rag(group_id, message, user_rag_memory_id): return result else: # 文档不存在,创建新文档 - api_logger.info(f"文档不存在,创建新文档: group_id={group_id}") + api_logger.info(f"文档不存在,创建新文档: end_user_id={end_user_id}") result = await memory_konwledges_up( kb_id=user_rag_memory_id, parent_id=user_rag_memory_id, @@ -520,13 +520,13 @@ async def write_rag(group_id, message, user_rag_memory_id): new_document_id = find_document_id_by_kb_and_filename( db=db, kb_id=user_rag_memory_id, - file_name=f"{group_id}.txt" + file_name=f"{end_user_id}.txt" ) if new_document_id: await parse_document_by_id(new_document_id, db=db, current_user=current_user) else: - api_logger.error(f"创建文档后无法找到文档ID: group_id={group_id}") + api_logger.error(f"创建文档后无法找到文档ID: end_user_id={end_user_id}") return result finally: # 确保数据库会话被关闭 diff --git a/api/app/services/memory_perceptual_service.py b/api/app/services/memory_perceptual_service.py index d257e80f..b9d96a0b 100644 --- a/api/app/services/memory_perceptual_service.py +++ b/api/app/services/memory_perceptual_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.core.error_codes import BizCode from app.core.exceptions import BusinessException from app.core.logging_config import get_business_logger -from app.models.memory_perceptual_model import PerceptualType, FileStorageType +from app.models.memory_perceptual_model import PerceptualType, FileStorageService from app.repositories.memory_perceptual_repository import MemoryPerceptualRepository from app.schemas.memory_perceptual_schema import ( PerceptualQuerySchema, @@ -137,8 +137,19 @@ class MemoryPerceptualService: memory_items = [] for memory in memories: meta_data = memory.meta_data or {} - content = meta_data.get("content") - content = Content(**content) + content = meta_data.get("content", {}) + + # 安全地提取 content 字段,提供默认值 + if content: + content_obj = Content(**content) + topic = content_obj.topic + domain = content_obj.domain + keywords = content_obj.keywords + else: + topic = "Unknown" + domain = "Unknown" + keywords = [] + memory_item = PerceptualMemoryItem( id=memory.id, perceptual_type=PerceptualType(memory.perceptual_type), @@ -146,11 +157,12 @@ class MemoryPerceptualService: file_name=memory.file_name, file_ext=memory.file_ext, summary=memory.summary, - topic=content.topic, - domain=content.domain, - keywords=content.keywords, + meta_data=meta_data, + topic=topic, + domain=domain, + keywords=keywords, created_time=int(memory.created_time.timestamp()*1000), - storage_type=FileStorageType(memory.storage_service), + storage_service=FileStorageService(memory.storage_service), ) memory_items.append(memory_item) diff --git a/api/app/services/memory_reflection_service.py b/api/app/services/memory_reflection_service.py index af72e3cc..402a40a1 100644 --- a/api/app/services/memory_reflection_service.py +++ b/api/app/services/memory_reflection_service.py @@ -13,7 +13,7 @@ from app.db import get_db from app.core.logging_config import get_api_logger from app.core.memory.storage_services.reflection_engine import ReflectionConfig, ReflectionEngine from app.core.memory.storage_services.reflection_engine.self_reflexion import ReflectionRange, ReflectionBaseline -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.models.app_model import App from app.models.app_release_model import AppRelease @@ -73,7 +73,7 @@ class WorkspaceAppService: "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, "releases": [], - "data_configs": [], + "memory_configs": [], "end_users": [] } @@ -101,11 +101,11 @@ class WorkspaceAppService: if memory_content: processed_configs.add(memory_content) - data_config_info = self._get_data_config(memory_content) + memory_config_info = self._get_memory_config(memory_content) - if data_config_info: - if not any(dc["config_id"] == data_config_info["config_id"] for dc in app_info["data_configs"]): - app_info["data_configs"].append(data_config_info) + if memory_config_info: + if not any(dc["config_id"] == memory_config_info["config_id"] for dc in app_info["memory_configs"]): + app_info["memory_configs"].append(memory_config_info) app_info["releases"].append(release_info) @@ -120,30 +120,30 @@ class WorkspaceAppService: return None - def _get_data_config(self, memory_content: str) -> Dict[str, Any]: - """Retrieve data_comfig information based on memory_comtent""" + def _get_memory_config(self, memory_content: str) -> Dict[str, Any]: + """Retrieve memory_config information based on memory_content""" try: - data_config_result = DataConfigRepository.query_reflection_config_by_id(self.db, int(memory_content)) + memory_config_result = MemoryConfigRepository.query_reflection_config_by_id(self.db, int(memory_content)) - # data_config_query, data_config_params = DataConfigRepository.build_select_reflection(memory_content) - # data_config_result = self.db.execute(text(data_config_query), data_config_params).fetchone() - # if data_config_result is None: + # memory_config_query, memory_config_params = MemoryConfigRepository.build_select_reflection(memory_content) + # memory_config_result = self.db.execute(text(memory_config_query), memory_config_params).fetchone() + # if memory_config_result is None: # return None - if data_config_result: + if memory_config_result: return { - "config_id": data_config_result.config_id, - "enable_self_reflexion": data_config_result.enable_self_reflexion, - "iteration_period": data_config_result.iteration_period, - "reflexion_range": data_config_result.reflexion_range, - "baseline": data_config_result.baseline, - "reflection_model_id": data_config_result.reflection_model_id, - "memory_verify": data_config_result.memory_verify, - "quality_assessment": data_config_result.quality_assessment, - "user_id": data_config_result.user_id + "config_id": memory_config_result.config_id, + "enable_self_reflexion": memory_config_result.enable_self_reflexion, + "iteration_period": memory_config_result.iteration_period, + "reflexion_range": memory_config_result.reflexion_range, + "baseline": memory_config_result.baseline, + "reflection_model_id": memory_config_result.reflection_model_id, + "memory_verify": memory_config_result.memory_verify, + "quality_assessment": memory_config_result.quality_assessment, + "user_id": memory_config_result.user_id } except Exception as e: - api_logger.warning(f"查询data_config失败,memory_content: {memory_content}, 错误: {str(e)}") + api_logger.warning(f"查询memory_config失败,memory_content: {memory_content}, 错误: {str(e)}") return None @@ -226,7 +226,7 @@ class MemoryReflectionService: } config_data_id = config_data['config_id'] - reflection_config = WorkspaceAppService(self.db)._get_data_config(config_data_id) + reflection_config = WorkspaceAppService(self.db)._get_memory_config(config_data_id) if reflection_config is not None and reflection_config['enable_self_reflexion']: reflection_config = self._create_reflection_config_from_data(reflection_config) # 3. 执行反思引擎 @@ -280,7 +280,7 @@ class MemoryReflectionService: config_data_id=config_data['config_id'] - reflection_config=WorkspaceAppService(self.db)._get_data_config(config_data_id) + reflection_config=WorkspaceAppService(self.db)._get_memory_config(config_data_id) if reflection_config is not None and reflection_config['enable_self_reflexion']: reflection_config= self._create_reflection_config_from_data(reflection_config) iteration_period = int(reflection_config.iteration_period) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index c276f337..80d8c717 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -19,7 +19,7 @@ from app.core.memory.analytics.hot_memory_tags import ( ) from app.core.memory.analytics.recent_activity_stats import get_recent_activity_stats from app.models.user_model import User -from app.repositories.data_config_repository import DataConfigRepository +from app.repositories.memory_config_repository import MemoryConfigRepository from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_config_schema import ConfigurationError from app.schemas.memory_storage_schema import ( @@ -129,7 +129,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) if not params.rerank_id: params.rerank_id = configs.get('rerank') - config = DataConfigRepository.create(self.db, params) + config = MemoryConfigRepository.create(self.db, params) self.db.commit() return {"affected": 1, "config_id": config.config_id} @@ -146,20 +146,20 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Delete --- def delete(self, key: ConfigParamsDelete) -> Dict[str, Any]: # 删除配置参数(按配置ID) - success = DataConfigRepository.delete(self.db, key.config_id) + success = MemoryConfigRepository.delete(self.db, key.config_id) if not success: raise ValueError("未找到配置") return {"affected": 1} # --- Update --- def update(self, update: ConfigUpdate) -> Dict[str, Any]: # 部分更新配置参数 - config = DataConfigRepository.update(self.db, update) + config = MemoryConfigRepository.update(self.db, update) if not config: raise ValueError("未找到配置") return {"affected": 1} def update_extracted(self, update: ConfigUpdateExtracted) -> Dict[str, Any]: # 更新记忆萃取引擎配置参数 - config = DataConfigRepository.update_extracted(self.db, update) + config = MemoryConfigRepository.update_extracted(self.db, update) if not config: raise ValueError("未找到配置") return {"affected": 1} @@ -170,14 +170,14 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Read --- def get_extracted(self, key: ConfigKey) -> Dict[str, Any]: # 获取萃取配置参数 - result = DataConfigRepository.get_extracted_config(self.db, key.config_id) + result = MemoryConfigRepository.get_extracted_config(self.db, key.config_id) if not result: raise ValueError("未找到配置") return result # --- Read All --- def get_all(self, workspace_id = None) -> List[Dict[str, Any]]: # 获取所有配置参数 - configs = DataConfigRepository.get_all(self.db, workspace_id) + configs = MemoryConfigRepository.get_all(self.db, workspace_id) # 将 ORM 对象转换为字典列表 data_list = [] @@ -187,7 +187,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, - "group_id": config.group_id, + "end_user_id": config.end_user_id, "user_id": config.user_id, "apply_id": config.apply_id, "llm_id": config.llm_id, @@ -395,8 +395,8 @@ _neo4j_connector = Neo4jConnector() async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_DIALOGUE, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_DIALOGUE, + end_user_id=end_user_id, ) data = {"search_for": "dialogue", "num": result[0]["num"]} return data @@ -404,8 +404,8 @@ async def search_dialogue(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_CHUNK, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_CHUNK, + end_user_id=end_user_id, ) data = {"search_for": "chunk", "num": result[0]["num"]} return data @@ -413,8 +413,8 @@ async def search_chunk(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_STATEMENT, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_STATEMENT, + end_user_id=end_user_id, ) data = {"search_for": "statement", "num": result[0]["num"]} return data @@ -422,8 +422,8 @@ async def search_statement(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ENTITY, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_ENTITY, + end_user_id=end_user_id, ) data = {"search_for": "entity", "num": result[0]["num"]} return data @@ -431,8 +431,8 @@ async def search_entity(end_user_id: Optional[str] = None) -> Dict[str, Any]: async def search_all(end_user_id: Optional[str] = None) -> Dict[str, Any]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ALL, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_ALL, + end_user_id=end_user_id, ) # 检查结果是否为空或长度不足 @@ -466,8 +466,8 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A 聚合 dialogue/chunk/statement/entity 四类计数,返回统一的分布结构,便于前端一次性消费。 """ result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_ALL, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_ALL, + end_user_id=end_user_id, ) # 检查结果是否为空或长度不足 @@ -497,21 +497,19 @@ async def kb_type_distribution(end_user_id: Optional[str] = None) -> Dict[str, A async def search_detials(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_DETIALS, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_DETIALS, + end_user_id=end_user_id, ) return result async def search_edges(end_user_id: Optional[str] = None) -> List[Dict[str, Any]]: result = await _neo4j_connector.execute_query( - DataConfigRepository.SEARCH_FOR_EDGES, - group_id=end_user_id, + MemoryConfigRepository.SEARCH_FOR_EDGES, + end_user_id=end_user_id, ) return result - - async def analytics_hot_memory_tags( db: Session, current_user: User, @@ -574,7 +572,7 @@ async def analytics_hot_memory_tags( # 步骤4: 只调用一次LLM进行筛选 tag_names = [tag for tag, _ in sorted_tags] - # 使用第一个用户的group_id来获取LLM配置 + # 使用第一个用户的end_user_id来获取LLM配置 # 因为同一工作空间下的用户应该使用相同的配置 first_end_user_id = str(end_users[0].id) filtered_tag_names = await filter_tags_with_llm(tag_names, first_end_user_id) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 17dfd7eb..755dda14 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -91,7 +91,7 @@ async def run_pilot_extraction( dialog = DialogData( context=context, ref_id="pilot_dialog_1", - group_id=str(memory_config.workspace_id), + end_user_id=str(memory_config.workspace_id), user_id=str(memory_config.tenant_id), apply_id=str(memory_config.config_id), metadata={"source": "pilot_run", "input_type": "frontend_text"}, diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 863bccb0..3a90a821 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -155,10 +155,10 @@ class MemoryInsightHelper: """ query = """ MATCH (d:Dialogue) - WHERE d.group_id = $group_id AND d.created_at IS NOT NULL AND d.created_at <> '' + WHERE d.end_user_id = $end_user_id AND d.created_at IS NOT NULL AND d.created_at <> '' RETURN d.created_at AS creation_time """ - records = await self.neo4j_connector.execute_query(query, group_id=self.user_id) + records = await self.neo4j_connector.execute_query(query, end_user_id=self.user_id) if not records: return [] @@ -211,17 +211,17 @@ class MemoryInsightHelper: async def get_social_connections(self) -> dict | None: """Find the user with whom the most memories are shared.""" query = """ - MATCH (c1:Chunk {group_id: $group_id}) + MATCH (c1:Chunk {end_user_id: $end_user_id}) OPTIONAL MATCH (c1)-[:CONTAINS]->(s:Statement) OPTIONAL MATCH (s)<-[:CONTAINS]-(c2:Chunk) - WHERE c1.group_id <> c2.group_id AND s IS NOT NULL AND c2 IS NOT NULL - WITH c2.group_id AS other_user_id, COUNT(DISTINCT s) AS common_statements + WHERE c1.end_user_id <> c2.end_user_id AND s IS NOT NULL AND c2 IS NOT NULL + WITH c2.end_user_id AS other_user_id, COUNT(DISTINCT s) AS common_statements WHERE common_statements > 0 RETURN other_user_id, common_statements ORDER BY common_statements DESC LIMIT 1 """ - records = await self.neo4j_connector.execute_query(query, group_id=self.user_id) + records = await self.neo4j_connector.execute_query(query, end_user_id=self.user_id) if not records or not records[0].get("other_user_id"): return None @@ -230,7 +230,7 @@ class MemoryInsightHelper: time_range_query = """ MATCH (c:Chunk) - WHERE c.group_id IN [$user_id, $other_user_id] + WHERE c.end_user_id IN [$user_id, $other_user_id] RETURN min(c.created_at) AS start_time, max(c.created_at) AS end_time """ time_records = await self.neo4j_connector.execute_query( @@ -294,11 +294,11 @@ class UserSummaryHelper: """Fetch recent statements authored by the user/group for context.""" query = ( "MATCH (s:Statement) " - "WHERE s.group_id = $group_id AND s.statement IS NOT NULL " + "WHERE s.end_user_id = $end_user_id AND s.statement IS NOT NULL " "RETURN s.statement AS statement, s.created_at AS created_at " "ORDER BY created_at DESC LIMIT $limit" ) - rows = await self.connector.execute_query(query, group_id=self.user_id, limit=limit) + rows = await self.connector.execute_query(query, end_user_id=self.user_id, limit=limit) records = [] for r in rows: try: @@ -1152,7 +1152,7 @@ async def analytics_user_summary(end_user_id: Optional[str] = None) -> Dict[str, import re # 创建 UserSummaryHelper 实例 - user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_GROUP_ID", "group_123")) + user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_end_user_id", "group_123")) try: # 1) 收集上下文数据 @@ -1273,10 +1273,10 @@ async def analytics_node_statistics( if end_user_id: query = f""" MATCH (n:{node_type}) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await _neo4j_connector.execute_query(query, group_id=end_user_id) + result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) else: query = f""" MATCH (n:{node_type}) @@ -1387,10 +1387,10 @@ async def analytics_memory_types( # 查询 Statement 节点数量 query = """ MATCH (n:Statement) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN count(n) as count """ - result = await _neo4j_connector.execute_query(query, group_id=end_user_id) + result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) statement_count = result[0]["count"] if result and len(result) > 0 else 0 # 取三分之一作为隐性记忆数量 implicit_count = round(statement_count / 3) @@ -1504,7 +1504,7 @@ async def analytics_graph_data( 包含节点、边和统计信息的字典 """ try: - # 1. 获取 group_id + # 1. 获取 end_user_id user_uuid = uuid.UUID(end_user_id) repo = EndUserRepository(db) end_user = repo.get_by_id(user_uuid) @@ -1528,7 +1528,7 @@ async def analytics_graph_data( # 基于中心节点的扩展查询 node_query = f""" MATCH path = (center)-[*1..{depth}]-(connected) - WHERE center.group_id = $group_id + WHERE center.end_user_id = $end_user_id AND elementId(center) = $center_node_id WITH collect(DISTINCT center) + collect(DISTINCT connected) as all_nodes UNWIND all_nodes as n @@ -1539,7 +1539,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "center_node_id": center_node_id, "limit": limit } @@ -1547,7 +1547,7 @@ async def analytics_graph_data( # 按节点类型过滤查询 node_query = """ MATCH (n) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id AND labels(n)[0] IN $node_types RETURN elementId(n) as id, @@ -1556,7 +1556,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "node_types": node_types, "limit": limit } @@ -1564,7 +1564,7 @@ async def analytics_graph_data( # 查询所有节点 node_query = """ MATCH (n) - WHERE n.group_id = $group_id + WHERE n.end_user_id = $end_user_id RETURN elementId(n) as id, labels(n)[0] as label, @@ -1572,7 +1572,7 @@ async def analytics_graph_data( LIMIT $limit """ node_params = { - "group_id": end_user_id, + "end_user_id": end_user_id, "limit": limit } diff --git a/api/app/tasks.py b/api/app/tasks.py index 5f2b1ef5..cdd7945e 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -4,6 +4,7 @@ import os import re import time import uuid +from uuid import UUID from datetime import datetime, timezone from math import ceil from typing import Any, Dict, List, Optional @@ -382,16 +383,16 @@ def build_graphrag_for_kb(kb_id: uuid.UUID): @celery_app.task(name="app.core.memory.agent.read_message", bind=True) -def read_message_task(self, group_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str,storage_type:str,user_rag_memory_id:str) -> Dict[str, Any]: +def read_message_task(self, end_user_id: str, message: str, history: List[Dict[str, Any]], search_switch: str, config_id: str, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a read message via MemoryAgentService. Args: - group_id: Group ID for the memory agent (also used as end_user_id) + end_user_id: Group ID for the memory agent (also used as end_user_id) message: User message to process history: Conversation history search_switch: Search switch parameter - config_id: Optional configuration ID + config_id: Configuration ID as string (will be converted to UUID) Returns: Dict containing the result and metadata @@ -401,14 +402,22 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, """ start_time = time.time() + # Convert config_id string to UUID + actual_config_id = None + if config_id: + try: + actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id + except (ValueError, AttributeError): + # If conversion fails, leave as None and try to resolve + pass + # Resolve config_id if None - actual_config_id = config_id if actual_config_id is None: try: from app.services.memory_agent_service import get_end_user_connected_config db = next(get_db()) try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) actual_config_id = connected_config.get("memory_config_id") finally: db.close() @@ -420,24 +429,42 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, db = next(get_db()) try: service = MemoryAgentService() - return await service.read_memory(group_id, message, history, search_switch, actual_config_id, db, storage_type, user_rag_memory_id) + return await service.read_memory(end_user_id, message, history, search_switch, actual_config_id, db, storage_type, user_rag_memory_id) finally: db.close() try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time return { "status": "SUCCESS", "result": result, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id } except BaseException as e: elapsed_time = time.time() - start_time + # Handle ExceptionGroup from TaskGroup if hasattr(e, 'exceptions'): error_messages = [f"{type(sub_e).__name__}: {str(sub_e)}" for sub_e in e.exceptions] detailed_error = "; ".join(error_messages) @@ -446,7 +473,7 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, return { "status": "FAILURE", "error": detailed_error, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id @@ -454,19 +481,13 @@ def read_message_task(self, group_id: str, message: str, history: List[Dict[str, @celery_app.task(name="app.core.memory.agent.write_message", bind=True) -def write_message_task(self, group_id: str, message, config_id: str, storage_type: str, user_rag_memory_id: str) -> Dict[str, Any]: +def write_message_task(self, end_user_id: str, message: str, config_id: str, storage_type:str, user_rag_memory_id:str) -> Dict[str, Any]: """Celery task to process a write message via MemoryAgentService. - 支持两种消息格式: - 1. 字符串格式(向后兼容):message="user: xxx\nassistant: yyy" - 2. 结构化消息列表(推荐):message=[{"role": "user", "content": "xxx"}, {"role": "assistant", "content": "yyy"}] - Args: - group_id: Group ID for the memory agent (also used as end_user_id) - message: Message to write (str or list[dict]) - config_id: Optional configuration ID - storage_type: Storage type (neo4j/rag) - user_rag_memory_id: RAG memory ID + end_user_id: Group ID for the memory agent (also used as end_user_id) + message: Message to write + config_id: Configuration ID as string (will be converted to UUID) Returns: Dict containing the result and metadata @@ -477,30 +498,46 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ from app.core.logging_config import get_logger logger = get_logger(__name__) - logger.info(f"[CELERY WRITE] Starting write task - group_id={group_id}, config_id={config_id}, storage_type={storage_type}") + logger.info(f"[CELERY WRITE] Starting write task - end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") start_time = time.time() + # Convert config_id string to UUID + actual_config_id = None + if config_id: + try: + actual_config_id = uuid.UUID(config_id) if isinstance(config_id, str) else config_id + logger.info(f"[CELERY WRITE] Converted config_id to UUID: {actual_config_id} (type: {type(actual_config_id).__name__})") + except (ValueError, AttributeError) as e: + logger.error(f"[CELERY WRITE] Invalid config_id format: {config_id}, error: {e}") + return { + "status": "FAILURE", + "error": f"Invalid config_id format: {config_id}", + "end_user_id": end_user_id, + "config_id": config_id, + "elapsed_time": 0.0, + "task_id": self.request.id + } + # Resolve config_id if None - actual_config_id = config_id if actual_config_id is None: try: from app.services.memory_agent_service import get_end_user_connected_config db = next(get_db()) try: - connected_config = get_end_user_connected_config(group_id, db) + connected_config = get_end_user_connected_config(end_user_id, db) actual_config_id = connected_config.get("memory_config_id") finally: db.close() except Exception: # Log but continue - will fail later with proper error pass - + async def _run() -> str: db = next(get_db()) try: - logger.info(f"[CELERY WRITE] Executing MemoryAgentService.write_memory") + logger.info(f"[CELERY WRITE] Executing MemoryAgentService.write_memory with config_id={actual_config_id} (type: {type(actual_config_id).__name__})") service = MemoryAgentService() - result = await service.write_memory(group_id, message, actual_config_id, db, storage_type, user_rag_memory_id) + result = await service.write_memory(end_user_id, message, actual_config_id, db, storage_type, user_rag_memory_id) logger.info(f"[CELERY WRITE] Write completed successfully: {result}") return result except Exception as e: @@ -510,7 +547,24 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ db.close() try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time logger.info(f"[CELERY WRITE] Task completed successfully - elapsed_time={elapsed_time:.2f}s, task_id={self.request.id}") @@ -518,13 +572,14 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ return { "status": "SUCCESS", "result": result, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id } except BaseException as e: elapsed_time = time.time() - start_time + # Handle ExceptionGroup from TaskGroup if hasattr(e, 'exceptions'): error_messages = [f"{type(sub_e).__name__}: {str(sub_e)}" for sub_e in e.exceptions] detailed_error = "; ".join(error_messages) @@ -536,7 +591,7 @@ def write_message_task(self, group_id: str, message, config_id: str, storage_typ return { "status": "FAILURE", "error": detailed_error, - "group_id": group_id, + "end_user_id": end_user_id, "config_id": config_id, "elapsed_time": elapsed_time, "task_id": self.request.id @@ -878,7 +933,24 @@ def regenerate_memory_cache(self) -> Dict[str, Any]: } try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time result["elapsed_time"] = elapsed_time result["task_id"] = self.request.id @@ -951,7 +1023,7 @@ def workspace_reflection_task(self) -> Dict[str, Any]: end_users = data['end_users'] for base, config, user in zip(releases, data_configs, end_users): - if int(base['config']) == int(config['config_id']) and base['app_id'] == user['app_id']: + if str(base['config']) == str(config['config_id']) and str(base['app_id']) == str(user['app_id']): # 调用反思服务 api_logger.info(f"为用户 {user['id']} 启动反思,config_id: {config['config_id']}") @@ -1005,7 +1077,24 @@ def workspace_reflection_task(self) -> Dict[str, Any]: } try: - result = asyncio.run(_run()) + # 使用 nest_asyncio 来避免事件循环冲突 + try: + import nest_asyncio + nest_asyncio.apply() + except ImportError: + pass + + # 尝试获取现有事件循环,如果不存在则创建新的 + try: + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + result = loop.run_until_complete(_run()) elapsed_time = time.time() - start_time result["elapsed_time"] = elapsed_time result["task_id"] = self.request.id @@ -1023,7 +1112,7 @@ def workspace_reflection_task(self) -> Dict[str, Any]: @celery_app.task(name="app.tasks.run_forgetting_cycle_task", bind=True) -def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str, Any]: +def run_forgetting_cycle_task(self, config_id: Optional[uuid.UUID] = None) -> Dict[str, Any]: """定时任务:运行遗忘周期 定期执行遗忘周期,识别并融合低激活值的知识节点。 @@ -1051,7 +1140,7 @@ def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str # 运行遗忘周期 report = await forget_service.trigger_forgetting( db=db, - group_id=None, # 处理所有组 + end_user_id=None, # 处理所有组 config_id=config_id ) @@ -1081,4 +1170,11 @@ def run_forgetting_cycle_task(self, config_id: Optional[int] = None) -> Dict[str "duration_seconds": duration } - return asyncio.run(_run()) + # 运行异步函数 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(_run()) + return result + finally: + loop.close() diff --git a/api/app/utils/app_config_utils.py b/api/app/utils/app_config_utils.py index 514e4565..ae41d8bf 100644 --- a/api/app/utils/app_config_utils.py +++ b/api/app/utils/app_config_utils.py @@ -83,6 +83,13 @@ class AgentConfigProxy: def agent_config_4_app_release(release: AppRelease) -> AgentConfig: config_dict = release.config + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} agent_config = AgentConfig( app_id=release.app_id, @@ -100,6 +107,14 @@ def agent_config_4_app_release(release: AppRelease) -> AgentConfig: def multi_agent_config_4_app_release(release: AppRelease) -> MultiAgentConfig: config_dict = release.config + + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} agent_config = MultiAgentConfig( app_id=release.app_id, @@ -120,6 +135,14 @@ def multi_agent_config_4_app_release(release: AppRelease) -> MultiAgentConfig: def workflow_config_4_app_release(release: AppRelease) -> WorkflowConfig: config_dict = release.config + + # 如果 config 是字符串,解析为字典 + if isinstance(config_dict, str): + import json + try: + config_dict = json.loads(config_dict) + except json.JSONDecodeError: + config_dict = {} config = WorkflowConfig( id=config_dict.get("id"), diff --git a/api/uv.lock b/api/uv.lock index bccaef2c..f3b23325 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4462,4 +4462,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, -] +] \ No newline at end of file From e3b6ede99240faab54f41dc07aea72971791d385 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 11:54:38 +0800 Subject: [PATCH 062/175] feat(sandbox): add Python 3 code execution sandbox support --- .gitignore | 3 + sandbox/Dockerfile | 42 ++++ sandbox/app/config.py | 134 ++++++++++++ sandbox/app/controllers/__init__.py | 8 + sandbox/app/controllers/health_controller.py | 12 ++ sandbox/app/controllers/sandbox_controller.py | 59 ++++++ sandbox/app/core/__init__.py | 1 + sandbox/app/core/encryption.py | 32 +++ sandbox/app/core/executor.py | 48 +++++ sandbox/app/core/runners/__init__.py | 1 + sandbox/app/core/runners/python/__init__.py | 4 + sandbox/app/core/runners/python/env.py | 50 +++++ sandbox/app/core/runners/python/prescript.py | 56 +++++ .../app/core/runners/python/python_runner.py | 151 ++++++++++++++ sandbox/app/core/runners/python/settings.py | 62 ++++++ sandbox/app/dependencies.py | 161 +++++++++++++++ sandbox/app/logger.py | 42 ++++ sandbox/app/middleware/__init__.py | 1 + sandbox/app/middleware/auth.py | 15 ++ sandbox/app/middleware/concurrency.py | 48 +++++ sandbox/app/models.py | 80 +++++++ sandbox/app/services/__init__.py | 1 + sandbox/app/services/python_service.py | 80 +++++++ sandbox/config.yaml | 20 ++ sandbox/dependencies/python-requirements.txt | 4 + sandbox/lib/seccomp_nodejs/Cargo.lock | 7 + sandbox/lib/seccomp_nodejs/Cargo.toml | 6 + sandbox/lib/seccomp_nodejs/src/lib.rs | 0 sandbox/lib/seccomp_python/Cargo.lock | 23 +++ sandbox/lib/seccomp_python/Cargo.toml | 12 ++ sandbox/lib/seccomp_python/src/lib.rs | 195 ++++++++++++++++++ sandbox/lib/seccomp_python/src/syscalls.rs | 85 ++++++++ sandbox/main.py | 97 +++++++++ sandbox/requirements.txt | 20 ++ sandbox/script/env.sh | 53 +++++ 35 files changed, 1613 insertions(+) create mode 100644 sandbox/Dockerfile create mode 100644 sandbox/app/config.py create mode 100644 sandbox/app/controllers/__init__.py create mode 100644 sandbox/app/controllers/health_controller.py create mode 100644 sandbox/app/controllers/sandbox_controller.py create mode 100644 sandbox/app/core/__init__.py create mode 100644 sandbox/app/core/encryption.py create mode 100644 sandbox/app/core/executor.py create mode 100644 sandbox/app/core/runners/__init__.py create mode 100644 sandbox/app/core/runners/python/__init__.py create mode 100644 sandbox/app/core/runners/python/env.py create mode 100644 sandbox/app/core/runners/python/prescript.py create mode 100644 sandbox/app/core/runners/python/python_runner.py create mode 100644 sandbox/app/core/runners/python/settings.py create mode 100644 sandbox/app/dependencies.py create mode 100644 sandbox/app/logger.py create mode 100644 sandbox/app/middleware/__init__.py create mode 100644 sandbox/app/middleware/auth.py create mode 100644 sandbox/app/middleware/concurrency.py create mode 100644 sandbox/app/models.py create mode 100644 sandbox/app/services/__init__.py create mode 100644 sandbox/app/services/python_service.py create mode 100644 sandbox/config.yaml create mode 100644 sandbox/dependencies/python-requirements.txt create mode 100644 sandbox/lib/seccomp_nodejs/Cargo.lock create mode 100644 sandbox/lib/seccomp_nodejs/Cargo.toml create mode 100644 sandbox/lib/seccomp_nodejs/src/lib.rs create mode 100644 sandbox/lib/seccomp_python/Cargo.lock create mode 100644 sandbox/lib/seccomp_python/Cargo.toml create mode 100644 sandbox/lib/seccomp_python/src/lib.rs create mode 100644 sandbox/lib/seccomp_python/src/syscalls.rs create mode 100644 sandbox/main.py create mode 100644 sandbox/requirements.txt create mode 100644 sandbox/script/env.sh diff --git a/.gitignore b/.gitignore index c2648945..de160688 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ nltk_data/ tika-server*.jar* cl100k_base.tiktoken libssl*.deb + +sandbox/lib/seccomp_python/target +sandbox/lib/seccomp_nodejs/target diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 00000000..677b991c --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.12-slim +USER root +WORKDIR /code +LABEL authors="Eterntiy" + +ARG NEED_MIRROR=0 + +RUN --mount=type=cache,id=mem_apt,target=/var/cache/apt,sharing=locked \ + if [ "$NEED_MIRROR" == "1" ]; then \ + sed -i 's|https://ports.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list; \ + sed -i 's|https://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list; \ + fi; \ + rm -f /etc/apt/apt.conf.d/docker-clean && \ + echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \ + chmod 1777 /tmp && \ + apt update && \ + apt --no-install-recommends install -y ca-certificates && \ + apt update && \ + apt install -y python3-pip pipx nginx unzip curl wget git vim less && \ + apt-get install -y --no-install-recommends tzdata libseccomp2 libseccomp-dev && \ + ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone && \ + apt install -y cargo + +COPY ./app /code/app +COPY ./dependencies /code/dependencies +COPY ./lib /code/lib +COPY ./script /code/script +COPY ./config.yaml /code/config.yaml +COPY ./main.py /code/main.py +COPY ./requirements.txt /code/requirements.txt + +RUN python -m venv .venv +RUN .venv/bin/python3 -m pip install -r requirements.txt + +RUN cargo build --release --manifest-path lib/seccomp_python/Cargo.toml + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl 127.0.0.1:8194/health + + +CMD [".venv/bin/python3", "main.py"] \ No newline at end of file diff --git a/sandbox/app/config.py b/sandbox/app/config.py new file mode 100644 index 00000000..3fa4cab5 --- /dev/null +++ b/sandbox/app/config.py @@ -0,0 +1,134 @@ +"""Configuration management""" +import os +from typing import List, Optional +from pydantic import BaseModel, Field +import yaml + +SANDBOX_USER_ID = 1000 +SANDBOX_GROUP_ID = 1000 + +DEFAULT_PYTHON_LIB_REQUIREMENTS_AMD = [ + "/usr/local/lib/python3.12", + "/usr/lib/python3", + "/usr/lib/x86_64-linux-gnu", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/nsswitch.conf", + "/etc/hosts", + "/etc/resolv.conf", + "/run/systemd/resolve/stub-resolv.conf", + "/run/resolvconf/resolv.conf", + "/etc/localtime", + "/usr/share/zoneinfo", + "/etc/timezone", +] + + +class AppConfig(BaseModel): + """Application configuration""" + port: int = 8194 + debug: bool = True + key: str = "redbear-sandbox" + + +class ProxyConfig(BaseModel): + """Proxy configuration""" + socks5: str = "" + http: str = "" + https: str = "" + + +class Config(BaseModel): + """Global configuration""" + app: AppConfig = Field(default_factory=AppConfig) + max_workers: int = 4 + max_requests: int = 50 + worker_timeout: int = 30 + nodejs_path: str = "node" + enable_network: bool = True + enable_preload: bool = False + + python_path: str = "" + python_lib_paths: list = Field(default=DEFAULT_PYTHON_LIB_REQUIREMENTS_AMD) + python_deps_update_interval: str = "30m" + allowed_syscalls: List[int] = Field(default_factory=list) + proxy: ProxyConfig = Field(default_factory=ProxyConfig) + + +# Global configuration instance +_config: Optional[Config] = None + + +def load_config(config_path: str) -> Config: + """Load configuration from YAML file""" + global _config + + # Load from file + if os.path.exists(config_path): + with open(config_path, 'r') as f: + data = yaml.safe_load(f) + _config = Config(**data) + else: + _config = Config() + + # Override with environment variables + if os.getenv("DEBUG"): + _config.app.debug = os.getenv("DEBUG").lower() in ("true", "1", "yes") + + if os.getenv("MAX_WORKERS"): + _config.max_workers = int(os.getenv("MAX_WORKERS")) + + if os.getenv("MAX_REQUESTS"): + _config.max_requests = int(os.getenv("MAX_REQUESTS")) + + if os.getenv("SANDBOX_PORT"): + _config.app.port = int(os.getenv("SANDBOX_PORT")) + + if os.getenv("WORKER_TIMEOUT"): + _config.worker_timeout = int(os.getenv("WORKER_TIMEOUT")) + + if os.getenv("API_KEY"): + _config.app.key = os.getenv("API_KEY") + + if os.getenv("NODEJS_PATH"): + _config.nodejs_path = os.getenv("NODEJS_PATH") + + if os.getenv("ENABLE_NETWORK"): + _config.enable_network = os.getenv("ENABLE_NETWORK").lower() in ("true", "1", "yes") + + if os.getenv("ENABLE_PRELOAD"): + _config.enable_preload = os.getenv("ENABLE_PRELOAD").lower() in ("true", "1", "yes") + + if os.getenv("ALLOWED_SYSCALLS"): + _config.allowed_syscalls = [int(x) for x in os.getenv("ALLOWED_SYSCALLS").split(",")] + + if os.getenv("SOCKS5_PROXY"): + _config.proxy.socks5 = os.getenv("SOCKS5_PROXY") + + if os.getenv("HTTP_PROXY"): + _config.proxy.http = os.getenv("HTTP_PROXY") + + if os.getenv("HTTPS_PROXY"): + _config.proxy.https = os.getenv("HTTPS_PROXY") + + # python + if os.getenv("PYTHON_PATH"): + _config.python_path = os.getenv("PYTHON_PATH") + + if os.getenv("PYTHON_LIB_PATH"): + _config.python_lib_paths = os.getenv("PYTHON_LIB_PATH").split(',') + + if os.getenv("PYTHON_DEPS_UPDATE_INTERVAL"): + _config.python_deps_update_interval = os.getenv("PYTHON_DEPS_UPDATE_INTERVAL") + + return _config + + +config_path = os.getenv("CONFIG_PATH", "config.yaml") +load_config(config_path) + + +def get_config() -> Config: + """Get global configuration""" + if _config is None: + raise RuntimeError("Configuration not loaded. Call load_config() first.") + return _config diff --git a/sandbox/app/controllers/__init__.py b/sandbox/app/controllers/__init__.py new file mode 100644 index 00000000..b1d965ae --- /dev/null +++ b/sandbox/app/controllers/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from . import health_controller, sandbox_controller + +manager_router = APIRouter() + +manager_router.include_router(health_controller.router) +manager_router.include_router(sandbox_controller.router) diff --git a/sandbox/app/controllers/health_controller.py b/sandbox/app/controllers/health_controller.py new file mode 100644 index 00000000..4d872e58 --- /dev/null +++ b/sandbox/app/controllers/health_controller.py @@ -0,0 +1,12 @@ +"""Health check endpoint""" +from fastapi import APIRouter + +from app.models import HealthResponse + +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + """Health check endpoint""" + return HealthResponse(status="healthy", version="2.0.0") diff --git a/sandbox/app/controllers/sandbox_controller.py b/sandbox/app/controllers/sandbox_controller.py new file mode 100644 index 00000000..1a713f52 --- /dev/null +++ b/sandbox/app/controllers/sandbox_controller.py @@ -0,0 +1,59 @@ +"""Sandbox API endpoints""" +from fastapi import APIRouter, Depends + +from app.middleware.auth import verify_api_key +from app.middleware.concurrency import check_max_requests, acquire_worker +from app.models import ( + RunCodeRequest, + ApiResponse, + UpdateDependencyRequest, + error_response +) +from app.services.python_service import ( + run_python_code, + list_python_dependencies, + update_python_dependencies +) + +router = APIRouter( + prefix="/v1/sandbox", + tags=["sandbox"], + dependencies=[Depends(verify_api_key)] +) + + +@router.post( + "/run", + response_model=ApiResponse, + dependencies=[Depends(check_max_requests), + Depends(acquire_worker)] +) +async def run_code(request: RunCodeRequest): + """Execute code in sandbox""" + if request.language == "python3": + return await run_python_code(request.code, request.preload, request.options) + elif request.language == "nodejs": + # TODO + return error_response(-400, "TODO") + else: + return error_response(-400, "unsupported language") + + +@router.get("/dependencies", response_model=ApiResponse) +async def get_dependencies(language: str): + """Get installed dependencies""" + if language == "python3": + return await list_python_dependencies() + else: + return error_response(-400, "unsupported language") + + +@router.post("/dependencies/update", response_model=ApiResponse) +async def update_dependencies(request: UpdateDependencyRequest): + """Update dependencies""" + if request.language == "python3": + return await update_python_dependencies() + else: + return error_response(-400, "unsupported language") + + diff --git a/sandbox/app/core/__init__.py b/sandbox/app/core/__init__.py new file mode 100644 index 00000000..e1abba12 --- /dev/null +++ b/sandbox/app/core/__init__.py @@ -0,0 +1 @@ +"""Core functionality package""" diff --git a/sandbox/app/core/encryption.py b/sandbox/app/core/encryption.py new file mode 100644 index 00000000..5e0855c9 --- /dev/null +++ b/sandbox/app/core/encryption.py @@ -0,0 +1,32 @@ +"""Code encryption utilities""" +import base64 + + +def encrypt_code(code: bytes, key: bytes) -> str: + """Encrypt code using XOR cipher with base64 encoding + + Args: + code: Plain code string + key: Encryption key bytes + + Returns: + Base64 encoded encrypted code + """ + encrypted_code = bytearray(len(code)) + for i in range(len(code)): + encrypted_code[i] = code[i] ^ key[i % 64] + encoded_code = base64.b64encode(encrypted_code).decode("utf-8") + return encoded_code + + +def generate_key(length: int = 64) -> bytes: + """Generate random encryption key + + Args: + length: Key length in bytes (default 64 for 512 bits) + + Returns: + Random key bytes + """ + import secrets + return secrets.token_bytes(length) diff --git a/sandbox/app/core/executor.py b/sandbox/app/core/executor.py new file mode 100644 index 00000000..6edc48c0 --- /dev/null +++ b/sandbox/app/core/executor.py @@ -0,0 +1,48 @@ +"""Code execution engine""" +import os +from typing import Optional +from abc import ABC, abstractmethod + +from app.config import get_config +from app.logger import get_logger +from app.models import RunnerOptions + + +class ExecutionResult: + """Result of code execution""" + + def __init__(self, stdout: str = "", stderr: str = "", exit_code: int = 0, error: Optional[str] = None): + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + self.error = error + + +class CodeExecutor(ABC): + """Base code executor""" + + def __init__(self): + self.logger = get_logger() + self.config = get_config() + + @abstractmethod + async def run( + self, + code: str, + options: RunnerOptions, + preload: str = "", + timeout: Optional[int] = None + ) -> ExecutionResult: + pass + + def cleanup_temp_file(self, file_path: str) -> None: + """Remove temporary file + + Args: + file_path: Path to file to remove + """ + try: + if os.path.exists(file_path): + os.remove(file_path) + except Exception as e: + self.logger.warning(f"Failed to cleanup temp file {file_path}: {e}") diff --git a/sandbox/app/core/runners/__init__.py b/sandbox/app/core/runners/__init__.py new file mode 100644 index 00000000..96c5e380 --- /dev/null +++ b/sandbox/app/core/runners/__init__.py @@ -0,0 +1 @@ +"""Code runners package""" diff --git a/sandbox/app/core/runners/python/__init__.py b/sandbox/app/core/runners/python/__init__.py new file mode 100644 index 00000000..99a56ef7 --- /dev/null +++ b/sandbox/app/core/runners/python/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: UTF-8 -*- +# Author: Eternity +# @Email: 1533512157@qq.com +# @Time : 2026/1/23 11:27 diff --git a/sandbox/app/core/runners/python/env.py b/sandbox/app/core/runners/python/env.py new file mode 100644 index 00000000..d82b0522 --- /dev/null +++ b/sandbox/app/core/runners/python/env.py @@ -0,0 +1,50 @@ +import asyncio +import tempfile +import stat +from pathlib import Path + +from app.config import get_config +from app.core.runners.python.settings import LIB_PATH +from app.logger import get_logger + +logger = get_logger() + + +async def prepare_python_dependencies_env(): + config = get_config() + + with tempfile.TemporaryDirectory(dir="/") as root_path: + root = Path(root_path) + + env_sh = root / "env.sh" + with open("script/env.sh") as f: + env_sh.write_text(f.read()) + env_sh.chmod(env_sh.stat().st_mode | stat.S_IXUSR) + + for lib_path in config.python_lib_paths: + lib_path = Path(lib_path) + + if not lib_path.exists(): + logger.warning("python lib path %s is not available", lib_path) + continue + + cmd = [ + "bash", + str(env_sh), + str(lib_path), + str(LIB_PATH), + ] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + retcode = process.returncode + + if retcode != 0: + logger.error( + f"create env error for file {lib_path}: retcode={retcode}, stderr={stderr.decode()}" + ) diff --git a/sandbox/app/core/runners/python/prescript.py b/sandbox/app/core/runners/python/prescript.py new file mode 100644 index 00000000..4790be73 --- /dev/null +++ b/sandbox/app/core/runners/python/prescript.py @@ -0,0 +1,56 @@ +import ctypes +import os +import sys +import traceback +from base64 import b64decode + + +# Setup exception hook +def excepthook(etype, value, tb): + sys.stderr.write("".join(traceback.format_exception(etype, value, tb))) + sys.stderr.flush() + sys.exit(-1) + + +sys.excepthook = excepthook + +# Load security library if available +lib = ctypes.CDLL("./libpython.so") +lib.init_seccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool] +lib.init_seccomp.restype = None + +# Get running path +running_path = sys.argv[1] +if not running_path: + exit(-1) + +# Get decrypt key +key = sys.argv[2] +if not key: + exit(-1) + +key = b64decode(key) + +os.chdir(running_path) + +# Preload code +{{preload}} + +# Apply security if library is available +lib.init_seccomp({{uid}}, {{gid}}, {{enable_network}}) + +# Decrypt and execute code +code = b64decode("{{code}}") + + +def decrypt(code, key): + key_len = len(key) + code_len = len(code) + code = bytearray(code) + for i in range(code_len): + code[i] = code[i] ^ key[i % key_len] + return bytes(code) + + +code = decrypt(code, key) +exec(code) diff --git a/sandbox/app/core/runners/python/python_runner.py b/sandbox/app/core/runners/python/python_runner.py new file mode 100644 index 00000000..faac5f0c --- /dev/null +++ b/sandbox/app/core/runners/python/python_runner.py @@ -0,0 +1,151 @@ +"""Python code runner""" +import asyncio +import base64 +import os +import uuid +from typing import Optional + +from app.config import SANDBOX_USER_ID, SANDBOX_GROUP_ID, get_config +from app.core.encryption import generate_key, encrypt_code +from app.core.executor import CodeExecutor, ExecutionResult +from app.core.runners.python.settings import check_lib_avaiable, release_lib_binary, LIB_PATH +from app.models import RunnerOptions + +# Python sandbox prescript template +with open("app/core/runners/python/prescript.py") as f: + PYTHON_PRESCRIPT = f.read() + + +class PythonRunner(CodeExecutor): + """Python code runner with security isolation""" + + def __init__(self): + super().__init__() + + @staticmethod + def init_enviroment(code: bytes, preload, options: RunnerOptions) -> tuple[str, str]: + if not check_lib_avaiable(): + release_lib_binary(False) + config = get_config() + code_file_name = uuid.uuid4().hex.replace("-", "_") + + script = PYTHON_PRESCRIPT.replace("{{uid}}", str(SANDBOX_USER_ID), 1) + script = script.replace("{{gid}}", str(SANDBOX_GROUP_ID), 1) + script = script.replace( + "{{enable_network}}", + str(int(options.enable_network and config.enable_network) + ), + 1 + ) + script = script.replace("{{preload}}", f"{preload}\n", 1) + + key = generate_key(64) + + encoded_code = encrypt_code(code, key) + encoded_key = base64.b64encode(key).decode("utf-8") + + script = script.replace("{{code}}", encoded_code, 1) + + code_path = f"{LIB_PATH}/tmp/{code_file_name}.py" + try: + os.makedirs(os.path.dirname(code_path), mode=0o755, exist_ok=True) + with open(code_path, "w", encoding="utf-8") as f: + f.write(script) + os.chmod(code_path, 0o755) + + except OSError as e: + raise RuntimeError(f"Failed to write {code_path}") from e + + return code_path, encoded_key + + async def run( + self, + code: str, + options: RunnerOptions, + preload: str = "", + timeout: Optional[int] = None + ) -> ExecutionResult: + """Run Python code in sandbox + + Args: + options: + code: Base64 encoded encrypted code + preload: Preload code to execute before main code + timeout: Execution timeout in seconds + + Returns: + ExecutionResult with stdout, stderr, and exit code + """ + config = self.config + + if timeout is None: + timeout = config.worker_timeout + + # Check if preload is allowed + if not config.enable_preload: + preload = "" + code = base64.b64decode(code) + script_path, encoded_key = self.init_enviroment(code, preload, options=options) + + try: + # Setup environment + env = {} + + # Add proxy settings if configured + if config.proxy.socks5: + env["HTTPS_PROXY"] = config.proxy.socks5 + env["HTTP_PROXY"] = config.proxy.socks5 + elif config.proxy.https or config.proxy.http: + if config.proxy.https: + env["HTTPS_PROXY"] = config.proxy.https + if config.proxy.http: + env["HTTP_PROXY"] = config.proxy.http + + # Add allowed syscalls if configured + if config.allowed_syscalls: + env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls)) + + # Execute with Python interpreter + + process = await asyncio.create_subprocess_exec( + config.python_path, + script_path, + LIB_PATH, + encoded_key, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=LIB_PATH + ) + + # Wait for completion with timeout + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout + ) + + return ExecutionResult( + stdout=stdout.decode('utf-8', errors='replace'), + stderr=stderr.decode('utf-8', errors='replace'), + exit_code=process.returncode + ) + + except asyncio.TimeoutError: + # Kill process on timeout + try: + process.kill() + await process.wait() + except: + pass + + return ExecutionResult( + stdout="", + stderr="Execution timeout", + exit_code=-1, + error="Execution timeout" + ) + + finally: + # Cleanup temporary file + self.cleanup_temp_file(script_path) diff --git a/sandbox/app/core/runners/python/settings.py b/sandbox/app/core/runners/python/settings.py new file mode 100644 index 00000000..aee8827b --- /dev/null +++ b/sandbox/app/core/runners/python/settings.py @@ -0,0 +1,62 @@ +import os + +from app.logger import get_logger + +logger = get_logger() + +RELEASE_LIB_PATH = "./lib/seccomp_python/target/release/libpython.so" +LIB_PATH = "/var/sandbox/sandbox-python" +LIB_NAME = "libpython.so" + +try: + with open(RELEASE_LIB_PATH, "rb") as f: + _PYTHON_LIB = f.read() +except: + logger.critical("failed to load python lib") + raise + + +def check_lib_avaiable(): + return os.path.exists(os.path.join(LIB_PATH, LIB_NAME)) + + +def release_lib_binary(force_remove: bool): + logger.info("init runtime enviroment") + lib_file = os.path.join(LIB_PATH, LIB_NAME) + if os.path.exists(lib_file): + if force_remove: + try: + os.remove(lib_file) + except OSError: + logger.critical(f"failed to remove {os.path.join(LIB_PATH, LIB_NAME)}") + raise + + try: + os.makedirs(LIB_PATH, mode=0o755, exist_ok=True) + except OSError: + logger.critical(f"failed to create {LIB_PATH}") + raise + + try: + with open(lib_file, "wb") as f: + f.write(_PYTHON_LIB) + os.chmod(lib_file, 0o755) + except OSError: + logger.critical(f"failed to write {lib_file}") + raise + else: + try: + os.makedirs(LIB_PATH, mode=0o755, exist_ok=True) + except OSError: + logger.critical(f"failed to create {LIB_PATH}") + raise + + try: + with open(lib_file, "wb") as f: + f.write(_PYTHON_LIB) + os.chmod(lib_file, 0o755) + except OSError: + logger.critical(f"failed to write {lib_file}") + raise + + logger.info("python runner environment initialized") diff --git a/sandbox/app/dependencies.py b/sandbox/app/dependencies.py new file mode 100644 index 00000000..6e88aaf2 --- /dev/null +++ b/sandbox/app/dependencies.py @@ -0,0 +1,161 @@ +"""Dependency management""" +import asyncio +from pathlib import Path +from typing import List, Dict + +from app.config import get_config +from app.core.runners.python.env import prepare_python_dependencies_env +from app.logger import get_logger + + +async def setup_dependencies(): + """Setup initial dependencies""" + logger = get_logger() + + try: + logger.info("Installing Python dependencies...") + await install_python_dependencies() + logger.info("Python dependencies installed") + + logger.info("Preparing Python dependencies environment...") + await prepare_python_dependencies_env() + logger.info("Python dependencies environment ready") + + except Exception as e: + logger.error(f"Failed to setup dependencies: {e}") + + +async def update_dependencies(): + # TODO + return + + +async def install_python_dependencies(): + """Install Python dependencies from requirements file""" + logger = get_logger() + config = get_config() + + # Check if requirements file exists + req_file = Path("dependencies/python-requirements.txt") + if not req_file.exists(): + logger.warning("Python requirements file not found, skipping installation") + return + + # Read requirements + requirements = req_file.read_text().strip() + if not requirements: + logger.info("No Python requirements to install") + return + + # Install using pip + cmd = [ + config.python_path, + "-m", + "pip", + "install", + "--upgrade" + ] + + # Add packages from requirements + for line in requirements.split("\n"): + line = line.strip() + if line and not line.startswith("#"): + cmd.append(line) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + logger.error(f"Failed to install Python dependencies: {stderr.decode()}") + else: + logger.info("Python dependencies installed successfully") + + except Exception as e: + logger.error(f"Error installing Python dependencies: {e}") + + +async def list_dependencies(language: str) -> List[Dict[str, str]]: + """List installed dependencies + + Args: + language: Language (python or Node.js) + + Returns: + List of dependencies with name and version + """ + if language == "python": + return await list_python_packages() + else: + return [] + + +async def list_python_packages() -> List[Dict[str, str]]: + """List installed Python packages""" + config = get_config() + + try: + process = await asyncio.create_subprocess_exec( + config.python_path, + "-m", + "pip", + "list", + "--format=freeze", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + return [] + + # Parse output + packages = [] + for line in stdout.decode().split("\n"): + line = line.strip() + if line and "==" in line: + name, version = line.split("==", 1) + packages.append({"name": name, "version": version}) + + return packages + + except Exception as e: + get_logger().error(f"Failed to list Python packages: {e}") + return [] + + +async def update_dependencies_periodically(): + """Periodically update dependencies""" + logger = get_logger() + config = get_config() + + # Parse interval + interval_str = config.python_deps_update_interval + + # Convert to seconds + if interval_str.endswith("m"): + interval = int(interval_str[:-1]) * 60 + elif interval_str.endswith("h"): + interval = int(interval_str[:-1]) * 3600 + elif interval_str.endswith("s"): + interval = int(interval_str[:-1]) + else: + interval = 1800 # Default 30 minutes + + logger.info(f"Starting periodic dependency updates every {interval} seconds") + + while True: + await asyncio.sleep(interval) + + try: + logger.info("Updating Python dependencies...") + # TODO: await update_dependencies("python") + logger.info("Python dependencies updated successfully") + except Exception as e: + logger.error(f"Failed to update Python dependencies: {e}") diff --git a/sandbox/app/logger.py b/sandbox/app/logger.py new file mode 100644 index 00000000..de2ccc9e --- /dev/null +++ b/sandbox/app/logger.py @@ -0,0 +1,42 @@ +"""Logging configuration""" +import logging +import sys +from typing import Optional + +from app.config import get_config + +_logger: Optional[logging.Logger] = None + + +def setup_logger() -> logging.Logger: + """Setup application logger""" + global _logger + + config = get_config() + + # Create logger + _logger = logging.getLogger("sandbox") + _logger.setLevel(logging.DEBUG if config.app.debug else logging.INFO) + + # Create console handler + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.DEBUG if config.app.debug else logging.INFO) + + # Create formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + handler.setFormatter(formatter) + + # Add handler to logger + _logger.addHandler(handler) + + return _logger + + +def get_logger() -> logging.Logger: + """Get application logger""" + if _logger is None: + return setup_logger() + return _logger diff --git a/sandbox/app/middleware/__init__.py b/sandbox/app/middleware/__init__.py new file mode 100644 index 00000000..77d6403c --- /dev/null +++ b/sandbox/app/middleware/__init__.py @@ -0,0 +1 @@ +"""Middleware package""" diff --git a/sandbox/app/middleware/auth.py b/sandbox/app/middleware/auth.py new file mode 100644 index 00000000..8a93a793 --- /dev/null +++ b/sandbox/app/middleware/auth.py @@ -0,0 +1,15 @@ +"""Authentication middleware""" +from fastapi import Header, HTTPException, status + +from app.config import get_config + + +async def verify_api_key(x_api_key: str = Header(..., alias="X-Api-Key")): + """Verify API key from request header""" + config = get_config() + if x_api_key != config.app.key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key" + ) + return x_api_key diff --git a/sandbox/app/middleware/concurrency.py b/sandbox/app/middleware/concurrency.py new file mode 100644 index 00000000..8d8325a4 --- /dev/null +++ b/sandbox/app/middleware/concurrency.py @@ -0,0 +1,48 @@ +"""Concurrency control middleware""" +import asyncio +from fastapi import HTTPException, status + +from app.config import get_config +from app.models import error_response + + +# Global semaphores +_worker_semaphore: None | asyncio.Semaphore = None +_request_counter = 0 +_request_lock = asyncio.Lock() + + +def init_concurrency_control(): + """Initialize concurrency control""" + global _worker_semaphore + config = get_config() + _worker_semaphore = asyncio.Semaphore(config.max_workers) + + +async def check_max_requests(): + """Check if max requests limit is reached""" + global _request_counter + config = get_config() + + async with _request_lock: + if _request_counter >= config.max_requests: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=error_response(-503, "Too many requests") + ) + _request_counter += 1 + + try: + yield + finally: + async with _request_lock: + _request_counter -= 1 + + +async def acquire_worker(): + """Acquire a worker slot""" + if _worker_semaphore is None: + init_concurrency_control() + + async with _worker_semaphore: + yield diff --git a/sandbox/app/models.py b/sandbox/app/models.py new file mode 100644 index 00000000..e7492b4c --- /dev/null +++ b/sandbox/app/models.py @@ -0,0 +1,80 @@ +"""Data models""" +from typing import Optional, Any + +from pydantic import BaseModel, Field + + +class RunnerOptions(BaseModel): + enable_network: bool = Field(default=False, description="Sandbox network flag") + + +class RunCodeRequest(BaseModel): + """Request model for code execution""" + language: str = Field(..., description="Programming language (python3 or nodejs)") + code: str = Field(..., description="Base64 encoded encrypted code") + preload: Optional[str] = Field(default="", description="Preload code") + options: RunnerOptions = Field(default_factory=RunnerOptions, description="Enable network access") + + +class RunCodeResponse(BaseModel): + """Response model for code execution""" + stdout: str = Field(default="", description="Standard output") + stderr: str = Field(default="", description="Standard error") + + +class DependencyRequest(BaseModel): + """Request model for dependency operations""" + language: str = Field(..., description="Programming language") + + +class UpdateDependencyRequest(BaseModel): + """Request model for updating dependencies""" + language: str = Field(..., description="Programming language") + packages: list[str] = Field(default_factory=list, description="Packages to install") + + +class Dependency(BaseModel): + """Dependency information""" + name: str + version: str + + +class ListDependenciesResponse(BaseModel): + """Response model for listing dependencies""" + dependencies: list[Dependency] = Field(default_factory=list) + + +class RefreshDependenciesResponse(BaseModel): + """Response model for refreshing dependencies""" + dependencies: list[Dependency] = Field(default_factory=list) + + +class UpdateDependenciesResponse(BaseModel): + """Response model for updating dependencies""" + success: bool = True + installed: list[str] = Field(default_factory=list) + + +class HealthResponse(BaseModel): + """Health check response""" + status: str = "healthy" + version: str = "2.0.0" + + +class ApiResponse(BaseModel): + """Standard API response wrapper""" + code: int = Field(default=0, description="Response code (0 for success, negative for error)") + message: str = Field(default="success", description="Response message") + data: Optional[Any] = Field(default=None, description="Response data") + + +def success_response(data: Any) -> ApiResponse: + """Create success response""" + return ApiResponse(code=0, message="success", data=data) + + +def error_response(code: int, message: str) -> ApiResponse: + """Create error response""" + if code >= 0: + code = -1 + return ApiResponse(code=code, message=message, data=None) diff --git a/sandbox/app/services/__init__.py b/sandbox/app/services/__init__.py new file mode 100644 index 00000000..e3726046 --- /dev/null +++ b/sandbox/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package""" diff --git a/sandbox/app/services/python_service.py b/sandbox/app/services/python_service.py new file mode 100644 index 00000000..71cfda0d --- /dev/null +++ b/sandbox/app/services/python_service.py @@ -0,0 +1,80 @@ +"""Python execution service""" +import signal + +from app.core.runners.python.python_runner import PythonRunner +from app.dependencies import ( + list_dependencies as list_deps, + update_dependencies as update_deps +) +from app.logger import get_logger +from app.models import ( + success_response, + error_response, + RunCodeResponse, + ListDependenciesResponse, + UpdateDependenciesResponse, + Dependency, + RunnerOptions +) + + +async def run_python_code(code: str, preload: str, options: RunnerOptions): + """Execute Python code in sandbox + + Args: + options: + code: Base64 encoded encrypted code + preload: Preload code + + Returns: + API response with execution result + """ + logger = get_logger() + + try: + runner = PythonRunner() + result = await runner.run(code, options, preload) + if result.exit_code == -signal.SIGSYS: + return error_response(31, "sandbox security policy violation") + + if result.error: + return error_response(-500, result.error) + + return success_response(RunCodeResponse( + stdout=result.stdout, + stderr=result.stderr + )) + + except Exception as e: + logger.error(f"Python execution failed: {e}", exc_info=True) + return error_response(-500, str(e)) + + +async def list_python_dependencies(): + """List installed Python dependencies + + Returns: + API response with dependency list + """ + try: + deps = await list_deps("python") + dependencies = [ + Dependency(name=dep["name"], version=dep["version"]) + for dep in deps + ] + return success_response(ListDependenciesResponse(dependencies=dependencies)) + except Exception as e: + return error_response(500, str(e)) + + +async def update_python_dependencies(): + """Update Python dependencies + + Returns: + API response with update result + """ + try: + await update_deps() + return success_response(UpdateDependenciesResponse(success=True)) + except Exception as e: + return error_response(500, str(e)) diff --git a/sandbox/config.yaml b/sandbox/config.yaml new file mode 100644 index 00000000..d9581b34 --- /dev/null +++ b/sandbox/config.yaml @@ -0,0 +1,20 @@ +app: + port: 8194 + debug: true + key: redbear-sandbox + +max_workers: 4 +max_requests: 50 +worker_timeout: 30 +python_path: /usr/local/bin/python +nodejs_path: /usr/local/bin/node +enable_network: true +enable_preload: false +python_deps_update_interval: 30m + +allowed_syscalls: [] + +proxy: + socks5: '' + http: '' + https: '' diff --git a/sandbox/dependencies/python-requirements.txt b/sandbox/dependencies/python-requirements.txt new file mode 100644 index 00000000..1c3c2901 --- /dev/null +++ b/sandbox/dependencies/python-requirements.txt @@ -0,0 +1,4 @@ +requests==2.31.0 +# numpy==1.26.0 +# pandas==2.0.0 +jinja2==3.1.2 \ No newline at end of file diff --git a/sandbox/lib/seccomp_nodejs/Cargo.lock b/sandbox/lib/seccomp_nodejs/Cargo.lock new file mode 100644 index 00000000..b37698ee --- /dev/null +++ b/sandbox/lib/seccomp_nodejs/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "seccomp_nodejs" +version = "0.1.0" diff --git a/sandbox/lib/seccomp_nodejs/Cargo.toml b/sandbox/lib/seccomp_nodejs/Cargo.toml new file mode 100644 index 00000000..a8bd8932 --- /dev/null +++ b/sandbox/lib/seccomp_nodejs/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "seccomp_nodejs" +version = "0.1.0" +edition = "2024" + +[dependencies] \ No newline at end of file diff --git a/sandbox/lib/seccomp_nodejs/src/lib.rs b/sandbox/lib/seccomp_nodejs/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/sandbox/lib/seccomp_python/Cargo.lock b/sandbox/lib/seccomp_python/Cargo.lock new file mode 100644 index 00000000..881ad177 --- /dev/null +++ b/sandbox/lib/seccomp_python/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libseccomp-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60276e2d41bbb68b323e566047a1bfbf952050b157d8b5cdc74c07c1bf4ca3b6" + +[[package]] +name = "seccomp_python" +version = "0.1.0" +dependencies = [ + "libc", + "libseccomp-sys", +] diff --git a/sandbox/lib/seccomp_python/Cargo.toml b/sandbox/lib/seccomp_python/Cargo.toml new file mode 100644 index 00000000..07037172 --- /dev/null +++ b/sandbox/lib/seccomp_python/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "seccomp_python" +version = "0.1.0" +edition = "2024" + +[lib] +name = "python" +crate-type = ["cdylib"] + +[dependencies] +libc = "0.2.180" +libseccomp-sys = "0.3.0" diff --git a/sandbox/lib/seccomp_python/src/lib.rs b/sandbox/lib/seccomp_python/src/lib.rs new file mode 100644 index 00000000..08b46c54 --- /dev/null +++ b/sandbox/lib/seccomp_python/src/lib.rs @@ -0,0 +1,195 @@ +mod syscalls; + +use crate::syscalls::*; +use libc::{chdir, chroot, gid_t, uid_t, c_int}; +use libseccomp_sys::*; +use std::env; +use std::ffi::CString; +use std::str::FromStr; + + +/* + * get_allowed_syscalls - retrieve allowed syscalls for the sandbox + * @enable_network: enable network-related syscalls if non-zero + * + * Syscall selection order: + * 1. ALLOWED_SYSCALLS environment variable + * 2. Built-in default allowlist + * 3. Optional network syscall extension + * + * Returns: + * (allowed_syscalls, allowed_not_kill_syscalls) + * allowed_syscalls: syscalls fully allowed + * allowed_not_kill_syscalls: syscalls returning EPERM + */ +pub fn get_allowed_syscalls(enable_network: bool) -> (Vec, Vec) { + let mut allowed_syscalls = Vec::new(); + let mut allowed_not_kill_syscalls = Vec::new(); + + /* Syscalls that return error instead of killing */ + allowed_not_kill_syscalls.extend(ALLOW_ERROR_SYSCALLS); + + /* Load from environment variable ALLOWED_SYSCALLS */ + if let Ok(env_val) = env::var("ALLOWED_SYSCALLS") { + if !env_val.is_empty() { + for s in env_val.split(',') { + if let Ok(sc) = i32::from_str(s) { + allowed_syscalls.push(sc); + } + } + } + } + + /* Fallback to default syscalls if env not set */ + if allowed_syscalls.is_empty() { + allowed_syscalls.extend(ALLOW_SYSCALLS); + if enable_network { + allowed_syscalls.extend(ALLOW_NETWORK_SYSCALLS); + } + } + + (allowed_syscalls, allowed_not_kill_syscalls) +} + +/* + * setup_root - setup restricted filesystem root + * + * Perform chroot(".") and change working directory to "/". + * + * Return: + * 0 on success + * negative error code on failure + */ +fn setup_root() -> Result<(), c_int> { + let root = CString::new(".").unwrap(); + if unsafe { chroot(root.as_ptr()) } != 0 { + return Err(-1); + } + + let root_dir = CString::new("/").unwrap(); + if unsafe { chdir(root_dir.as_ptr()) } != 0 { + return Err(-2); + } + + Ok(()) +} + +/* + * set_no_new_privs - enable PR_SET_NO_NEW_PRIVS + * + * Prevent privilege escalation via execve. + * + * Return: + * 0 on success + * negative error code on failure + */ +fn set_no_new_privs() -> Result<(), c_int> { + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(-3); + } + Ok(()) +} + +/* + * drop_privileges - drop process privileges + * @uid: target user ID + * @gid: target group ID + * + * Permanently reduce process privileges. + * + * Return: + * 0 on success + * negative error code on failure + */ +fn drop_privileges(uid: uid_t, gid: gid_t) -> Result<(), c_int> { + if unsafe { libc::setgid(gid) } != 0 { + return Err(-4); + } + if unsafe { libc::setuid(uid) } != 0 { + return Err(-5); + } + Ok(()) +} + +/* + * install_seccomp - install seccomp filter + * @enable_network: enable network-related syscalls if non-zero + * + * Default action is SCMP_ACT_KILL_PROCESS. + * Allowed syscalls are explicitly whitelisted. + * + * Return: + * 0 on success + * negative error code on failure + */ +fn install_seccomp(enable_network: bool) -> Result<(), c_int> { + unsafe { + let ctx = seccomp_init(SCMP_ACT_KILL_PROCESS); + if ctx.is_null() { + return Err(-6); /* failed to init seccomp context */ + } + + let (allowed_syscalls, allowed_not_kill_syscalls) = get_allowed_syscalls(enable_network); + + /* add fully allowed syscalls */ + for &sc in &allowed_syscalls { + if seccomp_rule_add(ctx, SCMP_ACT_ALLOW, sc, 0) != 0 { + seccomp_release(ctx); + return Err(-7); + } + } + + /* add syscalls returning EPERM */ + for &sc in &allowed_not_kill_syscalls { + if seccomp_rule_add(ctx, SCMP_ACT_ERRNO(libc::EPERM as u16), sc, 0) != 0 { + seccomp_release(ctx); + return Err(-8); + } + } + + if seccomp_load(ctx) != 0 { + seccomp_release(ctx); + return Err(-9); + } + + seccomp_release(ctx); + Ok(()) + } +} + +/* + * init_seccomp - initialize seccomp sandbox + * @uid: target user ID + * @gid: target group ID + * @enable_network: enable network syscalls if non-zero + * + * Initialize the sandbox and apply privilege restrictions + * in the following order: + * 1. setup_root() + * 2. set_no_new_privs() + * 3. drop_privileges() + * 4. install_seccomp() + * + * This function must be called before executing any untrusted code. + * It is not thread-safe and must be invoked once per process. + * + * Return: + * 0 on success + * negative error code on failure + */ +#[unsafe(no_mangle)] +pub unsafe extern "C" fn init_seccomp(uid: uid_t, gid: gid_t, enable_network: i32) -> c_int { + if let Err(code) = setup_root() { + return code; + } + if let Err(code) = set_no_new_privs() { + return code; + } + if let Err(code) = drop_privileges(uid, gid) { + return code; + } + match install_seccomp(enable_network != 0) { + Ok(_) => 0, + Err(code) => code, + } +} diff --git a/sandbox/lib/seccomp_python/src/syscalls.rs b/sandbox/lib/seccomp_python/src/syscalls.rs new file mode 100644 index 00000000..07070d22 --- /dev/null +++ b/sandbox/lib/seccomp_python/src/syscalls.rs @@ -0,0 +1,85 @@ +// src/syscalls.rs + +pub static ALLOW_SYSCALLS: &[i32] = &[ + // file io + libc::SYS_read as i32, + libc::SYS_write as i32, + libc::SYS_openat as i32, + libc::SYS_close as i32, + libc::SYS_newfstatat as i32, + libc::SYS_ioctl as i32, + libc::SYS_lseek as i32, + libc::SYS_getdents64 as i32, + + // thread + libc::SYS_futex as i32, + + // memory + libc::SYS_mmap as i32, + libc::SYS_brk as i32, + libc::SYS_mprotect as i32, + libc::SYS_munmap as i32, + libc::SYS_rt_sigreturn as i32, + libc::SYS_mremap as i32, + + // user / group + libc::SYS_setuid as i32, + libc::SYS_setgid as i32, + libc::SYS_getuid as i32, + + // process + libc::SYS_getpid as i32, + libc::SYS_getppid as i32, + libc::SYS_gettid as i32, + libc::SYS_exit as i32, + libc::SYS_exit_group as i32, + libc::SYS_tgkill as i32, + libc::SYS_rt_sigaction as i32, + libc::SYS_sched_yield as i32, + libc::SYS_set_robust_list as i32, + libc::SYS_get_robust_list as i32, + libc::SYS_rseq as i32, + + // time + libc::SYS_clock_gettime as i32, + libc::SYS_gettimeofday as i32, + libc::SYS_nanosleep as i32, + libc::SYS_epoll_create1 as i32, + libc::SYS_epoll_ctl as i32, + libc::SYS_clock_nanosleep as i32, + libc::SYS_pselect6 as i32, + libc::SYS_rt_sigprocmask as i32, + libc::SYS_sigaltstack as i32, + libc::SYS_getrandom as i32, + +]; + +pub static ALLOW_ERROR_SYSCALLS: &[i32] = &[ + libc::SYS_clone as i32, + libc::SYS_mkdirat as i32, + libc::SYS_mkdir as i32, +]; + +pub static ALLOW_NETWORK_SYSCALLS: &[i32] = &[ + libc::SYS_socket as i32, + libc::SYS_connect as i32, + libc::SYS_bind as i32, + libc::SYS_listen as i32, + libc::SYS_accept as i32, + libc::SYS_sendto as i32, + libc::SYS_recvfrom as i32, + libc::SYS_getsockname as i32, + libc::SYS_recvmsg as i32, + libc::SYS_getpeername as i32, + libc::SYS_setsockopt as i32, + libc::SYS_ppoll as i32, + libc::SYS_uname as i32, + libc::SYS_sendmsg as i32, + libc::SYS_sendmmsg as i32, + libc::SYS_getsockopt as i32, + libc::SYS_fstat as i32, + libc::SYS_fcntl as i32, + libc::SYS_fstatfs as i32, + libc::SYS_poll as i32, + libc::SYS_epoll_pwait as i32, +]; diff --git a/sandbox/main.py b/sandbox/main.py new file mode 100644 index 00000000..fc417563 --- /dev/null +++ b/sandbox/main.py @@ -0,0 +1,97 @@ +""" +Redbear Sandbox - Main Entry Point +""" +import asyncio +import os +import sys +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI + +from app.config import get_config +from app.controllers import manager_router +from app.dependencies import setup_dependencies, update_dependencies_periodically +from app.logger import setup_logger, get_logger + +logger = get_logger() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager""" + logger = get_logger() + + # Startup + logger.info("Starting RedBear Sandbox...") + + # Setup dependencies in background + asyncio.create_task(setup_dependencies()) + + # Start periodic dependency updates + config = get_config() + if config.python_deps_update_interval: + asyncio.create_task(update_dependencies_periodically()) + + yield + + # Shutdown + logger.info("Shutting down Redbear Sandbox...") + + +def create_app() -> FastAPI: + """Create FastAPI application""" + config = get_config() + + app = FastAPI( + title="Sandbox", + description="Secure code execution sandbox", + version="2.0.0", + lifespan=lifespan, + debug=config.app.debug + ) + + app.include_router(manager_router) + + return app + + +def check_root_privileges(): + """Check if running with root privileges""" + if os.geteuid() != 0: + logger.info("Error: Sandbox must be run as root for security features (chroot, setuid)") + sys.exit(1) + + +def main(): + """Main entry point""" + # Check root privileges + check_root_privileges() + + # Setup logging + setup_logger() + + config = get_config() + logger = get_logger() + + logger.info(f"Starting server on port {config.app.port}") + logger.info(f"Debug mode: {config.app.debug}") + logger.info(f"Max workers: {config.max_workers}") + logger.info(f"Max requests: {config.max_requests}") + logger.info(f"Network enabled: {config.enable_network}") + + # Create app + app = create_app() + + # Run server + uvicorn.run( + app, + host="0.0.0.0", + port=config.app.port, + log_level="debug" if config.app.debug else "info", + access_log=config.app.debug + ) + + +if __name__ == "__main__": + main() diff --git a/sandbox/requirements.txt b/sandbox/requirements.txt new file mode 100644 index 00000000..0c91018a --- /dev/null +++ b/sandbox/requirements.txt @@ -0,0 +1,20 @@ +# Web Framework +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +pydantic==2.9.0 +pydantic-settings==2.5.0 + +# Configuration +PyYAML==6.0.2 + +# Security +pyseccomp==0.1.2 + + +# Async & Concurrency +aiofiles==24.1.0 + +# Testing +pytest==8.3.0 +pytest-asyncio==0.24.0 +httpx==0.27.0 diff --git a/sandbox/script/env.sh b/sandbox/script/env.sh new file mode 100644 index 00000000..f44f7208 --- /dev/null +++ b/sandbox/script/env.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Check if the correct number of arguments are provided +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +src="$1" +dest="$2" + +# Function to copy and link files +copy_and_link() { + local src_file="$1" + local dest_file="$2" + + if [ -L "$src_file" ]; then + # If src_file is a symbolic link, copy it without changing permissions + cp -P "$src_file" "$dest_file" + elif [ -b "$src_file" ] || [ -c "$src_file" ]; then + # If src_file is a device file, copy it and change permissions + cp "$src_file" "$dest_file" + chmod 444 "$dest_file" + else + # Otherwise, create a hard link and change the permissions to read-only + ln -f "$src_file" "$dest_file" 2>/dev/null || { cp "$src_file" "$dest_file" && chmod 444 "$dest_file"; } + fi +} + +# Check if src is a file or directory +if [ -f "$src" ]; then + # src is a file, create hard link directly in dest + mkdir -p "$(dirname "$dest/$src")" + copy_and_link "$src" "$dest/$src" +elif [ -d "$src" ]; then + # src is a directory, process as before + mkdir -p "$dest/$src" + + # Find all files in the source directory + find "$src" -type f,l | while read -r file; do + # Get the relative path of the file + rel_path="${file#$src/}" + # Get the directory of the relative path + rel_dir=$(dirname "$rel_path") + # Create the same directory structure in the destination + mkdir -p "$dest/$src/$rel_dir" + # Copy and link the file + copy_and_link "$file" "$dest/$src/$rel_path" + done +else + echo "Error: $src is neither a file nor a directory" + exit 1 +fi From 0fd8a122fb01a71e497ba15692b63bd5657b8204 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 11:59:13 +0800 Subject: [PATCH 063/175] feat(workflow): emit SSE events for node exception output --- api/app/core/workflow/executor.py | 50 ++++++++++++++++-------- api/app/core/workflow/nodes/base_node.py | 5 +++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 6721d7b0..f3feff60 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -261,7 +261,7 @@ class WorkflowExecutor: "data": { "execution_id": self.execution_id, "workspace_id": self.workspace_id, - "timestamp": start_time.isoformat() + "timestamp": int(start_time.timestamp() * 1000) } } @@ -293,20 +293,33 @@ class WorkflowExecutor: # Handle custom streaming events (chunks from nodes via stream writer) chunk_count += 1 event_type = data.get("type", "node_chunk") # "message" or "node_chunk" - logger.info(f"[CUSTOM] ✅ 收到 {event_type} #{chunk_count} from {data.get('node_id')}" - f"- execution_id: {self.execution_id}") - yield { - "event": event_type, # "message" or "node_chunk" - "data": { - "node_id": data.get("node_id"), - "chunk": data.get("chunk"), - "full_content": data.get("full_content"), - "chunk_index": data.get("chunk_index"), - "is_prefix": data.get("is_prefix"), - "is_suffix": data.get("is_suffix"), - "conversation_id": input_data.get("conversation_id"), + if event_type in ("message", "node_chunk"): + logger.info(f"[CUSTOM] ✅ 收到 {event_type} #{chunk_count} from {data.get('node_id')}" + f"- execution_id: {self.execution_id}") + yield { + "event": event_type, # "message" or "node_chunk" + "data": { + "node_id": data.get("node_id"), + "chunk": data.get("chunk"), + "full_content": data.get("full_content"), + "chunk_index": data.get("chunk_index"), + "is_prefix": data.get("is_prefix"), + "is_suffix": data.get("is_suffix"), + "conversation_id": input_data.get("conversation_id"), + } + } + elif event_type == "node_error": + yield { + "event": event_type, # "message" or "node_chunk" + "data": { + "node_id": data.get("node_id"), + "status": "failed", + "input": data.get("input_data"), + "elapsed_time": data.get("elapsed_time"), + "output": None, + "error": data.get("error") + } } - } elif mode == "debug": # Handle debug information (node execution status) @@ -325,14 +338,15 @@ class WorkflowExecutor: conversation_id = input_data.get("conversation_id") logger.info(f"[NODE-START] Node starts execution: {node_name} " f"- execution_id: {self.execution_id}") - yield { "event": "node_start", "data": { "node_id": node_name, "conversation_id": conversation_id, "execution_id": self.execution_id, - "timestamp": data.get("timestamp"), + "timestamp": int(datetime.datetime.fromisoformat( + data.get("timestamp") + ).timestamp() * 1000), } } elif event_type == "task_result": @@ -351,7 +365,9 @@ class WorkflowExecutor: "node_id": node_name, "conversation_id": conversation_id, "execution_id": self.execution_id, - "timestamp": data.get("timestamp"), + "timestamp": int(datetime.datetime.fromisoformat( + data.get("timestamp") + ).timestamp() * 1000), "state": result.get("node_outputs", {}).get(node_name), } } diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 0c015c89..61d5ca1e 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -541,6 +541,11 @@ class BaseNode(ABC): "error_node": self.node_id } else: + writer = get_stream_writer() + writer({ + "type": "node_error", + **node_output + }) # 无错误边:抛出异常停止工作流 logger.error(f"节点 {self.node_id} 执行失败,停止工作流: {error_message}") raise Exception(f"节点 {self.node_id} 执行失败: {error_message}") From 1fc04c37d3456790f2bfac96e743eb92629981e9 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 12:22:54 +0800 Subject: [PATCH 064/175] perf(sandbox): optimize code encryption handling --- sandbox/app/core/encryption.py | 3 ++- sandbox/app/core/runners/python/prescript.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sandbox/app/core/encryption.py b/sandbox/app/core/encryption.py index 5e0855c9..47a756c8 100644 --- a/sandbox/app/core/encryption.py +++ b/sandbox/app/core/encryption.py @@ -12,9 +12,10 @@ def encrypt_code(code: bytes, key: bytes) -> str: Returns: Base64 encoded encrypted code """ + key_length = len(key) encrypted_code = bytearray(len(code)) for i in range(len(code)): - encrypted_code[i] = code[i] ^ key[i % 64] + encrypted_code[i] = code[i] ^ key[i % key_length] encoded_code = base64.b64encode(encrypted_code).decode("utf-8") return encoded_code diff --git a/sandbox/app/core/runners/python/prescript.py b/sandbox/app/core/runners/python/prescript.py index 4790be73..950710ea 100644 --- a/sandbox/app/core/runners/python/prescript.py +++ b/sandbox/app/core/runners/python/prescript.py @@ -17,7 +17,7 @@ sys.excepthook = excepthook # Load security library if available lib = ctypes.CDLL("./libpython.so") lib.init_seccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool] -lib.init_seccomp.restype = None +lib.init_seccomp.restype = None # TODO: raise error info # Get running path running_path = sys.argv[1] From 85681db7b70017a4f1caf86f7ece84ab29741e13 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 12:28:40 +0800 Subject: [PATCH 065/175] perf(workflow): update standard node output structure --- api/app/core/workflow/executor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index f3feff60..c4662113 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -368,7 +368,9 @@ class WorkflowExecutor: "timestamp": int(datetime.datetime.fromisoformat( data.get("timestamp") ).timestamp() * 1000), - "state": result.get("node_outputs", {}).get(node_name), + "input": result.get("node_outputs", {}).get(node_name, {}).get("input"), + "output": result.get("node_outputs", {}).get(node_name, {}).get("output"), + "elapsed_time": result.get("node_outputs", {}).get(node_name, {}).get("elapsed_time"), } } From 8228d38859e6ed1782543363d16b9ee093351f71 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 26 Jan 2026 14:26:32 +0800 Subject: [PATCH 066/175] [add] migration script --- .../versions/325b759cd66b_2026011240.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 api/migrations/versions/325b759cd66b_2026011240.py diff --git a/api/migrations/versions/325b759cd66b_2026011240.py b/api/migrations/versions/325b759cd66b_2026011240.py new file mode 100644 index 00000000..66c8681c --- /dev/null +++ b/api/migrations/versions/325b759cd66b_2026011240.py @@ -0,0 +1,50 @@ +"""2026011240 + +Revision ID: 325b759cd66b +Revises: 9a936a9ebb20 +Create Date: 2026-01-26 12:37:35.946749 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '325b759cd66b' +down_revision: Union[str, None] = '9a936a9ebb20' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. 重命名表 data_config -> memory_config + op.rename_table('data_config', 'memory_config') + + # 2. 重命名列 group_id -> end_user_id + op.alter_column('memory_config', 'group_id', new_column_name='end_user_id') + + # 3. config_id: INTEGER -> UUID(保留旧值以便回滚) + op.alter_column('memory_config', 'config_id', new_column_name='config_id_old') + op.add_column('memory_config', sa.Column('config_id', sa.UUID(), nullable=True)) + op.execute("UPDATE memory_config SET config_id = apply_id::uuid") + op.drop_constraint('data_config_pkey', 'memory_config', type_='primary') + op.alter_column('memory_config', 'config_id', nullable=False) + op.create_primary_key('memory_config_pkey', 'memory_config', ['config_id']) + op.execute("DROP SEQUENCE IF EXISTS data_config_config_id_seq") + + +def downgrade() -> None: + # 1. config_id: UUID -> INTEGER(恢复旧值) + op.drop_constraint('memory_config_pkey', 'memory_config', type_='primary') + op.drop_column('memory_config', 'config_id') + op.alter_column('memory_config', 'config_id_old', new_column_name='config_id') + op.create_primary_key('data_config_pkey', 'memory_config', ['config_id']) + op.execute("CREATE SEQUENCE IF NOT EXISTS data_config_config_id_seq OWNED BY memory_config.config_id") + op.execute("SELECT setval('data_config_config_id_seq', COALESCE((SELECT MAX(config_id) FROM memory_config), 1))") + + # 2. 重命名列 end_user_id -> group_id + op.alter_column('memory_config', 'end_user_id', new_column_name='group_id') + + # 3. 重命名表 memory_config -> data_config + op.rename_table('memory_config', 'data_config') From b046411302553158e2af49eeb984ad3e564bf79b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 26 Jan 2026 15:39:35 +0800 Subject: [PATCH 067/175] [modify] migration script --- api/migrations/versions/325b759cd66b_2026011240.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/migrations/versions/325b759cd66b_2026011240.py b/api/migrations/versions/325b759cd66b_2026011240.py index 66c8681c..763b0289 100644 --- a/api/migrations/versions/325b759cd66b_2026011240.py +++ b/api/migrations/versions/325b759cd66b_2026011240.py @@ -25,22 +25,24 @@ def upgrade() -> None: op.alter_column('memory_config', 'group_id', new_column_name='end_user_id') # 3. config_id: INTEGER -> UUID(保留旧值以便回滚) - op.alter_column('memory_config', 'config_id', new_column_name='config_id_old') + op.drop_constraint('data_config_pkey', 'memory_config', type_='primary') + op.alter_column('memory_config', 'config_id', new_column_name='config_id_old', nullable=True) op.add_column('memory_config', sa.Column('config_id', sa.UUID(), nullable=True)) op.execute("UPDATE memory_config SET config_id = apply_id::uuid") - op.drop_constraint('data_config_pkey', 'memory_config', type_='primary') op.alter_column('memory_config', 'config_id', nullable=False) op.create_primary_key('memory_config_pkey', 'memory_config', ['config_id']) op.execute("DROP SEQUENCE IF EXISTS data_config_config_id_seq") def downgrade() -> None: - # 1. config_id: UUID -> INTEGER(恢复旧值) + # 1. config_id: UUID -> INTEGER(恢复旧值,空值生成新ID) + op.execute("CREATE SEQUENCE IF NOT EXISTS data_config_config_id_seq") + op.execute("UPDATE memory_config SET config_id_old = nextval('data_config_config_id_seq') WHERE config_id_old IS NULL") op.drop_constraint('memory_config_pkey', 'memory_config', type_='primary') op.drop_column('memory_config', 'config_id') - op.alter_column('memory_config', 'config_id_old', new_column_name='config_id') + op.alter_column('memory_config', 'config_id_old', new_column_name='config_id', nullable=False) op.create_primary_key('data_config_pkey', 'memory_config', ['config_id']) - op.execute("CREATE SEQUENCE IF NOT EXISTS data_config_config_id_seq OWNED BY memory_config.config_id") + op.execute("ALTER SEQUENCE data_config_config_id_seq OWNED BY memory_config.config_id") op.execute("SELECT setval('data_config_config_id_seq', COALESCE((SELECT MAX(config_id) FROM memory_config), 1))") # 2. 重命名列 end_user_id -> group_id From 2eff6b2e9da716ba88664ea9d830f8e57705e8e4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 15:46:28 +0800 Subject: [PATCH 068/175] feat(web): add workflow runtime info --- web/src/components/Chat/ChatContent.tsx | 17 +- web/src/components/Chat/types.ts | 5 +- web/src/components/Markdown/CodeBlock.tsx | 16 +- web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + web/src/utils/stream.ts | 14 ++ .../views/Workflow/components/Chat/Chat.tsx | 206 ++++++++++++++++-- .../Workflow/components/Chat/chat.module.css | 45 ++++ 8 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 web/src/views/Workflow/components/Chat/chat.module.css diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index c90f9208..a5d02b2b 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -8,6 +8,7 @@ import { type FC, useRef, useEffect } from 'react' import clsx from 'clsx' import Markdown from '@/components/Markdown' import type { ChatContentProps } from './types' +import { Spin } from 'antd' /** * 聊天内容显示组件 @@ -21,7 +22,8 @@ const ChatContent: FC = ({ empty, labelPosition = 'bottom', labelFormat, - errorDesc + errorDesc, + renderRuntime }) => { // 滚动容器引用,用于控制自动滚动到底部 const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) @@ -45,8 +47,8 @@ const ChatContent: FC = ({ 'rb:left-0 rb:text-left': item.role === 'assistant', // 助手消息左对齐 })}> {/* 流式加载时且内容为空则不显示 */} - {streamLoading && item.content === '' - ? null + {streamLoading && item.content === '' && !renderRuntime + ? : <> {/* 顶部标签(如时间戳、用户名等) */} {labelPosition === 'top' && @@ -55,16 +57,17 @@ const ChatContent: FC = ({ } {/* 消息气泡框 */} -
+ {item.subContent && renderRuntime && renderRuntime(item, index)} {/* 使用Markdown组件渲染消息内容 */} - +
{/* 底部标签(如时间戳、用户名等) */} {labelPosition === 'bottom' && diff --git a/web/src/components/Chat/types.ts b/web/src/components/Chat/types.ts index 851a8ccc..264ce39c 100644 --- a/web/src/components/Chat/types.ts +++ b/web/src/components/Chat/types.ts @@ -19,7 +19,9 @@ export interface ChatItem { /** 消息内容 */ content?: string | null; /** 创建时间 */ - created_at?: number | string + created_at?: number | string; + status?: string; + subContent?: Record[] } /** @@ -81,4 +83,5 @@ export interface ChatContentProps { /** 标签格式化函数 */ labelFormat: (item: ChatItem) => any; errorDesc?: string; + renderRuntime?: (item: ChatItem, index: number) => ReactNode; } \ No newline at end of file diff --git a/web/src/components/Markdown/CodeBlock.tsx b/web/src/components/Markdown/CodeBlock.tsx index 23d54c34..a125a997 100644 --- a/web/src/components/Markdown/CodeBlock.tsx +++ b/web/src/components/Markdown/CodeBlock.tsx @@ -6,6 +6,9 @@ import CopyBtn from './CopyBtn'; type ICodeBlockProps = { value: string; + needCopy?: boolean; + size?: 'small' | 'default'; + showLineNumbers?: boolean; } // enum languageType { @@ -16,6 +19,9 @@ type ICodeBlockProps = { const CodeBlock: FC = ({ value, + needCopy = true, + size = 'default', + showLineNumbers = false }) => { return ( @@ -23,24 +29,26 @@ const CodeBlock: FC = ({ {value} - + />} ) } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1df2eb6d..87a95c40 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1982,6 +1982,10 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re arrange: 'Arrange', redo: 'Redo', undo: 'Undo', + + input: 'Input', + output: 'Output', + error: 'Error Message', }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 39908757..fc683a66 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2076,6 +2076,10 @@ export const zh = { arrange: '整理', redo: '重做', undo: '撤销', + + input: '输入', + output: '输出', + error: '错误信息', }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index e4179e25..2501fde5 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -123,6 +123,20 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe let response = await makeSSERequest(url, data, token || '', config); switch (response.status) { + case 500: + case 502: + const errorData = await response.json(); + errorData.error || i18n.t('common.serviceUpgrading'); + message.warning(errorData.error || i18n.t('common.serviceUpgrading')); + break + case 400: + const error = await response.json(); + message.warning(errorData.error); + throw error || 'Bad Request'; + case 504: + const errorJson = await response.json(); + message.warning(errorJson.error || i18n.t('common.serverError')); + break case 401: if (url?.includes('/public')) { return message.warning(i18n.t('common.publicApiCannotRefreshToken')); diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 246c2e4c..4a1ac5a7 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -1,8 +1,9 @@ import { forwardRef, useImperativeHandle, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import clsx from 'clsx' -import { Input, Form, App } from 'antd' -import { Space, Button } from 'antd' +import { Input, Form, App, Space, Button, Collapse } from 'antd' +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons' +import CodeBlock from '@/components/Markdown/CodeBlock' import ChatIcon from '@/assets/images/application/chat.png' import RbDrawer from '@/components/RbDrawer'; @@ -13,8 +14,11 @@ import ChatContent from '@/components/Chat/ChatContent' import type { ChatItem } from '@/components/Chat/types' import ChatSendIcon from '@/assets/images/application/chatSend.svg' import dayjs from 'dayjs' -import type { ChatRef, VariableConfigModalRef, StartVariableItem, GraphRef } from '../../types' +import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types' import { type SSEMessage } from '@/utils/stream' +import type { Variable } from '../Properties/VariableList/types' +import styles from './chat.module.css' +import Markdown from '@/components/Markdown' const Chat = forwardRef(({ appId, graphRef }, ref) => { const { t } = useTranslation() @@ -24,7 +28,7 @@ const Chat = forwardRef(({ appId const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [chatList, setChatList] = useState([]) - const [variables, setVariables] = useState([]) + const [variables, setVariables] = useState([]) const [streamLoading, setStreamLoading] = useState(false) const [conversationId, setConversationId] = useState(null) @@ -39,7 +43,7 @@ const Chat = forwardRef(({ appId if (startNodes.length) { const curVariables = startNodes[0].config.variables?.defaultValue - curVariables.forEach((vo: StartVariableItem) => { + curVariables.forEach((vo: Variable) => { if (typeof vo.default !== 'undefined') { vo.value = vo.default } @@ -60,7 +64,7 @@ const Chat = forwardRef(({ appId const handleEditVariables = () => { variableConfigModalRef.current?.handleOpen(variables) } - const handleSave = (values: StartVariableItem[]) => { + const handleSave = (values: Variable[]) => { setVariables([...values]) } const handleSend = () => { @@ -97,13 +101,28 @@ const Chat = forwardRef(({ appId role: 'assistant', content: '', created_at: Date.now(), + subContent: [], }]) const handleStreamMessage = (data: SSEMessage[]) => { - setStreamLoading(false) - data.forEach(item => { - const { chunk, conversation_id } = item.data as { chunk: string; conversation_id: string | null; }; + const { chunk, conversation_id, node_id, input, output, error, elapsed_time, status } = item.data as { + chunk: string; + conversation_id: string | null; + node_id: string; + node_name?: string; + input?: any; + output?: any; + elapsed_time?: string; + error?: any; + state: Record; + status?: 'completed' | 'failed' + }; + + const node = graphRef.current?.getNodes().find(n => n.id === node_id); + const { name, icon } = node?.getData() || {} + + console.log('node', node?.getData()) switch(item.event) { case 'message': @@ -119,6 +138,66 @@ const Chat = forwardRef(({ appId return newList }) break + case 'node_start': + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + const newSubContent = newList[lastIndex].subContent || [] + const filterIndex = newSubContent.findIndex(vo => vo.id === node_id) + if (filterIndex > -1) { + newSubContent[filterIndex] = { + ...newSubContent[filterIndex], + node_id: node_id, + node_name: name, + icon, + content: {}, + } + } else { + newSubContent.push({ + id: node_id, + node_id: node_id, + node_name: name, + icon, + content: {}, + }) + } + newList[lastIndex] = { + ...newList[lastIndex], + subContent: newSubContent + } + } + return newList + }) + break + case 'node_end': + case 'node_error': + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + const newSubContent = newList[lastIndex].subContent || [] + const filterIndex = newSubContent.findIndex(vo => vo.node_id === node_id) + if (filterIndex > -1 && newSubContent[filterIndex].content) { + newSubContent[filterIndex] = { + ...newSubContent[filterIndex], + content: { + input, + output, + error, + }, + status: status || 'completed', + elapsed_time + } + } + newList[lastIndex] = { + ...newList[lastIndex], + subContent: newSubContent + } + } + return newList + }) + break case 'workflow_end': setChatList(prev => { const newList = [...prev] @@ -126,6 +205,7 @@ const Chat = forwardRef(({ appId if (lastIndex >= 0) { newList[lastIndex] = { ...newList[lastIndex], + status, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content } } @@ -142,14 +222,31 @@ const Chat = forwardRef(({ appId } form.setFieldValue('message', undefined) + setStreamLoading(true) draftRun(appId, { message: message, variables: params, stream: true, conversation_id: conversationId }, handleStreamMessage) + .catch((error) => { + setChatList(prev => { + const newList = [...prev] + const lastIndex = newList.length - 1 + if (lastIndex >= 0) { + newList[lastIndex] = { + ...newList[lastIndex], + status: 'failed', + content: null, + subContent: error.error + } + } + return newList + }) + }) .finally(() => { setLoading(false) + setStreamLoading(false) }) } // 暴露给父组件的方法 @@ -158,6 +255,11 @@ const Chat = forwardRef(({ appId handleClose })); + const getStatus = (status?: string) => { + return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]' + } + + console.log('chatList', chatList) return ( @@ -173,10 +275,7 @@ const Chat = forwardRef(({ appId onClose={handleClose} > } data={chatList} @@ -184,6 +283,87 @@ const Chat = forwardRef(({ appId labelPosition="bottom" labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} errorDesc={t('application.ReplyException')} + renderRuntime={(item, index) => { + return ( +
+ + {item.status === 'completed' ? : item.status === 'failed' ? : } + {t('application.workflow')} +
, + className: styles.collapseItem, + children: ( + Array.isArray(item.subContent) + ? + {item.subContent?.map(vo => ( + +
+ {vo.icon && } +
{vo.node_name || vo.node_id}
+
+ + {typeof vo.elapsed_time == 'number' && <>{vo.elapsed_time?.toFixed(3)}ms} + {vo.status === 'completed' ? : vo.status === 'failed' ? : } + + , + className: styles.collapseItem, + children: ( + + {vo.status === 'failed' && +
+
+ {t(`workflow.error`)} + +
+
+ +
+
+ } + {['input', 'output'].map(key => ( +
+
+ {t(`workflow.${key}`)} + +
+
+ +
+
+ ))} +
+ ) + }]} + /> + ))} +
+ :
+ +
+ ) + }]} + /> + + ) + }} />
diff --git a/web/src/views/Workflow/components/Chat/chat.module.css b/web/src/views/Workflow/components/Chat/chat.module.css new file mode 100644 index 00000000..99fe11f7 --- /dev/null +++ b/web/src/views/Workflow/components/Chat/chat.module.css @@ -0,0 +1,45 @@ +.completed { + background-color: rgba(54, 159, 33, 0.06); + border-color: rgba(54, 159, 33, 0.25); + border-radius: 8px; +} +.failed { + background-color: rgba(255, 138, 76, 0.08); + border-color: rgba(255, 138, 76, 0.20); + border-radius: 8px; +} +.default { + background-color: rgba(91, 97, 103, 0.08); + border-color: rgba(91, 97, 103, 0.30); + border-radius: 8px; +} +.collapse-item { + font-size: 12px; + line-height: 16px; +} +.collapse-item:global(.ant-collapse-item>.ant-collapse-header) { + padding: 8px 12px; +} +.collapse-item:global(.ant-collapse-item>.ant-collapse-header .ant-collapse-expand-icon) { + height: 16px; +} +.completed:global(.ant-collapse .ant-collapse-content), +.failed:global(.ant-collapse .ant-collapse-content) { + background-color: transparent; + border-top: none; +} +:global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { + padding-top: 0; +} +.collapse-item :global(.ant-collapse) { + /* background-color: #F0F3F8; */ + background-color: #FBFDFF; + border-radius: 6px; +} +.collapse-item :global(.ant-collapse>.ant-collapse-item:last-child), +.collapse-item :global(.ant-collapse>.ant-collapse-item:last-child>.ant-collapse-header) { + border-radius: 0 0 6px 6px; +} +.collapse-item :global(.ant-collapse .ant-collapse-content>.ant-collapse-content-box) { + padding: 0 4px 4px 4px; +} \ No newline at end of file From 7bfa7b3f029812a8966f639e0de3f6458c84adf1 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 16:00:47 +0800 Subject: [PATCH 069/175] fix(web): handleSSE bugfix --- web/src/utils/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index 2501fde5..be2220da 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -131,7 +131,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe break case 400: const error = await response.json(); - message.warning(errorData.error); + message.warning(error.error); throw error || 'Bad Request'; case 504: const errorJson = await response.json(); From 3b4b474ce869e4416db314df8669671a1c931a6f Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 16:32:58 +0800 Subject: [PATCH 070/175] fix(sandbox): prevent imports from being blocked when network is disabled --- sandbox/lib/seccomp_python/src/syscalls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox/lib/seccomp_python/src/syscalls.rs b/sandbox/lib/seccomp_python/src/syscalls.rs index 07070d22..961fffac 100644 --- a/sandbox/lib/seccomp_python/src/syscalls.rs +++ b/sandbox/lib/seccomp_python/src/syscalls.rs @@ -10,6 +10,7 @@ pub static ALLOW_SYSCALLS: &[i32] = &[ libc::SYS_ioctl as i32, libc::SYS_lseek as i32, libc::SYS_getdents64 as i32, + libc::SYS_fstat as i32, // thread libc::SYS_futex as i32, @@ -77,7 +78,6 @@ pub static ALLOW_NETWORK_SYSCALLS: &[i32] = &[ libc::SYS_sendmsg as i32, libc::SYS_sendmmsg as i32, libc::SYS_getsockopt as i32, - libc::SYS_fstat as i32, libc::SYS_fcntl as i32, libc::SYS_fstatfs as i32, libc::SYS_poll as i32, From 399357f75293e9bbd24aa7cf8591ddb1d629156e Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 26 Jan 2026 17:06:55 +0800 Subject: [PATCH 071/175] =?UTF-8?q?user=5Fid->=E7=8E=B0=E5=AE=9E=E4=B8=BAc?= =?UTF-8?q?onfig=5Fid=5Fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 80d8c717..1a25c779 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "user_id": config.user_id, + "config_id_old": config.user_id, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From 7cd0d78424659f8cda7a9cd381798274f74131b5 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 26 Jan 2026 17:21:10 +0800 Subject: [PATCH 072/175] =?UTF-8?q?user=5Fid->=E6=98=BE=E7=A4=BA=E4=B8=BAc?= =?UTF-8?q?onfig=5Fid=5Fold=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1a25c779..1707f8fa 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": config.user_id, + "config_id_old": config.config_id_old, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From ebc41b2eec3ed5f1dcb46fc5b66e6c87abe1f437 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:22:48 +0800 Subject: [PATCH 073/175] Fix/memory bug fix (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 80d8c717..1707f8fa 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "user_id": config.user_id, + "config_id_old": config.config_id_old, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From e1f56078366ea06fa330853ca2bb0ddfd6d2eef2 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 26 Jan 2026 17:37:40 +0800 Subject: [PATCH 074/175] =?UTF-8?q?user=5Fid->=E6=98=BE=E7=A4=BA=E4=B8=BAc?= =?UTF-8?q?onfig=5Fid=5Fold=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1707f8fa..1a25c779 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": config.config_id_old, + "config_id_old": config.user_id, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From 46f0f3cee90f7cf852bf5bcf89866b57448f1ffa Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 17:43:25 +0800 Subject: [PATCH 075/175] feat(web): update read_all_config select valueKey --- web/src/components/CustomSelect/index.tsx | 19 +++++++++++++------ web/src/views/ApplicationConfig/Agent.tsx | 6 ++++-- web/src/views/Workflow/constant.ts | 4 ++-- web/src/views/Workflow/types.ts | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/web/src/components/CustomSelect/index.tsx b/web/src/components/CustomSelect/index.tsx index 1887d635..6153a76d 100644 --- a/web/src/components/CustomSelect/index.tsx +++ b/web/src/components/CustomSelect/index.tsx @@ -15,7 +15,7 @@ interface ApiResponse { interface CustomSelectProps extends Omit { url: string; params?: Record; - valueKey?: string; + valueKey?: string | string[]; labelKey?: string; placeholder?: string; hasAll?: boolean; @@ -66,11 +66,18 @@ const CustomSelect: FC = ({ {...props} > {hasAll && {allTitle || t('common.all')}} - {displayOptions.map((option) => ( - - {String(option[labelKey])} - - ))} + {displayOptions.map((option) => { + const getValue = () => { + if (typeof valueKey === 'string') return option[valueKey]; + return valueKey.find(key => option[key] != null) ? option[valueKey.find(key => option[key] != null)!] : undefined; + }; + const value = getValue(); + return ( + + {String(option[labelKey])} + + ); + })} ); }; diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 77e90440..97a622d1 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -79,7 +79,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], placeholder={t('common.pleaseSelect')} url={url} hasAll={false} - valueKey='config_id' + valueKey={['config_id_old', 'config_id']} labelKey="config_name" /> @@ -126,12 +126,14 @@ const Agent = forwardRef((_props, ref) => { getApplicationConfig(id as string).then(res => { const response = res as Config let allTools = Array.isArray(response.tools) ? response.tools : [] + const memoryContent = response.memory?.memory_content + const convertedMemoryContent = memoryContent && !isNaN(Number(memoryContent)) ? Number(memoryContent) : memoryContent form.setFieldsValue({ ...response, tools: allTools, memory: { ...response.memory, - memory_content: response.memory?.memory_content ? Number(response.memory?.memory_content) : undefined + memory_content: convertedMemoryContent } }) setData({ diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index e250e184..aab8be7d 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -200,7 +200,7 @@ export const nodeLibrary: NodeLibrary[] = [ config_id: { type: 'customSelect', url: memoryConfigListUrl, - valueKey: 'config_id', + valueKey: ['config_id_old', 'config_id'], labelKey: 'config_name' }, search_switch: { @@ -223,7 +223,7 @@ export const nodeLibrary: NodeLibrary[] = [ config_id: { type: 'customSelect', url: memoryConfigListUrl, - valueKey: 'config_id', + valueKey: ['config_id_old', 'config_id'], labelKey: 'config_name' } } diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 909c30e4..31d1f512 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -14,7 +14,7 @@ export interface NodeConfig { url?: string; params?: { [key: string]: unknown; } - valueKey?: string; + valueKey?: string | string[]; labelKey?: string; defaultValue?: any; From 722746c78b7c95a2d72e11ac637945fa65daac5a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Mon, 26 Jan 2026 17:47:05 +0800 Subject: [PATCH 076/175] =?UTF-8?q?user=5Fid->=E6=98=BE=E7=A4=BA=E4=B8=BAc?= =?UTF-8?q?onfig=5Fid=5Fold=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1a25c779..0ede7bd3 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": config.user_id, + "config_id_old": int(config.user_id), "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From f1f887faaebc3caa74feb05730a76faae6bb30f3 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 17:29:44 +0800 Subject: [PATCH 077/175] feat(workflow): Add a new node for executing code --- api/app/core/workflow/nodes/code/__init__.py | 3 + api/app/core/workflow/nodes/code/config.py | 50 +++++++ api/app/core/workflow/nodes/code/node.py | 122 ++++++++++++++++++ api/app/core/workflow/nodes/configs.py | 14 +- api/app/core/workflow/nodes/node_factory.py | 5 +- sandbox/app/core/executor.py | 1 - .../app/core/runners/python/python_runner.py | 5 +- sandbox/app/services/python_service.py | 4 +- 8 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 api/app/core/workflow/nodes/code/config.py create mode 100644 api/app/core/workflow/nodes/code/node.py diff --git a/api/app/core/workflow/nodes/code/__init__.py b/api/app/core/workflow/nodes/code/__init__.py index e69de29b..e42af93d 100644 --- a/api/app/core/workflow/nodes/code/__init__.py +++ b/api/app/core/workflow/nodes/code/__init__.py @@ -0,0 +1,3 @@ +from app.core.workflow.nodes.code.node import CodeNode + +__all__ = ["CodeNode"] \ No newline at end of file diff --git a/api/app/core/workflow/nodes/code/config.py b/api/app/core/workflow/nodes/code/config.py new file mode 100644 index 00000000..35b757e9 --- /dev/null +++ b/api/app/core/workflow/nodes/code/config.py @@ -0,0 +1,50 @@ +from typing import Literal +from pydantic import Field, BaseModel + +from app.core.workflow.nodes.base_config import BaseNodeConfig, VariableType + + +class InputVariable(BaseModel): + name: str = Field( + ..., + description="variable name" + ) + + variable: str = Field( + ..., + description="variable selector" + ) + + +class OutputVariable(BaseModel): + name: str = Field( + ..., + description="variable name" + ) + + type: VariableType = Field( + ..., + description="variable selector" + ) + + +class CodeNodeConfig(BaseNodeConfig): + input_variables: list[InputVariable] = Field( + default_factory=list, + description="input variables" + ) + + output_variables: list[OutputVariable] = Field( + default_factory=list, + description="output variables" + ) + + code_content: str = Field( + default="", + description="code content" + ) + + language: Literal['python3', 'nodejs'] = Field( + ..., + description="language" + ) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py new file mode 100644 index 00000000..3e15089b --- /dev/null +++ b/api/app/core/workflow/nodes/code/node.py @@ -0,0 +1,122 @@ +import base64 +import json +import logging +import re +from string import Template +from textwrap import dedent +from typing import Any + +import httpx +from sympy.physics.vector import vlatex + +from app.core.workflow.nodes import BaseNode, WorkflowState +from app.core.workflow.nodes.base_config import VariableType +from app.core.workflow.nodes.code.config import CodeNodeConfig + +logger = logging.getLogger(__name__) + +SCRIPT_TEMPLATE = Template(dedent(""" +$code + +import json +from base64 import b64decode + +# decode and prepare input dict +inputs_obj = json.loads(b64decode('$inputs_variable').decode('utf-8')) + +# execute main function +output_obj = main(**inputs_obj) + +# convert output to json and print +output_json = json.dumps(output_obj, indent=4) +result = "<>" + output_json + "<>" +print(result) +""")) + + +class CodeNode(BaseNode): + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): + super().__init__(node_config, workflow_config) + self.typed_config: CodeNodeConfig | None = None + + def extract_result(self, content: str): + match = re.search(r'<>(.*?)<>', content, re.DOTALL) + if match: + extracted = match.group(1) + exec_result = json.loads(extracted) + result = {} + for output in self.typed_config.output_variables: + value = exec_result.get(output.name) + if not value: + raise RuntimeError(f"Return value {output.name} does not exist") + match output.type: + case VariableType.STRING: + if not isinstance(value, str): + raise RuntimeError(f"Return value {output.name} should be a string") + case VariableType.BOOLEAN: + if not isinstance(value, bool): + raise RuntimeError(f"Return value {output.name} should be a boolean") + case VariableType.NUMBER: + if not isinstance(value, (int, float)): + raise RuntimeError(f"Return value {output.name} should be a number") + case VariableType.OBJECT: + if not isinstance(value, dict): + raise RuntimeError(f"Return value {output.name} should be a dictionary") + case VariableType.ARRAY_STRING: + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of strings") + case VariableType.ARRAY_NUMBER: + if not isinstance(value, list) or not all(isinstance(v, (int, float)) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of numbers") + case VariableType.ARRAY_OBJECT: + if not isinstance(value, list) or not all(isinstance(v, dict) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of dictionaries") + case VariableType.ARRAY_BOOLEAN: + if not isinstance(value, list) or not all(isinstance(v, bool) for v in value): + raise RuntimeError(f"Return value {output.name} should be a list of booleans") + result[output.name] = value + return result + else: + raise RuntimeError("The output of main must be a dictionary") + + async def execute(self, state: WorkflowState) -> Any: + self.typed_config = CodeNodeConfig(**self.config) + input_variable_dict = {} + for input_variable in self.typed_config.input_variables: + input_variable_dict[input_variable.name] = self.get_variable(input_variable.variable, state) + code = base64.b64decode( + self.typed_config.code + ).decode("utf-8") + + input_variable_dict = base64.b64encode( + json.dumps(input_variable_dict).encode("utf-8") + ).decode("utf-8") + + final_script = SCRIPT_TEMPLATE.substitute( + code=code, + inputs_variable=input_variable_dict, + ) + + async with httpx.AsyncClient() as client: + response = await client.post( + "http://sandbox:8194/v1/sandbox/run", + headers={ + "x-api-key": 'redbear-sandbox' + }, + json={ + "language": "python3", + "code": base64.b64encode(final_script.encode("utf-8")).decode("utf-8"), + "options": { + "enable_network": True + } + } + ) + resp = response.json() + + match resp['code']: + case 31: + raise RuntimeError("Operation not permitted") + case 0: + return self.extract_result(resp["data"]["stdout"]) + case _: + raise Exception(resp["message"]) diff --git a/api/app/core/workflow/nodes/configs.py b/api/app/core/workflow/nodes/configs.py index 4d31efaa..d73754f6 100644 --- a/api/app/core/workflow/nodes/configs.py +++ b/api/app/core/workflow/nodes/configs.py @@ -10,21 +10,22 @@ from app.core.workflow.nodes.base_config import ( VariableDefinition, VariableType, ) +from app.core.workflow.nodes.code.config import CodeNodeConfig +from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig from app.core.workflow.nodes.end.config import EndNodeConfig from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig from app.core.workflow.nodes.if_else.config import IfElseNodeConfig from app.core.workflow.nodes.jinja_render.config import JinjaRenderNodeConfig from app.core.workflow.nodes.knowledge.config import KnowledgeRetrievalNodeConfig from app.core.workflow.nodes.llm.config import LLMNodeConfig, MessageConfig -from app.core.workflow.nodes.start.config import StartNodeConfig -from app.core.workflow.nodes.transform.config import TransformNodeConfig -from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig +from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig from app.core.workflow.nodes.question_classifier.config import QuestionClassifierNodeConfig +from app.core.workflow.nodes.start.config import StartNodeConfig from app.core.workflow.nodes.tool.config import ToolNodeConfig -from app.core.workflow.nodes.memory.config import MemoryReadNodeConfig, MemoryWriteNodeConfig +from app.core.workflow.nodes.transform.config import TransformNodeConfig +from app.core.workflow.nodes.variable_aggregator.config import VariableAggregatorNodeConfig -from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig __all__ = [ # 基础类 "BaseNodeConfig", @@ -49,5 +50,6 @@ __all__ = [ "QuestionClassifierNodeConfig", "ToolNodeConfig", "MemoryReadNodeConfig", - "MemoryWriteNodeConfig" + "MemoryWriteNodeConfig", + "CodeNodeConfig" ] diff --git a/api/app/core/workflow/nodes/node_factory.py b/api/app/core/workflow/nodes/node_factory.py index 9fca8d7a..fb2fe00f 100644 --- a/api/app/core/workflow/nodes/node_factory.py +++ b/api/app/core/workflow/nodes/node_factory.py @@ -10,6 +10,7 @@ from typing import Any, Union from app.core.workflow.nodes.agent import AgentNode from app.core.workflow.nodes.assigner import AssignerNode from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.nodes.code import CodeNode from app.core.workflow.nodes.cycle_graph.node import CycleGraphNode from app.core.workflow.nodes.end import EndNode from app.core.workflow.nodes.enums import NodeType @@ -49,7 +50,8 @@ WorkflowNode = Union[ QuestionClassifierNode, ToolNode, MemoryReadNode, - MemoryWriteNode + MemoryWriteNode, + CodeNode ] @@ -81,6 +83,7 @@ class NodeFactory: NodeType.TOOL: ToolNode, NodeType.MEMORY_READ: MemoryReadNode, NodeType.MEMORY_WRITE: MemoryWriteNode, + NodeType.CODE: CodeNode, } @classmethod diff --git a/sandbox/app/core/executor.py b/sandbox/app/core/executor.py index 6edc48c0..e87b510c 100644 --- a/sandbox/app/core/executor.py +++ b/sandbox/app/core/executor.py @@ -15,7 +15,6 @@ class ExecutionResult: self.stdout = stdout self.stderr = stderr self.exit_code = exit_code - self.error = error class CodeExecutor(ABC): diff --git a/sandbox/app/core/runners/python/python_runner.py b/sandbox/app/core/runners/python/python_runner.py index faac5f0c..30792b91 100644 --- a/sandbox/app/core/runners/python/python_runner.py +++ b/sandbox/app/core/runners/python/python_runner.py @@ -9,12 +9,15 @@ from app.config import SANDBOX_USER_ID, SANDBOX_GROUP_ID, get_config from app.core.encryption import generate_key, encrypt_code from app.core.executor import CodeExecutor, ExecutionResult from app.core.runners.python.settings import check_lib_avaiable, release_lib_binary, LIB_PATH +from app.logger import get_logger from app.models import RunnerOptions # Python sandbox prescript template with open("app/core/runners/python/prescript.py") as f: PYTHON_PRESCRIPT = f.read() +logger = get_logger() + class PythonRunner(CodeExecutor): """Python code runner with security isolation""" @@ -106,6 +109,7 @@ class PythonRunner(CodeExecutor): env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls)) # Execute with Python interpreter + logger.info(encoded_key) process = await asyncio.create_subprocess_exec( config.python_path, @@ -143,7 +147,6 @@ class PythonRunner(CodeExecutor): stdout="", stderr="Execution timeout", exit_code=-1, - error="Execution timeout" ) finally: diff --git a/sandbox/app/services/python_service.py b/sandbox/app/services/python_service.py index 71cfda0d..5700841d 100644 --- a/sandbox/app/services/python_service.py +++ b/sandbox/app/services/python_service.py @@ -37,8 +37,8 @@ async def run_python_code(code: str, preload: str, options: RunnerOptions): if result.exit_code == -signal.SIGSYS: return error_response(31, "sandbox security policy violation") - if result.error: - return error_response(-500, result.error) + if result.stderr: + return error_response(500, result.stderr) return success_response(RunCodeResponse( stdout=result.stdout, From f76bffb4823252ed482867c3fe112a1cf09f5a16 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 18:32:18 +0800 Subject: [PATCH 078/175] fix(web): KnowledgeConfigModal bugfix --- .../components/Knowledge/KnowledgeConfigModal.tsx | 2 +- .../Properties/Knowledge/KnowledgeConfigModal.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/Knowledge/KnowledgeConfigModal.tsx b/web/src/views/ApplicationConfig/components/Knowledge/KnowledgeConfigModal.tsx index abf56b18..70b17a11 100644 --- a/web/src/views/ApplicationConfig/components/Knowledge/KnowledgeConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/Knowledge/KnowledgeConfigModal.tsx @@ -66,7 +66,7 @@ const KnowledgeConfigModal = forwardRef { if (values?.retrieve_type) { const fieldsToReset = Object.keys(values).filter(key => - key !== 'kb_id' && key !== 'retrieve_type' + key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k' ) as (keyof KnowledgeConfigForm)[]; form.resetFields(fieldsToReset); } diff --git a/web/src/views/Workflow/components/Properties/Knowledge/KnowledgeConfigModal.tsx b/web/src/views/Workflow/components/Properties/Knowledge/KnowledgeConfigModal.tsx index 77ca21a2..196ce8e3 100644 --- a/web/src/views/Workflow/components/Properties/Knowledge/KnowledgeConfigModal.tsx +++ b/web/src/views/Workflow/components/Properties/Knowledge/KnowledgeConfigModal.tsx @@ -66,7 +66,7 @@ const KnowledgeConfigModal = forwardRef { if (values?.retrieve_type) { const fieldsToReset = Object.keys(values).filter(key => - key !== 'kb_id' && key !== 'retrieve_type' + key !== 'kb_id' && key !== 'retrieve_type' && key !== 'top_k' ) as (keyof KnowledgeConfigForm)[]; form.resetFields(fieldsToReset); } @@ -108,6 +108,7 @@ const KnowledgeConfigModal = forwardRef {/* Top K */} @@ -116,13 +117,12 @@ const KnowledgeConfigModal = forwardRef form.setFieldValue('top_k', value)} + // onChange={(value) => form.setFieldValue('top_k', value)} /> {/* 语义相似度阈值 similarity_threshold */} From 5267bd60a566893d9269a20c4a073642b479fa33 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 26 Jan 2026 18:40:28 +0800 Subject: [PATCH 079/175] fix(web): iteration's variable add parameter-extractor node --- web/src/views/Workflow/constant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index e250e184..7b15c049 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -284,7 +284,7 @@ export const nodeLibrary: NodeLibrary[] = [ config: { input: { type: 'variableList', - filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'], + filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor'], filterVariableNames: ['message'] }, parallel: { From 1f615a06add14d193f7d2840bddefb53712ced32 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 26 Jan 2026 18:50:22 +0800 Subject: [PATCH 080/175] fix(sandbox): treat non-zero exit codes as errors instead of relying only on stderr --- api/app/core/workflow/nodes/code/config.py | 2 +- api/app/core/workflow/nodes/code/node.py | 4 ++-- sandbox/app/services/python_service.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/core/workflow/nodes/code/config.py b/api/app/core/workflow/nodes/code/config.py index 35b757e9..8af13f12 100644 --- a/api/app/core/workflow/nodes/code/config.py +++ b/api/app/core/workflow/nodes/code/config.py @@ -39,7 +39,7 @@ class CodeNodeConfig(BaseNodeConfig): description="output variables" ) - code_content: str = Field( + code: str = Field( default="", description="code content" ) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index 3e15089b..5262a7e2 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -47,7 +47,7 @@ class CodeNode(BaseNode): result = {} for output in self.typed_config.output_variables: value = exec_result.get(output.name) - if not value: + if value is None: raise RuntimeError(f"Return value {output.name} does not exist") match output.type: case VariableType.STRING: @@ -104,7 +104,7 @@ class CodeNode(BaseNode): "x-api-key": 'redbear-sandbox' }, json={ - "language": "python3", + "language": self.typed_config.language, "code": base64.b64encode(final_script.encode("utf-8")).decode("utf-8"), "options": { "enable_network": True diff --git a/sandbox/app/services/python_service.py b/sandbox/app/services/python_service.py index 5700841d..210b2086 100644 --- a/sandbox/app/services/python_service.py +++ b/sandbox/app/services/python_service.py @@ -37,7 +37,7 @@ async def run_python_code(code: str, preload: str, options: RunnerOptions): if result.exit_code == -signal.SIGSYS: return error_response(31, "sandbox security policy violation") - if result.stderr: + if result.stderr and result.exit_code != 0: return error_response(500, result.stderr) return success_response(RunCodeResponse( From a5b8d3afa5ef19723ae5cce57f3fbbee70ff51f8 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:05:07 +0800 Subject: [PATCH 081/175] Fix/memory bug fix (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 1707f8fa..0ede7bd3 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -188,7 +188,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": config.config_id_old, + "config_id_old": int(config.user_id), "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, From 80ca247435fe9d79a0c0c71fd4b0113284eaf359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:05:20 +0800 Subject: [PATCH 082/175] Refactor/benchmark test (#196) * [changes]refactor locomo_test * [fix]Fix the circular import of ModelParameters * [changes]The benchmark test can run stably. * [fix]Complete end-to-end LoCoMo repair * [fix]Complete the end-to-end longmemeval and memsciqa fixes * [changes]Complete the benchmark test description document to ensure that the configuration parameters take effect. * [changes]refactor locomo_test * [fix]Fix the circular import of ModelParameters * [changes]The benchmark test can run stably. * [fix]Complete end-to-end LoCoMo repair * [fix]Complete the end-to-end longmemeval and memsciqa fixes * [changes]Complete the benchmark test description document to ensure that the configuration parameters take effect. * [changes]Benchmark test adaptation for end_user_id * [changes]refactor locomo_test * [fix]Fix the circular import of ModelParameters * [changes]The benchmark test can run stably. * [fix]Complete end-to-end LoCoMo repair * [fix]Complete the end-to-end longmemeval and memsciqa fixes * [changes]Complete the benchmark test description document to ensure that the configuration parameters take effect. * [fix]Complete the end-to-end longmemeval and memsciqa fixes * [changes]Complete the benchmark test description document to ensure that the configuration parameters take effect. * [changes]Benchmark test adaptation for end_user_id --- .../memory/evaluation/.env.evaluation.example | 224 +++++ api/app/core/memory/evaluation/.gitignore | 13 + api/app/core/memory/evaluation/benchmark.md | 772 +++++++++++++++- .../memory/evaluation/check_enduser_data.py | 371 ++++++++ .../core/memory/evaluation/common/metrics.py | 2 +- .../memory/evaluation/dialogue_queries.py | 6 +- .../memory/evaluation/extraction_utils.py | 369 +++++--- .../evaluation/locomo/locomo_benchmark.py | 863 +++++++++++------- .../memory/evaluation/locomo/locomo_test.py | 293 +++--- .../memory/evaluation/locomo/locomo_utils.py | 69 +- .../evaluation/locomo/qwen_search_eval.py | 96 +- ...earch_eval.py => longmemeval_benchmark.py} | 181 ++-- .../evaluation/longmemeval/test_eval.py | 132 ++- .../evaluation/memsciqa/memsciqa-test.py | 224 +++-- .../{evaluate_qa.py => memsciqa_benchmark.py} | 123 ++- api/app/core/memory/evaluation/run_eval.py | 21 +- .../extraction_orchestrator.py | 22 +- api/app/models/agent_app_config_model.py | 2 +- api/app/models/multi_agent_model.py | 2 +- api/app/schemas/multi_agent_schema.py | 2 +- api/app/services/master_agent_router.py | 2 +- api/app/utils/app_config_utils.py | 2 +- 22 files changed, 2760 insertions(+), 1031 deletions(-) create mode 100644 api/app/core/memory/evaluation/.env.evaluation.example create mode 100644 api/app/core/memory/evaluation/.gitignore create mode 100644 api/app/core/memory/evaluation/check_enduser_data.py rename api/app/core/memory/evaluation/longmemeval/{qwen_search_eval.py => longmemeval_benchmark.py} (93%) rename api/app/core/memory/evaluation/memsciqa/{evaluate_qa.py => memsciqa_benchmark.py} (76%) diff --git a/api/app/core/memory/evaluation/.env.evaluation.example b/api/app/core/memory/evaluation/.env.evaluation.example new file mode 100644 index 00000000..be089eb4 --- /dev/null +++ b/api/app/core/memory/evaluation/.env.evaluation.example @@ -0,0 +1,224 @@ +# ============================================================================ +# 基准测试统一配置文件示例 +# ============================================================================ +# 复制此文件为 .env.evaluation 并根据需要修改 +# 支持的基准测试:LoCoMo、LongMemEval、MemSciQA +# ============================================================================ + +# ============================================================================ +# 通用配置(所有基准测试共用) +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Neo4j 配置 +# ---------------------------------------------------------------------------- +# 默认 Group ID(建议各基准测试使用独立的 group) +EVAL_GROUP_ID=benchmark_default + +# ---------------------------------------------------------------------------- +# 模型配置(必需) +# ---------------------------------------------------------------------------- +# ⚠️ 必填:从数据库 models 表中选择有效的模型 ID +# +# 如何获取模型 ID: +# 1. 查询数据库:SELECT id, model_name FROM models WHERE is_active = true; +# 2. 或通过系统管理界面查看 +# 3. 确保模型可用且配置正确 + +# LLM 模型 ID(必填) +EVAL_LLM_ID=your_llm_model_id_here + +# Embedding 模型 ID(必填) +EVAL_EMBEDDING_ID=your_embedding_model_id_here + +# ---------------------------------------------------------------------------- +# 检索参数 +# ---------------------------------------------------------------------------- +# 检索类型: "keyword", "embedding", "hybrid" +EVAL_SEARCH_TYPE=hybrid + +# 检索结果数量限制(默认值) +EVAL_SEARCH_LIMIT=12 + +# 上下文最大字符数(默认值) +EVAL_MAX_CONTEXT_CHARS=8000 + +# ---------------------------------------------------------------------------- +# LLM 参数 +# ---------------------------------------------------------------------------- +# LLM 温度参数(0.0 = 确定性输出) +EVAL_LLM_TEMPERATURE=0.0 + +# LLM 最大生成 token 数 +EVAL_LLM_MAX_TOKENS=32 + +# LLM 超时时间(秒) +EVAL_LLM_TIMEOUT=10.0 + +# LLM 最大重试次数 +EVAL_LLM_MAX_RETRIES=1 + +# ---------------------------------------------------------------------------- +# 数据处理参数 +# ---------------------------------------------------------------------------- +# Chunker 策略 +EVAL_CHUNKER_STRATEGY=RecursiveChunker + +# 是否在导入前清空现有数据 +EVAL_RESET_ON_INGEST=true + +# 是否保存详细日志 +EVAL_SAVE_DETAILED_LOGS=true + +# ============================================================================ +# LoCoMo 基准测试专用配置 +# ============================================================================ +# 数据集:locomo10.json +# 运行:python locomo_benchmark.py --sample_size 20 +# ---------------------------------------------------------------------------- + +# Group ID(LoCoMo 专用) +LOCOMO_GROUP_ID=locomo_benchmark + +# 测试样本数量 +# 建议值:20(快速测试)、100(中等测试)、1986(完整测试) +LOCOMO_SAMPLE_SIZE=20 + +# 检索结果数量限制 +LOCOMO_SEARCH_LIMIT=12 + +# 上下文最大字符数 +LOCOMO_CONTEXT_CHAR_BUDGET=8000 + +# 导入的对话数量 +LOCOMO_MAX_DIALOGUES=1 + +# 跳过数据摄入(true=跳过,false=摄入) +# 首次运行设置为 false,后续运行可设置为 true 以节省时间 +LOCOMO_SKIP_INGEST=false + +# 结果保存目录 +LOCOMO_OUTPUT_DIR=locomo/results + +# ============================================================================ +# LongMemEval 基准测试专用配置 +# ============================================================================ +# 数据集:longmemeval_oracle_zh.json +# 运行:python longmemeval_benchmark.py --sample_size 3 +# 特点:支持时间推理问题的增强检索 +# ---------------------------------------------------------------------------- + +# Group ID(LongMemEval 专用) +LONGMEMEVAL_GROUP_ID=longmemeval_zh_bak_3 + +# 测试样本数量(<=0 表示全部样本) +LONGMEMEVAL_SAMPLE_SIZE=3 + +# 起始样本索引 +LONGMEMEVAL_START_INDEX=0 + +# 检索结果数量限制 +LONGMEMEVAL_SEARCH_LIMIT=8 + +# 上下文最大字符数 +LONGMEMEVAL_CONTEXT_CHAR_BUDGET=4000 + +# LLM 最大生成 token 数 +LONGMEMEVAL_LLM_MAX_TOKENS=16 + +# 每条样本最多摄入的上下文段数 +LONGMEMEVAL_MAX_CONTEXTS_PER_ITEM=2 + +# 是否保存分块结果 +LONGMEMEVAL_SAVE_CHUNK_OUTPUT=true + +# 自定义分块输出路径(留空使用默认) +LONGMEMEVAL_SAVE_CHUNK_OUTPUT_PATH= + +# 摄入前是否清空组数据 +LONGMEMEVAL_RESET_GROUP_BEFORE_INGEST=false + +# 是否跳过摄入,仅检索评估 +LONGMEMEVAL_SKIP_INGEST=false + +# 结果保存目录 +LONGMEMEVAL_OUTPUT_DIR=longmemeval/results + +# ============================================================================ +# MemSciQA 基准测试专用配置 +# ============================================================================ +# 数据集:msc_self_instruct.jsonl +# 运行:python memsciqa_benchmark.py --sample_size 1 +# 特点:对话记忆检索评估 +# ---------------------------------------------------------------------------- + +# Group ID(MemSciQA 专用,独立数据集) +MEMSCIQA_GROUP_ID=memsciqa_benchmark + +# 测试样本数量 +MEMSCIQA_SAMPLE_SIZE=1 # 0或者-1标识测试数据集中的所有样本 + +# 检索结果数量限制 +MEMSCIQA_SEARCH_LIMIT=8 + +# 上下文最大字符数 +MEMSCIQA_CONTEXT_CHAR_BUDGET=4000 + +# LLM 最大生成 token 数 +MEMSCIQA_LLM_MAX_TOKENS=64 + +# 跳过数据摄入(true=跳过,false=摄入) +# 首次运行设置为 false,后续运行可设置为 true 以节省时间 +MEMSCIQA_SKIP_INGEST=false + +# 结果保存目录(相对于 memsciqa 脚本所在目录) +# 使用 "results" 会保存到 api/app/core/memory/evaluation/memsciqa/results/ +MEMSCIQA_OUTPUT_DIR=results + +# ============================================================================ +# 高级配置(可选) +# ============================================================================ + +# BM25 权重(用于混合检索,0.0-1.0) +EVAL_RERANK_ALPHA=0.6 + +# 是否使用遗忘重排序 +EVAL_USE_FORGETTING_RERANK=false + +# 是否使用 LLM 重排序 +EVAL_USE_LLM_RERANK=false + +# 连接重置间隔(每 N 个问题重置一次) +EVAL_RESET_INTERVAL=5 + +# 性能阈值(低于此值触发重置) +EVAL_PERFORMANCE_THRESHOLD=0.6 + +# ============================================================================ +# 快速配置指南 +# ============================================================================ +# 1. 复制此文件为 .env.evaluation +# 2. 修改 EVAL_LLM_ID 和 EVAL_EMBEDDING_ID 为你的模型 ID +# 3. 根据需要修改各基准测试的专用配置 +# 4. 运行测试: +# - LoCoMo: python locomo/locomo_benchmark.py --sample_size 20 +# - LongMemEval: python longmemeval/longmemeval_benchmark.py --sample_size 3 --all +# - MemSciQA: python memsciqa/memsciqa_benchmark.py --sample_size 10 +# 配置优先级: +# 命令行参数 > 特定配置(如 LOCOMO_*)> 通用配置(EVAL_*)> 代码默认值 +# ============================================================================ + + +# 执行LoCoMo测试 +# 只摄入前5条消息,评估3个问题(最小测试) +# python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 3 --max_ingest_messages 5 +# +# 如果数据已经摄入,跳过摄入阶段直接测试 +# python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 5 --skip_ingest + + +# 执行longmemeval测试 +# python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --sample-size 10 --max-contexts-per-item 3 --reset-group-before-ingest + +# 执行memsciqa测试 +# python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --sample-size 1 diff --git a/api/app/core/memory/evaluation/.gitignore b/api/app/core/memory/evaluation/.gitignore new file mode 100644 index 00000000..38b1055a --- /dev/null +++ b/api/app/core/memory/evaluation/.gitignore @@ -0,0 +1,13 @@ +# 忽略实际的评估配置文件(包含敏感信息) +.env.evaluation + +# 保留示例文件 +!.env.evaluation.example + +# 忽略测试结果文件 +*/results/*.json +*/results/*.log + +# 忽略数据集文件(文件过大,不应提交到 Git) +dataset/*.json +dataset/*.jsonl diff --git a/api/app/core/memory/evaluation/benchmark.md b/api/app/core/memory/evaluation/benchmark.md index 2853b22b..7c31cccd 100644 --- a/api/app/core/memory/evaluation/benchmark.md +++ b/api/app/core/memory/evaluation/benchmark.md @@ -1,30 +1,748 @@ -⏬数据集下载地址: - Locomo10.json:https://github.com/snap-research/locomo/tree/main/data - LongMemEval_oracle.json:https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned - msc_self_instruct.jsonl:https://huggingface.co/datasets/MemGPT/MSC-Self-Instruct - 上方数据集下载好后全部放入app/core/memory/data文件夹中 +# 1.数据集下载地址 +Locomo10.json : https://github.com/snap-research/locomo/tree/main/data +LongMemEval_oracle.json : https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned +msc_self_instruct.jsonl : https://huggingface.co/datasets/MemGPT/MSC-Self-Instruct -全流程基准测试运行: - locomo: - python -m app.core.memory.evaluation.run_eval --dataset locomo --sample-size 1 --reset-group --group-id yyw1 --search-type hybrid --search-limit 8 --context-char-budget 12000 --llm-max-tokens 32 - LongMemEval: - python -m app.core.memory.evaluation.run_eval --dataset longmemeval --sample-size 10 --start-index 0 --group-id longmemeval_zh_bak_2 --search-limit 8 --context-char-budget 4000 --search-type hybrid --max-contexts-per-item 2 --reset-group - memsciqa: - python -m app.core.memory.evaluation.run_eval --dataset memsciqa --sample-size 10 --reset-group --group-id group_memsci +数据集下载之后保存至api\app\core\memory\evaluation\dataset目录下 +# 2.配置说明 +文件api\app\core\memory\evaluation\.env.evaluation.example对三个基准测试所需配置有着详细的说明 +**实际配置文件**:api\app\core\memory\evaluation\.env.evaluation +```python +# 当使用不带配置参数的命令行执行基准测试,基准测试所需的配置参数根据.env.evaluation中的参数执行 +python -m app.core.memory.evaluation.locomo.locomo_benchmark +``` +**检查neo4j指定的grou_id是否摄入数据** +```python +# 1. 进入交互模式 +python -m app.core.memory.evaluation.check_enduser_data -单独检索评估运行命令: - python -m app.core.memory.evaluation.locomo.locomo_test - python -m app.core.memory.evaluation.longmemeval.test_eval - python -m app.core.memory.evaluation.memsciqa.memsciqa-test - 需要先在项目中修改需要检测评估的group_id。 +# 2. 选择 "1" 检查指定 group +# 3. 输入 group_id,例如: locomo_benchmark +# 4. 选择是否显示详细统计 (y/n) +``` +# 3.locomo -参数及解释: - ● --dataset longmemeval - 指定数据集 - ● --sample-size 10 - 评估10个样本 - ● --start-index 0 - 从第0个样本开始 - ● --group-id longmemeval_zh_bak_2 - 使用指定的组ID - ● --search-limit 8 - 检索限制8条 - ● --context-char-budget 4000 - 上下文字符预算4000 - ● --search-type hybrid - 使用混合检索 - ● --max-contexts-per-item 2 - 每个样本最多摄入2个上下文 - ● --reset-group - 运行前清空组数据 \ No newline at end of file +### (1)locomo执行命令 +```python +# 首先进入api目录 +cd api + +# 只摄入前5条消息,评估3个问题(最小测试) +python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 3 --max_ingest_messages 5 + +# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) +python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 5 --skip_ingest +``` +### (2)locomo结果说明 + +#### 结果示例 +```json +{ + "dataset": "locomo", + "sample_size": 0, + "timestamp": "2026-01-26T11:24:28.239156", + "params": { + "group_id": "locomo_benchmark", + "search_type": "hybrid", + "search_limit": 12, + "context_char_budget": 8000, + "llm_id": "2c9b0782-7a85-4740-ba84-4baf77f256c4", + "embedding_id": "e2a6392d-ca63-4d59-a523-647420b59cb2" + }, + "overall_metrics": { + "f1": 0.0, + "bleu1": 0.0, + "jaccard": 0.0, + "locomo_f1": 0.0 + }, + "by_category": {}, + "latency": { + "search": { + "mean": 0.0, + "p50": 0.0, + "p95": 0.0, + "iqr": 0.0 + }, + "llm": { + "mean": 0.0, + "p50": 0.0, + "p95": 0.0, + "iqr": 0.0 + } + }, + "context_stats": { + "avg_retrieved_docs": 0.0, + "avg_context_chars": 0.0, + "avg_context_tokens": 0.0 + }, + "samples": [] +} +``` + +#### 参数详解 + +##### 1. 核心评估指标 (overall_metrics) + +**🎯 关键进步指标:** + +- **`f1`** (F1 Score): 精确率和召回率的调和平均值 + - 范围:0.0 - 1.0 + - **越高越好**,衡量检索和生成答案的准确性 + - 这是最重要的综合性能指标 + - 优秀标准:> 0.85 + +- **`bleu1`** (BLEU-1): 单词级别的匹配度 + - 范围:0.0 - 1.0 + - **越高越好**,衡量生成答案与标准答案的词汇重叠度 + - 关注词汇层面的准确性 + +- **`jaccard`** (Jaccard 相似度): 集合相似度 + - 范围:0.0 - 1.0 + - **越高越好**,衡量答案集合的相似性 + - 计算公式:交集大小 / 并集大小 + +- **`locomo_f1`**: Locomo 特定的 F1 分数 + - 范围:0.0 - 1.0 + - **越高越好**,针对 Locomo 数据集优化的评估指标 + - 考虑了长对话记忆的特殊性 + +##### 2. 性能指标 (latency) + +**⚡ 关键效率指标:** + +- **`search`**: 检索延迟统计(单位:毫秒) + - `mean`: 平均延迟 + - `p50`: 中位数延迟(50%的请求在此时间内完成) + - `p95`: 95分位数延迟(95%的请求在此时间内完成) + - `iqr`: 四分位距(Q3-Q1,衡量稳定性) + - **越低越好**,衡量记忆检索速度 + - 优秀标准:p95 < 2000ms + +- **`llm`**: LLM 推理延迟统计(单位:毫秒) + - `mean`: 平均推理时间 + - `p50`: 中位数推理时间 + - `p95`: 95分位数推理时间 + - `iqr`: 四分位距(越小越稳定) + - **越低越好**,衡量答案生成速度 + - 优秀标准:p95 < 3000ms + +##### 3. 上下文统计 (context_stats) + +**📊 资源效率指标:** + +- **`avg_retrieved_docs`**: 平均检索文档数 + - 反映检索策略的广度 + - 需要平衡:太少可能信息不足,太多增加噪音和延迟 + - 建议范围:8-15 个文档 + +- **`avg_context_chars`**: 平均上下文字符数 + - 反映检索内容的总量 + - 应在满足准确性前提下尽量精简 + - 受 `context_char_budget` 参数限制 + +- **`avg_context_tokens`**: 平均上下文 token 数 + - **越低越好**(在保持准确性前提下) + - 直接影响 API 调用成本和推理速度 + - 成本效益比 = f1 / avg_context_tokens + +##### 4. 分类统计 (by_category) + +- 按问题类型分类的性能指标 +- 帮助识别系统在不同场景下的强弱项 +- 可针对性优化特定类型的问题 + +#### 系统进步衡量标准 + +**一级指标(最重要):** +- `f1` 和 `locomo_f1` 提升 → 核心能力提升 +- 目标:f1 > 0.85 + +**二级指标(重要):** +- `latency.p95` 降低 → 用户体验提升 +- 目标:search.p95 < 2000ms, llm.p95 < 3000ms + +**三级指标(辅助):** +- `avg_context_tokens` 降低(在保持 f1 前提下)→ 成本优化 +- `iqr` 降低 → 性能稳定性提升 +# 4.longmemeval +支持时间推理问题的增强检索 +### (1)执行命令 +```python +# 首先进入api目录 +cd api + +# 不带参数运行 - 使用环境变量 +python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark + +# 命令行参数覆盖环境变量 +python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --sample-size 2 + +# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) +python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --skip_ingest +``` +### (2)结果说明 + +#### 结果示例 +```json +{ + "dataset": "longmemeval", + "items": 1, + "accuracy_by_type": { + "single-session-user": 1.0 + }, + "f1_by_type": { + "single-session-user": 1.0 + }, + "jaccard_by_type": { + "single-session-user": 1.0 + }, + "samples": [ + { + "question": "What degree did I graduate with?", + "prediction": "Business Administration", + "answer": "Business Administration", + "question_type": "single-session-user", + "is_temporal": false, + "question_id": "e47becba", + "options": [], + "context_count": 13, + "context_chars": 1268, + "retrieved_dialogue_count": 0, + "retrieved_statement_count": 12, + "metrics": { + "exact_match": true, + "f1": 1.0, + "jaccard": 1.0 + }, + "timing": { + "search_ms": 1483.100175857544, + "llm_ms": 995.8682060241699 + } + } + ], + "latency": { + "search": { + "mean": 1483.100175857544, + "p50": 1483.100175857544, + "p95": 1483.100175857544, + "iqr": 0.0 + }, + "llm": { + "mean": 995.8682060241699, + "p50": 995.8682060241699, + "p95": 995.8682060241699, + "iqr": 0.0 + } + }, + "context": { + "avg_tokens": 204.0, + "avg_chars": 1268, + "count_avg": 13 + }, + "params": { + "group_id": "longmemeval_zh_bak_3", + "search_limit": 8, + "context_char_budget": 4000, + "search_type": "hybrid", + "llm_id": "6dc52e1b-9cec-4194-af66-a74c6307fc3f", + "embedding_id": "e2a6392d-ca63-4d59-a523-647420b59cb2", + "sample_size": 1, + "start_index": 0 + }, + "timestamp": "2026-01-24T21:36:10.818308", + "metric_summary": { + "score_accuracy": 100.0, + "latency_median_s": 2.478968381881714, + "latency_iqr_s": 0.0, + "avg_context_tokens_k": 0.204 + }, + "diagnostics": { + "duplicate_previews_top": [], + "unique_preview_count": 1 + } +} +``` + +#### 参数详解 + +##### 1. 核心评估指标 + +**🎯 关键进步指标:** + +- **`accuracy_by_type`**: 按问题类型分类的准确率 + - 范围:0.0 - 1.0 + - **越高越好**,1.0 表示 100% 准确 + - 问题类型包括: + - `single-session-user`: 单会话用户信息 + - `single-session-event`: 单会话事件信息 + - `multi-session-user`: 多会话用户信息 + - `multi-session-event`: 多会话事件信息 + - 可以识别系统在不同场景下的强弱项 + +- **`f1_by_type`**: 按问题类型的 F1 分数 + - 范围:0.0 - 1.0 + - **越高越好**,综合评估精确率和召回率 + - 比单纯的准确率更全面 + +- **`jaccard_by_type`**: 按问题类型的 Jaccard 相似度 + - 范围:0.0 - 1.0 + - **越高越好**,衡量答案集合匹配度 + - 对于集合类答案特别有用 + +##### 2. 样本级指标 (samples) + +**详细诊断指标:** + +- **`metrics.exact_match`**: 精确匹配(布尔值) + - **true 越多越好**,最严格的评估标准 + - 要求预测答案与标准答案完全一致 + +- **`metrics.f1`**: 单个样本的 F1 分数 + - 范围:0.0 - 1.0 + - **越高越好**,衡量单个问题的回答质量 + +- **`is_temporal`**: 是否为时间推理问题 + - 布尔值,标识问题是否涉及时间推理 + - 时间推理问题通常更具挑战性 + +- **`context_count`**: 检索到的上下文数量 + - 反映检索策略的有效性 + - 建议范围:8-15 个上下文片段 + +- **`retrieved_dialogue_count`**: 检索到的对话数 +- **`retrieved_statement_count`**: 检索到的陈述数 + - 这两个指标帮助理解检索的内容类型分布 + - 可用于优化检索策略 + +- **`timing.search_ms`**: 单个问题的检索延迟(毫秒) +- **`timing.llm_ms`**: 单个问题的 LLM 推理延迟(毫秒) + - **越低越好**,反映单次查询的响应速度 + +##### 3. 汇总指标 (metric_summary) + +**📊 关键 KPI:** + +- **`score_accuracy`**: 总体准确率百分比 + - 范围:0.0 - 100.0 + - **越高越好**,最直观的性能指标 + - 优秀标准:> 90.0 + +- **`latency_median_s`**: 中位延迟(秒) + - **越低越好**,反映真实响应速度 + - 优秀标准:< 3.0 秒 + +- **`latency_iqr_s`**: 延迟四分位距(秒) + - **越低越好**,反映性能稳定性 + - 越小说明响应时间越稳定 + +- **`avg_context_tokens_k`**: 平均上下文 token 数(千) + - **越低越好**(在保持准确性前提下) + - 直接影响 API 调用成本 + - 成本效益比 = score_accuracy / (avg_context_tokens_k * 1000) + +##### 4. 上下文统计 (context) + +- **`avg_tokens`**: 平均 token 数 +- **`avg_chars`**: 平均字符数 +- **`count_avg`**: 平均上下文片段数 + - 这些指标反映检索内容的规模 + - 需要在准确性和效率之间平衡 + +##### 5. 性能指标 (latency) + +**⚡ 效率指标:** + +- **`search`**: 检索延迟统计(单位:毫秒) + - `mean`: 平均延迟 + - `p50`: 中位数延迟 + - `p95`: 95分位数延迟 + - `iqr`: 四分位距 + - **越低越好**,衡量记忆检索速度 + +- **`llm`**: LLM 推理延迟统计(单位:毫秒) + - `mean`: 平均推理时间 + - `p50`: 中位数推理时间 + - `p95`: 95分位数推理时间 + - `iqr`: 四分位距 + - **越低越好**,衡量答案生成速度 + +##### 6. 诊断信息 (diagnostics) + +- **`duplicate_previews_top`**: 重复预览统计 + - 列出出现频率最高的重复内容 + - 帮助发现检索冗余问题 + - 应该尽量减少重复 + +- **`unique_preview_count`**: 唯一预览数量 + - 反映检索多样性 + - **越高越好**,说明检索到的内容更丰富 + +#### 系统进步衡量标准 + +**一级指标(最重要):** +- `score_accuracy` 提升 → 核心能力提升 +- 目标:> 90.0% +- 各类型的 `accuracy_by_type` 均衡提升 → 全面能力提升 + +**二级指标(重要):** +- `latency_median_s` 降低 → 用户体验提升 +- 目标:< 3.0 秒 +- `exact_match` 比例提升 → 精确度提升 + +**三级指标(辅助):** +- `avg_context_tokens_k` 降低(在保持准确性前提下)→ 成本优化 +- `unique_preview_count` 提升 → 检索多样性提升 +- `latency_iqr_s` 降低 → 性能稳定性提升 + +**特殊关注:** +- 时间推理问题(`is_temporal: true`)的准确率 +- 多会话问题的准确率(通常更具挑战性) +# 5.memsciqa +对话记忆检索评估 +### (1)执行命令 +```python +# 首先进入api目录 +cd api + +# 不带参数运行 - 使用环境变量 +python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark + +# 命令行参数覆盖环境变量 +python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --sample-size 100 + +# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) +python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --skip_ingest +``` +### (2)结果说明 + +#### 结果示例 +```json +{ + "dataset": "memsciqa", + "items": 1, + "metrics": { + "accuracy": 0.0, + "f1": 0.0, + "bleu1": 0.0, + "jaccard": 0.0 + }, + "latency": { + "search": { + "mean": 0.0, + "p50": 0.0, + "p95": 0.0, + "iqr": 0.0 + }, + "llm": { + "mean": 3067.7285194396973, + "p50": 3067.7285194396973, + "p95": 3067.7285194396973, + "iqr": 0.0 + } + }, + "avg_context_tokens": 4.0 +} +``` + +#### 参数详解 + +##### 1. 核心评估指标 (metrics) + +**🎯 关键进步指标:** + +- **`accuracy`**: 准确率 + - 范围:0.0 - 1.0 + - **越高越好**,最直接的性能指标 + - 衡量系统回答正确的问题比例 + - 优秀标准:> 0.85 + +- **`f1`**: F1 分数 + - 范围:0.0 - 1.0 + - **越高越好**,平衡精确率和召回率 + - 计算公式:2 * (precision * recall) / (precision + recall) + - 比单纯的准确率更全面,特别适合不平衡数据集 + +- **`bleu1`**: BLEU-1 分数 + - 范围:0.0 - 1.0 + - **越高越好**,衡量词汇级别的匹配度 + - 关注生成答案与标准答案的单词重叠 + - 源自机器翻译评估,适用于自然语言生成 + +- **`jaccard`**: Jaccard 相似度 + - 范围:0.0 - 1.0 + - **越高越好**,衡量集合相似性 + - 计算公式:|A ∩ B| / |A ∪ B| + - 对于多答案或集合类问题特别有用 + +##### 2. 性能指标 (latency) + +**⚡ 效率指标:** + +- **`search`**: 检索延迟统计(单位:毫秒) + - `mean`: 平均检索延迟 + - `p50`: 中位数延迟(50%的请求在此时间内完成) + - `p95`: 95分位数延迟(95%的请求在此时间内完成) + - `iqr`: 四分位距(Q3-Q1,衡量稳定性) + - **越低越好**,衡量记忆检索效率 + - 优秀标准:p95 < 2000ms + +- **`llm`**: LLM 推理延迟统计(单位:毫秒) + - `mean`: 平均推理时间 + - `p50`: 中位数推理时间 + - `p95`: 95分位数推理时间 + - `iqr`: 四分位距(越小越稳定) + - **越低越好**,衡量答案生成速度 + - 优秀标准:p95 < 3000ms + - 注意:LLM 延迟通常占总延迟的大部分 + +##### 3. 资源指标 + +- **`avg_context_tokens`**: 平均上下文 token 数 + - **越低越好**(在保持准确性前提下) + - 直接影响: + - API 调用成本(按 token 计费) + - 推理速度(token 越多越慢) + - 上下文窗口占用 + - 成本效益比 = accuracy / avg_context_tokens + - 建议范围:根据模型上下文窗口和成本预算调整 + +##### 4. 数据集特点 + +- **`items`**: 评估的问题数量 + - 样本量越大,评估结果越可靠 + - 建议至少 100 个样本以获得稳定的评估结果 + +- **对话记忆特性**: + - MemSciQA 专注于对话历史中的记忆检索 + - 评估系统从多轮对话中提取和回忆信息的能力 + - 模拟真实的对话场景 + +#### 系统进步衡量标准 + +**一级指标(最重要):** +- `accuracy` 提升 → 核心能力提升 +- 目标:> 0.85 +- `f1` 提升 → 综合性能提升 +- 目标:> 0.80 + +**二级指标(重要):** +- `latency.p95` 降低 → 用户体验提升 + - search.p95 目标:< 2000ms + - llm.p95 目标:< 3000ms +- `iqr` 降低 → 性能稳定性提升 + +**三级指标(辅助):** +- `avg_context_tokens` 降低(在保持准确性前提下)→ 成本优化 +- `bleu1` 和 `jaccard` 提升 → 答案质量提升 + +**综合评估:** +- 成本效益比 = accuracy / avg_context_tokens + - 该比值越高,说明系统在相同成本下性能越好 +- 总延迟 = search.p95 + llm.p95 + - 应控制在 5 秒以内以保证良好的用户体验 + +#### 优化建议 + +**提升准确性:** +- 优化检索算法(调整 hybrid search 参数) +- 改进 embedding 模型质量 +- 增加检索上下文数量(`search_limit`) +- 优化 prompt 工程 + +**提升效率:** +- 减少不必要的检索文档 +- 使用更快的 LLM 模型或量化版本 +- 实施缓存策略(相似问题复用结果) +- 优化数据库索引 + +**平衡性能:** +- 监控 accuracy vs latency 的权衡 +- 监控 accuracy vs cost (tokens) 的权衡 +- 根据业务需求调整优先级 + + +--- + +# 6. 三个基准测试对比总结 + +## 6.1 测试特点对比 + +| 基准测试 | 主要评估目标 | 数据集特点 | 适用场景 | +|---------|------------|-----------|---------| +| **Locomo** | 长对话记忆检索 | 长对话历史,多轮交互 | 评估长期记忆保持和检索能力 | +| **LongMemEval** | 时间推理和多会话记忆 | 支持时间推理,多会话场景 | 评估时间感知和跨会话记忆能力 | +| **MemSciQA** | 对话记忆问答 | 对话历史问答 | 评估对话上下文理解和记忆提取 | + +## 6.2 核心指标对比 + +### 准确性指标 + +| 指标 | Locomo | LongMemEval | MemSciQA | 说明 | +|-----|--------|-------------|----------|------| +| **F1 Score** | ✅ | ✅ | ✅ | 所有测试都使用,最重要的综合指标 | +| **Accuracy** | ❌ | ✅ | ✅ | 直观的准确率指标 | +| **BLEU-1** | ✅ | ❌ | ✅ | 词汇级别匹配度 | +| **Jaccard** | ✅ | ✅ | ✅ | 集合相似度 | +| **Exact Match** | ❌ | ✅ | ❌ | 最严格的评估标准 | + +### 性能指标 + +所有三个测试都包含: +- **检索延迟** (search latency): mean, p50, p95, iqr +- **LLM 延迟** (llm latency): mean, p50, p95, iqr +- **上下文统计**: token 数、字符数、文档数 + +## 6.3 关键进步指标优先级 + +### 🥇 一级指标(必须关注) + +1. **准确性指标** + - Locomo: `f1`, `locomo_f1` + - LongMemEval: `score_accuracy`, `accuracy_by_type` + - MemSciQA: `accuracy`, `f1` + - **目标**: > 85% 或 > 0.85 + +2. **综合性能** + - 所有测试的 F1 分数应保持一致性 + - 不同类型问题的准确率应均衡 + +### 🥈 二级指标(重要) + +3. **响应延迟** + - `latency.p95` (95分位数延迟) + - **目标**: + - search.p95 < 2000ms + - llm.p95 < 3000ms + - 总延迟 < 5000ms + +4. **性能稳定性** + - `iqr` (四分位距) + - **目标**: 越小越好,说明性能稳定 + +### 🥉 三级指标(优化) + +5. **成本效率** + - `avg_context_tokens` + - **目标**: 在保持准确性前提下最小化 + - 成本效益比 = accuracy / avg_context_tokens + +6. **检索质量** + - `avg_retrieved_docs` 的合理性 + - `unique_preview_count` (LongMemEval) + - 检索内容的多样性和相关性 + +## 6.4 系统优化路径 + +### 阶段一:提升准确性(优先级最高) + +**目标**: 所有测试的准确率 > 85% + +**优化方向**: +1. 改进 embedding 模型质量 +2. 优化检索算法(hybrid search 参数) +3. 增加检索上下文数量(`search_limit`) +4. 优化 prompt 工程 +5. 改进记忆存储结构 + +**监控指标**: +- Locomo: `f1`, `locomo_f1` +- LongMemEval: `score_accuracy`, `exact_match` 比例 +- MemSciQA: `accuracy`, `f1` + +### 阶段二:优化性能(准确性达标后) + +**目标**: p95 延迟 < 5 秒,性能稳定 + +**优化方向**: +1. 优化数据库索引和查询 +2. 实施缓存策略 +3. 使用更快的 LLM 模型 +4. 并行化检索和推理 +5. 减少不必要的检索 + +**监控指标**: +- `latency.p50`, `latency.p95` +- `iqr` (稳定性) +- 各阶段耗时分布 + +### 阶段三:降低成本(性能达标后) + +**目标**: 在保持准确性和性能前提下,最小化成本 + +**优化方向**: +1. 精简检索上下文 +2. 优化 context 选择策略 +3. 使用更小的 LLM 模型 +4. 实施智能缓存 +5. 批处理优化 + +**监控指标**: +- `avg_context_tokens` +- 成本效益比 = accuracy / avg_context_tokens +- API 调用成本 + +## 6.5 评估最佳实践 + +### 测试执行建议 + +1. **初始测试**: 使用小样本快速验证 + ```bash + --sample_size 10 + ``` + +2. **完整评估**: 使用足够大的样本量 + ```bash + --sample_size 100 # 或更多 + ``` + +3. **增量测试**: 数据已摄入时跳过摄入阶段 + ```bash + --skip_ingest + ``` + +4. **参数调优**: 系统性地调整参数并记录结果 + - 调整 `search_limit`: 4, 8, 12, 16 + - 调整 `context_char_budget`: 2000, 4000, 8000 + - 尝试不同的 `search_type`: vector, keyword, hybrid + +### 结果分析建议 + +1. **横向对比**: 比较三个测试的结果,识别系统的强弱项 +2. **纵向对比**: 跟踪同一测试在不同版本的表现 +3. **分类分析**: 关注不同问题类型的性能差异 +4. **异常诊断**: 分析失败案例,找出根本原因 + +### 持续监控 + +建议建立监控仪表板,跟踪: +- 核心指标趋势(准确率、延迟) +- 成本效益比趋势 +- 不同问题类型的性能分布 +- 异常样本和失败模式 + +## 6.6 性能基准参考 + +### 优秀水平(Production Ready) + +- **准确性**: accuracy/f1 > 0.90 +- **延迟**: p95 < 3 秒 +- **稳定性**: iqr < 500ms +- **成本效益**: accuracy/tokens > 0.0001 + +### 良好水平(Acceptable) + +- **准确性**: accuracy/f1 > 0.85 +- **延迟**: p95 < 5 秒 +- **稳定性**: iqr < 1000ms +- **成本效益**: accuracy/tokens > 0.00005 + +### 需要改进(Below Target) + +- **准确性**: accuracy/f1 < 0.85 +- **延迟**: p95 > 5 秒 +- **稳定性**: iqr > 1000ms +- **成本效益**: accuracy/tokens < 0.00005 + +--- + +**注**: 以上标准仅供参考,实际目标应根据具体业务需求和资源约束调整。 diff --git a/api/app/core/memory/evaluation/check_enduser_data.py b/api/app/core/memory/evaluation/check_enduser_data.py new file mode 100644 index 00000000..18ecbb34 --- /dev/null +++ b/api/app/core/memory/evaluation/check_enduser_data.py @@ -0,0 +1,371 @@ +""" +交互式 Neo4j End User 数据检查工具 + +用于查询指定 end_user_id 在 Neo4j 中是否存在数据,以及数据的详细统计信息。 + +使用方法: + python check_group_data.py + python check_group_data.py --group-id locomo_benchmark + python check_group_data.py --group-id memsciqa_benchmark --detailed +""" + +import asyncio +import argparse +import os +from pathlib import Path +from typing import Dict, Any +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}\n") + +from app.repositories.neo4j.neo4j_connector import Neo4jConnector + + +async def check_group_exists(end_user_id: str) -> Dict[str, Any]: + """ + 检查指定 end_user_id 是否存在数据 + + Args: + end_user_id: 要检查的 end_user ID + + Returns: + 包含统计信息的字典 + """ + connector = Neo4jConnector() + + try: + # 查询该 end_user 的节点总数 + query_total = """ + MATCH (n {end_user_id: $end_user_id}) + RETURN count(n) as total_nodes + """ + result_total = await connector.execute_query(query_total, end_user_id=end_user_id) + total_nodes = result_total[0]["total_nodes"] if result_total else 0 + + # 查询各类型节点的数量 + query_by_type = """ + MATCH (n {end_user_id: $end_user_id}) + RETURN labels(n) as labels, count(n) as count + ORDER BY count DESC + """ + result_by_type = await connector.execute_query(query_by_type, end_user_id=end_user_id) + + # 查询关系数量 + query_relationships = """ + MATCH (n {end_user_id: $end_user_id})-[r]-() + RETURN count(DISTINCT r) as total_relationships + """ + result_rel = await connector.execute_query(query_relationships, end_user_id=end_user_id) + total_relationships = result_rel[0]["total_relationships"] if result_rel else 0 + + return { + "exists": total_nodes > 0, + "total_nodes": total_nodes, + "total_relationships": total_relationships, + "nodes_by_type": result_by_type + } + + finally: + await connector.close() + + +async def get_detailed_stats(end_user_id: str) -> Dict[str, Any]: + """ + 获取详细的统计信息 + + Args: + end_user_id: 要检查的 end_user ID + + Returns: + 详细统计信息字典 + """ + connector = Neo4jConnector() + + try: + stats = {} + + # Chunk 节点统计 + query_chunks = """ + MATCH (c:Chunk {end_user_id: $end_user_id}) + RETURN count(c) as count, + avg(size(c.content)) as avg_content_length + """ + result_chunks = await connector.execute_query(query_chunks, end_user_id=end_user_id) + if result_chunks and result_chunks[0]["count"] > 0: + stats["chunks"] = { + "count": result_chunks[0]["count"], + "avg_content_length": int(result_chunks[0]["avg_content_length"]) if result_chunks[0]["avg_content_length"] else 0 + } + + # Statement 节点统计 + query_statements = """ + MATCH (s:Statement {end_user_id: $end_user_id}) + RETURN count(s) as count + """ + result_statements = await connector.execute_query(query_statements, end_user_id=end_user_id) + if result_statements and result_statements[0]["count"] > 0: + stats["statements"] = { + "count": result_statements[0]["count"] + } + + # Entity 节点统计 + query_entities = """ + MATCH (e:Entity {end_user_id: $end_user_id}) + RETURN count(e) as count, + count(DISTINCT e.entity_type) as unique_types + """ + result_entities = await connector.execute_query(query_entities, end_user_id=end_user_id) + if result_entities and result_entities[0]["count"] > 0: + stats["entities"] = { + "count": result_entities[0]["count"], + "unique_types": result_entities[0]["unique_types"] + } + + # Dialogue 节点统计 + query_dialogues = """ + MATCH (d:Dialogue {end_user_id: $end_user_id}) + RETURN count(d) as count + """ + result_dialogues = await connector.execute_query(query_dialogues, end_user_id=end_user_id) + if result_dialogues and result_dialogues[0]["count"] > 0: + stats["dialogues"] = { + "count": result_dialogues[0]["count"] + } + + # Summary 节点统计 + query_summaries = """ + MATCH (s:Summary {end_user_id: $end_user_id}) + RETURN count(s) as count + """ + result_summaries = await connector.execute_query(query_summaries, end_user_id=end_user_id) + if result_summaries and result_summaries[0]["count"] > 0: + stats["summaries"] = { + "count": result_summaries[0]["count"] + } + + return stats + + finally: + await connector.close() + + +async def list_all_end_users() -> list: + """ + 列出数据库中所有的 end_user_id + + Returns: + end_user_id 列表及其节点数量 + """ + connector = Neo4jConnector() + + try: + query = """ + MATCH (n) + WHERE n.end_user_id IS NOT NULL + RETURN DISTINCT n.end_user_id as end_user_id, count(n) as node_count + ORDER BY node_count DESC + """ + results = await connector.execute_query(query) + return results + + finally: + await connector.close() + + +def print_results(end_user_id: str, stats: Dict[str, Any], detailed_stats: Dict[str, Any] = None): + """ + 打印查询结果 + + Args: + end_user_id: End User ID + stats: 基本统计信息 + detailed_stats: 详细统计信息(可选) + """ + print(f"\n{'='*60}") + print(f"📊 End User ID: {end_user_id}") + print(f"{'='*60}\n") + + if not stats["exists"]: + print("❌ 该 end_user_id 不存在数据") + print("\n💡 提示: 请先运行基准测试以摄入数据") + return + + print(f"✅ 该 end_user_id 存在数据\n") + print(f"📈 基本统计:") + print(f" 总节点数: {stats['total_nodes']}") + print(f" 总关系数: {stats['total_relationships']}") + + if stats["nodes_by_type"]: + print(f"\n📋 节点类型分布:") + for item in stats["nodes_by_type"]: + labels = ", ".join(item["labels"]) + count = item["count"] + print(f" {labels}: {count}") + + if detailed_stats: + print(f"\n🔍 详细统计:") + + if "chunks" in detailed_stats: + print(f" Chunks: {detailed_stats['chunks']['count']} 个") + print(f" 平均内容长度: {detailed_stats['chunks']['avg_content_length']} 字符") + + if "statements" in detailed_stats: + print(f" Statements: {detailed_stats['statements']['count']} 个") + + if "entities" in detailed_stats: + print(f" Entities: {detailed_stats['entities']['count']} 个") + print(f" 唯一类型数: {detailed_stats['entities']['unique_types']}") + + if "dialogues" in detailed_stats: + print(f" Dialogues: {detailed_stats['dialogues']['count']} 个") + + if "summaries" in detailed_stats: + print(f" Summaries: {detailed_stats['summaries']['count']} 个") + + print(f"\n{'='*60}\n") + + +async def interactive_mode(): + """ + 交互式模式 + """ + print("\n" + "="*60) + print("🔍 Neo4j End User 数据检查工具 - 交互模式") + print("="*60 + "\n") + + while True: + print("\n请选择操作:") + print(" 1. 检查指定 end_user_id") + print(" 2. 列出所有 end_user_id") + print(" 3. 退出") + + choice = input("\n请输入选项 (1-3): ").strip() + + if choice == "1": + end_user_id = input("\n请输入 end_user_id: ").strip() + if not end_user_id: + print("❌ end_user_id 不能为空") + continue + + detailed = input("是否显示详细统计? (y/n, 默认 n): ").strip().lower() == 'y' + + print("\n🔄 正在查询...") + stats = await check_group_exists(end_user_id) + + detailed_stats = None + if detailed and stats["exists"]: + detailed_stats = await get_detailed_stats(end_user_id) + + print_results(end_user_id, stats, detailed_stats) + + elif choice == "2": + print("\n🔄 正在查询所有 end_user_id...") + end_users = await list_all_end_users() + + if not end_users: + print("\n❌ 数据库中没有任何 end_user 数据") + else: + print(f"\n{'='*60}") + print(f"📋 数据库中的所有 End User ID") + print(f"{'='*60}\n") + + for idx, end_user in enumerate(end_users, 1): + print(f" {idx}. {end_user['end_user_id']}") + print(f" 节点数: {end_user['node_count']}") + + print(f"\n{'='*60}\n") + + elif choice == "3": + print("\n👋 再见!") + break + + else: + print("\n❌ 无效的选项,请重新选择") + + +async def main(): + """ + 主函数 + """ + parser = argparse.ArgumentParser( + description="检查 Neo4j 中指定 end_user_id 的数据情况", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 交互模式 + python check_group_data.py + + # 检查指定 end_user + python check_group_data.py --end-user-id locomo_benchmark + + # 检查并显示详细统计 + python check_group_data.py --end-user-id memsciqa_benchmark --detailed + + # 列出所有 end_user + python check_group_data.py --list-all + """ + ) + + parser.add_argument( + "--end-user-id", + type=str, + help="要检查的 end_user ID" + ) + + parser.add_argument( + "--detailed", + action="store_true", + help="显示详细统计信息" + ) + + parser.add_argument( + "--list-all", + action="store_true", + help="列出所有 end_user_id" + ) + + args = parser.parse_args() + + # 如果没有提供任何参数,进入交互模式 + if not args.end_user_id and not args.list_all: + await interactive_mode() + return + + # 列出所有 end_user + if args.list_all: + print("\n🔄 正在查询所有 end_user_id...") + end_users = await list_all_end_users() + + if not end_users: + print("\n❌ 数据库中没有任何 end_user 数据") + else: + print(f"\n{'='*60}") + print(f"📋 数据库中的所有 End User ID") + print(f"{'='*60}\n") + + for idx, end_user in enumerate(end_users, 1): + print(f" {idx}. {end_user['end_user_id']}") + print(f" 节点数: {end_user['node_count']}") + + print(f"\n{'='*60}\n") + return + + # 检查指定 end_user + if args.end_user_id: + print(f"\n🔄 正在查询 end_user_id: {args.end_user_id}...") + stats = await check_group_exists(args.end_user_id) + + detailed_stats = None + if args.detailed and stats["exists"]: + print("🔄 正在获取详细统计...") + detailed_stats = await get_detailed_stats(args.end_user_id) + + print_results(args.end_user_id, stats, detailed_stats) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/api/app/core/memory/evaluation/common/metrics.py b/api/app/core/memory/evaluation/common/metrics.py index acc27fb9..961ce7f0 100644 --- a/api/app/core/memory/evaluation/common/metrics.py +++ b/api/app/core/memory/evaluation/common/metrics.py @@ -2,7 +2,7 @@ import math import re from typing import List, Dict - +# 评估指标的实现 def _normalize(text: str) -> List[str]: """Lowercase, strip punctuation, and split into tokens.""" text = text.lower().strip() diff --git a/api/app/core/memory/evaluation/dialogue_queries.py b/api/app/core/memory/evaluation/dialogue_queries.py index 25abe64e..0aace0ec 100644 --- a/api/app/core/memory/evaluation/dialogue_queries.py +++ b/api/app/core/memory/evaluation/dialogue_queries.py @@ -4,15 +4,17 @@ This file contains Cypher queries for searching dialogues, entities, and chunks. Placed in evaluation directory to avoid circular imports with src modules. """ +# 应该是neo4j browser的cypher语句,需要修改文件名 + # Entity search queries SEARCH_ENTITIES_BY_NAME = """ -MATCH (e:Entity) +MATCH (e:ExtractedEntity) WHERE e.name = $name RETURN e """ SEARCH_ENTITIES_BY_NAME_FALLBACK = """ -MATCH (e:Entity) +MATCH (e:ExtractedEntity) WHERE e.name CONTAINS $name RETURN e """ diff --git a/api/app/core/memory/evaluation/extraction_utils.py b/api/app/core/memory/evaluation/extraction_utils.py index 9e70bc28..43ef6fe0 100644 --- a/api/app/core/memory/evaluation/extraction_utils.py +++ b/api/app/core/memory/evaluation/extraction_utils.py @@ -1,34 +1,33 @@ +import os import asyncio import json -import os -import re +from typing import List, Dict, Any, Optional from datetime import datetime -from typing import Any, Dict, List, Optional +from uuid import UUID +import re from app.core.memory.llm_tools.openai_client import LLMClient -from app.core.memory.models.message_models import ( - ConversationContext, - ConversationMessage, - DialogData, -) +from app.core.memory.storage_services.extraction_engine.knowledge_extraction.chunk_extraction import DialogueChunker +from app.core.memory.models.message_models import DialogData, ConversationContext, ConversationMessage +import os +import sys +from pathlib import Path +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent / "app" / "core" / "memory" / "evaluation" / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") + +from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.core.memory.utils.llm.llm_utils import get_llm_client # 使用新的模块化架构 -from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ( - ExtractionOrchestrator, -) -from app.core.memory.storage_services.extraction_engine.knowledge_extraction.chunk_extraction import ( - DialogueChunker, -) -from app.core.memory.utils.config.definitions import ( - SELECTED_CHUNKER_STRATEGY, - SELECTED_EMBEDDING_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.db import get_db_context +from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ExtractionOrchestrator # Import from database module from app.repositories.neo4j.graph_saver import save_dialog_and_statements_to_neo4j -from app.repositories.neo4j.neo4j_connector import Neo4jConnector # Cypher queries for evaluation # Note: Entity, chunk, and dialogue search queries have been moved to evaluation/dialogue_queries.py @@ -41,11 +40,14 @@ async def ingest_contexts_via_full_pipeline( embedding_name: str | None = None, save_chunk_output: bool = False, save_chunk_output_path: str | None = None, + reset_group: bool = False, ) -> bool: - """DEPRECATED: 此函数使用旧的流水线架构,建议使用新的 ExtractionOrchestrator + """ + 使用新的 ExtractionOrchestrator 运行完整的提取流水线 Run the full extraction pipeline on provided dialogue contexts and save to Neo4j. - This function mirrors the steps in main(), but starts from raw text contexts. + This function uses the new ExtractionOrchestrator architecture for better maintainability. + Args: contexts: List of dialogue texts, each containing lines like "role: message". end_user_id: Group ID to assign to generated DialogData and graph nodes. @@ -53,25 +55,59 @@ async def ingest_contexts_via_full_pipeline( embedding_name: Optional embedding model ID; defaults to SELECTED_EMBEDDING_ID. save_chunk_output: If True, write chunked DialogData list to a JSON file for debugging. save_chunk_output_path: Optional output path; defaults to src/chunker_test_output.txt. + reset_group: If True, clear existing data for this group before ingestion. Returns: True if data saved successfully, False otherwise. """ - chunker_strategy = chunker_strategy or SELECTED_CHUNKER_STRATEGY - embedding_name = embedding_name or SELECTED_EMBEDDING_ID + chunker_strategy = chunker_strategy or os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker") + embedding_name = embedding_name or os.getenv("EVAL_EMBEDDING_ID") + + # Check if we should reset from environment variable if not explicitly set + if not reset_group: + reset_group = os.getenv("EVAL_RESET_ON_INGEST", "false").lower() in ("true", "1", "yes") + + # Step 0: Reset group if requested + if reset_group: + print(f"[Ingestion] 🗑️ 清空 end_user '{end_user_id}' 的现有数据...") + try: + from app.repositories.neo4j.neo4j_connector import Neo4jConnector + connector = Neo4jConnector() + try: + # 删除该 end_user 的所有节点和关系 + query = """ + MATCH (n {end_user_id: $end_user_id}) + DETACH DELETE n + """ + await connector.execute_query(query, end_user_id=end_user_id) + print(f"[Ingestion] ✅ End User '{end_user_id}' 已清空") + finally: + await connector.close() + except Exception as e: + print(f"[Ingestion] ⚠️ 清空 end_user 失败: {e}") + # 继续执行,不中断摄入流程 - # Initialize llm client with graceful fallback + # Step 1: Initialize LLM client llm_client = None - llm_available = True try: - from app.core.memory.utils.config import definitions as config_defs - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(config_defs.SELECTED_LLM_ID) + # 使用评估配置中的 LLM ID + llm_id = os.getenv("EVAL_LLM_ID") + if not llm_id: + print("[Ingestion] ❌ EVAL_LLM_ID not set in .env.evaluation") + return False + + from app.db import get_db + + db = next(get_db()) + try: + llm_client = get_llm_client(llm_id, db) + finally: + db.close() except Exception as e: - print(f"[Ingestion] LLM client unavailable, will skip LLM-dependent steps: {e}") - llm_available = False + print(f"[Ingestion] LLM client unavailable: {e}") + return False - # Step A: Build DialogData list from contexts with robust parsing + # Step 2: Parse contexts and create DialogData with chunks + print(f"[Ingestion] Parsing {len(contexts)} contexts...") chunker = DialogueChunker(chunker_strategy) dialog_data_list: List[DialogData] = [] @@ -94,7 +130,7 @@ async def ingest_contexts_via_full_pipeline( line = raw.strip() if not line: continue - m = re.match(r'^\s*([^::]+)\s*[::]\s*(.+)$', line) + m = re.match(r'^\s*([^::]+)\s*[::]\s*(.+)', line) if m: role = m.group(1).strip() msg = m.group(2).strip() @@ -118,10 +154,12 @@ async def ingest_contexts_via_full_pipeline( dialog_data_list.append(dialog) if not dialog_data_list: - print("No dialogs to process for ingestion.") + print("[Ingestion] No dialogs to process.") return False - # Optionally save chunking outputs for debugging + print(f"[Ingestion] Parsed {len(dialog_data_list)} dialogs with chunks") + + # Step 3: Optionally save chunking outputs for debugging if save_chunk_output: try: def _serialize_datetime(obj): @@ -137,124 +175,185 @@ async def ingest_contexts_via_full_pipeline( combined_output = [dd.model_dump() for dd in dialog_data_list] with open(out_path, "w", encoding="utf-8") as f: json.dump(combined_output, f, ensure_ascii=False, indent=4, default=_serialize_datetime) - print(f"Saved chunking results to: {out_path}") + print(f"[Ingestion] Saved chunking results to: {out_path}") except Exception as e: - print(f"Failed to save chunking results: {e}") + print(f"[Ingestion] Failed to save chunking results: {e}") - # Step B-G: 使用新的 ExtractionOrchestrator 执行完整的提取流水线 - if not llm_available: - print("[Ingestion] Skipping extraction pipeline (no LLM).") - return False - - # 初始化 embedder 客户端 - from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient + # Step 4: Initialize embedder client from app.core.models.base import RedBearModelConfig - from app.services.memory_config_service import MemoryConfigService + from app.core.memory.utils.config.config_utils import get_embedder_config + from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient + from app.db import get_db try: - with get_db_context() as db: - embedder_config_dict = MemoryConfigService(db).get_embedder_config(embedding_name or SELECTED_EMBEDDING_ID) - embedder_config = RedBearModelConfig(**embedder_config_dict) - embedder_client = OpenAIEmbedderClient(embedder_config) + db = next(get_db()) + try: + embedder_config_dict = get_embedder_config(embedding_name, db) + embedder_config = RedBearModelConfig(**embedder_config_dict) + embedder_client = OpenAIEmbedderClient(embedder_config) + finally: + db.close() except Exception as e: print(f"[Ingestion] Failed to initialize embedder client: {e}") - print("[Ingestion] Skipping extraction pipeline (embedder initialization failed).") return False + # Step 5: Initialize Neo4j connector connector = Neo4jConnector() - # 初始化并运行 ExtractionOrchestrator - from app.core.memory.utils.config.config_utils import get_pipeline_config - config = get_pipeline_config() + # Step 6: 构建 MemoryConfig(从环境变量直接构建,不依赖数据库) + print("[Ingestion] 构建 MemoryConfig from environment variables...") + from app.schemas.memory_config_schema import MemoryConfig + + try: + # 从环境变量获取配置参数 + llm_id = os.getenv("EVAL_LLM_ID") + embedding_id = os.getenv("EVAL_EMBEDDING_ID") + chunker_strategy_env = os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker") + + if not llm_id or not embedding_id: + print("[Ingestion] ❌ EVAL_LLM_ID or EVAL_EMBEDDING_ID is not set in .env.evaluation") + print("[Ingestion] Please set both EVAL_LLM_ID and EVAL_EMBEDDING_ID") + await connector.close() + return False + + # 从数据库获取模型信息(仅用于显示名称) + from app.db import get_db + db = next(get_db()) + try: + from sqlalchemy import text + # 获取 LLM 模型信息(从 model_configs 表) + llm_result = db.execute( + text("SELECT name FROM model_configs WHERE id = :id"), + {"id": llm_id} + ).fetchone() + llm_model_name = llm_result[0] if llm_result else "Unknown LLM" + + # 获取 Embedding 模型信息(从 model_configs 表) + emb_result = db.execute( + text("SELECT name FROM model_configs WHERE id = :id"), + {"id": embedding_id} + ).fetchone() + embedding_model_name = emb_result[0] if emb_result else "Unknown Embedding" + except Exception as e: + # 如果查询失败,使用默认名称 + print(f"[Ingestion] Warning: Failed to query model names from database: {e}") + llm_model_name = f"LLM ({llm_id[:8]}...)" + embedding_model_name = f"Embedding ({embedding_id[:8]}...)" + finally: + db.close() + + # 构建 MemoryConfig 对象(使用最小必需配置) + from uuid import uuid4 + memory_config = MemoryConfig( + config_id=0, # 评估环境不需要真实的 config_id + config_name="evaluation_config", + workspace_id=uuid4(), # 临时 workspace_id + workspace_name="evaluation_workspace", + tenant_id=uuid4(), # 临时 tenant_id + llm_model_id=UUID(llm_id), + llm_model_name=llm_model_name, + embedding_model_id=UUID(embedding_id), + embedding_model_name=embedding_model_name, + storage_type="neo4j", + chunker_strategy=chunker_strategy_env, + reflexion_enabled=False, + reflexion_iteration_period=3, + reflexion_range="partial", + reflexion_baseline="TIME", + loaded_at=datetime.now(), + # 可选字段使用默认值 + rerank_model_id=None, + rerank_model_name=None, + llm_params={}, + embedding_params={}, + config_version="2.0", + ) + + print(f"[Ingestion] ✅ 构建 MemoryConfig 成功") + print(f"[Ingestion] LLM: {llm_model_name}") + print(f"[Ingestion] Embedding: {embedding_model_name}") + print(f"[Ingestion] Chunker: {chunker_strategy_env}") + + except Exception as e: + print(f"[Ingestion] ❌ Failed to build MemoryConfig: {e}") + print(f"[Ingestion] Please check:") + print(f"[Ingestion] 1. EVAL_LLM_ID and EVAL_EMBEDDING_ID are set in .env.evaluation") + print(f"[Ingestion] 2. Model IDs exist in the models table") + print(f"[Ingestion] 3. Database connection is working") + await connector.close() + return False + + # Step 7: Initialize and run ExtractionOrchestrator + print("[Ingestion] Running extraction pipeline with ExtractionOrchestrator...") + from app.services.memory_config_service import MemoryConfigService + config = MemoryConfigService.get_pipeline_config(memory_config) orchestrator = ExtractionOrchestrator( llm_client=llm_client, embedder_client=embedder_client, connector=connector, config=config, + embedding_id=str(memory_config.embedding_model_id), # 传递 embedding_id ) - # 创建一个包装的 orchestrator 来修复时间提取器的输出 - # 保存原始的 _assign_extracted_data 方法 - original_assign = orchestrator._assign_extracted_data - - def clean_temporal_value(value): - """清理 temporal_validity 字段的值,将无效值转换为 None""" - if value is None: - return None - if isinstance(value, str): - # 处理字符串形式的 'null', 'None', 空字符串等 - if value.lower() in ('null', 'none', '') or value.strip() == '': - return None - return value - - async def patched_assign_extracted_data(*args, **kwargs): - """包装方法:在赋值后清理 temporal_validity 中的无效字符串""" - result = await original_assign(*args, **kwargs) + try: + # Run the complete extraction pipeline + result = await orchestrator.run(dialog_data_list, is_pilot_run=False) - # 清理返回的 dialog_data_list 中的 temporal_validity - for dialog in result: - if hasattr(dialog, 'chunks') and dialog.chunks: - for chunk in dialog.chunks: - if hasattr(chunk, 'statements') and chunk.statements: - for statement in chunk.statements: - if hasattr(statement, 'temporal_validity') and statement.temporal_validity: - tv = statement.temporal_validity - # 清理 valid_at 和 invalid_at - if hasattr(tv, 'valid_at'): - tv.valid_at = clean_temporal_value(tv.valid_at) - if hasattr(tv, 'invalid_at'): - tv.invalid_at = clean_temporal_value(tv.invalid_at) - return result - - # 替换方法 - orchestrator._assign_extracted_data = patched_assign_extracted_data - - # 同时包装 _create_nodes_and_edges 方法,在创建节点前再次清理 - original_create = orchestrator._create_nodes_and_edges - - async def patched_create_nodes_and_edges(dialog_data_list_arg): - """包装方法:在创建节点前再次清理 temporal_validity""" - # 最后一次清理,确保万无一失 - for dialog in dialog_data_list_arg: - if hasattr(dialog, 'chunks') and dialog.chunks: - for chunk in dialog.chunks: - if hasattr(chunk, 'statements') and chunk.statements: - for statement in chunk.statements: - if hasattr(statement, 'temporal_validity') and statement.temporal_validity: - tv = statement.temporal_validity - if hasattr(tv, 'valid_at'): - tv.valid_at = clean_temporal_value(tv.valid_at) - if hasattr(tv, 'invalid_at'): - tv.invalid_at = clean_temporal_value(tv.invalid_at) + # Handle different return formats: + # - Pilot mode: 7 values (without dedup_details) + # - Normal mode: 8 values (with dedup_details at the end) + if len(result) == 8: + # Normal mode: includes dedup_details + ( + dialogue_nodes, + chunk_nodes, + statement_nodes, + entity_nodes, + statement_chunk_edges, + statement_entity_edges, + entity_entity_edges, + _, # dedup_details - not needed here + ) = result + elif len(result) == 7: + # Pilot mode or older version: no dedup_details + ( + dialogue_nodes, + chunk_nodes, + statement_nodes, + entity_nodes, + statement_chunk_edges, + statement_entity_edges, + entity_entity_edges, + ) = result + else: + raise ValueError(f"Unexpected number of return values: {len(result)}") - return await original_create(dialog_data_list_arg) - - orchestrator._create_nodes_and_edges = patched_create_nodes_and_edges - - # 运行完整的提取流水线 - # orchestrator.run 返回 7 个元素的元组 - result = await orchestrator.run(dialog_data_list, is_pilot_run=False) - ( - dialogue_nodes, - chunk_nodes, - statement_nodes, - entity_nodes, - statement_chunk_edges, - statement_entity_edges, - entity_entity_edges, - ) = result - - # statement_chunk_edges 已经由 orchestrator 创建,无需重复创建 + print(f"[Ingestion] Extraction completed: {len(statement_nodes)} statements, {len(entity_nodes)} entities") + + except ValueError as e: + # If unpacking fails, provide helpful error message + print(f"[Ingestion] Extraction pipeline result unpacking failed: {e}") + print(f"[Ingestion] Result type: {type(result)}, length: {len(result) if hasattr(result, '__len__') else 'N/A'}") + if hasattr(result, '__len__') and len(result) > 0: + print(f"[Ingestion] First element type: {type(result[0])}") + await connector.close() + return False + except Exception as e: + print(f"[Ingestion] Extraction pipeline failed: {e}") + import traceback + traceback.print_exc() + await connector.close() + return False - # Step G: 生成记忆摘要 + # Step 7: Generate memory summaries print("[Ingestion] Generating memory summaries...") try: from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import ( memory_summary_generation, ) - from app.repositories.neo4j.add_edges import add_memory_summary_statement_edges from app.repositories.neo4j.add_nodes import add_memory_summary_nodes + from app.repositories.neo4j.add_edges import add_memory_summary_statement_edges summaries = await memory_summary_generation( chunked_dialogs=dialog_data_list, @@ -266,7 +365,8 @@ async def ingest_contexts_via_full_pipeline( print(f"[Ingestion] Warning: Failed to generate memory summaries: {e}") summaries = [] - # Step H: Save to Neo4j + # Step 8: Save to Neo4j + print("[Ingestion] Saving to Neo4j...") try: success = await save_dialog_and_statements_to_neo4j( dialogue_nodes=dialogue_nodes, @@ -284,18 +384,21 @@ async def ingest_contexts_via_full_pipeline( try: await add_memory_summary_nodes(summaries, connector) await add_memory_summary_statement_edges(summaries, connector) - print(f"Successfully saved {len(summaries)} memory summary nodes to Neo4j") + print(f"[Ingestion] Saved {len(summaries)} memory summary nodes to Neo4j") except Exception as e: - print(f"Warning: Failed to save summary nodes: {e}") + print(f"[Ingestion] Warning: Failed to save summary nodes: {e}") await connector.close() + if success: - print("Successfully saved extracted data to Neo4j!") + print("[Ingestion] Successfully saved all data to Neo4j!") else: - print("Failed to save data to Neo4j") + print("[Ingestion] Failed to save data to Neo4j") return success + except Exception as e: - print(f"Failed to save data to Neo4j: {e}") + print(f"[Ingestion] Failed to save data to Neo4j: {e}") + await connector.close() return False diff --git a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py index 1c70c28e..eed75016 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py +++ b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py @@ -15,134 +15,145 @@ import json import os import time from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import List, Dict, Any, Optional +from pathlib import Path +from dotenv import load_dotenv -try: - from dotenv import load_dotenv -except ImportError: - def load_dotenv(): - pass +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") +from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +from app.core.models.base import RedBearModelConfig +from app.core.memory.utils.config.config_utils import get_embedder_config +from app.core.memory.utils.llm.llm_utils import get_llm_client from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - bleu1, f1_score, + bleu1, jaccard, latency_stats, + avg_context_tokens ) from app.core.memory.evaluation.locomo.locomo_metrics import ( - get_category_name, locomo_f1_score, locomo_multi_f1, + get_category_name ) from app.core.memory.evaluation.locomo.locomo_utils import ( - extract_conversations, - ingest_conversations_if_needed, load_locomo_data, + extract_conversations, resolve_temporal_references, - retrieve_relevant_information, select_and_format_information, -) -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.memory.utils.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_end_user_id, - SELECTED_LLM_ID, + retrieve_relevant_information, ) from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.core.models.base import RedBearModelConfig from app.db import get_db_context -from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.services.memory_config_service import MemoryConfigService +# Get configuration from environment variables +PROJECT_ROOT = str(Path(__file__).resolve().parents[5]) # api directory +SELECTED_EMBEDDING_ID = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") +SELECTED_end_user_id = os.getenv("LOCOMO_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "locomo_benchmark") +SELECTED_LLM_ID = os.getenv("EVAL_LLM_ID", "2c9b0782-7a85-4740-ba84-4baf77f256c4") -async def run_locomo_benchmark( - sample_size: int = 20, - end_user_id: Optional[str] = None, - search_type: str = "hybrid", - search_limit: int = 12, - context_char_budget: int = 8000, - reset_group: bool = False, - skip_ingest: bool = False, - output_dir: Optional[str] = None -) -> Dict[str, Any]: + +# ============================================================================ +# Step 1: Data Loading +# ============================================================================ + +def step_load_data(data_path: str, sample_size: int) -> List[Dict[str, Any]]: """ - Run LoCoMo benchmark evaluation. - - This function orchestrates the complete evaluation pipeline: - 1. Load LoCoMo dataset (only QA pairs from first conversation) - 2. Check/ingest conversations into database (only first conversation, unless skip_ingest=True) - 3. For each question: - - Retrieve relevant information - - Generate answer using LLM - - Calculate metrics - 4. Aggregate results and save to file - - Note: By default, only the first conversation is ingested into the database, - and only QA pairs from that conversation are evaluated. This ensures that - all questions have corresponding memory in the database for retrieval. + Load QA pairs from LoCoMo dataset. Args: - sample_size: Number of QA pairs to evaluate (from first conversation) - end_user_id: Database group ID for retrieval (uses default if None) - search_type: "keyword", "embedding", or "hybrid" - search_limit: Max documents to retrieve per query - context_char_budget: Max characters for context - reset_group: Whether to clear and re-ingest data (not implemented) - skip_ingest: If True, skip data ingestion and use existing data in Neo4j - output_dir: Directory to save results (uses default if None) + data_path: Path to locomo10.json file + sample_size: Number of QA pairs to load (0 for all) Returns: - Dictionary with evaluation results including metrics, timing, and samples + List of QA items from the first conversation """ - # Use default end_user_id if not provided - end_user_id = end_user_id or SELECTED_end_user_id + print("📂 Loading LoCoMo data...") - # Determine data path - data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") - if not os.path.exists(data_path): - # Fallback to current directory - data_path = os.path.join(os.getcwd(), "data", "locomo10.json") + # Load the dataset + qa_items = load_locomo_data(data_path, sample_size) - print(f"\n{'='*60}") - print("🚀 Starting LoCoMo Benchmark Evaluation") - print(f"{'='*60}") - print("📊 Configuration:") - print(f" Sample size: {sample_size}") - print(f" Group ID: {end_user_id}") - print(f" Search type: {search_type}") - print(f" Search limit: {search_limit}") - print(f" Context budget: {context_char_budget} chars") - print(f" Data path: {data_path}") - print(f"{'='*60}\n") + print(f"✅ Loaded {len(qa_items)} QA pairs from first conversation\n") + return qa_items + + +# ============================================================================ +# Step 2: Data Ingestion +# ============================================================================ + +async def ingest_conversations_if_needed( + conversations: List[str], + end_user_id: str, + reset: bool = False +) -> bool: + """ + Ingest conversations into Neo4j database. - # Step 1: Load LoCoMo data - print("📂 Loading LoCoMo dataset...") + Args: + conversations: List of conversation strings (already formatted) + end_user_id: Database end_user ID + reset: Whether to reset the group before ingestion + + Returns: + True if successful, False otherwise + """ try: - # Only load QA pairs from the first conversation (index 0) - # since we only ingest the first conversation into the database - qa_items = load_locomo_data(data_path, sample_size, conversation_index=0) - print(f"✅ Loaded {len(qa_items)} QA pairs from conversation 0\n") + from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline + + # Conversations are already formatted as strings, use them directly + await ingest_contexts_via_full_pipeline(conversations, end_user_id) + return True + except Exception as e: - print(f"❌ Failed to load data: {e}") - return { - "error": f"Data loading failed: {e}", - "timestamp": datetime.now().isoformat() - } + print(f"⚠️ Ingestion error: {e}") + import traceback + traceback.print_exc() + return False + + +async def step_ingest_data( + data_path: str, + end_user_id: str, + skip_ingest: bool, + reset_group: bool, + max_messages: Optional[int] = None +) -> bool: + """ + Ingest conversations into Neo4j database if needed. - # Step 2: Extract conversations and ingest if needed + Args: + data_path: Path to locomo10.json file + end_user_id: Database end_user ID + skip_ingest: Whether to skip ingestion + reset_group: Whether to reset the group before ingestion + max_messages: Maximum messages per dialogue to ingest (for testing) + + Returns: + True if ingestion succeeded or was skipped, False otherwise + """ if skip_ingest: print("⏭️ Skipping data ingestion (using existing data in Neo4j)") - print(f" Group ID: {end_user_id}\n") + print(f" End User ID: {end_user_id}\n") else: print("💾 Checking database ingestion...") try: - conversations = extract_conversations(data_path, max_dialogues=1) + # Extract conversations with optional message limit + conversations = extract_conversations( + data_path, + max_dialogues=1, + max_messages_per_dialogue=max_messages + ) print(f"📝 Extracted {len(conversations)} conversations") # Always ingest for now (ingestion check not implemented) - print(f"🔄 Ingesting conversations into group '{end_user_id}'...") + print(f"🔄 Ingesting conversations into end_user '{end_user_id}'...") success = await ingest_conversations_if_needed( conversations=conversations, end_user_id=end_user_id, @@ -156,238 +167,249 @@ async def run_locomo_benchmark( except Exception as e: print(f"❌ Ingestion failed: {e}") + import traceback + traceback.print_exc() print("⚠️ Continuing with evaluation (database may be empty)\n") - # Step 3: Initialize clients + return True + + +# ============================================================================ +# Step 3: Initialize Clients +# ============================================================================ + +def step_initialize_clients(llm_id: str, embedding_id: str): + """ + Initialize Neo4j connector, LLM client, and embedder. + + Args: + llm_id: LLM model ID + embedding_id: Embedding model ID + + Returns: + Tuple of (connector, llm_client, embedder) + """ print("🔧 Initializing clients...") + connector = Neo4jConnector() - # Initialize LLM client with database context - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(SELECTED_LLM_ID) + # Get database session + from app.db import get_db + db = next(get_db()) + try: + llm_client = get_llm_client(llm_id, db) + cfg_dict = get_embedder_config(embedding_id, db) + embedder = OpenAIEmbedderClient( + model_config=RedBearModelConfig.model_validate(cfg_dict) + ) + finally: + db.close() - # Initialize embedder - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) print("✅ Clients initialized\n") - - # Step 4: Process questions + return connector, llm_client, embedder + + +# ============================================================================ +# Step 4: Process Questions +# ============================================================================ + +async def step_process_all_questions( + qa_items: List[Dict[str, Any]], + end_user_id: str, + search_type: str, + search_limit: int, + context_char_budget: int, + connector: Neo4jConnector, + embedder: OpenAIEmbedderClient, + llm_client: Any +) -> List[Dict[str, Any]]: + """Process all QA items: retrieve, generate, and calculate metrics.""" print(f"🔍 Processing {len(qa_items)} questions...") print(f"{'='*60}\n") - # Tracking variables - latencies_search: List[float] = [] - latencies_llm: List[float] = [] - context_counts: List[int] = [] - context_chars: List[int] = [] - context_tokens: List[int] = [] - - # Metric lists - f1_scores: List[float] = [] - bleu1_scores: List[float] = [] - jaccard_scores: List[float] = [] - locomo_f1_scores: List[float] = [] - - # Per-category tracking - category_counts: Dict[str, int] = {} - category_f1: Dict[str, List[float]] = {} - category_bleu1: Dict[str, List[float]] = {} - category_jaccard: Dict[str, List[float]] = {} - category_locomo_f1: Dict[str, List[float]] = {} - - # Detailed samples samples: List[Dict[str, Any]] = [] - - # Fixed anchor date for temporal resolution anchor_date = datetime(2023, 5, 8) - try: - for idx, item in enumerate(qa_items, 1): - question = item.get("question", "") - ground_truth = item.get("answer", "") - category = get_category_name(item) - - # Ensure ground truth is a string - ground_truth_str = str(ground_truth) if ground_truth is not None else "" - - print(f"[{idx}/{len(qa_items)}] Category: {category}") - print(f"❓ Question: {question}") - print(f"✅ Ground Truth: {ground_truth_str}") - - # Step 4a: Retrieve relevant information - t_search_start = time.time() - try: - retrieved_info = await retrieve_relevant_information( - question=question, - end_user_id=end_user_id, - search_type=search_type, - search_limit=search_limit, - connector=connector, - embedder=embedder - ) - t_search_end = time.time() - search_latency = (t_search_end - t_search_start) * 1000 - latencies_search.append(search_latency) - - print(f"🔍 Retrieved {len(retrieved_info)} documents ({search_latency:.1f}ms)") - - except Exception as e: - print(f"❌ Retrieval failed: {e}") - retrieved_info = [] - search_latency = 0.0 - latencies_search.append(search_latency) - - # Step 4b: Select and format context - context_text = select_and_format_information( - retrieved_info=retrieved_info, + for idx, item in enumerate(qa_items, 1): + question = item.get("question", "") + ground_truth = item.get("answer", "") + category = get_category_name(item) + ground_truth_str = str(ground_truth) if ground_truth is not None else "" + + print(f"[{idx}/{len(qa_items)}] Category: {category}") + print(f"❓ Question: {question}") + print(f"✅ Ground Truth: {ground_truth_str}") + + # Retrieve + t_search_start = time.time() + try: + retrieved_info = await retrieve_relevant_information( question=question, - max_chars=context_char_budget + end_user_id=end_user_id, + search_type=search_type, + search_limit=search_limit, + connector=connector, + embedder=embedder ) - - # Resolve temporal references - context_text = resolve_temporal_references(context_text, anchor_date) - - # Add reference date to context - if context_text: - context_text = f"Reference date: {anchor_date.date().isoformat()}\n\n{context_text}" + search_latency = (time.time() - t_search_start) * 1000 + print(f"🔍 Retrieved {len(retrieved_info)} documents ({search_latency:.1f}ms)") + except Exception as e: + print(f"❌ Retrieval failed: {e}") + retrieved_info = [] + search_latency = 0.0 + + # Format context + context_text = select_and_format_information( + retrieved_info=retrieved_info, + question=question, + max_chars=context_char_budget + ) + context_text = resolve_temporal_references(context_text, anchor_date) + if context_text: + context_text = f"Reference date: {anchor_date.date().isoformat()}\n\n{context_text}" + else: + context_text = "No relevant context found." + + print(f"📝 Context: {len(context_text)} chars, {len(retrieved_info)} docs") + + # Generate answer + messages = [ + { + "role": "system", + "content": ( + "You are a precise QA assistant. Answer following these rules:\n" + "1) Extract the EXACT information mentioned in the context\n" + "2) For time questions: calculate actual dates from relative times\n" + "3) Return ONLY the answer text in simplest form\n" + "4) For dates, use format 'DD Month YYYY' (e.g., '7 May 2023')\n" + "5) If no clear answer found, respond with 'Unknown'" + ) + }, + { + "role": "user", + "content": f"Question: {question}\n\nContext:\n{context_text}" + } + ] + + t_llm_start = time.time() + try: + response = await llm_client.chat(messages=messages) + llm_latency = (time.time() - t_llm_start) * 1000 + if hasattr(response, 'content'): + prediction = response.content.strip() + elif isinstance(response, dict): + prediction = response["choices"][0]["message"]["content"].strip() else: - context_text = "No relevant context found." - - # Track context statistics - context_counts.append(len(retrieved_info)) - context_chars.append(len(context_text)) - context_tokens.append(len(context_text.split())) - - print(f"📝 Context: {len(context_text)} chars, {len(retrieved_info)} docs") - - # Step 4c: Generate answer with LLM - messages = [ - { - "role": "system", - "content": ( - "You are a precise QA assistant. Answer following these rules:\n" - "1) Extract the EXACT information mentioned in the context\n" - "2) For time questions: calculate actual dates from relative times\n" - "3) Return ONLY the answer text in simplest form\n" - "4) For dates, use format 'DD Month YYYY' (e.g., '7 May 2023')\n" - "5) If no clear answer found, respond with 'Unknown'" - ) - }, - { - "role": "user", - "content": f"Question: {question}\n\nContext:\n{context_text}" - } - ] - - t_llm_start = time.time() - try: - response = await llm_client.chat(messages=messages) - t_llm_end = time.time() - llm_latency = (t_llm_end - t_llm_start) * 1000 - latencies_llm.append(llm_latency) - - # Extract prediction from response - if hasattr(response, 'content'): - prediction = response.content.strip() - elif isinstance(response, dict): - prediction = response["choices"][0]["message"]["content"].strip() - else: - prediction = "Unknown" - - print(f"🤖 Prediction: {prediction} ({llm_latency:.1f}ms)") - - except Exception as e: - print(f"❌ LLM failed: {e}") prediction = "Unknown" - llm_latency = 0.0 - latencies_llm.append(llm_latency) - - # Step 4d: Calculate metrics - f1_val = f1_score(prediction, ground_truth_str) - bleu1_val = bleu1(prediction, ground_truth_str) - jaccard_val = jaccard(prediction, ground_truth_str) - - # LoCoMo-specific F1: use multi-answer for category 1 (Multi-Hop) - if item.get("category") == 1: - locomo_f1_val = locomo_multi_f1(prediction, ground_truth_str) - else: - locomo_f1_val = locomo_f1_score(prediction, ground_truth_str) - - # Accumulate metrics - f1_scores.append(f1_val) - bleu1_scores.append(bleu1_val) - jaccard_scores.append(jaccard_val) - locomo_f1_scores.append(locomo_f1_val) - - # Track by category - category_counts[category] = category_counts.get(category, 0) + 1 - category_f1.setdefault(category, []).append(f1_val) - category_bleu1.setdefault(category, []).append(bleu1_val) - category_jaccard.setdefault(category, []).append(jaccard_val) - category_locomo_f1.setdefault(category, []).append(locomo_f1_val) - - print(f"📊 Metrics - F1: {f1_val:.3f}, BLEU-1: {bleu1_val:.3f}, " - f"Jaccard: {jaccard_val:.3f}, LoCoMo F1: {locomo_f1_val:.3f}") - print() - - # Save sample details - samples.append({ - "question": question, - "ground_truth": ground_truth_str, - "prediction": prediction, - "category": category, - "metrics": { - "f1": f1_val, - "bleu1": bleu1_val, - "jaccard": jaccard_val, - "locomo_f1": locomo_f1_val - }, - "retrieval": { - "num_docs": len(retrieved_info), - "context_length": len(context_text) - }, - "timing": { - "search_ms": search_latency, - "llm_ms": llm_latency - } - }) + print(f"🤖 Prediction: {prediction} ({llm_latency:.1f}ms)") + except Exception as e: + print(f"❌ LLM failed: {e}") + prediction = "Unknown" + llm_latency = 0.0 + + # Calculate metrics + f1_val = f1_score(prediction, ground_truth_str) + bleu1_val = bleu1(prediction, ground_truth_str) + jaccard_val = jaccard(prediction, ground_truth_str) + if item.get("category") == 1: + locomo_f1_val = locomo_multi_f1(prediction, ground_truth_str) + else: + locomo_f1_val = locomo_f1_score(prediction, ground_truth_str) + + print(f"📊 Metrics - F1: {f1_val:.3f}, BLEU-1: {bleu1_val:.3f}, " + f"Jaccard: {jaccard_val:.3f}, LoCoMo F1: {locomo_f1_val:.3f}") + print() + + samples.append({ + "question": question, + "ground_truth": ground_truth_str, + "prediction": prediction, + "category": category, + "metrics": { + "f1": f1_val, + "bleu1": bleu1_val, + "jaccard": jaccard_val, + "locomo_f1": locomo_f1_val + }, + "retrieval": { + "num_docs": len(retrieved_info), + "context_length": len(context_text) + }, + "context_tokens": len(context_text.split()), + "timing": { + "search_ms": search_latency, + "llm_ms": llm_latency + } + }) - finally: - # Close connector - await connector.close() - - # Step 5: Aggregate results + return samples + + +# ============================================================================ +# Step 5: Aggregate Results +# ============================================================================ + +def step_aggregate_results(samples: List[Dict[str, Any]]) -> Dict[str, Any]: + """Aggregate metrics from all samples.""" print(f"\n{'='*60}") print("📊 Aggregating Results") print(f"{'='*60}\n") + if not samples: + return { + "overall_metrics": {}, + "by_category": {}, + "latency": {}, + "context_stats": {} + } + + # Extract metrics + f1_scores = [s["metrics"]["f1"] for s in samples] + bleu1_scores = [s["metrics"]["bleu1"] for s in samples] + jaccard_scores = [s["metrics"]["jaccard"] for s in samples] + locomo_f1_scores = [s["metrics"]["locomo_f1"] for s in samples] + + # Extract timing + latencies_search = [s["timing"]["search_ms"] for s in samples] + latencies_llm = [s["timing"]["llm_ms"] for s in samples] + + # Extract context stats + context_counts = [s["retrieval"]["num_docs"] for s in samples] + context_chars = [s["retrieval"]["context_length"] for s in samples] + context_tokens = [s["context_tokens"] for s in samples] + # Overall metrics overall_metrics = { - "f1": sum(f1_scores) / max(len(f1_scores), 1) if f1_scores else 0.0, - "bleu1": sum(bleu1_scores) / max(len(bleu1_scores), 1) if bleu1_scores else 0.0, - "jaccard": sum(jaccard_scores) / max(len(jaccard_scores), 1) if jaccard_scores else 0.0, - "locomo_f1": sum(locomo_f1_scores) / max(len(locomo_f1_scores), 1) if locomo_f1_scores else 0.0 + "f1": sum(f1_scores) / len(f1_scores) if f1_scores else 0.0, + "bleu1": sum(bleu1_scores) / len(bleu1_scores) if bleu1_scores else 0.0, + "jaccard": sum(jaccard_scores) / len(jaccard_scores) if jaccard_scores else 0.0, + "locomo_f1": sum(locomo_f1_scores) / len(locomo_f1_scores) if locomo_f1_scores else 0.0 } # Per-category metrics + category_data: Dict[str, Dict[str, List[float]]] = {} + for sample in samples: + cat = sample["category"] + if cat not in category_data: + category_data[cat] = { + "f1": [], + "bleu1": [], + "jaccard": [], + "locomo_f1": [] + } + category_data[cat]["f1"].append(sample["metrics"]["f1"]) + category_data[cat]["bleu1"].append(sample["metrics"]["bleu1"]) + category_data[cat]["jaccard"].append(sample["metrics"]["jaccard"]) + category_data[cat]["locomo_f1"].append(sample["metrics"]["locomo_f1"]) + by_category: Dict[str, Dict[str, Any]] = {} - for cat in category_counts: - f1_list = category_f1.get(cat, []) - b1_list = category_bleu1.get(cat, []) - j_list = category_jaccard.get(cat, []) - lf_list = category_locomo_f1.get(cat, []) - + for cat, metrics_lists in category_data.items(): by_category[cat] = { - "count": category_counts[cat], - "f1": sum(f1_list) / max(len(f1_list), 1) if f1_list else 0.0, - "bleu1": sum(b1_list) / max(len(b1_list), 1) if b1_list else 0.0, - "jaccard": sum(j_list) / max(len(j_list), 1) if j_list else 0.0, - "locomo_f1": sum(lf_list) / max(len(lf_list), 1) if lf_list else 0.0 + "count": len(metrics_lists["f1"]), + "f1": sum(metrics_lists["f1"]) / len(metrics_lists["f1"]), + "bleu1": sum(metrics_lists["bleu1"]) / len(metrics_lists["bleu1"]), + "jaccard": sum(metrics_lists["jaccard"]) / len(metrics_lists["jaccard"]), + "locomo_f1": sum(metrics_lists["locomo_f1"]) / len(metrics_lists["locomo_f1"]) } # Latency statistics @@ -398,12 +420,181 @@ async def run_locomo_benchmark( # Context statistics context_stats = { - "avg_retrieved_docs": sum(context_counts) / max(len(context_counts), 1) if context_counts else 0.0, - "avg_context_chars": sum(context_chars) / max(len(context_chars), 1) if context_chars else 0.0, - "avg_context_tokens": sum(context_tokens) / max(len(context_tokens), 1) if context_tokens else 0.0 + "avg_retrieved_docs": sum(context_counts) / len(context_counts) if context_counts else 0.0, + "avg_context_chars": sum(context_chars) / len(context_chars) if context_chars else 0.0, + "avg_context_tokens": sum(context_tokens) / len(context_tokens) if context_tokens else 0.0 } - # Build result dictionary + return { + "overall_metrics": overall_metrics, + "by_category": by_category, + "latency": latency, + "context_stats": context_stats + } + + +# ============================================================================ +# Step 6: Result Saving +# ============================================================================ + +def step_save_results( + result: Dict[str, Any], + output_dir: Optional[str] +) -> str: + """ + Save evaluation results to JSON file. + + Args: + result: Complete result dictionary + output_dir: Directory to save results (uses default if None) + + Returns: + Path to saved file + """ + if output_dir is None: + # Use absolute path to ensure results are saved in the correct location + script_dir = Path(__file__).resolve().parent + output_dir = script_dir / "results" + else: + # Convert to Path object + output_dir = Path(output_dir) + # If relative path, make it relative to script directory + if not output_dir.is_absolute(): + script_dir = Path(__file__).resolve().parent + output_dir = script_dir / output_dir + + # Create directory if it doesn't exist + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = output_dir / f"locomo_{timestamp_str}.json" + + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"✅ Results saved to: {output_path}\n") + return str(output_path) + except Exception as e: + print(f"❌ Failed to save results: {e}") + print("📊 Printing results to console instead:\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return "" + + +# ============================================================================ +# Main Orchestration Function +# ============================================================================ + + +async def run_locomo_benchmark( + sample_size: int = 20, + end_user_id: Optional[str] = None, + search_type: str = "hybrid", + search_limit: int = 12, + context_char_budget: int = 8000, + reset_group: bool = False, + skip_ingest: bool = False, + output_dir: Optional[str] = None, + max_ingest_messages: Optional[int] = None +) -> Dict[str, Any]: + """ + Run LoCoMo benchmark evaluation. + + This function orchestrates the complete evaluation pipeline by calling + well-defined step functions: + 1. Load LoCoMo dataset (only QA pairs from first conversation) + 2. Ingest conversations into database (unless skip_ingest=True) + 3. Initialize clients (Neo4j, LLM, Embedder) + 4. Process all questions (retrieve, generate, calculate metrics) + 5. Aggregate results + 6. Save results to file + + Note: By default, only the first conversation is ingested into the database, + and only QA pairs from that conversation are evaluated. This ensures that + all questions have corresponding memory in the database for retrieval. + + Args: + sample_size: Number of QA pairs to evaluate (from first conversation) + end_user_id: Database end_user ID for retrieval (uses default if None) + search_type: "keyword", "embedding", or "hybrid" + search_limit: Max documents to retrieve per query + context_char_budget: Max characters for context + reset_group: Whether to clear and re-ingest data + skip_ingest: If True, skip data ingestion and use existing data in Neo4j + output_dir: Directory to save results (uses default if None) + max_ingest_messages: Max messages per dialogue to ingest (for testing, None = all) + + Returns: + Dictionary with evaluation results including metrics, timing, and samples + """ + # Use default end_user_id if not provided + # 优先级:命令行参数 > LOCOMO_END_USER_ID > EVAL_END_USER_ID > 默认值 + if end_user_id is None: + end_user_id = os.getenv("LOCOMO_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "locomo_benchmark") + + # Get model IDs from config + llm_id = os.getenv("EVAL_LLM_ID", "6dc52e1b-9cec-4194-af66-a74c6307fc3f") + embedding_id = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") + + # Determine data path + dataset_dir = Path(__file__).resolve().parent.parent / "dataset" + data_path = dataset_dir / "locomo10.json" + if not os.path.exists(data_path): + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 locomo10.json 放置在: {dataset_dir}" + ) + + # Print configuration + print(f"\n{'='*60}") + print("🚀 Starting LoCoMo Benchmark Evaluation") + print(f"{'='*60}") + print("📊 Configuration:") + print(f" Sample size: {sample_size}") + print(f" End User ID: {end_user_id}") + print(f" Search type: {search_type}") + print(f" Search limit: {search_limit}") + print(f" Context budget: {context_char_budget} chars") + print(f" Data path: {data_path}") + if max_ingest_messages: + print(f" Max ingest messages: {max_ingest_messages} (testing mode)") + print(f"{'='*60}\n") + + # Step 1: Load LoCoMo data (加载数据) + try: + qa_items = step_load_data(data_path, sample_size) + except Exception as e: + print(f"❌ Failed to load data: {e}") + return { + "error": f"Data loading failed: {e}", + "timestamp": datetime.now().isoformat() + } + + # Step 2: Ingest data if needed(数据摄入) + await step_ingest_data(data_path, end_user_id, skip_ingest, reset_group, max_ingest_messages) + + # Step 3: Initialize clients (初始化客户端) + connector, llm_client, embedder = step_initialize_clients(llm_id, embedding_id) + + # Step 4: Process all questions (处理所有问题) + try: + samples = await step_process_all_questions( + qa_items=qa_items, + end_user_id=end_user_id, + search_type=search_type, + search_limit=search_limit, + context_char_budget=context_char_budget, + connector=connector, + embedder=embedder, + llm_client=llm_client + ) + finally: + await connector.close() + + # Step 5: Aggregate results (聚合答案) + aggregated = step_aggregate_results(samples) + + # Build final result dictionary result = { "dataset": "locomo", "sample_size": len(qa_items), @@ -413,37 +604,18 @@ async def run_locomo_benchmark( "search_type": search_type, "search_limit": search_limit, "context_char_budget": context_char_budget, - "llm_id": SELECTED_LLM_ID, - "embedding_id": SELECTED_EMBEDDING_ID + "llm_id": llm_id, + "embedding_id": embedding_id }, - "overall_metrics": overall_metrics, - "by_category": by_category, - "latency": latency, - "context_stats": context_stats, + "overall_metrics": aggregated["overall_metrics"], + "by_category": aggregated["by_category"], + "latency": aggregated["latency"], + "context_stats": aggregated["context_stats"], "samples": samples } - # Step 6: Save results - if output_dir is None: - output_dir = os.path.join( - os.path.dirname(__file__), - "results" - ) - - os.makedirs(output_dir, exist_ok=True) - - # Generate timestamped filename - timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = os.path.join(output_dir, f"locomo_{timestamp_str}.json") - - try: - with open(output_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"✅ Results saved to: {output_path}\n") - except Exception as e: - print(f"❌ Failed to save results: {e}") - print("📊 Printing results to console instead:\n") - print(json.dumps(result, ensure_ascii=False, indent=2)) + # Step 6: Save results (保存结果) + step_save_results(result, output_dir) return result @@ -454,7 +626,25 @@ def main(): This function provides a CLI interface for running LoCoMo benchmarks with configurable parameters. + + Configuration priority: Command-line args > Environment variables > Code defaults """ + # Load environment variables first + load_dotenv() + + # Get defaults from environment variables + env_sample_size = os.getenv("LOCOMO_SAMPLE_SIZE") + env_search_limit = os.getenv("LOCOMO_SEARCH_LIMIT") + env_context_budget = os.getenv("LOCOMO_CONTEXT_CHAR_BUDGET") + env_output_dir = os.getenv("LOCOMO_OUTPUT_DIR") + env_skip_ingest = os.getenv("LOCOMO_SKIP_INGEST", "false").lower() in ("true", "1", "yes") + + # Convert to appropriate types with fallback to code defaults + default_sample_size = int(env_sample_size) if env_sample_size else 20 + default_search_limit = int(env_search_limit) if env_search_limit else 12 + default_context_budget = int(env_context_budget) if env_context_budget else 8000 + default_output_dir = env_output_dir if env_output_dir else None + parser = argparse.ArgumentParser( description="Run LoCoMo benchmark evaluation", formatter_class=argparse.ArgumentDefaultsHelpFormatter @@ -463,14 +653,14 @@ def main(): parser.add_argument( "--sample_size", type=int, - default=20, - help="Number of QA pairs to evaluate" + default=default_sample_size, + help=f"Number of QA pairs to evaluate (env: LOCOMO_SAMPLE_SIZE={env_sample_size or 'not set'}, 0 for all)" ) parser.add_argument( "--end_user_id", type=str, default=None, - help="Database group ID for retrieval (uses default if not specified)" + help="Database end user ID for retrieval (uses LOCOMO_END_USER_ID or EVAL_END_USER_ID if not specified)" ) parser.add_argument( "--search_type", @@ -482,14 +672,14 @@ def main(): parser.add_argument( "--search_limit", type=int, - default=12, - help="Maximum number of documents to retrieve per query" + default=default_search_limit, + help=f"Maximum number of documents to retrieve per query (env: LOCOMO_SEARCH_LIMIT={env_search_limit or 'not set'})" ) parser.add_argument( "--context_char_budget", type=int, - default=8000, - help="Maximum characters for context" + default=default_context_budget, + help=f"Maximum characters for context (env: LOCOMO_CONTEXT_CHAR_BUDGET={env_context_budget or 'not set'})" ) parser.add_argument( "--reset_group", @@ -499,20 +689,24 @@ def main(): parser.add_argument( "--skip_ingest", action="store_true", - help="Skip data ingestion and use existing data in Neo4j" + default=env_skip_ingest, + help=f"Skip data ingestion and use existing data in Neo4j (env: LOCOMO_SKIP_INGEST={os.getenv('LOCOMO_SKIP_INGEST', 'false')})" ) parser.add_argument( "--output_dir", type=str, + default=default_output_dir, + help=f"Directory to save results (env: LOCOMO_OUTPUT_DIR={env_output_dir or 'not set'})" + ) + parser.add_argument( + "--max_ingest_messages", + type=int, default=None, - help="Directory to save results (uses default if not specified)" + help="Maximum messages per dialogue to ingest (for testing, default: all messages)" ) args = parser.parse_args() - # Load environment variables - load_dotenv() - # Run benchmark result = asyncio.run(run_locomo_benchmark( sample_size=args.sample_size, @@ -522,7 +716,8 @@ def main(): context_char_budget=args.context_char_budget, reset_group=args.reset_group, skip_ingest=args.skip_ingest, - output_dir=args.output_dir + output_dir=args.output_dir, + max_ingest_messages=args.max_ingest_messages )) # Print summary diff --git a/api/app/core/memory/evaluation/locomo/locomo_test.py b/api/app/core/memory/evaluation/locomo/locomo_test.py index 01c45123..2cb0664c 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_test.py +++ b/api/app/core/memory/evaluation/locomo/locomo_test.py @@ -1,30 +1,29 @@ # file name: check_neo4j_connection_fixed.py import asyncio -import json -import math import os -import re import sys +import json import time +import math +import re from datetime import datetime, timedelta -from typing import Any, Dict, List +from typing import List, Dict, Any from pathlib import Path - from dotenv import load_dotenv -# 1 -# 添加项目根目录到路径 -current_dir = Path(__file__).resolve().parent -project_root = str(current_dir.parent) -if project_root not in sys.path: - sys.path.insert(0, project_root) -# 关键:将 src 目录置于最前,确保从当前仓库加载模块 -src_dir = os.path.join(project_root, "src") -if src_dir not in sys.path: - sys.path.insert(0, src_dir) - +# Load main .env load_dotenv() +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") + +# Get group_id from config +group_id = os.getenv("EVAL_GROUP_ID", "locomo_test") +print(f"✅ 使用配置的 group_id: {group_id}") + # 首先定义 _loc_normalize 函数,因为其他函数依赖它 def _loc_normalize(text: str) -> str: text = str(text) if text is not None else "" @@ -37,7 +36,7 @@ def _loc_normalize(text: str) -> str: # 尝试从 metrics.py 导入基础指标 try: - from common.metrics import bleu1, f1_score, jaccard + from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard print("✅ 从 metrics.py 导入基础指标成功") except ImportError as e: print(f"❌ 从 metrics.py 导入失败: {e}") @@ -107,23 +106,8 @@ except ImportError as e: # 尝试从 qwen_search_eval.py 导入 LoCoMo 特定指标 try: - # 添加 evaluation 目录路径 - evaluation_dir = os.path.join(project_root, "evaluation") - if evaluation_dir not in sys.path: - sys.path.insert(0, evaluation_dir) - - # 尝试从不同位置导入 - try: - from locomo.qwen_search_eval import ( - _resolve_relative_times, - loc_f1_score, - loc_multi_f1, - ) - print("✅ 从 locomo.qwen_search_eval 导入 LoCoMo 特定指标成功") - except ImportError: - from qwen_search_eval import _resolve_relative_times, loc_f1_score, loc_multi_f1 - print("✅ 从 qwen_search_eval 导入 LoCoMo 特定指标成功") - + from app.core.memory.evaluation.locomo.qwen_search_eval import loc_f1_score, loc_multi_f1, _resolve_relative_times + print("✅ 从 qwen_search_eval 导入 LoCoMo 特定指标成功") except ImportError as e: print(f"❌ 从 qwen_search_eval.py 导入失败: {e}") # 回退到本地实现 LoCoMo 特定函数 @@ -429,31 +413,36 @@ def enhanced_context_selection(contexts: List[str], question: str, question_inde async def run_enhanced_evaluation(): """使用增强方法进行完整评估 - 解决中间性能衰减问题""" - try: - from dotenv import load_dotenv - except Exception: - def load_dotenv(): - return None - + from dotenv import load_dotenv + from uuid import UUID + from datetime import datetime + from dataclasses import dataclass + # 修正导入路径:使用 app.core.memory.src 前缀 - from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient - from app.core.memory.utils.config.definitions import ( - SELECTED_EMBEDDING_ID, - SELECTED_LLM_ID, - ) - from app.core.memory.utils.llm.llm_utils import MemoryClientFactory - from app.core.models.base import RedBearModelConfig - from app.db import get_db_context - from app.repositories.neo4j.graph_search import search_graph_by_embedding from app.repositories.neo4j.neo4j_connector import Neo4jConnector + from app.repositories.neo4j.graph_search import search_graph_by_embedding + from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient + from app.core.models.base import RedBearModelConfig + from app.core.memory.utils.llm.llm_utils import get_llm_client + from app.core.memory.utils.config.config_utils import get_embedder_config + from app.schemas.memory_config_schema import MemoryConfig from app.services.memory_config_service import MemoryConfigService + + # Get model IDs from config + llm_id = os.getenv("EVAL_LLM_ID", "6dc52e1b-9cec-4194-af66-a74c6307fc3f") + embedding_id = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") - # 加载数据 - # 获取项目根目录 - current_file = os.path.abspath(__file__) - evaluation_dir = os.path.dirname(os.path.dirname(current_file)) # evaluation目录 - memory_dir = os.path.dirname(evaluation_dir) # memory目录 - data_path = os.path.join(memory_dir, "data", "locomo10.json") + # 加载数据 - 使用统一的 dataset 目录 + data_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "dataset", "locomo10.json") + + if not os.path.exists(data_path): + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 locomo10.json 放置在: api/app/core/memory/evaluation/dataset/" + ) + + print(f"✅ 找到数据文件: {data_path}") + with open(data_path, "r", encoding="utf-8") as f: raw = json.load(f) @@ -463,64 +452,109 @@ async def run_enhanced_evaluation(): qa_items.extend(entry.get("qa", [])) else: qa_items.extend(raw.get("qa", [])) - - items = qa_items[:20] # 测试多少个问题 + + # 测试多少个问题 - 可通过环境变量设置 + sample_size = int(os.getenv("LOCOMO_SAMPLE_SIZE", "20")) + items = qa_items[:sample_size] + print(f"📊 将测试 {len(items)} 个问题(总共 {len(qa_items)} 个可用)") # 初始化增强监控器 monitor = EnhancedEvaluationMonitor(reset_interval=5, performance_threshold=0.6) - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm = factory.get_llm_client(SELECTED_LLM_ID) + # 获取数据库会话并初始化 LLM 客户端 + from app.db import get_db + db = next(get_db()) - # 初始化embedder - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - - # 初始化连接器 - connector = Neo4jConnector() - - # 初始化结果字典 - results = { - "questions": [], - "overall_metrics": {"f1": 0.0, "b1": 0.0, "j": 0.0, "loc_f1": 0.0}, - "category_metrics": {}, - "retrieval_stats": {"total_questions": len(items), "avg_context_length": 0, "avg_retrieved_docs": 0}, - "performance_trend": "stable", - "timestamp": datetime.now().isoformat(), - "enhanced_strategy": True - } - - total_f1 = 0.0 - total_bleu1 = 0.0 - total_jaccard = 0.0 - total_loc_f1 = 0.0 - total_context_length = 0 - total_retrieved_docs = 0 - category_stats = {} - try: - for i, item in enumerate(items): - monitor.question_count += 1 + llm = get_llm_client(llm_id, db) + + # 初始化embedder + cfg_dict = get_embedder_config(embedding_id, db) + embedder = OpenAIEmbedderClient( + model_config=RedBearModelConfig.model_validate(cfg_dict) + ) + + # 🔧 创建 MemoryConfig 对象用于搜索 + # 方案1:如果有配置ID,从数据库加载 + config_id = os.getenv("EVAL_CONFIG_ID") + if config_id: + print(f"📋 从数据库加载配置 ID: {config_id}") + memory_config_service = MemoryConfigService(db) + memory_config = memory_config_service.load_memory_config(config_id, service_name="locomo_test") + else: + # 方案2:创建临时配置对象用于测试 + print(f"📋 创建临时测试配置") + from uuid import UUID + from datetime import datetime + + # 将字符串 ID 转换为 UUID + try: + embedding_uuid = UUID(embedding_id) + llm_uuid = UUID(llm_id) + except ValueError as e: + raise ValueError(f"无效的 UUID 格式: {e}") + + memory_config = MemoryConfig( + config_id=1, # 临时 ID + config_name="locomo_test_config", + workspace_id=UUID("00000000-0000-0000-0000-000000000000"), # 临时 workspace + workspace_name="test_workspace", + tenant_id=UUID("00000000-0000-0000-0000-000000000000"), # 临时 tenant + embedding_model_id=embedding_uuid, + embedding_model_name="test_embedding", + llm_model_id=llm_uuid, + llm_model_name="test_llm", + storage_type="neo4j", + chunker_strategy="RecursiveChunker", + reflexion_enabled=False, + reflexion_iteration_period=3, + reflexion_range="partial", + reflexion_baseline="Time", + loaded_at=datetime.now() + ) + + print(f"✅ MemoryConfig 已准备: embedding_id={memory_config.embedding_model_id}, llm_id={memory_config.llm_model_id}") + + # 初始化连接器 + connector = Neo4jConnector() - # 获取近期性能用于重置判断 - recent_performance = monitor.get_recent_performance() + # 初始化结果字典 + results = { + "questions": [], + "overall_metrics": {"f1": 0.0, "b1": 0.0, "j": 0.0, "loc_f1": 0.0}, + "category_metrics": {}, + "retrieval_stats": {"total_questions": len(items), "avg_context_length": 0, "avg_retrieved_docs": 0}, + "performance_trend": "stable", + "timestamp": datetime.now().isoformat(), + "enhanced_strategy": True + } - # 增强的重置判断 - should_reset = monitor.should_reset_connections(current_f1=recent_performance) - if should_reset and i > 0: - print(f"🔄 重置Neo4j连接 (问题 {i+1}/{len(items)}, 近期性能: {recent_performance:.3f})...") - await connector.close() - connector = Neo4jConnector() # 创建新连接 - print("✅ 连接重置完成") + total_f1 = 0.0 + total_bleu1 = 0.0 + total_jaccard = 0.0 + total_loc_f1 = 0.0 + total_context_length = 0 + total_retrieved_docs = 0 + category_stats = {} - q = item.get("question", "") - ref = item.get("answer", "") - ref_str = str(ref) if ref is not None else "" + try: + for i, item in enumerate(items): + monitor.question_count += 1 + + # 获取近期性能用于重置判断 + recent_performance = monitor.get_recent_performance() + + # 增强的重置判断 + should_reset = monitor.should_reset_connections(current_f1=recent_performance) + if should_reset and i > 0: + print(f"🔄 重置Neo4j连接 (问题 {i+1}/{len(items)}, 近期性能: {recent_performance:.3f})...") + await connector.close() + connector = Neo4jConnector() # 创建新连接 + print("✅ 连接重置完成") + + q = item.get("question", "") + ref = item.get("answer", "") + ref_str = str(ref) if ref is not None else "" print(f"\n🔍 [{i+1}/{len(items)}] 问题: {q}") print(f"✅ 真实答案: {ref_str}") @@ -548,10 +582,12 @@ async def run_enhanced_evaluation(): contexts_all = [] try: - # 使用统一的搜索服务 - from app.core.memory.storage_services.search import run_hybrid_search + # 使用旧版本的搜索服务(重构前的版本) + from app.core.memory.src.search import run_hybrid_search - print("🔀 使用混合搜索服务...") + print(f"🔀 使用混合搜索服务(旧版本)...") + print(f"📍 检索参数: group_id={group_id}, limit=20, search_type=hybrid") + print(f"📍 查询文本: {q}") search_results = await run_hybrid_search( query_text=q, @@ -559,15 +595,27 @@ async def run_enhanced_evaluation(): end_user_id="locomo_sk", limit=20, include=["statements", "chunks", "entities", "summaries"], - alpha=0.6, # BM25权重 - embedding_id=SELECTED_EMBEDDING_ID + output_path=None, + memory_config=memory_config, # 🔧 添加必需的 memory_config 参数 + rerank_alpha=0.6, # BM25权重 + use_forgetting_rerank=False, + use_llm_rerank=False ) - # 处理搜索结果 - 新的搜索服务返回统一的结构 - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) + # 处理搜索结果 - 旧版本返回包含 reranked_results 的结构 + # 对于 hybrid 搜索,使用 reranked_results + if "reranked_results" in search_results: + reranked = search_results["reranked_results"] + chunks = reranked.get("chunks", []) + statements = reranked.get("statements", []) + entities = reranked.get("entities", []) + summaries = reranked.get("summaries", []) + else: + # 单一搜索类型的结果 + chunks = search_results.get("chunks", []) + statements = search_results.get("statements", []) + entities = search_results.get("entities", []) + summaries = search_results.get("summaries", []) print(f"✅ 混合检索成功: {len(chunks)} chunks, {len(statements)} 条陈述, {len(entities)} 个实体, {len(summaries)} 个摘要") @@ -609,6 +657,8 @@ async def run_enhanced_evaluation(): print(f"📊 有效上下文数量: {len(contexts_all)}") except Exception as e: print(f"❌ 检索失败: {e}") + import traceback + print(f"详细错误信息:\n{traceback.format_exc()}") contexts_all = [] t1 = time.time() @@ -728,14 +778,17 @@ async def run_enhanced_evaluation(): print("="*60) - except Exception as e: - print(f"❌ 评估过程中发生错误: {e}") - # 即使出错,也返回已有的结果 - import traceback - traceback.print_exc() + except Exception as e: + print(f"❌ 评估过程中发生错误: {e}") + # 即使出错,也返回已有的结果 + import traceback + traceback.print_exc() + finally: + await connector.close() + finally: - await connector.close() + db.close() # 关闭数据库会话 # 计算总体指标 n = len(items) diff --git a/api/app/core/memory/evaluation/locomo/locomo_utils.py b/api/app/core/memory/evaluation/locomo/locomo_utils.py index d3b74947..6ad68470 100644 --- a/api/app/core/memory/evaluation/locomo/locomo_utils.py +++ b/api/app/core/memory/evaluation/locomo/locomo_utils.py @@ -15,8 +15,14 @@ import json import re from datetime import datetime, timedelta from typing import List, Dict, Any, Optional +from pathlib import Path +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) -from app.core.memory.utils.definitions import PROJECT_ROOT from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline @@ -82,7 +88,7 @@ def load_locomo_data( return qa_items[:sample_size] -def extract_conversations(data_path: str, max_dialogues: int = 1) -> List[str]: +def extract_conversations(data_path: str, max_dialogues: int = 1, max_messages_per_dialogue: Optional[int] = None) -> List[str]: """ Extract conversation texts from LoCoMo data for ingestion. @@ -93,6 +99,7 @@ def extract_conversations(data_path: str, max_dialogues: int = 1) -> List[str]: Args: data_path: Path to locomo10.json file max_dialogues: Maximum number of dialogues to extract (default: 1) + max_messages_per_dialogue: Maximum messages per dialogue (default: None = all messages) Returns: List of conversation strings formatted for ingestion. @@ -141,13 +148,21 @@ def extract_conversations(data_path: str, max_dialogues: int = 1) -> List[str]: continue lines.append(f"{role}: {text}") + + # Limit messages if specified + if max_messages_per_dialogue and len(lines) >= max_messages_per_dialogue: + break + + # Break outer loop if we've reached the message limit + if max_messages_per_dialogue and len(lines) >= max_messages_per_dialogue: + break if lines: contents.append("\n".join(lines)) return contents - +# 时间解析:将相对时间表达转换为绝对日期 def resolve_temporal_references(text: str, anchor_date: datetime) -> str: """ Resolve relative temporal references to absolute dates. @@ -225,6 +240,8 @@ def resolve_temporal_references(text: str, anchor_date: datetime) -> str: t, flags=re.IGNORECASE ) + + # 中文支持 t = re.sub( r"\bnext\s+week\b", (anchor_date + timedelta(days=7)).date().isoformat(), @@ -345,6 +362,50 @@ def select_and_format_information( return "\n\n".join(selected) +# 记忆系统核心能力:写入与读取 +async def ingest_conversations_if_needed( + conversations: List[str], + end_user_id: str, + reset: bool = False +) -> bool: + """ + Wrapper for conversation ingestion using external extraction pipeline. + + This function populates the Neo4j database with processed conversation data + (chunks, statements, entities) so that the retrieval system has memory to search. + + The ingestion process: + 1. Parses conversation text into dialogue messages + 2. Chunks the dialogues into semantic units + 3. Extracts statements and entities using LLM + 4. Generates embeddings for all content + 5. Stores everything in Neo4j graph database + + Args: + conversations: List of raw conversation texts from LoCoMo dataset + Example: ["User: I went to Paris. AI: When was that?", ...] + end_user_id: Target end_user ID for database storage + reset: Whether to clear existing data first (not implemented in wrapper) + + Returns: + True if successful, False otherwise + + Note: + The external function uses "contexts" to mean "conversation texts". + This runs the full extraction pipeline: chunking → entity extraction → + statement extraction → embedding → Neo4j storage. + """ + try: + success = await ingest_contexts_via_full_pipeline( + contexts=conversations, + end_user_id=end_user_id, + save_chunk_output=True, + reset_group=reset + ) + return success + except Exception as e: + print(f"[Ingestion] Failed to ingest conversations: {e}") + return False async def retrieve_relevant_information( question: str, @@ -385,7 +446,7 @@ async def retrieve_relevant_information( search_graph, search_graph_by_embedding ) - from app.core.memory.storage_services.search import run_hybrid_search + from app.core.memory.src.search import run_hybrid_search contexts_all: List[str] = [] diff --git a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py index 6a5caa0c..889c5065 100644 --- a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py @@ -2,43 +2,29 @@ import argparse import asyncio import json import os -import statistics import time from datetime import datetime, timedelta -from typing import Any, Dict, List - -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None - +from typing import List, Dict, Any +import statistics import re +from pathlib import Path +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") -from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - bleu1, - jaccard, - latency_stats, -) -from app.core.memory.evaluation.common.metrics import f1_score as common_f1 -from app.core.memory.evaluation.extraction_utils import ( - ingest_contexts_via_full_pipeline, -) -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.memory.storage_services.search import run_hybrid_search -from app.core.memory.utils.config.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, - SELECTED_LLM_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.core.models.base import RedBearModelConfig -from app.db import get_db_context -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.services.memory_config_service import MemoryConfigService +from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding +from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +from app.core.models.base import RedBearModelConfig +from app.core.memory.utils.config.config_utils import get_embedder_config +from app.core.memory.src.search import run_hybrid_search # 使用旧版本(重构前) +from app.core.memory.utils.llm.llm_utils import get_llm_client +from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline +from app.core.memory.evaluation.common.metrics import f1_score as common_f1, bleu1, jaccard, latency_stats, avg_context_tokens # 参考 evaluation/locomo/evaluation.py 的 F1 计算逻辑(移除外部依赖,内联实现) @@ -265,7 +251,10 @@ async def run_locomo_eval( end_user_id = end_user_id or SELECTED_end_user_id data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") if not os.path.exists(data_path): - data_path = os.path.join(os.getcwd(), "data", "locomo10.json") + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 locomo10.json 放置在: {dataset_dir}" + ) with open(data_path, "r", encoding="utf-8") as f: raw = json.load(f) # LoCoMo 数据结构:顶层为若干对象,每个对象下有 qa 列表 @@ -343,13 +332,9 @@ async def run_locomo_eval( await ingest_contexts_via_full_pipeline(contents, end_user_id, save_chunk_output=True) # 使用异步LLM客户端 - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(SELECTED_LLM_ID) + llm_client = get_llm_client(llm_id) # 初始化embedder用于直接调用 - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) + cfg_dict = get_embedder_config(embedding_id) embedder = OpenAIEmbedderClient( model_config=RedBearModelConfig.model_validate(cfg_dict) ) @@ -480,8 +465,8 @@ async def run_locomo_eval( contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") else: # hybrid - # 🎯 关键修复:混合检索使用更严格的回退机制 - print("🔀 使用混合检索(带回退机制)...") + # 使用旧版本的混合检索(重构前) + print("🔀 使用混合检索(旧版本)...") try: search_results = await run_hybrid_search( query_text=q, @@ -490,16 +475,26 @@ async def run_locomo_eval( limit=adjusted_limit, include=["chunks", "statements", "entities", "summaries"], output_path=None, + rerank_alpha=0.6, + use_forgetting_rerank=False, + use_llm_rerank=False ) - # 🎯 关键修复:正确处理混合检索的扁平结构 - # 新的API返回扁平结构,直接从顶层获取结果 + # 处理旧版本的返回结构(包含 reranked_results) if search_results and isinstance(search_results, dict): - # 新API返回扁平结构:直接从顶层获取 - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) + # 对于 hybrid 搜索,使用 reranked_results + if "reranked_results" in search_results: + reranked = search_results["reranked_results"] + chunks = reranked.get("chunks", []) + statements = reranked.get("statements", []) + entities = reranked.get("entities", []) + summaries = reranked.get("summaries", []) + else: + # 单一搜索类型的结果 + chunks = search_results.get("chunks", []) + statements = search_results.get("statements", []) + entities = search_results.get("entities", []) + summaries = search_results.get("summaries", []) # 检查是否有有效结果 if chunks or statements or entities or summaries: @@ -799,8 +794,9 @@ async def run_locomo_eval( "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, - "llm_id": SELECTED_LLM_ID, - "retrieval_embedding_id": SELECTED_EMBEDDING_ID, + "llm_id": llm_id, + "retrieval_embedding_id": embedding_id, + "chunker_strategy": os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker"), "skip_ingest_if_exists": skip_ingest_if_exists, "llm_timeout": llm_timeout, "llm_max_retries": llm_max_retries, diff --git a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py b/api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py similarity index 93% rename from api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py rename to api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py index 8710a504..aaf46e35 100644 --- a/api/app/core/memory/evaluation/longmemeval/qwen_search_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py @@ -2,100 +2,67 @@ import argparse import asyncio import json import os +import time import re import statistics -import time from datetime import datetime, timedelta -from typing import Any, Dict, List - -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None - -# 确保可以找到 src 及项目根路径 -import sys +from typing import List, Dict, Any from pathlib import Path -_THIS_DIR = Path(__file__).resolve().parent -_PROJECT_ROOT = str(_THIS_DIR.parents[2]) -_SRC_DIR = os.path.join(_PROJECT_ROOT, "src") -for _p in (_SRC_DIR, _PROJECT_ROOT): - if _p not in sys.path: - sys.path.insert(0, _p) +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") -# 与现有评估脚本保持一致的导入方式 from app.repositories.neo4j.neo4j_connector import Neo4jConnector - -try: - # 优先从 extraction_utils1 导入 - from app.core.memory.evaluation.extraction_utils import ( - ingest_contexts_via_full_pipeline, # type: ignore - ) -except Exception: - ingest_contexts_via_full_pipeline = None # 在运行时做兜底检查 -from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - jaccard, - latency_stats, -) -from app.core.memory.evaluation.common.metrics import f1_score as common_f1 -from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.memory.utils.config.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_LLM_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.core.models.base import RedBearModelConfig -from app.db import get_db_context +from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding -from app.services.memory_config_service import MemoryConfigService - -try: - from app.core.memory.evaluation.common.metrics import exact_match -except Exception: - # 兜底:简单的大小写不敏感比较 - def exact_match(pred: str, ref: str) -> bool: - return str(pred).strip().lower() == str(ref).strip().lower() +from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +from app.core.models.base import RedBearModelConfig +from app.core.memory.utils.config.config_utils import get_embedder_config +from app.core.memory.utils.llm.llm_utils import get_llm_client +from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME +from app.core.memory.evaluation.common.metrics import f1_score as common_f1, jaccard, latency_stats, avg_context_tokens +from app.core.memory.evaluation.common.metrics import exact_match def load_dataset_any(path: str) -> List[Dict[str, Any]]: - """健壮地加载数据集(兼容 list 或多段 JSON)。""" + """健壮地加载数据集,支持三种格式: + 1. 标准 JSON 数组: [{...}, {...}] + 2. 单个 JSON 对象: {...} + 3. JSONL 格式(每行一个 JSON): {...}\n{...}\n{...} + """ with open(path, "r", encoding="utf-8") as f: - s = f.read().strip() + content = f.read().strip() + + # 尝试标准 JSON 解析 try: - obj = json.loads(s) - if isinstance(obj, list): - return obj - elif isinstance(obj, dict): - return [obj] + data = json.loads(content) + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + elif isinstance(data, dict): + return [data] except json.JSONDecodeError: pass - dec = json.JSONDecoder() - idx = 0 - items: List[Dict[str, Any]] = [] - while idx < len(s): - while idx < len(s) and s[idx].isspace(): - idx += 1 - if idx >= len(s): - break + + # 尝试 JSONL 格式(每行一个 JSON 对象) + items = [] + for line in content.splitlines(): + line = line.strip() + if not line: + continue try: - obj, end = dec.raw_decode(s, idx) - if isinstance(obj, list): - for it in obj: - if isinstance(it, dict): - items.append(it) - elif isinstance(obj, dict): + obj = json.loads(line) + if isinstance(obj, dict): items.append(obj) - idx = end + elif isinstance(obj, list): + items.extend(item for item in obj if isinstance(item, dict)) except json.JSONDecodeError: - nl = s.find("\n", idx) - if nl == -1: - break - idx = nl + 1 + continue + return items @@ -624,7 +591,7 @@ def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: async def run_longmemeval_test( sample_size: int = 3, - end_user_id: str = "longmemeval_zh_bak_3", + end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, @@ -639,18 +606,22 @@ async def run_longmemeval_test( skip_ingest: bool = False, ) -> Dict[str, Any]: """LongMemEval 评估测试:增强时间推理能力""" + + # Use environment variable with fallback chain + if end_user_id is None: + end_user_id = os.getenv("LONGMEMEVAL_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "longmemeval_zh_bak_3") # 数据路径 if not data_path: - # 固定使用中文数据集:data/longmemeval_oracle_zh.json - zh_proj = os.path.join(PROJECT_ROOT, "data", "longmemeval_oracle_zh.json") - zh_cwd = os.path.join(os.getcwd(), "data", "longmemeval_oracle_zh.json") - if os.path.exists(zh_proj): - data_path = zh_proj - elif os.path.exists(zh_cwd): - data_path = zh_cwd - else: - raise FileNotFoundError("未找到数据集: data/longmemeval_oracle_zh.json,请确保其存在于项目根目录或当前工作目录的 data 目录下。") + # 固定使用中文数据集:dataset/longmemeval_oracle_zh.json + dataset_dir = Path(__file__).resolve().parent.parent / "dataset" + data_path = str(dataset_dir / "longmemeval_oracle_zh.json") + + if not os.path.exists(data_path): + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 longmemeval_oracle_zh.json 放置在: {dataset_dir}" + ) qa_list: List[Dict[str, Any]] = load_dataset_any(data_path) # 支持评估全部样本:当 sample_size <= 0 时,取从 start_index 到末尾 @@ -702,16 +673,19 @@ async def run_longmemeval_test( ) # 初始化组件(摄入后再初始化连接器)- 使用异步LLM客户端 - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(SELECTED_LLM_ID) + from app.db import get_db + + db = next(get_db()) + try: + llm_client = get_llm_client(os.getenv("EVAL_LLM_ID"), db) + cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID"), db) + embedder = OpenAIEmbedderClient( + model_config=RedBearModelConfig.model_validate(cfg_dict) + ) + finally: + db.close() + connector = Neo4jConnector() - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) # 指标收集 latencies_llm: List[float] = [] @@ -768,10 +742,10 @@ async def run_longmemeval_test( if stmt_text: contexts_all.append(stmt_text) - # for sm in summaries: - # summary_text = str(sm.get("summary", "")).strip() - # if summary_text: - # contexts_all.append(summary_text) + for sm in summaries: + summary_text = str(sm.get("summary", "")).strip() + if summary_text: + contexts_all.append(summary_text) # 实体摘要(最多3个) scored = [e for e in entities if e.get("score") is not None] @@ -1228,8 +1202,8 @@ async def run_longmemeval_test( "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, - "llm_id": SELECTED_LLM_ID, - "embedding_id": SELECTED_EMBEDDING_ID, + "llm_id": os.getenv("EVAL_LLM_ID"), + "embedding_id": os.getenv("EVAL_EMBEDDING_ID"), "sample_size": sample_size, "start_index": start_index, }, @@ -1288,7 +1262,7 @@ def main(): parser.add_argument("--sample-size", type=int, default=3, help="样本数量(<=0 表示全部)") parser.add_argument("--all", action="store_true", help="评估全部样本(覆盖 --sample-size)") parser.add_argument("--start-index", type=int, default=0, help="起始样本索引") - parser.add_argument("--group-id", type=str, default="longmemeval_zh_bak_3", help="图数据库 Group ID") + parser.add_argument("--end-user-id", type=str, default=None, help="图数据库 End User ID,默认使用环境变量") parser.add_argument("--search-limit", type=int, default=8, help="检索条数上限") parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") @@ -1349,7 +1323,8 @@ def main(): # 保存结果到文件 try: - out_dir = os.path.join(PROJECT_ROOT, "evaluation", "longmemeval", "results") + # 使用相对路径而不是 PROJECT_ROOT + out_dir = Path(__file__).resolve().parent / "results" os.makedirs(out_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(out_dir, f"longmemeval_{result['params']['search_type']}_{ts}.json") diff --git a/api/app/core/memory/evaluation/longmemeval/test_eval.py b/api/app/core/memory/evaluation/longmemeval/test_eval.py index 67bd6ec2..08daa890 100644 --- a/api/app/core/memory/evaluation/longmemeval/test_eval.py +++ b/api/app/core/memory/evaluation/longmemeval/test_eval.py @@ -2,81 +2,67 @@ import argparse import asyncio import json import os +import time import re import statistics -import time from datetime import datetime, timedelta -from typing import Any, Dict, List +from typing import List, Dict, Any +from pathlib import Path -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") # 与现有评估脚本保持一致的导入方式 -from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - jaccard, - latency_stats, -) -from app.core.memory.evaluation.common.metrics import f1_score as common_f1 -from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.memory.utils.config.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_LLM_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.core.models.base import RedBearModelConfig -from app.db import get_db_context -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.services.memory_config_service import MemoryConfigService - -try: - from app.core.memory.evaluation.common.metrics import exact_match -except Exception: - # 兜底:简单的大小写不敏感比较 - def exact_match(pred: str, ref: str) -> bool: - return str(pred).strip().lower() == str(ref).strip().lower() +from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding +from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +from app.core.models.base import RedBearModelConfig +from app.core.memory.utils.config.config_utils import get_embedder_config +from app.core.memory.utils.llm.llm_utils import get_llm_client +from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME +from app.core.memory.evaluation.common.metrics import f1_score as common_f1, jaccard, latency_stats, avg_context_tokens +from app.core.memory.evaluation.common.metrics import exact_match def load_dataset_any(path: str) -> List[Dict[str, Any]]: - """健壮地加载数据集(兼容 list 或多段 JSON)。""" + """健壮地加载数据集,支持三种格式: + 1. 标准 JSON 数组: [{...}, {...}] + 2. 单个 JSON 对象: {...} + 3. JSONL 格式(每行一个 JSON): {...}\n{...}\n{...} + """ with open(path, "r", encoding="utf-8") as f: - s = f.read().strip() + content = f.read().strip() + + # 尝试标准 JSON 解析 try: - obj = json.loads(s) - if isinstance(obj, list): - return obj - elif isinstance(obj, dict): - return [obj] + data = json.loads(content) + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + elif isinstance(data, dict): + return [data] except json.JSONDecodeError: pass - dec = json.JSONDecoder() - idx = 0 - items: List[Dict[str, Any]] = [] - while idx < len(s): - while idx < len(s) and s[idx].isspace(): - idx += 1 - if idx >= len(s): - break + + # 尝试 JSONL 格式(每行一个 JSON 对象) + items = [] + for line in content.splitlines(): + line = line.strip() + if not line: + continue try: - obj, end = dec.raw_decode(s, idx) - if isinstance(obj, list): - for it in obj: - if isinstance(it, dict): - items.append(it) - elif isinstance(obj, dict): + obj = json.loads(line) + if isinstance(obj, dict): items.append(obj) - idx = end + elif isinstance(obj, list): + items.extend(item for item in obj if isinstance(item, dict)) except json.JSONDecodeError: - nl = s.find("\n", idx) - if nl == -1: - break - idx = nl + 1 + continue + return items @@ -640,15 +626,15 @@ async def run_longmemeval_test( # 数据路径 if not data_path: - # 固定使用中文数据集:data/longmemeval_oracle_zh.json - zh_proj = os.path.join(PROJECT_ROOT, "data", "longmemeval_oracle_zh.json") - zh_cwd = os.path.join(os.getcwd(), "data", "longmemeval_oracle_zh.json") - if os.path.exists(zh_proj): - data_path = zh_proj - elif os.path.exists(zh_cwd): - data_path = zh_cwd - else: - raise FileNotFoundError("未找到数据集: data/longmemeval_oracle_zh.json,请确保其存在于项目根目录或当前工作目录的 data 目录下。") + # 固定使用中文数据集:dataset/longmemeval_oracle_zh.json + dataset_dir = Path(__file__).resolve().parent.parent / "dataset" + data_path = str(dataset_dir / "longmemeval_oracle_zh.json") + + if not os.path.exists(data_path): + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 longmemeval_oracle_zh.json 放置在: {dataset_dir}" + ) qa_list: List[Dict[str, Any]] = load_dataset_any(data_path) # 支持评估全部样本:当 sample_size <= 0 时,取从 start_index 到末尾 @@ -658,13 +644,9 @@ async def run_longmemeval_test( items = qa_list[start_index:start_index + sample_size] # 初始化组件 - 使用异步LLM客户端 - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(SELECTED_LLM_ID) + llm_client = get_llm_client(os.getenv("EVAL_LLM_ID")) connector = Neo4jConnector() - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) + cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID")) embedder = OpenAIEmbedderClient( model_config=RedBearModelConfig.model_validate(cfg_dict) ) @@ -1203,8 +1185,8 @@ async def run_longmemeval_test( "search_limit": search_limit, "context_char_budget": context_char_budget, "search_type": search_type, - "llm_id": SELECTED_LLM_ID, - "embedding_id": SELECTED_EMBEDDING_ID, + "llm_id": os.getenv("EVAL_LLM_ID"), + "embedding_id": os.getenv("EVAL_EMBEDDING_ID"), "sample_size": sample_size, "start_index": start_index, }, diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py index 3023020a..e07b0cab 100644 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py @@ -2,81 +2,30 @@ import argparse import asyncio import json import os -import re import time from datetime import datetime -from typing import Any, Dict, List - -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None - -# 路径与模块导入保持与现有评估脚本一致 -import sys +from typing import List, Dict, Any +import re from pathlib import Path -_THIS_DIR = Path(__file__).resolve().parent -_PROJECT_ROOT = str(_THIS_DIR.parents[1]) -_SRC_DIR = os.path.join(_PROJECT_ROOT, "src") -for _p in (_SRC_DIR, _PROJECT_ROOT): - if _p not in sys.path: - sys.path.insert(0, _p) +from dotenv import load_dotenv + +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) + print(f"✅ 加载评估配置: {eval_config_path}") -# 对齐 locomo_test 的检索逻辑:直接使用 graph_search 与 Neo4jConnector/Embedder1 -from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - exact_match, - latency_stats, -) -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.memory.utils.config.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, - SELECTED_LLM_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.core.models.base import RedBearModelConfig -from app.db import get_db_context -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.services.memory_config_service import MemoryConfigService +from app.core.memory.src.search import run_hybrid_search # 使用与 evaluate_qa.py 相同的检索函数 +from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient +from app.core.models.base import RedBearModelConfig +from app.core.memory.utils.config.config_utils import get_embedder_config -try: - from app.core.memory.evaluation.common.metrics import bleu1, f1_score, jaccard -except Exception: - # 兜底:简单实现(必要时) - def f1_score(pred: str, ref: str) -> float: - ps = pred.lower().split() - rs = ref.lower().split() - if not ps or not rs: - return 0.0 - tp = len(set(ps) & set(rs)) - if tp == 0: - return 0.0 - precision = tp / len(ps) - recall = tp / len(rs) - if precision + recall == 0: - return 0.0 - return 2 * precision * recall / (precision + recall) +from app.core.memory.utils.llm.llm_utils import get_llm_client +from app.core.memory.evaluation.common.metrics import exact_match, latency_stats, avg_context_tokens - def bleu1(pred: str, ref: str) -> float: - ps = pred.lower().split() - rs = ref.lower().split() - if not ps or not rs: - return 0.0 - overlap = len([w for w in ps if w in rs]) - return overlap / max(len(ps), 1) - - def jaccard(pred: str, ref: str) -> float: - ps = set(pred.lower().split()) - rs = set(ref.lower().split()) - union = len(ps | rs) - if union == 0: - return 0.0 - return len(ps & rs) / union +from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: @@ -219,16 +168,16 @@ async def run_memsciqa_test( # 默认使用指定的 memsci 组 ID end_user_id = end_user_id or "group_memsci" - # 数据路径解析(项目根与当前工作目录兜底) + # 数据路径解析 if not data_path: - proj_path = os.path.join(PROJECT_ROOT, "data", "msc_self_instruct.jsonl") - cwd_path = os.path.join(os.getcwd(), "data", "msc_self_instruct.jsonl") - if os.path.exists(proj_path): - data_path = proj_path - elif os.path.exists(cwd_path): - data_path = cwd_path - else: - raise FileNotFoundError("未找到数据集: data/msc_self_instruct.jsonl,请确保其存在于项目根目录或当前工作目录的 data 目录下。") + dataset_dir = Path(__file__).resolve().parent.parent / "dataset" + data_path = str(dataset_dir / "msc_self_instruct.jsonl") + + if not os.path.exists(data_path): + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 msc_self_instruct.jsonl 放置在: {dataset_dir}" + ) # 加载数据 all_items = load_dataset_memsciqa(data_path) @@ -238,17 +187,13 @@ async def run_memsciqa_test( items = all_items[start_index:start_index + sample_size] # 初始化 LLM(纯测试:不进行摄入) - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm = factory.get_llm_client(SELECTED_LLM_ID) + llm = get_llm_client(os.getenv("EVAL_LLM_ID")) # 初始化 Neo4j 连接与向量检索 Embedder(对齐 locomo_test) connector = Neo4jConnector() embedder = None if search_type in ("embedding", "hybrid"): - with get_db_context() as db: - config_service = MemoryConfigService(db) - cfg_dict = config_service.get_embedder_config(SELECTED_EMBEDDING_ID) + cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID")) embedder = OpenAIEmbedderClient( model_config=RedBearModelConfig.model_validate(cfg_dict) ) @@ -273,7 +218,7 @@ async def run_memsciqa_test( question = item.get("self_instruct", {}).get("B", "") or item.get("question", "") reference = item.get("self_instruct", {}).get("A", "") or item.get("answer", "") - # 三路检索:chunks/statements/entities/summaries(对齐 qwen_search_eval.py) + # 检索:使用与 evaluate_qa.py 相同的 run_hybrid_search t0 = time.time() results = None try: @@ -302,57 +247,94 @@ async def run_memsciqa_test( search_ms = (t1 - t0) * 1000 latencies_search.append(search_ms) - # 构建上下文:包含 chunks、陈述、摘要和实体(对齐 qwen_search_eval.py) + # 构建上下文:与 evaluate_qa.py 完全一致的逻辑 contexts_all: List[str] = [] retrieved_counts: Dict[str, int] = {} if results: - chunks = results.get("chunks", []) - statements = results.get("statements", []) - entities = results.get("entities", []) - summaries = results.get("summaries", []) + # 处理 hybrid 搜索结果 + if search_type == "hybrid": + emb = results.get("embedding_search", {}) if isinstance(results.get("embedding_search"), dict) else {} + kw = results.get("keyword_search", {}) if isinstance(results.get("keyword_search"), dict) else {} + emb_dialogs = emb.get("dialogues", []) + emb_statements = emb.get("statements", []) + emb_entities = emb.get("entities", []) + kw_dialogs = kw.get("dialogues", []) + kw_statements = kw.get("statements", []) + kw_entities = kw.get("entities", []) + all_dialogs = emb_dialogs + kw_dialogs + all_statements = emb_statements + kw_statements + all_entities = emb_entities + kw_entities + + # 简单去重 + seen_dialog = set() + dialogues = [] + for d in all_dialogs: + key = (str(d.get("uuid", "")), str(d.get("content", ""))) + if key not in seen_dialog: + dialogues.append(d) + seen_dialog.add(key) + + seen_stmt = set() + statements = [] + for s in all_statements: + key = str(s.get("statement", "")) + if key not in seen_stmt: + statements.append(s) + seen_stmt.add(key) + + seen_ent = set() + entities = [] + for e in all_entities: + key = str(e.get("name", "")) + if key not in seen_ent: + entities.append(e) + seen_ent.add(key) + else: + # embedding 或 keyword 单独搜索 + dialogues = results.get("dialogues", []) + statements = results.get("statements", []) + entities = results.get("entities", []) + retrieved_counts = { - "chunks": len(chunks), + "dialogues": len(dialogues), "statements": len(statements), "entities": len(entities), - "summaries": len(summaries), } - # 优先使用 chunks - for c in chunks: - text = str(c.get("content", "")).strip() + + # 构建上下文文本 + for d in dialogues: + text = str(d.get("content", "")).strip() if text: contexts_all.append(text) - # 然后是 statements + for s in statements: text = str(s.get("statement", "")).strip() if text: contexts_all.append(text) - # 然后是 summaries - for sm in summaries: - text = str(sm.get("summary", "")).strip() - if text: - contexts_all.append(text) - # 实体摘要:最多加入前3个高分实体(对齐 qwen_search_eval.py) - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) + + # 实体摘要 + if entities: + scored = [e for e in entities if e.get("score") is not None] + top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] + if top_entities: + summary_lines = [] + for e in top_entities: + name = str(e.get("name", "")).strip() + etype = str(e.get("entity_type", "")).strip() + score = e.get("score") + if name: + meta = [] + if etype: + meta.append(f"type={etype}") + if isinstance(score, (int, float)): + meta.append(f"score={score:.3f}") + summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") + if summary_lines: + contexts_all.append("\n".join(summary_lines)) if verbose: if retrieved_counts: - print(f"✅ 检索成功: {retrieved_counts.get('chunks',0)} chunks, {retrieved_counts.get('statements',0)} 条陈述, {retrieved_counts.get('entities',0)} 个实体, {retrieved_counts.get('summaries',0)} 个摘要") + print(f"✅ 检索成功: {retrieved_counts.get('dialogues',0)} dialogues, {retrieved_counts.get('statements',0)} 条陈述, {retrieved_counts.get('entities',0)} 个实体, {retrieved_counts.get('summaries',0)} 个摘要") print(f"📊 有效上下文数量: {len(contexts_all)}") q_keywords = extract_question_keywords(question, max_keywords=8) if q_keywords: @@ -507,8 +489,8 @@ async def run_memsciqa_test( "llm_max_tokens": llm_max_tokens, "search_type": search_type, "start_index": start_index, - "llm_id": SELECTED_LLM_ID, - "retrieval_embedding_id": SELECTED_EMBEDDING_ID + "llm_id": os.getenv("EVAL_LLM_ID"), + "retrieval_embedding_id": os.getenv("EVAL_EMBEDDING_ID") }, "timestamp": datetime.now().isoformat(), } @@ -522,7 +504,7 @@ async def run_memsciqa_test( def main(): load_dotenv() parser = argparse.ArgumentParser(description="memsciqa 测试脚本(三路检索 + 智能上下文选择)") - parser.add_argument("--sample-size", type=int, default=30, help="样本数量(<=0 表示全部)") + parser.add_argument("--sample-size", type=int, default=10, help="样本数量(<=0 表示全部)") parser.add_argument("--all", action="store_true", help="评估全部样本(覆盖 --sample-size)") parser.add_argument("--start-index", type=int, default=0, help="起始样本索引") parser.add_argument("--group-id", type=str, default="group_memsci", help="图数据库 Group ID(默认 group_memsci)") diff --git a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py b/api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py similarity index 76% rename from api/app/core/memory/evaluation/memsciqa/evaluate_qa.py rename to api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py index 869fdb60..40684f4c 100644 --- a/api/app/core/memory/evaluation/memsciqa/evaluate_qa.py +++ b/api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py @@ -4,35 +4,20 @@ import json import os import time from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List +from typing import List, Dict, Any +from pathlib import Path +from dotenv import load_dotenv -if TYPE_CHECKING: - from app.schemas.memory_config_schema import MemoryConfig +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None - -from app.core.memory.evaluation.common.metrics import ( - avg_context_tokens, - exact_match, - latency_stats, -) -from app.core.memory.evaluation.extraction_utils import ( - ingest_contexts_via_full_pipeline, -) -from app.core.memory.storage_services.search import run_hybrid_search -from app.core.memory.utils.config.definitions import ( - PROJECT_ROOT, - SELECTED_EMBEDDING_ID, - SELECTED_GROUP_ID, - SELECTED_LLM_ID, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.db import get_db_context from app.repositories.neo4j.neo4j_connector import Neo4jConnector +from app.core.memory.src.search import run_hybrid_search # 使用旧版本(重构前) +from app.core.memory.utils.llm.llm_utils import get_llm_client +from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline +from app.core.memory.evaluation.common.metrics import exact_match, latency_stats, avg_context_tokens def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: @@ -135,24 +120,37 @@ def _combine_dialogues_for_hybrid(results: Dict[str, Any]) -> List[Dict[str, Any return merged + async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: end_user_id = end_user_id or SELECTED_GROUP_ID + # Load data - data_path = os.path.join(PROJECT_ROOT, "data", "msc_self_instruct.jsonl") + dataset_dir = Path(__file__).resolve().parent.parent / "dataset" + data_path = dataset_dir / "msc_self_instruct.jsonl" + if not os.path.exists(data_path): - data_path = os.path.join(os.getcwd(), "data", "msc_self_instruct.jsonl") + raise FileNotFoundError( + f"数据集文件不存在: {data_path}\n" + f"请将 msc_self_instruct.jsonl 放置在: {dataset_dir}" + ) with open(data_path, "r", encoding="utf-8") as f: lines = f.readlines() items: List[Dict[str, Any]] = [json.loads(l) for l in lines[:sample_size]] + + # 改为:每条样本仅摄入一个上下文(完整对话转录),避免多上下文摄入 # 说明:memsciqa 数据集的每个样本天然只有一个对话,保持按样本一上下文的策略 contexts: List[str] = [build_context_from_dialog(item) for item in items] await ingest_contexts_via_full_pipeline(contexts, end_user_id) # LLM client (使用异步调用) - with get_db_context() as db: - factory = MemoryClientFactory(db) - llm_client = factory.get_llm_client(SELECTED_LLM_ID) + from app.db import get_db + + db = next(get_db()) + try: + llm_client = get_llm_client(os.getenv("EVAL_LLM_ID"), db) + finally: + db.close() # Evaluate each item connector = Neo4jConnector() @@ -177,7 +175,6 @@ async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None limit=search_limit, include=["dialogues", "statements", "entities"], output_path=None, - memory_config=memory_config, ) except Exception: results = None @@ -261,11 +258,7 @@ async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None pred = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else str(resp).strip()) # Metrics: F1, BLEU-1, Jaccard; keep exact match for reference correct_flags.append(exact_match(pred, reference)) - from app.core.memory.evaluation.common.metrics import ( - bleu1, - f1_score, - jaccard, - ) + from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard f1s.append(f1_score(str(pred), str(reference))) b1s.append(bleu1(str(pred), str(reference))) jss.append(jaccard(str(pred), str(reference))) @@ -295,15 +288,39 @@ async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None def main(): + # Load environment variables first load_dotenv() + + # Get defaults from environment variables + env_sample_size = os.getenv("MEMSCIQA_SAMPLE_SIZE") + env_search_limit = os.getenv("MEMSCIQA_SEARCH_LIMIT") + env_context_budget = os.getenv("MEMSCIQA_CONTEXT_CHAR_BUDGET") + env_llm_max_tokens = os.getenv("MEMSCIQA_LLM_MAX_TOKENS") + env_skip_ingest = os.getenv("MEMSCIQA_SKIP_INGEST", "false").lower() in ("true", "1", "yes") + env_output_dir = os.getenv("MEMSCIQA_OUTPUT_DIR") + + # Convert to appropriate types with fallback to code defaults + default_sample_size = int(env_sample_size) if env_sample_size else 1 + default_search_limit = int(env_search_limit) if env_search_limit else 8 + default_context_budget = int(env_context_budget) if env_context_budget else 4000 + default_llm_max_tokens = int(env_llm_max_tokens) if env_llm_max_tokens else 64 + default_output_dir = env_output_dir if env_output_dir else None + parser = argparse.ArgumentParser(description="Evaluate DMR (memsciqa) with graph search and Qwen") + parser.add_argument("--sample-size", type=int, default=1, help="评测样本数量") - parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") + parser.add_argument("--end-user-id", type=str, default=None, help="可选 end_user_id,默认使用环境变量") parser.add_argument("--search-limit", type=int, default=8, help="每类检索最大返回数") parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") + parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") - parser.add_argument("--llm-max-tokens", type=int, default=64, help="LLM 最大生成长度") + parser.add_argument("--llm-max-tokens", type=int, default=default_llm_max_tokens, + help=f"LLM 最大生成长度 (env: MEMSCIQA_LLM_MAX_TOKENS={env_llm_max_tokens or 'not set'})") parser.add_argument("--search-type", type=str, choices=["keyword","embedding","hybrid"], default="hybrid", help="检索类型") + parser.add_argument("--skip-ingest", action="store_true", default=env_skip_ingest, + help=f"跳过数据摄入,使用 Neo4j 中的现有数据 (env: MEMSCIQA_SKIP_INGEST={os.getenv('MEMSCIQA_SKIP_INGEST', 'false')})") + parser.add_argument("--output-dir", type=str, default=default_output_dir, + help=f"结果保存目录 (env: MEMSCIQA_OUTPUT_DIR={env_output_dir or 'not set'})") args = parser.parse_args() result = asyncio.run( @@ -315,9 +332,37 @@ def main(): llm_temperature=args.llm_temperature, llm_max_tokens=args.llm_max_tokens, search_type=args.search_type, + skip_ingest=args.skip_ingest, ) ) + + # Print results to console print(json.dumps(result, ensure_ascii=False, indent=2)) + + # Save results to file + output_dir = args.output_dir + if output_dir is None: + # Use absolute path to ensure results are saved in the correct location + script_dir = Path(__file__).resolve().parent + output_dir = script_dir / "results" + elif not Path(output_dir).is_absolute(): + # If relative path, make it relative to this script's directory + script_dir = Path(__file__).resolve().parent + output_dir = script_dir / output_dir + else: + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") + output_path = output_dir / f"memsciqa_{timestamp_str}.json" + + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"\n✅ 结果已保存到: {output_path}") + except Exception as e: + print(f"\n❌ 保存结果失败: {e}") if __name__ == "__main__": diff --git a/api/app/core/memory/evaluation/run_eval.py b/api/app/core/memory/evaluation/run_eval.py index c5aacb2f..56b2e790 100644 --- a/api/app/core/memory/evaluation/run_eval.py +++ b/api/app/core/memory/evaluation/run_eval.py @@ -2,20 +2,16 @@ import argparse import asyncio import json import os -import sys from typing import Any, Dict +from pathlib import Path +from dotenv import load_dotenv -# Add src directory to Python path for proper imports when running from evaluation directory -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')) - -try: - from dotenv import load_dotenv -except Exception: - def load_dotenv(): - return None +# Load evaluation config +eval_config_path = Path(__file__).resolve().parent / ".env.evaluation" +if eval_config_path.exists(): + load_dotenv(eval_config_path, override=True) from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.utils.config.definitions import SELECTED_GROUP_ID, PROJECT_ROOT from app.core.memory.evaluation.memsciqa.evaluate_qa import run_memsciqa_eval from app.core.memory.evaluation.longmemeval.qwen_search_eval import run_longmemeval_test @@ -36,8 +32,9 @@ async def run( start_index: int | None = None, max_contexts_per_item: int | None = None, ) -> Dict[str, Any]: - # 恢复原始风格:统一入口做路由,并沿用各数据集既有默认 - end_user_id = end_user_id or SELECTED_GROUP_ID + # Use environment variable with fallback chain if not provided + if end_user_id is None: + end_user_id = os.getenv("EVAL_END_USER_ID", "benchmark_default") if reset_group: connector = Neo4jConnector() diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 8c69c7cf..7b7e854b 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -1064,13 +1064,16 @@ class ExtractionOrchestrator: if statement.triplet_extraction_info: triplet_info = statement.triplet_extraction_info - # 创建实体索引到ID的映射 + # 创建实体索引到ID的映射(支持多种索引方式) entity_idx_to_id = {} # 创建实体节点 for entity_idx, entity in enumerate(triplet_info.entities): - # 映射实体索引到实体ID + # 映射实体索引到实体ID(使用多个键以提高容错性) + # 1. 使用实体自己的 entity_idx entity_idx_to_id[entity.entity_idx] = entity.id + # 2. 使用枚举索引(从0开始) + entity_idx_to_id[entity_idx] = entity.id if entity.id not in entity_id_set: entity_connect_strength = getattr(entity, 'connect_strength', 'Strong') @@ -1149,9 +1152,18 @@ class ExtractionOrchestrator: relationship_result ) else: - logger.warning( - f"跳过三元组 - 无法找到实体ID: subject_id={triplet.subject_id}, " - f"object_id={triplet.object_id}, statement_id={statement.id}" + # 改进的警告信息,包含更多调试信息 + missing_subject = "subject" if not subject_entity_id else "" + missing_object = "object" if not object_entity_id else "" + missing_both = " and " if (not subject_entity_id and not object_entity_id) else "" + + logger.debug( + f"跳过三元组 - 无法找到{missing_subject}{missing_both}{missing_object}实体ID: " + f"subject_id={triplet.subject_id} ({triplet.subject_name}), " + f"object_id={triplet.object_id} ({triplet.object_name}), " + f"predicate={triplet.predicate}, " + f"statement_id={statement.id}, " + f"available_indices={sorted(entity_idx_to_id.keys())}" ) logger.info( diff --git a/api/app/models/agent_app_config_model.py b/api/app/models/agent_app_config_model.py index 0a7a5935..96752c8e 100644 --- a/api/app/models/agent_app_config_model.py +++ b/api/app/models/agent_app_config_model.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import relationship from app.base.type import PydanticType from app.db import Base -from app.schemas import ModelParameters +from app.schemas.app_schema import ModelParameters class AgentConfig(Base): diff --git a/api/app/models/multi_agent_model.py b/api/app/models/multi_agent_model.py index 544ddb27..400c05ad 100644 --- a/api/app/models/multi_agent_model.py +++ b/api/app/models/multi_agent_model.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import relationship from app.base.type import PydanticType from app.db import Base -from app.schemas import ModelParameters +from app.schemas.app_schema import ModelParameters class OrchestrationMode(StrEnum): diff --git a/api/app/schemas/multi_agent_schema.py b/api/app/schemas/multi_agent_schema.py index c0d72cdd..8fba2929 100644 --- a/api/app/schemas/multi_agent_schema.py +++ b/api/app/schemas/multi_agent_schema.py @@ -4,7 +4,7 @@ import datetime from typing import Optional, List, Dict, Any, Union from pydantic import BaseModel, Field, ConfigDict, field_serializer -from app.schemas import ModelParameters +from app.schemas.app_schema import ModelParameters # ==================== 子 Agent 配置 ==================== diff --git a/api/app/services/master_agent_router.py b/api/app/services/master_agent_router.py index 3971aab7..87fdb22c 100644 --- a/api/app/services/master_agent_router.py +++ b/api/app/services/master_agent_router.py @@ -5,7 +5,7 @@ import uuid from typing import Dict, Any, List, Optional, Tuple from sqlalchemy.orm import Session -from app.schemas import ModelParameters +from app.schemas.app_schema import ModelParameters from app.services.conversation_state_manager import ConversationStateManager from app.models import ModelConfig, AgentConfig from app.core.logging_config import get_business_logger diff --git a/api/app/utils/app_config_utils.py b/api/app/utils/app_config_utils.py index ae41d8bf..06549989 100644 --- a/api/app/utils/app_config_utils.py +++ b/api/app/utils/app_config_utils.py @@ -57,7 +57,7 @@ def dict_to_model_parameters(data: Optional[Dict[str, Any]]) -> Optional[Any]: if data is None: return None - from app.schemas import ModelParameters + from app.schemas.app_schema import ModelParameters if isinstance(data, ModelParameters): return data From 87731090cab1097dbb30909e388b62774bb10283 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 26 Jan 2026 19:19:41 +0800 Subject: [PATCH 083/175] [modify] migration script --- api/migrations/versions/325b759cd66b_2026011240.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/migrations/versions/325b759cd66b_2026011240.py b/api/migrations/versions/325b759cd66b_2026011240.py index 763b0289..3d7443a8 100644 --- a/api/migrations/versions/325b759cd66b_2026011240.py +++ b/api/migrations/versions/325b759cd66b_2026011240.py @@ -31,6 +31,7 @@ def upgrade() -> None: op.execute("UPDATE memory_config SET config_id = apply_id::uuid") op.alter_column('memory_config', 'config_id', nullable=False) op.create_primary_key('memory_config_pkey', 'memory_config', ['config_id']) + op.execute("ALTER TABLE memory_config ALTER COLUMN config_id_old DROP DEFAULT") op.execute("DROP SEQUENCE IF EXISTS data_config_config_id_seq") From c3ea3b751b07c9325f80000e9a4d93a0a4790fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:30:07 +0800 Subject: [PATCH 084/175] delete benchmark-test (#204) * Refactor: Move evaluation folder to redbear-mem-benchmark submodule * [changes]Restore .gitmodules --- .../memory/evaluation/.env.evaluation.example | 224 --- api/app/core/memory/evaluation/.gitignore | 13 - api/app/core/memory/evaluation/__init__.py | 1 - api/app/core/memory/evaluation/benchmark.md | 748 --------- .../memory/evaluation/check_enduser_data.py | 371 ----- .../core/memory/evaluation/common/metrics.py | 100 -- .../memory/evaluation/dialogue_queries.py | 62 - .../memory/evaluation/extraction_utils.py | 444 ------ .../evaluation/locomo/locomo_benchmark.py | 770 ---------- .../evaluation/locomo/locomo_metrics.py | 225 --- .../memory/evaluation/locomo/locomo_test.py | 864 ----------- .../memory/evaluation/locomo/locomo_utils.py | 687 --------- .../evaluation/locomo/qwen_search_eval.py | 874 ----------- .../longmemeval/longmemeval_benchmark.py | 1339 ----------------- .../evaluation/longmemeval/test_eval.py | 1312 ---------------- .../evaluation/memsciqa/memsciqa-test.py | 559 ------- .../evaluation/memsciqa/memsciqa_benchmark.py | 369 ----- api/app/core/memory/evaluation/run_eval.py | 147 -- redbear-mem-benchmark | 2 +- 19 files changed, 1 insertion(+), 9110 deletions(-) delete mode 100644 api/app/core/memory/evaluation/.env.evaluation.example delete mode 100644 api/app/core/memory/evaluation/.gitignore delete mode 100644 api/app/core/memory/evaluation/__init__.py delete mode 100644 api/app/core/memory/evaluation/benchmark.md delete mode 100644 api/app/core/memory/evaluation/check_enduser_data.py delete mode 100644 api/app/core/memory/evaluation/common/metrics.py delete mode 100644 api/app/core/memory/evaluation/dialogue_queries.py delete mode 100644 api/app/core/memory/evaluation/extraction_utils.py delete mode 100644 api/app/core/memory/evaluation/locomo/locomo_benchmark.py delete mode 100644 api/app/core/memory/evaluation/locomo/locomo_metrics.py delete mode 100644 api/app/core/memory/evaluation/locomo/locomo_test.py delete mode 100644 api/app/core/memory/evaluation/locomo/locomo_utils.py delete mode 100644 api/app/core/memory/evaluation/locomo/qwen_search_eval.py delete mode 100644 api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py delete mode 100644 api/app/core/memory/evaluation/longmemeval/test_eval.py delete mode 100644 api/app/core/memory/evaluation/memsciqa/memsciqa-test.py delete mode 100644 api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py delete mode 100644 api/app/core/memory/evaluation/run_eval.py diff --git a/api/app/core/memory/evaluation/.env.evaluation.example b/api/app/core/memory/evaluation/.env.evaluation.example deleted file mode 100644 index be089eb4..00000000 --- a/api/app/core/memory/evaluation/.env.evaluation.example +++ /dev/null @@ -1,224 +0,0 @@ -# ============================================================================ -# 基准测试统一配置文件示例 -# ============================================================================ -# 复制此文件为 .env.evaluation 并根据需要修改 -# 支持的基准测试:LoCoMo、LongMemEval、MemSciQA -# ============================================================================ - -# ============================================================================ -# 通用配置(所有基准测试共用) -# ============================================================================ - -# ---------------------------------------------------------------------------- -# Neo4j 配置 -# ---------------------------------------------------------------------------- -# 默认 Group ID(建议各基准测试使用独立的 group) -EVAL_GROUP_ID=benchmark_default - -# ---------------------------------------------------------------------------- -# 模型配置(必需) -# ---------------------------------------------------------------------------- -# ⚠️ 必填:从数据库 models 表中选择有效的模型 ID -# -# 如何获取模型 ID: -# 1. 查询数据库:SELECT id, model_name FROM models WHERE is_active = true; -# 2. 或通过系统管理界面查看 -# 3. 确保模型可用且配置正确 - -# LLM 模型 ID(必填) -EVAL_LLM_ID=your_llm_model_id_here - -# Embedding 模型 ID(必填) -EVAL_EMBEDDING_ID=your_embedding_model_id_here - -# ---------------------------------------------------------------------------- -# 检索参数 -# ---------------------------------------------------------------------------- -# 检索类型: "keyword", "embedding", "hybrid" -EVAL_SEARCH_TYPE=hybrid - -# 检索结果数量限制(默认值) -EVAL_SEARCH_LIMIT=12 - -# 上下文最大字符数(默认值) -EVAL_MAX_CONTEXT_CHARS=8000 - -# ---------------------------------------------------------------------------- -# LLM 参数 -# ---------------------------------------------------------------------------- -# LLM 温度参数(0.0 = 确定性输出) -EVAL_LLM_TEMPERATURE=0.0 - -# LLM 最大生成 token 数 -EVAL_LLM_MAX_TOKENS=32 - -# LLM 超时时间(秒) -EVAL_LLM_TIMEOUT=10.0 - -# LLM 最大重试次数 -EVAL_LLM_MAX_RETRIES=1 - -# ---------------------------------------------------------------------------- -# 数据处理参数 -# ---------------------------------------------------------------------------- -# Chunker 策略 -EVAL_CHUNKER_STRATEGY=RecursiveChunker - -# 是否在导入前清空现有数据 -EVAL_RESET_ON_INGEST=true - -# 是否保存详细日志 -EVAL_SAVE_DETAILED_LOGS=true - -# ============================================================================ -# LoCoMo 基准测试专用配置 -# ============================================================================ -# 数据集:locomo10.json -# 运行:python locomo_benchmark.py --sample_size 20 -# ---------------------------------------------------------------------------- - -# Group ID(LoCoMo 专用) -LOCOMO_GROUP_ID=locomo_benchmark - -# 测试样本数量 -# 建议值:20(快速测试)、100(中等测试)、1986(完整测试) -LOCOMO_SAMPLE_SIZE=20 - -# 检索结果数量限制 -LOCOMO_SEARCH_LIMIT=12 - -# 上下文最大字符数 -LOCOMO_CONTEXT_CHAR_BUDGET=8000 - -# 导入的对话数量 -LOCOMO_MAX_DIALOGUES=1 - -# 跳过数据摄入(true=跳过,false=摄入) -# 首次运行设置为 false,后续运行可设置为 true 以节省时间 -LOCOMO_SKIP_INGEST=false - -# 结果保存目录 -LOCOMO_OUTPUT_DIR=locomo/results - -# ============================================================================ -# LongMemEval 基准测试专用配置 -# ============================================================================ -# 数据集:longmemeval_oracle_zh.json -# 运行:python longmemeval_benchmark.py --sample_size 3 -# 特点:支持时间推理问题的增强检索 -# ---------------------------------------------------------------------------- - -# Group ID(LongMemEval 专用) -LONGMEMEVAL_GROUP_ID=longmemeval_zh_bak_3 - -# 测试样本数量(<=0 表示全部样本) -LONGMEMEVAL_SAMPLE_SIZE=3 - -# 起始样本索引 -LONGMEMEVAL_START_INDEX=0 - -# 检索结果数量限制 -LONGMEMEVAL_SEARCH_LIMIT=8 - -# 上下文最大字符数 -LONGMEMEVAL_CONTEXT_CHAR_BUDGET=4000 - -# LLM 最大生成 token 数 -LONGMEMEVAL_LLM_MAX_TOKENS=16 - -# 每条样本最多摄入的上下文段数 -LONGMEMEVAL_MAX_CONTEXTS_PER_ITEM=2 - -# 是否保存分块结果 -LONGMEMEVAL_SAVE_CHUNK_OUTPUT=true - -# 自定义分块输出路径(留空使用默认) -LONGMEMEVAL_SAVE_CHUNK_OUTPUT_PATH= - -# 摄入前是否清空组数据 -LONGMEMEVAL_RESET_GROUP_BEFORE_INGEST=false - -# 是否跳过摄入,仅检索评估 -LONGMEMEVAL_SKIP_INGEST=false - -# 结果保存目录 -LONGMEMEVAL_OUTPUT_DIR=longmemeval/results - -# ============================================================================ -# MemSciQA 基准测试专用配置 -# ============================================================================ -# 数据集:msc_self_instruct.jsonl -# 运行:python memsciqa_benchmark.py --sample_size 1 -# 特点:对话记忆检索评估 -# ---------------------------------------------------------------------------- - -# Group ID(MemSciQA 专用,独立数据集) -MEMSCIQA_GROUP_ID=memsciqa_benchmark - -# 测试样本数量 -MEMSCIQA_SAMPLE_SIZE=1 # 0或者-1标识测试数据集中的所有样本 - -# 检索结果数量限制 -MEMSCIQA_SEARCH_LIMIT=8 - -# 上下文最大字符数 -MEMSCIQA_CONTEXT_CHAR_BUDGET=4000 - -# LLM 最大生成 token 数 -MEMSCIQA_LLM_MAX_TOKENS=64 - -# 跳过数据摄入(true=跳过,false=摄入) -# 首次运行设置为 false,后续运行可设置为 true 以节省时间 -MEMSCIQA_SKIP_INGEST=false - -# 结果保存目录(相对于 memsciqa 脚本所在目录) -# 使用 "results" 会保存到 api/app/core/memory/evaluation/memsciqa/results/ -MEMSCIQA_OUTPUT_DIR=results - -# ============================================================================ -# 高级配置(可选) -# ============================================================================ - -# BM25 权重(用于混合检索,0.0-1.0) -EVAL_RERANK_ALPHA=0.6 - -# 是否使用遗忘重排序 -EVAL_USE_FORGETTING_RERANK=false - -# 是否使用 LLM 重排序 -EVAL_USE_LLM_RERANK=false - -# 连接重置间隔(每 N 个问题重置一次) -EVAL_RESET_INTERVAL=5 - -# 性能阈值(低于此值触发重置) -EVAL_PERFORMANCE_THRESHOLD=0.6 - -# ============================================================================ -# 快速配置指南 -# ============================================================================ -# 1. 复制此文件为 .env.evaluation -# 2. 修改 EVAL_LLM_ID 和 EVAL_EMBEDDING_ID 为你的模型 ID -# 3. 根据需要修改各基准测试的专用配置 -# 4. 运行测试: -# - LoCoMo: python locomo/locomo_benchmark.py --sample_size 20 -# - LongMemEval: python longmemeval/longmemeval_benchmark.py --sample_size 3 --all -# - MemSciQA: python memsciqa/memsciqa_benchmark.py --sample_size 10 -# 配置优先级: -# 命令行参数 > 特定配置(如 LOCOMO_*)> 通用配置(EVAL_*)> 代码默认值 -# ============================================================================ - - -# 执行LoCoMo测试 -# 只摄入前5条消息,评估3个问题(最小测试) -# python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 3 --max_ingest_messages 5 -# -# 如果数据已经摄入,跳过摄入阶段直接测试 -# python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 5 --skip_ingest - - -# 执行longmemeval测试 -# python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --sample-size 10 --max-contexts-per-item 3 --reset-group-before-ingest - -# 执行memsciqa测试 -# python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --sample-size 1 diff --git a/api/app/core/memory/evaluation/.gitignore b/api/app/core/memory/evaluation/.gitignore deleted file mode 100644 index 38b1055a..00000000 --- a/api/app/core/memory/evaluation/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# 忽略实际的评估配置文件(包含敏感信息) -.env.evaluation - -# 保留示例文件 -!.env.evaluation.example - -# 忽略测试结果文件 -*/results/*.json -*/results/*.log - -# 忽略数据集文件(文件过大,不应提交到 Git) -dataset/*.json -dataset/*.jsonl diff --git a/api/app/core/memory/evaluation/__init__.py b/api/app/core/memory/evaluation/__init__.py deleted file mode 100644 index e9d6aa6c..00000000 --- a/api/app/core/memory/evaluation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Evaluation package with dataset-specific pipelines and a unified runner.""" diff --git a/api/app/core/memory/evaluation/benchmark.md b/api/app/core/memory/evaluation/benchmark.md deleted file mode 100644 index 7c31cccd..00000000 --- a/api/app/core/memory/evaluation/benchmark.md +++ /dev/null @@ -1,748 +0,0 @@ -# 1.数据集下载地址 -Locomo10.json : https://github.com/snap-research/locomo/tree/main/data -LongMemEval_oracle.json : https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned -msc_self_instruct.jsonl : https://huggingface.co/datasets/MemGPT/MSC-Self-Instruct - -数据集下载之后保存至api\app\core\memory\evaluation\dataset目录下 -# 2.配置说明 -文件api\app\core\memory\evaluation\.env.evaluation.example对三个基准测试所需配置有着详细的说明 -**实际配置文件**:api\app\core\memory\evaluation\.env.evaluation -```python -# 当使用不带配置参数的命令行执行基准测试,基准测试所需的配置参数根据.env.evaluation中的参数执行 -python -m app.core.memory.evaluation.locomo.locomo_benchmark -``` -**检查neo4j指定的grou_id是否摄入数据** -```python -# 1. 进入交互模式 -python -m app.core.memory.evaluation.check_enduser_data - -# 2. 选择 "1" 检查指定 group -# 3. 输入 group_id,例如: locomo_benchmark -# 4. 选择是否显示详细统计 (y/n) -``` -# 3.locomo - -### (1)locomo执行命令 -```python -# 首先进入api目录 -cd api - -# 只摄入前5条消息,评估3个问题(最小测试) -python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 3 --max_ingest_messages 5 - -# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) -python -m app.core.memory.evaluation.locomo.locomo_benchmark --sample_size 5 --skip_ingest -``` -### (2)locomo结果说明 - -#### 结果示例 -```json -{ - "dataset": "locomo", - "sample_size": 0, - "timestamp": "2026-01-26T11:24:28.239156", - "params": { - "group_id": "locomo_benchmark", - "search_type": "hybrid", - "search_limit": 12, - "context_char_budget": 8000, - "llm_id": "2c9b0782-7a85-4740-ba84-4baf77f256c4", - "embedding_id": "e2a6392d-ca63-4d59-a523-647420b59cb2" - }, - "overall_metrics": { - "f1": 0.0, - "bleu1": 0.0, - "jaccard": 0.0, - "locomo_f1": 0.0 - }, - "by_category": {}, - "latency": { - "search": { - "mean": 0.0, - "p50": 0.0, - "p95": 0.0, - "iqr": 0.0 - }, - "llm": { - "mean": 0.0, - "p50": 0.0, - "p95": 0.0, - "iqr": 0.0 - } - }, - "context_stats": { - "avg_retrieved_docs": 0.0, - "avg_context_chars": 0.0, - "avg_context_tokens": 0.0 - }, - "samples": [] -} -``` - -#### 参数详解 - -##### 1. 核心评估指标 (overall_metrics) - -**🎯 关键进步指标:** - -- **`f1`** (F1 Score): 精确率和召回率的调和平均值 - - 范围:0.0 - 1.0 - - **越高越好**,衡量检索和生成答案的准确性 - - 这是最重要的综合性能指标 - - 优秀标准:> 0.85 - -- **`bleu1`** (BLEU-1): 单词级别的匹配度 - - 范围:0.0 - 1.0 - - **越高越好**,衡量生成答案与标准答案的词汇重叠度 - - 关注词汇层面的准确性 - -- **`jaccard`** (Jaccard 相似度): 集合相似度 - - 范围:0.0 - 1.0 - - **越高越好**,衡量答案集合的相似性 - - 计算公式:交集大小 / 并集大小 - -- **`locomo_f1`**: Locomo 特定的 F1 分数 - - 范围:0.0 - 1.0 - - **越高越好**,针对 Locomo 数据集优化的评估指标 - - 考虑了长对话记忆的特殊性 - -##### 2. 性能指标 (latency) - -**⚡ 关键效率指标:** - -- **`search`**: 检索延迟统计(单位:毫秒) - - `mean`: 平均延迟 - - `p50`: 中位数延迟(50%的请求在此时间内完成) - - `p95`: 95分位数延迟(95%的请求在此时间内完成) - - `iqr`: 四分位距(Q3-Q1,衡量稳定性) - - **越低越好**,衡量记忆检索速度 - - 优秀标准:p95 < 2000ms - -- **`llm`**: LLM 推理延迟统计(单位:毫秒) - - `mean`: 平均推理时间 - - `p50`: 中位数推理时间 - - `p95`: 95分位数推理时间 - - `iqr`: 四分位距(越小越稳定) - - **越低越好**,衡量答案生成速度 - - 优秀标准:p95 < 3000ms - -##### 3. 上下文统计 (context_stats) - -**📊 资源效率指标:** - -- **`avg_retrieved_docs`**: 平均检索文档数 - - 反映检索策略的广度 - - 需要平衡:太少可能信息不足,太多增加噪音和延迟 - - 建议范围:8-15 个文档 - -- **`avg_context_chars`**: 平均上下文字符数 - - 反映检索内容的总量 - - 应在满足准确性前提下尽量精简 - - 受 `context_char_budget` 参数限制 - -- **`avg_context_tokens`**: 平均上下文 token 数 - - **越低越好**(在保持准确性前提下) - - 直接影响 API 调用成本和推理速度 - - 成本效益比 = f1 / avg_context_tokens - -##### 4. 分类统计 (by_category) - -- 按问题类型分类的性能指标 -- 帮助识别系统在不同场景下的强弱项 -- 可针对性优化特定类型的问题 - -#### 系统进步衡量标准 - -**一级指标(最重要):** -- `f1` 和 `locomo_f1` 提升 → 核心能力提升 -- 目标:f1 > 0.85 - -**二级指标(重要):** -- `latency.p95` 降低 → 用户体验提升 -- 目标:search.p95 < 2000ms, llm.p95 < 3000ms - -**三级指标(辅助):** -- `avg_context_tokens` 降低(在保持 f1 前提下)→ 成本优化 -- `iqr` 降低 → 性能稳定性提升 -# 4.longmemeval -支持时间推理问题的增强检索 -### (1)执行命令 -```python -# 首先进入api目录 -cd api - -# 不带参数运行 - 使用环境变量 -python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark - -# 命令行参数覆盖环境变量 -python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --sample-size 2 - -# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) -python -m app.core.memory.evaluation.longmemeval.longmemeval_benchmark --skip_ingest -``` -### (2)结果说明 - -#### 结果示例 -```json -{ - "dataset": "longmemeval", - "items": 1, - "accuracy_by_type": { - "single-session-user": 1.0 - }, - "f1_by_type": { - "single-session-user": 1.0 - }, - "jaccard_by_type": { - "single-session-user": 1.0 - }, - "samples": [ - { - "question": "What degree did I graduate with?", - "prediction": "Business Administration", - "answer": "Business Administration", - "question_type": "single-session-user", - "is_temporal": false, - "question_id": "e47becba", - "options": [], - "context_count": 13, - "context_chars": 1268, - "retrieved_dialogue_count": 0, - "retrieved_statement_count": 12, - "metrics": { - "exact_match": true, - "f1": 1.0, - "jaccard": 1.0 - }, - "timing": { - "search_ms": 1483.100175857544, - "llm_ms": 995.8682060241699 - } - } - ], - "latency": { - "search": { - "mean": 1483.100175857544, - "p50": 1483.100175857544, - "p95": 1483.100175857544, - "iqr": 0.0 - }, - "llm": { - "mean": 995.8682060241699, - "p50": 995.8682060241699, - "p95": 995.8682060241699, - "iqr": 0.0 - } - }, - "context": { - "avg_tokens": 204.0, - "avg_chars": 1268, - "count_avg": 13 - }, - "params": { - "group_id": "longmemeval_zh_bak_3", - "search_limit": 8, - "context_char_budget": 4000, - "search_type": "hybrid", - "llm_id": "6dc52e1b-9cec-4194-af66-a74c6307fc3f", - "embedding_id": "e2a6392d-ca63-4d59-a523-647420b59cb2", - "sample_size": 1, - "start_index": 0 - }, - "timestamp": "2026-01-24T21:36:10.818308", - "metric_summary": { - "score_accuracy": 100.0, - "latency_median_s": 2.478968381881714, - "latency_iqr_s": 0.0, - "avg_context_tokens_k": 0.204 - }, - "diagnostics": { - "duplicate_previews_top": [], - "unique_preview_count": 1 - } -} -``` - -#### 参数详解 - -##### 1. 核心评估指标 - -**🎯 关键进步指标:** - -- **`accuracy_by_type`**: 按问题类型分类的准确率 - - 范围:0.0 - 1.0 - - **越高越好**,1.0 表示 100% 准确 - - 问题类型包括: - - `single-session-user`: 单会话用户信息 - - `single-session-event`: 单会话事件信息 - - `multi-session-user`: 多会话用户信息 - - `multi-session-event`: 多会话事件信息 - - 可以识别系统在不同场景下的强弱项 - -- **`f1_by_type`**: 按问题类型的 F1 分数 - - 范围:0.0 - 1.0 - - **越高越好**,综合评估精确率和召回率 - - 比单纯的准确率更全面 - -- **`jaccard_by_type`**: 按问题类型的 Jaccard 相似度 - - 范围:0.0 - 1.0 - - **越高越好**,衡量答案集合匹配度 - - 对于集合类答案特别有用 - -##### 2. 样本级指标 (samples) - -**详细诊断指标:** - -- **`metrics.exact_match`**: 精确匹配(布尔值) - - **true 越多越好**,最严格的评估标准 - - 要求预测答案与标准答案完全一致 - -- **`metrics.f1`**: 单个样本的 F1 分数 - - 范围:0.0 - 1.0 - - **越高越好**,衡量单个问题的回答质量 - -- **`is_temporal`**: 是否为时间推理问题 - - 布尔值,标识问题是否涉及时间推理 - - 时间推理问题通常更具挑战性 - -- **`context_count`**: 检索到的上下文数量 - - 反映检索策略的有效性 - - 建议范围:8-15 个上下文片段 - -- **`retrieved_dialogue_count`**: 检索到的对话数 -- **`retrieved_statement_count`**: 检索到的陈述数 - - 这两个指标帮助理解检索的内容类型分布 - - 可用于优化检索策略 - -- **`timing.search_ms`**: 单个问题的检索延迟(毫秒) -- **`timing.llm_ms`**: 单个问题的 LLM 推理延迟(毫秒) - - **越低越好**,反映单次查询的响应速度 - -##### 3. 汇总指标 (metric_summary) - -**📊 关键 KPI:** - -- **`score_accuracy`**: 总体准确率百分比 - - 范围:0.0 - 100.0 - - **越高越好**,最直观的性能指标 - - 优秀标准:> 90.0 - -- **`latency_median_s`**: 中位延迟(秒) - - **越低越好**,反映真实响应速度 - - 优秀标准:< 3.0 秒 - -- **`latency_iqr_s`**: 延迟四分位距(秒) - - **越低越好**,反映性能稳定性 - - 越小说明响应时间越稳定 - -- **`avg_context_tokens_k`**: 平均上下文 token 数(千) - - **越低越好**(在保持准确性前提下) - - 直接影响 API 调用成本 - - 成本效益比 = score_accuracy / (avg_context_tokens_k * 1000) - -##### 4. 上下文统计 (context) - -- **`avg_tokens`**: 平均 token 数 -- **`avg_chars`**: 平均字符数 -- **`count_avg`**: 平均上下文片段数 - - 这些指标反映检索内容的规模 - - 需要在准确性和效率之间平衡 - -##### 5. 性能指标 (latency) - -**⚡ 效率指标:** - -- **`search`**: 检索延迟统计(单位:毫秒) - - `mean`: 平均延迟 - - `p50`: 中位数延迟 - - `p95`: 95分位数延迟 - - `iqr`: 四分位距 - - **越低越好**,衡量记忆检索速度 - -- **`llm`**: LLM 推理延迟统计(单位:毫秒) - - `mean`: 平均推理时间 - - `p50`: 中位数推理时间 - - `p95`: 95分位数推理时间 - - `iqr`: 四分位距 - - **越低越好**,衡量答案生成速度 - -##### 6. 诊断信息 (diagnostics) - -- **`duplicate_previews_top`**: 重复预览统计 - - 列出出现频率最高的重复内容 - - 帮助发现检索冗余问题 - - 应该尽量减少重复 - -- **`unique_preview_count`**: 唯一预览数量 - - 反映检索多样性 - - **越高越好**,说明检索到的内容更丰富 - -#### 系统进步衡量标准 - -**一级指标(最重要):** -- `score_accuracy` 提升 → 核心能力提升 -- 目标:> 90.0% -- 各类型的 `accuracy_by_type` 均衡提升 → 全面能力提升 - -**二级指标(重要):** -- `latency_median_s` 降低 → 用户体验提升 -- 目标:< 3.0 秒 -- `exact_match` 比例提升 → 精确度提升 - -**三级指标(辅助):** -- `avg_context_tokens_k` 降低(在保持准确性前提下)→ 成本优化 -- `unique_preview_count` 提升 → 检索多样性提升 -- `latency_iqr_s` 降低 → 性能稳定性提升 - -**特殊关注:** -- 时间推理问题(`is_temporal: true`)的准确率 -- 多会话问题的准确率(通常更具挑战性) -# 5.memsciqa -对话记忆检索评估 -### (1)执行命令 -```python -# 首先进入api目录 -cd api - -# 不带参数运行 - 使用环境变量 -python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark - -# 命令行参数覆盖环境变量 -python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --sample-size 100 - -# 如果数据已经摄入,跳过摄入阶段直接测试(使用skip_ingest参数) -python -m app.core.memory.evaluation.memsciqa.memsciqa_benchmark --skip_ingest -``` -### (2)结果说明 - -#### 结果示例 -```json -{ - "dataset": "memsciqa", - "items": 1, - "metrics": { - "accuracy": 0.0, - "f1": 0.0, - "bleu1": 0.0, - "jaccard": 0.0 - }, - "latency": { - "search": { - "mean": 0.0, - "p50": 0.0, - "p95": 0.0, - "iqr": 0.0 - }, - "llm": { - "mean": 3067.7285194396973, - "p50": 3067.7285194396973, - "p95": 3067.7285194396973, - "iqr": 0.0 - } - }, - "avg_context_tokens": 4.0 -} -``` - -#### 参数详解 - -##### 1. 核心评估指标 (metrics) - -**🎯 关键进步指标:** - -- **`accuracy`**: 准确率 - - 范围:0.0 - 1.0 - - **越高越好**,最直接的性能指标 - - 衡量系统回答正确的问题比例 - - 优秀标准:> 0.85 - -- **`f1`**: F1 分数 - - 范围:0.0 - 1.0 - - **越高越好**,平衡精确率和召回率 - - 计算公式:2 * (precision * recall) / (precision + recall) - - 比单纯的准确率更全面,特别适合不平衡数据集 - -- **`bleu1`**: BLEU-1 分数 - - 范围:0.0 - 1.0 - - **越高越好**,衡量词汇级别的匹配度 - - 关注生成答案与标准答案的单词重叠 - - 源自机器翻译评估,适用于自然语言生成 - -- **`jaccard`**: Jaccard 相似度 - - 范围:0.0 - 1.0 - - **越高越好**,衡量集合相似性 - - 计算公式:|A ∩ B| / |A ∪ B| - - 对于多答案或集合类问题特别有用 - -##### 2. 性能指标 (latency) - -**⚡ 效率指标:** - -- **`search`**: 检索延迟统计(单位:毫秒) - - `mean`: 平均检索延迟 - - `p50`: 中位数延迟(50%的请求在此时间内完成) - - `p95`: 95分位数延迟(95%的请求在此时间内完成) - - `iqr`: 四分位距(Q3-Q1,衡量稳定性) - - **越低越好**,衡量记忆检索效率 - - 优秀标准:p95 < 2000ms - -- **`llm`**: LLM 推理延迟统计(单位:毫秒) - - `mean`: 平均推理时间 - - `p50`: 中位数推理时间 - - `p95`: 95分位数推理时间 - - `iqr`: 四分位距(越小越稳定) - - **越低越好**,衡量答案生成速度 - - 优秀标准:p95 < 3000ms - - 注意:LLM 延迟通常占总延迟的大部分 - -##### 3. 资源指标 - -- **`avg_context_tokens`**: 平均上下文 token 数 - - **越低越好**(在保持准确性前提下) - - 直接影响: - - API 调用成本(按 token 计费) - - 推理速度(token 越多越慢) - - 上下文窗口占用 - - 成本效益比 = accuracy / avg_context_tokens - - 建议范围:根据模型上下文窗口和成本预算调整 - -##### 4. 数据集特点 - -- **`items`**: 评估的问题数量 - - 样本量越大,评估结果越可靠 - - 建议至少 100 个样本以获得稳定的评估结果 - -- **对话记忆特性**: - - MemSciQA 专注于对话历史中的记忆检索 - - 评估系统从多轮对话中提取和回忆信息的能力 - - 模拟真实的对话场景 - -#### 系统进步衡量标准 - -**一级指标(最重要):** -- `accuracy` 提升 → 核心能力提升 -- 目标:> 0.85 -- `f1` 提升 → 综合性能提升 -- 目标:> 0.80 - -**二级指标(重要):** -- `latency.p95` 降低 → 用户体验提升 - - search.p95 目标:< 2000ms - - llm.p95 目标:< 3000ms -- `iqr` 降低 → 性能稳定性提升 - -**三级指标(辅助):** -- `avg_context_tokens` 降低(在保持准确性前提下)→ 成本优化 -- `bleu1` 和 `jaccard` 提升 → 答案质量提升 - -**综合评估:** -- 成本效益比 = accuracy / avg_context_tokens - - 该比值越高,说明系统在相同成本下性能越好 -- 总延迟 = search.p95 + llm.p95 - - 应控制在 5 秒以内以保证良好的用户体验 - -#### 优化建议 - -**提升准确性:** -- 优化检索算法(调整 hybrid search 参数) -- 改进 embedding 模型质量 -- 增加检索上下文数量(`search_limit`) -- 优化 prompt 工程 - -**提升效率:** -- 减少不必要的检索文档 -- 使用更快的 LLM 模型或量化版本 -- 实施缓存策略(相似问题复用结果) -- 优化数据库索引 - -**平衡性能:** -- 监控 accuracy vs latency 的权衡 -- 监控 accuracy vs cost (tokens) 的权衡 -- 根据业务需求调整优先级 - - ---- - -# 6. 三个基准测试对比总结 - -## 6.1 测试特点对比 - -| 基准测试 | 主要评估目标 | 数据集特点 | 适用场景 | -|---------|------------|-----------|---------| -| **Locomo** | 长对话记忆检索 | 长对话历史,多轮交互 | 评估长期记忆保持和检索能力 | -| **LongMemEval** | 时间推理和多会话记忆 | 支持时间推理,多会话场景 | 评估时间感知和跨会话记忆能力 | -| **MemSciQA** | 对话记忆问答 | 对话历史问答 | 评估对话上下文理解和记忆提取 | - -## 6.2 核心指标对比 - -### 准确性指标 - -| 指标 | Locomo | LongMemEval | MemSciQA | 说明 | -|-----|--------|-------------|----------|------| -| **F1 Score** | ✅ | ✅ | ✅ | 所有测试都使用,最重要的综合指标 | -| **Accuracy** | ❌ | ✅ | ✅ | 直观的准确率指标 | -| **BLEU-1** | ✅ | ❌ | ✅ | 词汇级别匹配度 | -| **Jaccard** | ✅ | ✅ | ✅ | 集合相似度 | -| **Exact Match** | ❌ | ✅ | ❌ | 最严格的评估标准 | - -### 性能指标 - -所有三个测试都包含: -- **检索延迟** (search latency): mean, p50, p95, iqr -- **LLM 延迟** (llm latency): mean, p50, p95, iqr -- **上下文统计**: token 数、字符数、文档数 - -## 6.3 关键进步指标优先级 - -### 🥇 一级指标(必须关注) - -1. **准确性指标** - - Locomo: `f1`, `locomo_f1` - - LongMemEval: `score_accuracy`, `accuracy_by_type` - - MemSciQA: `accuracy`, `f1` - - **目标**: > 85% 或 > 0.85 - -2. **综合性能** - - 所有测试的 F1 分数应保持一致性 - - 不同类型问题的准确率应均衡 - -### 🥈 二级指标(重要) - -3. **响应延迟** - - `latency.p95` (95分位数延迟) - - **目标**: - - search.p95 < 2000ms - - llm.p95 < 3000ms - - 总延迟 < 5000ms - -4. **性能稳定性** - - `iqr` (四分位距) - - **目标**: 越小越好,说明性能稳定 - -### 🥉 三级指标(优化) - -5. **成本效率** - - `avg_context_tokens` - - **目标**: 在保持准确性前提下最小化 - - 成本效益比 = accuracy / avg_context_tokens - -6. **检索质量** - - `avg_retrieved_docs` 的合理性 - - `unique_preview_count` (LongMemEval) - - 检索内容的多样性和相关性 - -## 6.4 系统优化路径 - -### 阶段一:提升准确性(优先级最高) - -**目标**: 所有测试的准确率 > 85% - -**优化方向**: -1. 改进 embedding 模型质量 -2. 优化检索算法(hybrid search 参数) -3. 增加检索上下文数量(`search_limit`) -4. 优化 prompt 工程 -5. 改进记忆存储结构 - -**监控指标**: -- Locomo: `f1`, `locomo_f1` -- LongMemEval: `score_accuracy`, `exact_match` 比例 -- MemSciQA: `accuracy`, `f1` - -### 阶段二:优化性能(准确性达标后) - -**目标**: p95 延迟 < 5 秒,性能稳定 - -**优化方向**: -1. 优化数据库索引和查询 -2. 实施缓存策略 -3. 使用更快的 LLM 模型 -4. 并行化检索和推理 -5. 减少不必要的检索 - -**监控指标**: -- `latency.p50`, `latency.p95` -- `iqr` (稳定性) -- 各阶段耗时分布 - -### 阶段三:降低成本(性能达标后) - -**目标**: 在保持准确性和性能前提下,最小化成本 - -**优化方向**: -1. 精简检索上下文 -2. 优化 context 选择策略 -3. 使用更小的 LLM 模型 -4. 实施智能缓存 -5. 批处理优化 - -**监控指标**: -- `avg_context_tokens` -- 成本效益比 = accuracy / avg_context_tokens -- API 调用成本 - -## 6.5 评估最佳实践 - -### 测试执行建议 - -1. **初始测试**: 使用小样本快速验证 - ```bash - --sample_size 10 - ``` - -2. **完整评估**: 使用足够大的样本量 - ```bash - --sample_size 100 # 或更多 - ``` - -3. **增量测试**: 数据已摄入时跳过摄入阶段 - ```bash - --skip_ingest - ``` - -4. **参数调优**: 系统性地调整参数并记录结果 - - 调整 `search_limit`: 4, 8, 12, 16 - - 调整 `context_char_budget`: 2000, 4000, 8000 - - 尝试不同的 `search_type`: vector, keyword, hybrid - -### 结果分析建议 - -1. **横向对比**: 比较三个测试的结果,识别系统的强弱项 -2. **纵向对比**: 跟踪同一测试在不同版本的表现 -3. **分类分析**: 关注不同问题类型的性能差异 -4. **异常诊断**: 分析失败案例,找出根本原因 - -### 持续监控 - -建议建立监控仪表板,跟踪: -- 核心指标趋势(准确率、延迟) -- 成本效益比趋势 -- 不同问题类型的性能分布 -- 异常样本和失败模式 - -## 6.6 性能基准参考 - -### 优秀水平(Production Ready) - -- **准确性**: accuracy/f1 > 0.90 -- **延迟**: p95 < 3 秒 -- **稳定性**: iqr < 500ms -- **成本效益**: accuracy/tokens > 0.0001 - -### 良好水平(Acceptable) - -- **准确性**: accuracy/f1 > 0.85 -- **延迟**: p95 < 5 秒 -- **稳定性**: iqr < 1000ms -- **成本效益**: accuracy/tokens > 0.00005 - -### 需要改进(Below Target) - -- **准确性**: accuracy/f1 < 0.85 -- **延迟**: p95 > 5 秒 -- **稳定性**: iqr > 1000ms -- **成本效益**: accuracy/tokens < 0.00005 - ---- - -**注**: 以上标准仅供参考,实际目标应根据具体业务需求和资源约束调整。 diff --git a/api/app/core/memory/evaluation/check_enduser_data.py b/api/app/core/memory/evaluation/check_enduser_data.py deleted file mode 100644 index 18ecbb34..00000000 --- a/api/app/core/memory/evaluation/check_enduser_data.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -交互式 Neo4j End User 数据检查工具 - -用于查询指定 end_user_id 在 Neo4j 中是否存在数据,以及数据的详细统计信息。 - -使用方法: - python check_group_data.py - python check_group_data.py --group-id locomo_benchmark - python check_group_data.py --group-id memsciqa_benchmark --detailed -""" - -import asyncio -import argparse -import os -from pathlib import Path -from typing import Dict, Any -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}\n") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector - - -async def check_group_exists(end_user_id: str) -> Dict[str, Any]: - """ - 检查指定 end_user_id 是否存在数据 - - Args: - end_user_id: 要检查的 end_user ID - - Returns: - 包含统计信息的字典 - """ - connector = Neo4jConnector() - - try: - # 查询该 end_user 的节点总数 - query_total = """ - MATCH (n {end_user_id: $end_user_id}) - RETURN count(n) as total_nodes - """ - result_total = await connector.execute_query(query_total, end_user_id=end_user_id) - total_nodes = result_total[0]["total_nodes"] if result_total else 0 - - # 查询各类型节点的数量 - query_by_type = """ - MATCH (n {end_user_id: $end_user_id}) - RETURN labels(n) as labels, count(n) as count - ORDER BY count DESC - """ - result_by_type = await connector.execute_query(query_by_type, end_user_id=end_user_id) - - # 查询关系数量 - query_relationships = """ - MATCH (n {end_user_id: $end_user_id})-[r]-() - RETURN count(DISTINCT r) as total_relationships - """ - result_rel = await connector.execute_query(query_relationships, end_user_id=end_user_id) - total_relationships = result_rel[0]["total_relationships"] if result_rel else 0 - - return { - "exists": total_nodes > 0, - "total_nodes": total_nodes, - "total_relationships": total_relationships, - "nodes_by_type": result_by_type - } - - finally: - await connector.close() - - -async def get_detailed_stats(end_user_id: str) -> Dict[str, Any]: - """ - 获取详细的统计信息 - - Args: - end_user_id: 要检查的 end_user ID - - Returns: - 详细统计信息字典 - """ - connector = Neo4jConnector() - - try: - stats = {} - - # Chunk 节点统计 - query_chunks = """ - MATCH (c:Chunk {end_user_id: $end_user_id}) - RETURN count(c) as count, - avg(size(c.content)) as avg_content_length - """ - result_chunks = await connector.execute_query(query_chunks, end_user_id=end_user_id) - if result_chunks and result_chunks[0]["count"] > 0: - stats["chunks"] = { - "count": result_chunks[0]["count"], - "avg_content_length": int(result_chunks[0]["avg_content_length"]) if result_chunks[0]["avg_content_length"] else 0 - } - - # Statement 节点统计 - query_statements = """ - MATCH (s:Statement {end_user_id: $end_user_id}) - RETURN count(s) as count - """ - result_statements = await connector.execute_query(query_statements, end_user_id=end_user_id) - if result_statements and result_statements[0]["count"] > 0: - stats["statements"] = { - "count": result_statements[0]["count"] - } - - # Entity 节点统计 - query_entities = """ - MATCH (e:Entity {end_user_id: $end_user_id}) - RETURN count(e) as count, - count(DISTINCT e.entity_type) as unique_types - """ - result_entities = await connector.execute_query(query_entities, end_user_id=end_user_id) - if result_entities and result_entities[0]["count"] > 0: - stats["entities"] = { - "count": result_entities[0]["count"], - "unique_types": result_entities[0]["unique_types"] - } - - # Dialogue 节点统计 - query_dialogues = """ - MATCH (d:Dialogue {end_user_id: $end_user_id}) - RETURN count(d) as count - """ - result_dialogues = await connector.execute_query(query_dialogues, end_user_id=end_user_id) - if result_dialogues and result_dialogues[0]["count"] > 0: - stats["dialogues"] = { - "count": result_dialogues[0]["count"] - } - - # Summary 节点统计 - query_summaries = """ - MATCH (s:Summary {end_user_id: $end_user_id}) - RETURN count(s) as count - """ - result_summaries = await connector.execute_query(query_summaries, end_user_id=end_user_id) - if result_summaries and result_summaries[0]["count"] > 0: - stats["summaries"] = { - "count": result_summaries[0]["count"] - } - - return stats - - finally: - await connector.close() - - -async def list_all_end_users() -> list: - """ - 列出数据库中所有的 end_user_id - - Returns: - end_user_id 列表及其节点数量 - """ - connector = Neo4jConnector() - - try: - query = """ - MATCH (n) - WHERE n.end_user_id IS NOT NULL - RETURN DISTINCT n.end_user_id as end_user_id, count(n) as node_count - ORDER BY node_count DESC - """ - results = await connector.execute_query(query) - return results - - finally: - await connector.close() - - -def print_results(end_user_id: str, stats: Dict[str, Any], detailed_stats: Dict[str, Any] = None): - """ - 打印查询结果 - - Args: - end_user_id: End User ID - stats: 基本统计信息 - detailed_stats: 详细统计信息(可选) - """ - print(f"\n{'='*60}") - print(f"📊 End User ID: {end_user_id}") - print(f"{'='*60}\n") - - if not stats["exists"]: - print("❌ 该 end_user_id 不存在数据") - print("\n💡 提示: 请先运行基准测试以摄入数据") - return - - print(f"✅ 该 end_user_id 存在数据\n") - print(f"📈 基本统计:") - print(f" 总节点数: {stats['total_nodes']}") - print(f" 总关系数: {stats['total_relationships']}") - - if stats["nodes_by_type"]: - print(f"\n📋 节点类型分布:") - for item in stats["nodes_by_type"]: - labels = ", ".join(item["labels"]) - count = item["count"] - print(f" {labels}: {count}") - - if detailed_stats: - print(f"\n🔍 详细统计:") - - if "chunks" in detailed_stats: - print(f" Chunks: {detailed_stats['chunks']['count']} 个") - print(f" 平均内容长度: {detailed_stats['chunks']['avg_content_length']} 字符") - - if "statements" in detailed_stats: - print(f" Statements: {detailed_stats['statements']['count']} 个") - - if "entities" in detailed_stats: - print(f" Entities: {detailed_stats['entities']['count']} 个") - print(f" 唯一类型数: {detailed_stats['entities']['unique_types']}") - - if "dialogues" in detailed_stats: - print(f" Dialogues: {detailed_stats['dialogues']['count']} 个") - - if "summaries" in detailed_stats: - print(f" Summaries: {detailed_stats['summaries']['count']} 个") - - print(f"\n{'='*60}\n") - - -async def interactive_mode(): - """ - 交互式模式 - """ - print("\n" + "="*60) - print("🔍 Neo4j End User 数据检查工具 - 交互模式") - print("="*60 + "\n") - - while True: - print("\n请选择操作:") - print(" 1. 检查指定 end_user_id") - print(" 2. 列出所有 end_user_id") - print(" 3. 退出") - - choice = input("\n请输入选项 (1-3): ").strip() - - if choice == "1": - end_user_id = input("\n请输入 end_user_id: ").strip() - if not end_user_id: - print("❌ end_user_id 不能为空") - continue - - detailed = input("是否显示详细统计? (y/n, 默认 n): ").strip().lower() == 'y' - - print("\n🔄 正在查询...") - stats = await check_group_exists(end_user_id) - - detailed_stats = None - if detailed and stats["exists"]: - detailed_stats = await get_detailed_stats(end_user_id) - - print_results(end_user_id, stats, detailed_stats) - - elif choice == "2": - print("\n🔄 正在查询所有 end_user_id...") - end_users = await list_all_end_users() - - if not end_users: - print("\n❌ 数据库中没有任何 end_user 数据") - else: - print(f"\n{'='*60}") - print(f"📋 数据库中的所有 End User ID") - print(f"{'='*60}\n") - - for idx, end_user in enumerate(end_users, 1): - print(f" {idx}. {end_user['end_user_id']}") - print(f" 节点数: {end_user['node_count']}") - - print(f"\n{'='*60}\n") - - elif choice == "3": - print("\n👋 再见!") - break - - else: - print("\n❌ 无效的选项,请重新选择") - - -async def main(): - """ - 主函数 - """ - parser = argparse.ArgumentParser( - description="检查 Neo4j 中指定 end_user_id 的数据情况", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - # 交互模式 - python check_group_data.py - - # 检查指定 end_user - python check_group_data.py --end-user-id locomo_benchmark - - # 检查并显示详细统计 - python check_group_data.py --end-user-id memsciqa_benchmark --detailed - - # 列出所有 end_user - python check_group_data.py --list-all - """ - ) - - parser.add_argument( - "--end-user-id", - type=str, - help="要检查的 end_user ID" - ) - - parser.add_argument( - "--detailed", - action="store_true", - help="显示详细统计信息" - ) - - parser.add_argument( - "--list-all", - action="store_true", - help="列出所有 end_user_id" - ) - - args = parser.parse_args() - - # 如果没有提供任何参数,进入交互模式 - if not args.end_user_id and not args.list_all: - await interactive_mode() - return - - # 列出所有 end_user - if args.list_all: - print("\n🔄 正在查询所有 end_user_id...") - end_users = await list_all_end_users() - - if not end_users: - print("\n❌ 数据库中没有任何 end_user 数据") - else: - print(f"\n{'='*60}") - print(f"📋 数据库中的所有 End User ID") - print(f"{'='*60}\n") - - for idx, end_user in enumerate(end_users, 1): - print(f" {idx}. {end_user['end_user_id']}") - print(f" 节点数: {end_user['node_count']}") - - print(f"\n{'='*60}\n") - return - - # 检查指定 end_user - if args.end_user_id: - print(f"\n🔄 正在查询 end_user_id: {args.end_user_id}...") - stats = await check_group_exists(args.end_user_id) - - detailed_stats = None - if args.detailed and stats["exists"]: - print("🔄 正在获取详细统计...") - detailed_stats = await get_detailed_stats(args.end_user_id) - - print_results(args.end_user_id, stats, detailed_stats) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/api/app/core/memory/evaluation/common/metrics.py b/api/app/core/memory/evaluation/common/metrics.py deleted file mode 100644 index 961ce7f0..00000000 --- a/api/app/core/memory/evaluation/common/metrics.py +++ /dev/null @@ -1,100 +0,0 @@ -import math -import re -from typing import List, Dict - -# 评估指标的实现 -def _normalize(text: str) -> List[str]: - """Lowercase, strip punctuation, and split into tokens.""" - text = text.lower().strip() - # Python's re doesn't support \p classes; use a simple non-word filter - text = re.sub(r"[^\w\s]", " ", text) - tokens = [t for t in text.split() if t] - return tokens - - -def exact_match(pred: str, ref: str) -> float: - return float(_normalize(pred) == _normalize(ref)) - - -def jaccard(pred: str, ref: str) -> float: - p = set(_normalize(pred)) - r = set(_normalize(ref)) - if not p and not r: - return 1.0 - if not p or not r: - return 0.0 - return len(p & r) / len(p | r) - - -def f1_score(pred: str, ref: str) -> float: - p_tokens = _normalize(pred) - r_tokens = _normalize(ref) - if not p_tokens and not r_tokens: - return 1.0 - if not p_tokens or not r_tokens: - return 0.0 - p_set = set(p_tokens) - r_set = set(r_tokens) - tp = len(p_set & r_set) - precision = tp / len(p_set) if p_set else 0.0 - recall = tp / len(r_set) if r_set else 0.0 - if precision + recall == 0: - return 0.0 - return 2 * precision * recall / (precision + recall) - - -def bleu1(pred: str, ref: str) -> float: - """Unigram BLEU (BLEU-1) with clipping and brevity penalty.""" - p_tokens = _normalize(pred) - r_tokens = _normalize(ref) - if not p_tokens: - return 0.0 - # Clipped count - r_counts: Dict[str, int] = {} - for t in r_tokens: - r_counts[t] = r_counts.get(t, 0) + 1 - clipped = 0 - p_counts: Dict[str, int] = {} - for t in p_tokens: - p_counts[t] = p_counts.get(t, 0) + 1 - for t, c in p_counts.items(): - clipped += min(c, r_counts.get(t, 0)) - precision = clipped / max(len(p_tokens), 1) - # Brevity penalty - ref_len = len(r_tokens) - pred_len = len(p_tokens) - if pred_len > ref_len or pred_len == 0: - bp = 1.0 - else: - bp = math.exp(1 - ref_len / max(pred_len, 1)) - return bp * precision - - -def percentile(values: List[float], p: float) -> float: - if not values: - return 0.0 - vals = sorted(values) - k = (len(vals) - 1) * p - f = math.floor(k) - c = math.ceil(k) - if f == c: - return vals[int(k)] - return vals[f] + (k - f) * (vals[c] - vals[f]) - - -def latency_stats(latencies_ms: List[float]) -> Dict[str, float]: - """Return basic latency stats: mean, p50, p95, iqr (p75-p25).""" - if not latencies_ms: - return {"mean": 0.0, "p50": 0.0, "p95": 0.0, "iqr": 0.0} - p25 = percentile(latencies_ms, 0.25) - p50 = percentile(latencies_ms, 0.50) - p75 = percentile(latencies_ms, 0.75) - p95 = percentile(latencies_ms, 0.95) - mean = sum(latencies_ms) / max(len(latencies_ms), 1) - return {"mean": mean, "p50": p50, "p95": p95, "iqr": p75 - p25} - - -def avg_context_tokens(contexts: List[str]) -> float: - if not contexts: - return 0.0 - return sum(len(_normalize(c)) for c in contexts) / len(contexts) diff --git a/api/app/core/memory/evaluation/dialogue_queries.py b/api/app/core/memory/evaluation/dialogue_queries.py deleted file mode 100644 index 0aace0ec..00000000 --- a/api/app/core/memory/evaluation/dialogue_queries.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Dialogue search queries for evaluation purposes. -This file contains Cypher queries for searching dialogues, entities, and chunks. -Placed in evaluation directory to avoid circular imports with src modules. -""" - -# 应该是neo4j browser的cypher语句,需要修改文件名 - -# Entity search queries -SEARCH_ENTITIES_BY_NAME = """ -MATCH (e:ExtractedEntity) -WHERE e.name = $name -RETURN e -""" - -SEARCH_ENTITIES_BY_NAME_FALLBACK = """ -MATCH (e:ExtractedEntity) -WHERE e.name CONTAINS $name -RETURN e -""" - -# Chunk search queries -SEARCH_CHUNKS_BY_CONTENT = """ -MATCH (c:Chunk) -WHERE c.content CONTAINS $content -RETURN c -""" - -# Dialogue search queries -SEARCH_DIALOGUE_BY_DIALOG_ID = """ -MATCH (d:Dialogue) -WHERE d.dialog_id = $dialog_id -RETURN d -""" - -SEARCH_DIALOGUES_BY_CONTENT = """ -MATCH (d:Dialogue) -WHERE d.content CONTAINS $q -RETURN d -""" - -DIALOGUE_EMBEDDING_SEARCH = """ -WITH $embedding AS q -MATCH (d:Dialogue) -WHERE d.dialog_embedding IS NOT NULL - AND ($end_user_id IS NULL OR d.end_user_id = $end_user_id) -WITH d, q, d.dialog_embedding AS v -WITH d, - reduce(dot = 0.0, i IN range(0, size(q)-1) | dot + toFloat(q[i]) * toFloat(v[i])) AS dot, - sqrt(reduce(qs = 0.0, i IN range(0, size(q)-1) | qs + toFloat(q[i]) * toFloat(q[i]))) AS qnorm, - sqrt(reduce(vs = 0.0, i IN range(0, size(v)-1) | vs + toFloat(v[i]) * toFloat(v[i]))) AS vnorm -WITH d, CASE WHEN qnorm = 0 OR vnorm = 0 THEN 0.0 ELSE dot / (qnorm * vnorm) END AS score -WHERE score > $threshold -RETURN d.id AS dialog_id, - d.end_user_id AS end_user_id, - d.content AS content, - d.created_at AS created_at, - d.expired_at AS expired_at, - score -ORDER BY score DESC -LIMIT $limit -""" diff --git a/api/app/core/memory/evaluation/extraction_utils.py b/api/app/core/memory/evaluation/extraction_utils.py deleted file mode 100644 index 43ef6fe0..00000000 --- a/api/app/core/memory/evaluation/extraction_utils.py +++ /dev/null @@ -1,444 +0,0 @@ -import os -import asyncio -import json -from typing import List, Dict, Any, Optional -from datetime import datetime -from uuid import UUID -import re - -from app.core.memory.llm_tools.openai_client import LLMClient -from app.core.memory.storage_services.extraction_engine.knowledge_extraction.chunk_extraction import DialogueChunker -from app.core.memory.models.message_models import DialogData, ConversationContext, ConversationMessage -import os -import sys -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent / "app" / "core" / "memory" / "evaluation" / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.utils.llm.llm_utils import get_llm_client - -# 使用新的模块化架构 -from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ExtractionOrchestrator - -# Import from database module -from app.repositories.neo4j.graph_saver import save_dialog_and_statements_to_neo4j - -# Cypher queries for evaluation -# Note: Entity, chunk, and dialogue search queries have been moved to evaluation/dialogue_queries.py - - -async def ingest_contexts_via_full_pipeline( - contexts: List[str], - end_user_id: str, - chunker_strategy: str | None = None, - embedding_name: str | None = None, - save_chunk_output: bool = False, - save_chunk_output_path: str | None = None, - reset_group: bool = False, -) -> bool: - """ - 使用新的 ExtractionOrchestrator 运行完整的提取流水线 - - Run the full extraction pipeline on provided dialogue contexts and save to Neo4j. - This function uses the new ExtractionOrchestrator architecture for better maintainability. - - Args: - contexts: List of dialogue texts, each containing lines like "role: message". - end_user_id: Group ID to assign to generated DialogData and graph nodes. - chunker_strategy: Optional chunker strategy; defaults to SELECTED_CHUNKER_STRATEGY. - embedding_name: Optional embedding model ID; defaults to SELECTED_EMBEDDING_ID. - save_chunk_output: If True, write chunked DialogData list to a JSON file for debugging. - save_chunk_output_path: Optional output path; defaults to src/chunker_test_output.txt. - reset_group: If True, clear existing data for this group before ingestion. - Returns: - True if data saved successfully, False otherwise. - """ - chunker_strategy = chunker_strategy or os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker") - embedding_name = embedding_name or os.getenv("EVAL_EMBEDDING_ID") - - # Check if we should reset from environment variable if not explicitly set - if not reset_group: - reset_group = os.getenv("EVAL_RESET_ON_INGEST", "false").lower() in ("true", "1", "yes") - - # Step 0: Reset group if requested - if reset_group: - print(f"[Ingestion] 🗑️ 清空 end_user '{end_user_id}' 的现有数据...") - try: - from app.repositories.neo4j.neo4j_connector import Neo4jConnector - connector = Neo4jConnector() - try: - # 删除该 end_user 的所有节点和关系 - query = """ - MATCH (n {end_user_id: $end_user_id}) - DETACH DELETE n - """ - await connector.execute_query(query, end_user_id=end_user_id) - print(f"[Ingestion] ✅ End User '{end_user_id}' 已清空") - finally: - await connector.close() - except Exception as e: - print(f"[Ingestion] ⚠️ 清空 end_user 失败: {e}") - # 继续执行,不中断摄入流程 - - # Step 1: Initialize LLM client - llm_client = None - try: - # 使用评估配置中的 LLM ID - llm_id = os.getenv("EVAL_LLM_ID") - if not llm_id: - print("[Ingestion] ❌ EVAL_LLM_ID not set in .env.evaluation") - return False - - from app.db import get_db - - db = next(get_db()) - try: - llm_client = get_llm_client(llm_id, db) - finally: - db.close() - except Exception as e: - print(f"[Ingestion] LLM client unavailable: {e}") - return False - - # Step 2: Parse contexts and create DialogData with chunks - print(f"[Ingestion] Parsing {len(contexts)} contexts...") - chunker = DialogueChunker(chunker_strategy) - dialog_data_list: List[DialogData] = [] - - for idx, ctx in enumerate(contexts): - messages: List[ConversationMessage] = [] - - # Improved parsing: capture multi-line message blocks, normalize roles - pattern = r"^\s*(用户|AI|assistant|user)\s*[::]\s*(.+?)(?=\n\s*(?:用户|AI|assistant|user)\s*[::]|\Z)" - matches = list(re.finditer(pattern, ctx, flags=re.MULTILINE | re.DOTALL)) - - if matches: - for m in matches: - raw_role = m.group(1).strip() - content = m.group(2).strip() - norm_role = "AI" if raw_role.lower() in ("ai", "assistant") else "用户" - messages.append(ConversationMessage(role=norm_role, msg=content)) - else: - # Fallback: line-by-line parsing - for raw in ctx.split("\n"): - line = raw.strip() - if not line: - continue - m = re.match(r'^\s*([^::]+)\s*[::]\s*(.+)', line) - if m: - role = m.group(1).strip() - msg = m.group(2).strip() - norm_role = "AI" if role.lower() in ("ai", "assistant") else "用户" - messages.append(ConversationMessage(role=norm_role, msg=msg)) - else: - # Final fallback: treat as user message - default_role = "AI" if re.match(r'^\s*(assistant|AI)\b', line, flags=re.IGNORECASE) else "用户" - messages.append(ConversationMessage(role=default_role, msg=line)) - - context_model = ConversationContext(msgs=messages) - dialog = DialogData( - context=context_model, - ref_id=f"pipeline_item_{idx}", - end_user_id=end_user_id, - user_id="default_user", - apply_id="default_application", - ) - # Generate chunks - dialog.chunks = await chunker.process_dialogue(dialog) - dialog_data_list.append(dialog) - - if not dialog_data_list: - print("[Ingestion] No dialogs to process.") - return False - - print(f"[Ingestion] Parsed {len(dialog_data_list)} dialogs with chunks") - - # Step 3: Optionally save chunking outputs for debugging - if save_chunk_output: - try: - def _serialize_datetime(obj): - if isinstance(obj, datetime): - return obj.isoformat() - raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") - - from app.core.config import settings - settings.ensure_memory_output_dir() - default_path = settings.get_memory_output_path("chunker_test_output.txt") - out_path = save_chunk_output_path or default_path - - combined_output = [dd.model_dump() for dd in dialog_data_list] - with open(out_path, "w", encoding="utf-8") as f: - json.dump(combined_output, f, ensure_ascii=False, indent=4, default=_serialize_datetime) - print(f"[Ingestion] Saved chunking results to: {out_path}") - except Exception as e: - print(f"[Ingestion] Failed to save chunking results: {e}") - - # Step 4: Initialize embedder client - from app.core.models.base import RedBearModelConfig - from app.core.memory.utils.config.config_utils import get_embedder_config - from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient - from app.db import get_db - - try: - db = next(get_db()) - try: - embedder_config_dict = get_embedder_config(embedding_name, db) - embedder_config = RedBearModelConfig(**embedder_config_dict) - embedder_client = OpenAIEmbedderClient(embedder_config) - finally: - db.close() - except Exception as e: - print(f"[Ingestion] Failed to initialize embedder client: {e}") - return False - - # Step 5: Initialize Neo4j connector - connector = Neo4jConnector() - - # Step 6: 构建 MemoryConfig(从环境变量直接构建,不依赖数据库) - print("[Ingestion] 构建 MemoryConfig from environment variables...") - from app.schemas.memory_config_schema import MemoryConfig - - try: - # 从环境变量获取配置参数 - llm_id = os.getenv("EVAL_LLM_ID") - embedding_id = os.getenv("EVAL_EMBEDDING_ID") - chunker_strategy_env = os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker") - - if not llm_id or not embedding_id: - print("[Ingestion] ❌ EVAL_LLM_ID or EVAL_EMBEDDING_ID is not set in .env.evaluation") - print("[Ingestion] Please set both EVAL_LLM_ID and EVAL_EMBEDDING_ID") - await connector.close() - return False - - # 从数据库获取模型信息(仅用于显示名称) - from app.db import get_db - db = next(get_db()) - try: - from sqlalchemy import text - # 获取 LLM 模型信息(从 model_configs 表) - llm_result = db.execute( - text("SELECT name FROM model_configs WHERE id = :id"), - {"id": llm_id} - ).fetchone() - llm_model_name = llm_result[0] if llm_result else "Unknown LLM" - - # 获取 Embedding 模型信息(从 model_configs 表) - emb_result = db.execute( - text("SELECT name FROM model_configs WHERE id = :id"), - {"id": embedding_id} - ).fetchone() - embedding_model_name = emb_result[0] if emb_result else "Unknown Embedding" - except Exception as e: - # 如果查询失败,使用默认名称 - print(f"[Ingestion] Warning: Failed to query model names from database: {e}") - llm_model_name = f"LLM ({llm_id[:8]}...)" - embedding_model_name = f"Embedding ({embedding_id[:8]}...)" - finally: - db.close() - - # 构建 MemoryConfig 对象(使用最小必需配置) - from uuid import uuid4 - memory_config = MemoryConfig( - config_id=0, # 评估环境不需要真实的 config_id - config_name="evaluation_config", - workspace_id=uuid4(), # 临时 workspace_id - workspace_name="evaluation_workspace", - tenant_id=uuid4(), # 临时 tenant_id - llm_model_id=UUID(llm_id), - llm_model_name=llm_model_name, - embedding_model_id=UUID(embedding_id), - embedding_model_name=embedding_model_name, - storage_type="neo4j", - chunker_strategy=chunker_strategy_env, - reflexion_enabled=False, - reflexion_iteration_period=3, - reflexion_range="partial", - reflexion_baseline="TIME", - loaded_at=datetime.now(), - # 可选字段使用默认值 - rerank_model_id=None, - rerank_model_name=None, - llm_params={}, - embedding_params={}, - config_version="2.0", - ) - - print(f"[Ingestion] ✅ 构建 MemoryConfig 成功") - print(f"[Ingestion] LLM: {llm_model_name}") - print(f"[Ingestion] Embedding: {embedding_model_name}") - print(f"[Ingestion] Chunker: {chunker_strategy_env}") - - except Exception as e: - print(f"[Ingestion] ❌ Failed to build MemoryConfig: {e}") - print(f"[Ingestion] Please check:") - print(f"[Ingestion] 1. EVAL_LLM_ID and EVAL_EMBEDDING_ID are set in .env.evaluation") - print(f"[Ingestion] 2. Model IDs exist in the models table") - print(f"[Ingestion] 3. Database connection is working") - await connector.close() - return False - - # Step 7: Initialize and run ExtractionOrchestrator - print("[Ingestion] Running extraction pipeline with ExtractionOrchestrator...") - from app.services.memory_config_service import MemoryConfigService - config = MemoryConfigService.get_pipeline_config(memory_config) - - orchestrator = ExtractionOrchestrator( - llm_client=llm_client, - embedder_client=embedder_client, - connector=connector, - config=config, - embedding_id=str(memory_config.embedding_model_id), # 传递 embedding_id - ) - - try: - # Run the complete extraction pipeline - result = await orchestrator.run(dialog_data_list, is_pilot_run=False) - - # Handle different return formats: - # - Pilot mode: 7 values (without dedup_details) - # - Normal mode: 8 values (with dedup_details at the end) - if len(result) == 8: - # Normal mode: includes dedup_details - ( - dialogue_nodes, - chunk_nodes, - statement_nodes, - entity_nodes, - statement_chunk_edges, - statement_entity_edges, - entity_entity_edges, - _, # dedup_details - not needed here - ) = result - elif len(result) == 7: - # Pilot mode or older version: no dedup_details - ( - dialogue_nodes, - chunk_nodes, - statement_nodes, - entity_nodes, - statement_chunk_edges, - statement_entity_edges, - entity_entity_edges, - ) = result - else: - raise ValueError(f"Unexpected number of return values: {len(result)}") - - print(f"[Ingestion] Extraction completed: {len(statement_nodes)} statements, {len(entity_nodes)} entities") - - except ValueError as e: - # If unpacking fails, provide helpful error message - print(f"[Ingestion] Extraction pipeline result unpacking failed: {e}") - print(f"[Ingestion] Result type: {type(result)}, length: {len(result) if hasattr(result, '__len__') else 'N/A'}") - if hasattr(result, '__len__') and len(result) > 0: - print(f"[Ingestion] First element type: {type(result[0])}") - await connector.close() - return False - except Exception as e: - print(f"[Ingestion] Extraction pipeline failed: {e}") - import traceback - traceback.print_exc() - await connector.close() - return False - - # Step 7: Generate memory summaries - print("[Ingestion] Generating memory summaries...") - try: - from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import ( - memory_summary_generation, - ) - from app.repositories.neo4j.add_nodes import add_memory_summary_nodes - from app.repositories.neo4j.add_edges import add_memory_summary_statement_edges - - summaries = await memory_summary_generation( - chunked_dialogs=dialog_data_list, - llm_client=llm_client, - embedder_client=embedder_client - ) - print(f"[Ingestion] Generated {len(summaries)} memory summaries") - except Exception as e: - print(f"[Ingestion] Warning: Failed to generate memory summaries: {e}") - summaries = [] - - # Step 8: Save to Neo4j - print("[Ingestion] Saving to Neo4j...") - try: - success = await save_dialog_and_statements_to_neo4j( - dialogue_nodes=dialogue_nodes, - chunk_nodes=chunk_nodes, - statement_nodes=statement_nodes, - entity_nodes=entity_nodes, - entity_edges=entity_entity_edges, - statement_chunk_edges=statement_chunk_edges, - statement_entity_edges=statement_entity_edges, - connector=connector - ) - - # Save memory summaries separately - if summaries: - try: - await add_memory_summary_nodes(summaries, connector) - await add_memory_summary_statement_edges(summaries, connector) - print(f"[Ingestion] Saved {len(summaries)} memory summary nodes to Neo4j") - except Exception as e: - print(f"[Ingestion] Warning: Failed to save summary nodes: {e}") - - await connector.close() - - if success: - print("[Ingestion] Successfully saved all data to Neo4j!") - else: - print("[Ingestion] Failed to save data to Neo4j") - return success - - except Exception as e: - print(f"[Ingestion] Failed to save data to Neo4j: {e}") - await connector.close() - return False - - -async def handle_context_processing(args): - """Handle context-based processing from command line arguments.""" - contexts = [] - - if args.contexts: - contexts.extend(args.contexts) - - if args.context_file: - try: - with open(args.context_file, 'r', encoding='utf-8') as f: - contexts.extend(line.strip() for line in f if line.strip()) - except Exception as e: - print(f"Error reading context file: {e}") - return False - - if not contexts: - print("No contexts provided for processing.") - return False - - return await main_from_contexts(contexts, args.context_end_user_id) - - -async def main_from_contexts(contexts: List[str], end_user_id: str): - """Run the pipeline from provided dialogue contexts instead of test data.""" - print("=== Running pipeline from provided contexts ===") - - success = await ingest_contexts_via_full_pipeline( - contexts=contexts, - end_user_id=end_user_id, - chunker_strategy=SELECTED_CHUNKER_STRATEGY, - embedding_name=SELECTED_EMBEDDING_ID, - save_chunk_output=True - ) - - if success: - print("Successfully processed and saved contexts to Neo4j!") - else: - print("Failed to process contexts.") - - return success diff --git a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py b/api/app/core/memory/evaluation/locomo/locomo_benchmark.py deleted file mode 100644 index eed75016..00000000 --- a/api/app/core/memory/evaluation/locomo/locomo_benchmark.py +++ /dev/null @@ -1,770 +0,0 @@ -""" -LoCoMo Benchmark Script - -This module provides the main entry point for running LoCoMo benchmark evaluations. -It orchestrates data loading, ingestion, retrieval, LLM inference, and metric calculation -in a clean, maintainable way. - -Usage: - python locomo_benchmark.py --sample_size 20 --search_type hybrid -""" - -import argparse -import asyncio -import json -import os -import time -from datetime import datetime -from typing import List, Dict, Any, Optional -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.models.base import RedBearModelConfig -from app.core.memory.utils.config.config_utils import get_embedder_config -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.common.metrics import ( - f1_score, - bleu1, - jaccard, - latency_stats, - avg_context_tokens -) -from app.core.memory.evaluation.locomo.locomo_metrics import ( - locomo_f1_score, - locomo_multi_f1, - get_category_name -) -from app.core.memory.evaluation.locomo.locomo_utils import ( - load_locomo_data, - extract_conversations, - resolve_temporal_references, - select_and_format_information, - retrieve_relevant_information, -) -from app.core.memory.utils.llm.llm_utils import MemoryClientFactory -from app.db import get_db_context -from app.services.memory_config_service import MemoryConfigService - -# Get configuration from environment variables -PROJECT_ROOT = str(Path(__file__).resolve().parents[5]) # api directory -SELECTED_EMBEDDING_ID = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") -SELECTED_end_user_id = os.getenv("LOCOMO_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "locomo_benchmark") -SELECTED_LLM_ID = os.getenv("EVAL_LLM_ID", "2c9b0782-7a85-4740-ba84-4baf77f256c4") - - -# ============================================================================ -# Step 1: Data Loading -# ============================================================================ - -def step_load_data(data_path: str, sample_size: int) -> List[Dict[str, Any]]: - """ - Load QA pairs from LoCoMo dataset. - - Args: - data_path: Path to locomo10.json file - sample_size: Number of QA pairs to load (0 for all) - - Returns: - List of QA items from the first conversation - """ - print("📂 Loading LoCoMo data...") - - # Load the dataset - qa_items = load_locomo_data(data_path, sample_size) - - print(f"✅ Loaded {len(qa_items)} QA pairs from first conversation\n") - return qa_items - - -# ============================================================================ -# Step 2: Data Ingestion -# ============================================================================ - -async def ingest_conversations_if_needed( - conversations: List[str], - end_user_id: str, - reset: bool = False -) -> bool: - """ - Ingest conversations into Neo4j database. - - Args: - conversations: List of conversation strings (already formatted) - end_user_id: Database end_user ID - reset: Whether to reset the group before ingestion - - Returns: - True if successful, False otherwise - """ - try: - from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline - - # Conversations are already formatted as strings, use them directly - await ingest_contexts_via_full_pipeline(conversations, end_user_id) - return True - - except Exception as e: - print(f"⚠️ Ingestion error: {e}") - import traceback - traceback.print_exc() - return False - - -async def step_ingest_data( - data_path: str, - end_user_id: str, - skip_ingest: bool, - reset_group: bool, - max_messages: Optional[int] = None -) -> bool: - """ - Ingest conversations into Neo4j database if needed. - - Args: - data_path: Path to locomo10.json file - end_user_id: Database end_user ID - skip_ingest: Whether to skip ingestion - reset_group: Whether to reset the group before ingestion - max_messages: Maximum messages per dialogue to ingest (for testing) - - Returns: - True if ingestion succeeded or was skipped, False otherwise - """ - if skip_ingest: - print("⏭️ Skipping data ingestion (using existing data in Neo4j)") - print(f" End User ID: {end_user_id}\n") - else: - print("💾 Checking database ingestion...") - try: - # Extract conversations with optional message limit - conversations = extract_conversations( - data_path, - max_dialogues=1, - max_messages_per_dialogue=max_messages - ) - print(f"📝 Extracted {len(conversations)} conversations") - - # Always ingest for now (ingestion check not implemented) - print(f"🔄 Ingesting conversations into end_user '{end_user_id}'...") - success = await ingest_conversations_if_needed( - conversations=conversations, - end_user_id=end_user_id, - reset=reset_group - ) - - if success: - print("✅ Ingestion completed successfully\n") - else: - print("⚠️ Ingestion may have failed, continuing anyway\n") - - except Exception as e: - print(f"❌ Ingestion failed: {e}") - import traceback - traceback.print_exc() - print("⚠️ Continuing with evaluation (database may be empty)\n") - - return True - - -# ============================================================================ -# Step 3: Initialize Clients -# ============================================================================ - -def step_initialize_clients(llm_id: str, embedding_id: str): - """ - Initialize Neo4j connector, LLM client, and embedder. - - Args: - llm_id: LLM model ID - embedding_id: Embedding model ID - - Returns: - Tuple of (connector, llm_client, embedder) - """ - print("🔧 Initializing clients...") - - connector = Neo4jConnector() - - # Get database session - from app.db import get_db - db = next(get_db()) - try: - llm_client = get_llm_client(llm_id, db) - cfg_dict = get_embedder_config(embedding_id, db) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - finally: - db.close() - - print("✅ Clients initialized\n") - return connector, llm_client, embedder - - -# ============================================================================ -# Step 4: Process Questions -# ============================================================================ - -async def step_process_all_questions( - qa_items: List[Dict[str, Any]], - end_user_id: str, - search_type: str, - search_limit: int, - context_char_budget: int, - connector: Neo4jConnector, - embedder: OpenAIEmbedderClient, - llm_client: Any -) -> List[Dict[str, Any]]: - """Process all QA items: retrieve, generate, and calculate metrics.""" - print(f"🔍 Processing {len(qa_items)} questions...") - print(f"{'='*60}\n") - - samples: List[Dict[str, Any]] = [] - anchor_date = datetime(2023, 5, 8) - - for idx, item in enumerate(qa_items, 1): - question = item.get("question", "") - ground_truth = item.get("answer", "") - category = get_category_name(item) - ground_truth_str = str(ground_truth) if ground_truth is not None else "" - - print(f"[{idx}/{len(qa_items)}] Category: {category}") - print(f"❓ Question: {question}") - print(f"✅ Ground Truth: {ground_truth_str}") - - # Retrieve - t_search_start = time.time() - try: - retrieved_info = await retrieve_relevant_information( - question=question, - end_user_id=end_user_id, - search_type=search_type, - search_limit=search_limit, - connector=connector, - embedder=embedder - ) - search_latency = (time.time() - t_search_start) * 1000 - print(f"🔍 Retrieved {len(retrieved_info)} documents ({search_latency:.1f}ms)") - except Exception as e: - print(f"❌ Retrieval failed: {e}") - retrieved_info = [] - search_latency = 0.0 - - # Format context - context_text = select_and_format_information( - retrieved_info=retrieved_info, - question=question, - max_chars=context_char_budget - ) - context_text = resolve_temporal_references(context_text, anchor_date) - if context_text: - context_text = f"Reference date: {anchor_date.date().isoformat()}\n\n{context_text}" - else: - context_text = "No relevant context found." - - print(f"📝 Context: {len(context_text)} chars, {len(retrieved_info)} docs") - - # Generate answer - messages = [ - { - "role": "system", - "content": ( - "You are a precise QA assistant. Answer following these rules:\n" - "1) Extract the EXACT information mentioned in the context\n" - "2) For time questions: calculate actual dates from relative times\n" - "3) Return ONLY the answer text in simplest form\n" - "4) For dates, use format 'DD Month YYYY' (e.g., '7 May 2023')\n" - "5) If no clear answer found, respond with 'Unknown'" - ) - }, - { - "role": "user", - "content": f"Question: {question}\n\nContext:\n{context_text}" - } - ] - - t_llm_start = time.time() - try: - response = await llm_client.chat(messages=messages) - llm_latency = (time.time() - t_llm_start) * 1000 - if hasattr(response, 'content'): - prediction = response.content.strip() - elif isinstance(response, dict): - prediction = response["choices"][0]["message"]["content"].strip() - else: - prediction = "Unknown" - print(f"🤖 Prediction: {prediction} ({llm_latency:.1f}ms)") - except Exception as e: - print(f"❌ LLM failed: {e}") - prediction = "Unknown" - llm_latency = 0.0 - - # Calculate metrics - f1_val = f1_score(prediction, ground_truth_str) - bleu1_val = bleu1(prediction, ground_truth_str) - jaccard_val = jaccard(prediction, ground_truth_str) - if item.get("category") == 1: - locomo_f1_val = locomo_multi_f1(prediction, ground_truth_str) - else: - locomo_f1_val = locomo_f1_score(prediction, ground_truth_str) - - print(f"📊 Metrics - F1: {f1_val:.3f}, BLEU-1: {bleu1_val:.3f}, " - f"Jaccard: {jaccard_val:.3f}, LoCoMo F1: {locomo_f1_val:.3f}") - print() - - samples.append({ - "question": question, - "ground_truth": ground_truth_str, - "prediction": prediction, - "category": category, - "metrics": { - "f1": f1_val, - "bleu1": bleu1_val, - "jaccard": jaccard_val, - "locomo_f1": locomo_f1_val - }, - "retrieval": { - "num_docs": len(retrieved_info), - "context_length": len(context_text) - }, - "context_tokens": len(context_text.split()), - "timing": { - "search_ms": search_latency, - "llm_ms": llm_latency - } - }) - - return samples - - -# ============================================================================ -# Step 5: Aggregate Results -# ============================================================================ - -def step_aggregate_results(samples: List[Dict[str, Any]]) -> Dict[str, Any]: - """Aggregate metrics from all samples.""" - print(f"\n{'='*60}") - print("📊 Aggregating Results") - print(f"{'='*60}\n") - - if not samples: - return { - "overall_metrics": {}, - "by_category": {}, - "latency": {}, - "context_stats": {} - } - - # Extract metrics - f1_scores = [s["metrics"]["f1"] for s in samples] - bleu1_scores = [s["metrics"]["bleu1"] for s in samples] - jaccard_scores = [s["metrics"]["jaccard"] for s in samples] - locomo_f1_scores = [s["metrics"]["locomo_f1"] for s in samples] - - # Extract timing - latencies_search = [s["timing"]["search_ms"] for s in samples] - latencies_llm = [s["timing"]["llm_ms"] for s in samples] - - # Extract context stats - context_counts = [s["retrieval"]["num_docs"] for s in samples] - context_chars = [s["retrieval"]["context_length"] for s in samples] - context_tokens = [s["context_tokens"] for s in samples] - - # Overall metrics - overall_metrics = { - "f1": sum(f1_scores) / len(f1_scores) if f1_scores else 0.0, - "bleu1": sum(bleu1_scores) / len(bleu1_scores) if bleu1_scores else 0.0, - "jaccard": sum(jaccard_scores) / len(jaccard_scores) if jaccard_scores else 0.0, - "locomo_f1": sum(locomo_f1_scores) / len(locomo_f1_scores) if locomo_f1_scores else 0.0 - } - - # Per-category metrics - category_data: Dict[str, Dict[str, List[float]]] = {} - for sample in samples: - cat = sample["category"] - if cat not in category_data: - category_data[cat] = { - "f1": [], - "bleu1": [], - "jaccard": [], - "locomo_f1": [] - } - category_data[cat]["f1"].append(sample["metrics"]["f1"]) - category_data[cat]["bleu1"].append(sample["metrics"]["bleu1"]) - category_data[cat]["jaccard"].append(sample["metrics"]["jaccard"]) - category_data[cat]["locomo_f1"].append(sample["metrics"]["locomo_f1"]) - - by_category: Dict[str, Dict[str, Any]] = {} - for cat, metrics_lists in category_data.items(): - by_category[cat] = { - "count": len(metrics_lists["f1"]), - "f1": sum(metrics_lists["f1"]) / len(metrics_lists["f1"]), - "bleu1": sum(metrics_lists["bleu1"]) / len(metrics_lists["bleu1"]), - "jaccard": sum(metrics_lists["jaccard"]) / len(metrics_lists["jaccard"]), - "locomo_f1": sum(metrics_lists["locomo_f1"]) / len(metrics_lists["locomo_f1"]) - } - - # Latency statistics - latency = { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm) - } - - # Context statistics - context_stats = { - "avg_retrieved_docs": sum(context_counts) / len(context_counts) if context_counts else 0.0, - "avg_context_chars": sum(context_chars) / len(context_chars) if context_chars else 0.0, - "avg_context_tokens": sum(context_tokens) / len(context_tokens) if context_tokens else 0.0 - } - - return { - "overall_metrics": overall_metrics, - "by_category": by_category, - "latency": latency, - "context_stats": context_stats - } - - -# ============================================================================ -# Step 6: Result Saving -# ============================================================================ - -def step_save_results( - result: Dict[str, Any], - output_dir: Optional[str] -) -> str: - """ - Save evaluation results to JSON file. - - Args: - result: Complete result dictionary - output_dir: Directory to save results (uses default if None) - - Returns: - Path to saved file - """ - if output_dir is None: - # Use absolute path to ensure results are saved in the correct location - script_dir = Path(__file__).resolve().parent - output_dir = script_dir / "results" - else: - # Convert to Path object - output_dir = Path(output_dir) - # If relative path, make it relative to script directory - if not output_dir.is_absolute(): - script_dir = Path(__file__).resolve().parent - output_dir = script_dir / output_dir - - # Create directory if it doesn't exist - output_dir.mkdir(parents=True, exist_ok=True) - - timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = output_dir / f"locomo_{timestamp_str}.json" - - try: - with open(output_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"✅ Results saved to: {output_path}\n") - return str(output_path) - except Exception as e: - print(f"❌ Failed to save results: {e}") - print("📊 Printing results to console instead:\n") - print(json.dumps(result, ensure_ascii=False, indent=2)) - return "" - - -# ============================================================================ -# Main Orchestration Function -# ============================================================================ - - -async def run_locomo_benchmark( - sample_size: int = 20, - end_user_id: Optional[str] = None, - search_type: str = "hybrid", - search_limit: int = 12, - context_char_budget: int = 8000, - reset_group: bool = False, - skip_ingest: bool = False, - output_dir: Optional[str] = None, - max_ingest_messages: Optional[int] = None -) -> Dict[str, Any]: - """ - Run LoCoMo benchmark evaluation. - - This function orchestrates the complete evaluation pipeline by calling - well-defined step functions: - 1. Load LoCoMo dataset (only QA pairs from first conversation) - 2. Ingest conversations into database (unless skip_ingest=True) - 3. Initialize clients (Neo4j, LLM, Embedder) - 4. Process all questions (retrieve, generate, calculate metrics) - 5. Aggregate results - 6. Save results to file - - Note: By default, only the first conversation is ingested into the database, - and only QA pairs from that conversation are evaluated. This ensures that - all questions have corresponding memory in the database for retrieval. - - Args: - sample_size: Number of QA pairs to evaluate (from first conversation) - end_user_id: Database end_user ID for retrieval (uses default if None) - search_type: "keyword", "embedding", or "hybrid" - search_limit: Max documents to retrieve per query - context_char_budget: Max characters for context - reset_group: Whether to clear and re-ingest data - skip_ingest: If True, skip data ingestion and use existing data in Neo4j - output_dir: Directory to save results (uses default if None) - max_ingest_messages: Max messages per dialogue to ingest (for testing, None = all) - - Returns: - Dictionary with evaluation results including metrics, timing, and samples - """ - # Use default end_user_id if not provided - # 优先级:命令行参数 > LOCOMO_END_USER_ID > EVAL_END_USER_ID > 默认值 - if end_user_id is None: - end_user_id = os.getenv("LOCOMO_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "locomo_benchmark") - - # Get model IDs from config - llm_id = os.getenv("EVAL_LLM_ID", "6dc52e1b-9cec-4194-af66-a74c6307fc3f") - embedding_id = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") - - # Determine data path - dataset_dir = Path(__file__).resolve().parent.parent / "dataset" - data_path = dataset_dir / "locomo10.json" - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 locomo10.json 放置在: {dataset_dir}" - ) - - # Print configuration - print(f"\n{'='*60}") - print("🚀 Starting LoCoMo Benchmark Evaluation") - print(f"{'='*60}") - print("📊 Configuration:") - print(f" Sample size: {sample_size}") - print(f" End User ID: {end_user_id}") - print(f" Search type: {search_type}") - print(f" Search limit: {search_limit}") - print(f" Context budget: {context_char_budget} chars") - print(f" Data path: {data_path}") - if max_ingest_messages: - print(f" Max ingest messages: {max_ingest_messages} (testing mode)") - print(f"{'='*60}\n") - - # Step 1: Load LoCoMo data (加载数据) - try: - qa_items = step_load_data(data_path, sample_size) - except Exception as e: - print(f"❌ Failed to load data: {e}") - return { - "error": f"Data loading failed: {e}", - "timestamp": datetime.now().isoformat() - } - - # Step 2: Ingest data if needed(数据摄入) - await step_ingest_data(data_path, end_user_id, skip_ingest, reset_group, max_ingest_messages) - - # Step 3: Initialize clients (初始化客户端) - connector, llm_client, embedder = step_initialize_clients(llm_id, embedding_id) - - # Step 4: Process all questions (处理所有问题) - try: - samples = await step_process_all_questions( - qa_items=qa_items, - end_user_id=end_user_id, - search_type=search_type, - search_limit=search_limit, - context_char_budget=context_char_budget, - connector=connector, - embedder=embedder, - llm_client=llm_client - ) - finally: - await connector.close() - - # Step 5: Aggregate results (聚合答案) - aggregated = step_aggregate_results(samples) - - # Build final result dictionary - result = { - "dataset": "locomo", - "sample_size": len(qa_items), - "timestamp": datetime.now().isoformat(), - "params": { - "end_user_id": end_user_id, - "search_type": search_type, - "search_limit": search_limit, - "context_char_budget": context_char_budget, - "llm_id": llm_id, - "embedding_id": embedding_id - }, - "overall_metrics": aggregated["overall_metrics"], - "by_category": aggregated["by_category"], - "latency": aggregated["latency"], - "context_stats": aggregated["context_stats"], - "samples": samples - } - - # Step 6: Save results (保存结果) - step_save_results(result, output_dir) - - return result - - -def main(): - """ - Parse command-line arguments and run benchmark. - - This function provides a CLI interface for running LoCoMo benchmarks - with configurable parameters. - - Configuration priority: Command-line args > Environment variables > Code defaults - """ - # Load environment variables first - load_dotenv() - - # Get defaults from environment variables - env_sample_size = os.getenv("LOCOMO_SAMPLE_SIZE") - env_search_limit = os.getenv("LOCOMO_SEARCH_LIMIT") - env_context_budget = os.getenv("LOCOMO_CONTEXT_CHAR_BUDGET") - env_output_dir = os.getenv("LOCOMO_OUTPUT_DIR") - env_skip_ingest = os.getenv("LOCOMO_SKIP_INGEST", "false").lower() in ("true", "1", "yes") - - # Convert to appropriate types with fallback to code defaults - default_sample_size = int(env_sample_size) if env_sample_size else 20 - default_search_limit = int(env_search_limit) if env_search_limit else 12 - default_context_budget = int(env_context_budget) if env_context_budget else 8000 - default_output_dir = env_output_dir if env_output_dir else None - - parser = argparse.ArgumentParser( - description="Run LoCoMo benchmark evaluation", - formatter_class=argparse.ArgumentDefaultsHelpFormatter - ) - - parser.add_argument( - "--sample_size", - type=int, - default=default_sample_size, - help=f"Number of QA pairs to evaluate (env: LOCOMO_SAMPLE_SIZE={env_sample_size or 'not set'}, 0 for all)" - ) - parser.add_argument( - "--end_user_id", - type=str, - default=None, - help="Database end user ID for retrieval (uses LOCOMO_END_USER_ID or EVAL_END_USER_ID if not specified)" - ) - parser.add_argument( - "--search_type", - type=str, - default="hybrid", - choices=["keyword", "embedding", "hybrid"], - help="Search strategy to use" - ) - parser.add_argument( - "--search_limit", - type=int, - default=default_search_limit, - help=f"Maximum number of documents to retrieve per query (env: LOCOMO_SEARCH_LIMIT={env_search_limit or 'not set'})" - ) - parser.add_argument( - "--context_char_budget", - type=int, - default=default_context_budget, - help=f"Maximum characters for context (env: LOCOMO_CONTEXT_CHAR_BUDGET={env_context_budget or 'not set'})" - ) - parser.add_argument( - "--reset_group", - action="store_true", - help="Clear and re-ingest data (not implemented)" - ) - parser.add_argument( - "--skip_ingest", - action="store_true", - default=env_skip_ingest, - help=f"Skip data ingestion and use existing data in Neo4j (env: LOCOMO_SKIP_INGEST={os.getenv('LOCOMO_SKIP_INGEST', 'false')})" - ) - parser.add_argument( - "--output_dir", - type=str, - default=default_output_dir, - help=f"Directory to save results (env: LOCOMO_OUTPUT_DIR={env_output_dir or 'not set'})" - ) - parser.add_argument( - "--max_ingest_messages", - type=int, - default=None, - help="Maximum messages per dialogue to ingest (for testing, default: all messages)" - ) - - args = parser.parse_args() - - # Run benchmark - result = asyncio.run(run_locomo_benchmark( - sample_size=args.sample_size, - end_user_id=args.end_user_id, - search_type=args.search_type, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - reset_group=args.reset_group, - skip_ingest=args.skip_ingest, - output_dir=args.output_dir, - max_ingest_messages=args.max_ingest_messages - )) - - # Print summary - print(f"\n{'='*60}") - - # Check if there was an error - if 'error' in result: - print("❌ Benchmark Failed!") - print(f"{'='*60}") - print(f"Error: {result['error']}") - return - - print("🎉 Benchmark Complete!") - print(f"{'='*60}") - print("📊 Final Results:") - print(f" Sample size: {result.get('sample_size', 0)}") - print(f" F1: {result['overall_metrics']['f1']:.3f}") - print(f" BLEU-1: {result['overall_metrics']['bleu1']:.3f}") - print(f" Jaccard: {result['overall_metrics']['jaccard']:.3f}") - print(f" LoCoMo F1: {result['overall_metrics']['locomo_f1']:.3f}") - - if result.get('context_stats'): - print("\n📈 Context Statistics:") - print(f" Avg retrieved docs: {result['context_stats']['avg_retrieved_docs']:.1f}") - print(f" Avg context chars: {result['context_stats']['avg_context_chars']:.0f}") - print(f" Avg context tokens: {result['context_stats']['avg_context_tokens']:.0f}") - - if result.get('latency'): - print("\n⏱️ Latency Statistics:") - print(f" Search - Mean: {result['latency']['search']['mean']:.1f}ms, " - f"P50: {result['latency']['search']['p50']:.1f}ms, " - f"P95: {result['latency']['search']['p95']:.1f}ms") - print(f" LLM - Mean: {result['latency']['llm']['mean']:.1f}ms, " - f"P50: {result['latency']['llm']['p50']:.1f}ms, " - f"P95: {result['latency']['llm']['p95']:.1f}ms") - - if result.get('by_category'): - print("\n📂 Results by Category:") - for cat, metrics in result['by_category'].items(): - print(f" {cat}:") - print(f" Count: {metrics['count']}") - print(f" F1: {metrics['f1']:.3f}") - print(f" LoCoMo F1: {metrics['locomo_f1']:.3f}") - print(f" Jaccard: {metrics['jaccard']:.3f}") - - print(f"\n{'='*60}\n") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/locomo/locomo_metrics.py b/api/app/core/memory/evaluation/locomo/locomo_metrics.py deleted file mode 100644 index 20d5f2b5..00000000 --- a/api/app/core/memory/evaluation/locomo/locomo_metrics.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -LoCoMo-specific metric calculations. - -This module provides clean, simplified implementations of metrics used for -LoCoMo benchmark evaluation, including text normalization and F1 score variants. -""" - -import re -from typing import Dict, Any - - -def normalize_text(text: str) -> str: - """ - Normalize text for LoCoMo evaluation. - - Normalization steps: - - Convert to lowercase - - Remove commas - - Remove stop words (a, an, the, and) - - Remove punctuation - - Normalize whitespace - - Args: - text: Input text to normalize - - Returns: - Normalized text string with consistent formatting - - Examples: - >>> normalize_text("The cat, and the dog") - 'cat dog' - >>> normalize_text("Hello, World!") - 'hello world' - """ - # Ensure input is a string - text = str(text) if text is not None else "" - - # Convert to lowercase - text = text.lower() - - # Remove commas - text = re.sub(r"[\,]", " ", text) - - # Remove stop words - text = re.sub(r"\b(a|an|the|and)\b", " ", text) - - # Remove punctuation (keep only word characters and whitespace) - text = re.sub(r"[^\w\s]", " ", text) - - # Normalize whitespace (collapse multiple spaces to single space) - text = " ".join(text.split()) - - return text - - -def locomo_f1_score(prediction: str, ground_truth: str) -> float: - """ - Calculate LoCoMo F1 score for single-answer questions. - - Uses token-level precision and recall based on normalized text. - Treats tokens as sets (no duplicate counting). - - Args: - prediction: Model's predicted answer - ground_truth: Correct answer - - Returns: - F1 score between 0.0 and 1.0 - - Examples: - >>> locomo_f1_score("Paris", "Paris") - 1.0 - >>> locomo_f1_score("The cat", "cat") - 1.0 - >>> locomo_f1_score("dog", "cat") - 0.0 - """ - # Ensure inputs are strings - pred_str = str(prediction) if prediction is not None else "" - truth_str = str(ground_truth) if ground_truth is not None else "" - - # Normalize and tokenize - pred_tokens = normalize_text(pred_str).split() - truth_tokens = normalize_text(truth_str).split() - - # Handle empty cases - if not pred_tokens or not truth_tokens: - return 0.0 - - # Convert to sets for comparison - pred_set = set(pred_tokens) - truth_set = set(truth_tokens) - - # Calculate true positives (intersection) - true_positives = len(pred_set & truth_set) - - # Calculate precision and recall - precision = true_positives / len(pred_set) if pred_set else 0.0 - recall = true_positives / len(truth_set) if truth_set else 0.0 - - # Calculate F1 score - if precision + recall == 0: - return 0.0 - - f1 = 2 * precision * recall / (precision + recall) - return f1 - - -def locomo_multi_f1(prediction: str, ground_truth: str) -> float: - """ - Calculate LoCoMo F1 score for multi-answer questions. - - Handles comma-separated answers by: - 1. Splitting both prediction and ground truth by commas - 2. For each ground truth answer, finding the best matching prediction - 3. Averaging the F1 scores across all ground truth answers - - Args: - prediction: Model's predicted answer (may contain multiple comma-separated answers) - ground_truth: Correct answer (may contain multiple comma-separated answers) - - Returns: - Average F1 score across all ground truth answers (0.0 to 1.0) - - Examples: - >>> locomo_multi_f1("Paris, London", "Paris, London") - 1.0 - >>> locomo_multi_f1("Paris", "Paris, London") - 0.5 - >>> locomo_multi_f1("Paris, Berlin", "Paris, London") - 0.5 - """ - # Ensure inputs are strings - pred_str = str(prediction) if prediction is not None else "" - truth_str = str(ground_truth) if ground_truth is not None else "" - - # Split by commas and strip whitespace - predictions = [p.strip() for p in pred_str.split(',') if p.strip()] - ground_truths = [g.strip() for g in truth_str.split(',') if g.strip()] - - # Handle empty cases - if not predictions or not ground_truths: - return 0.0 - - # For each ground truth, find the best matching prediction - f1_scores = [] - for gt in ground_truths: - # Calculate F1 with each prediction and take the maximum - best_f1 = max(locomo_f1_score(pred, gt) for pred in predictions) - f1_scores.append(best_f1) - - # Return average F1 across all ground truths - return sum(f1_scores) / len(f1_scores) - - -def get_category_name(item: Dict[str, Any]) -> str: - """ - Extract and normalize category name from QA item. - - Handles both numeric categories (1-4) and string categories with various formats. - Supports multiple field names: "cat", "category", "type". - - Category mapping: - - 1 or "multi-hop" -> "Multi-Hop" - - 2 or "temporal" -> "Temporal" - - 3 or "open domain" -> "Open Domain" - - 4 or "single-hop" -> "Single-Hop" - - Args: - item: QA item dictionary containing category information - - Returns: - Standardized category name or "unknown" if not found - - Examples: - >>> get_category_name({"category": 1}) - 'Multi-Hop' - >>> get_category_name({"cat": "temporal"}) - 'Temporal' - >>> get_category_name({"type": "Single-Hop"}) - 'Single-Hop' - """ - # Numeric category mapping - CATEGORY_MAP = { - 1: "Multi-Hop", - 2: "Temporal", - 3: "Open Domain", - 4: "Single-Hop", - } - - # String category aliases (case-insensitive) - TYPE_ALIASES = { - "single-hop": "Single-Hop", - "singlehop": "Single-Hop", - "single hop": "Single-Hop", - "multi-hop": "Multi-Hop", - "multihop": "Multi-Hop", - "multi hop": "Multi-Hop", - "open domain": "Open Domain", - "opendomain": "Open Domain", - "temporal": "Temporal", - } - - # Try "cat" field first (string category) - cat = item.get("cat") - if isinstance(cat, str) and cat.strip(): - name = cat.strip() - lower = name.lower() - return TYPE_ALIASES.get(lower, name) - - # Try "category" field (can be int or string) - cat_num = item.get("category") - if isinstance(cat_num, int): - return CATEGORY_MAP.get(cat_num, "unknown") - elif isinstance(cat_num, str) and cat_num.strip(): - lower = cat_num.strip().lower() - return TYPE_ALIASES.get(lower, cat_num.strip()) - - # Try "type" field as fallback - cat_type = item.get("type") - if isinstance(cat_type, str) and cat_type.strip(): - lower = cat_type.strip().lower() - return TYPE_ALIASES.get(lower, cat_type.strip()) - - return "unknown" diff --git a/api/app/core/memory/evaluation/locomo/locomo_test.py b/api/app/core/memory/evaluation/locomo/locomo_test.py deleted file mode 100644 index 2cb0664c..00000000 --- a/api/app/core/memory/evaluation/locomo/locomo_test.py +++ /dev/null @@ -1,864 +0,0 @@ -# file name: check_neo4j_connection_fixed.py -import asyncio -import os -import sys -import json -import time -import math -import re -from datetime import datetime, timedelta -from typing import List, Dict, Any -from pathlib import Path -from dotenv import load_dotenv - -# Load main .env -load_dotenv() - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -# Get group_id from config -group_id = os.getenv("EVAL_GROUP_ID", "locomo_test") -print(f"✅ 使用配置的 group_id: {group_id}") - -# 首先定义 _loc_normalize 函数,因为其他函数依赖它 -def _loc_normalize(text: str) -> str: - text = str(text) if text is not None else "" - text = text.lower() - text = re.sub(r"[\,]", " ", text) - text = re.sub(r"\b(a|an|the|and)\b", " ", text) - text = re.sub(r"[^\w\s]", " ", text) - text = " ".join(text.split()) - return text - -# 尝试从 metrics.py 导入基础指标 -try: - from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard - print("✅ 从 metrics.py 导入基础指标成功") -except ImportError as e: - print(f"❌ 从 metrics.py 导入失败: {e}") - # 回退到本地实现 - def f1_score(pred: str, ref: str) -> float: - pred_str = str(pred) if pred is not None else "" - ref_str = str(ref) if ref is not None else "" - - p_tokens = _loc_normalize(pred_str).split() - r_tokens = _loc_normalize(ref_str).split() - if not p_tokens and not r_tokens: - return 1.0 - if not p_tokens or not r_tokens: - return 0.0 - p_set = set(p_tokens) - r_set = set(r_tokens) - tp = len(p_set & r_set) - precision = tp / len(p_set) if p_set else 0.0 - recall = tp / len(r_set) if r_set else 0.0 - if precision + recall == 0: - return 0.0 - return 2 * precision * recall / (precision + recall) - - def bleu1(pred: str, ref: str) -> float: - pred_str = str(pred) if pred is not None else "" - ref_str = str(ref) if ref is not None else "" - - p_tokens = _loc_normalize(pred_str).split() - r_tokens = _loc_normalize(ref_str).split() - if not p_tokens: - return 0.0 - - r_counts = {} - for t in r_tokens: - r_counts[t] = r_counts.get(t, 0) + 1 - - clipped = 0 - p_counts = {} - for t in p_tokens: - p_counts[t] = p_counts.get(t, 0) + 1 - - for t, c in p_counts.items(): - clipped += min(c, r_counts.get(t, 0)) - - precision = clipped / max(len(p_tokens), 1) - ref_len = len(r_tokens) - pred_len = len(p_tokens) - - if pred_len > ref_len or pred_len == 0: - bp = 1.0 - else: - bp = math.exp(1 - ref_len / max(pred_len, 1)) - - return bp * precision - - def jaccard(pred: str, ref: str) -> float: - pred_str = str(pred) if pred is not None else "" - ref_str = str(ref) if ref is not None else "" - - p = set(_loc_normalize(pred_str).split()) - r = set(_loc_normalize(ref_str).split()) - if not p and not r: - return 1.0 - if not p or not r: - return 0.0 - return len(p & r) / len(p | r) - -# 尝试从 qwen_search_eval.py 导入 LoCoMo 特定指标 -try: - from app.core.memory.evaluation.locomo.qwen_search_eval import loc_f1_score, loc_multi_f1, _resolve_relative_times - print("✅ 从 qwen_search_eval 导入 LoCoMo 特定指标成功") -except ImportError as e: - print(f"❌ 从 qwen_search_eval.py 导入失败: {e}") - # 回退到本地实现 LoCoMo 特定函数 - def _resolve_relative_times(text: str, anchor: datetime) -> str: - t = str(text) if text is not None else "" - t = re.sub(r"\btoday\b", anchor.date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\byesterday\b", (anchor - timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\btomorrow\b", (anchor + timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - - def _ago_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor - timedelta(days=n)).date().isoformat() - def _in_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor + timedelta(days=n)).date().isoformat() - - t = re.sub(r"\b(\d+)\s+days\s+ago\b", _ago_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\bin\s+(\d+)\s+days\b", _in_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\blast\s+week\b", (anchor - timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\bnext\s+week\b", (anchor + timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - return t - - def loc_f1_score(prediction: str, ground_truth: str) -> float: - p_tokens = _loc_normalize(prediction).split() - g_tokens = _loc_normalize(ground_truth).split() - if not p_tokens or not g_tokens: - return 0.0 - p = set(p_tokens) - g = set(g_tokens) - tp = len(p & g) - precision = tp / len(p) if p else 0.0 - recall = tp / len(g) if g else 0.0 - return (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0 - - def loc_multi_f1(prediction: str, ground_truth: str) -> float: - predictions = [p.strip() for p in str(prediction).split(',') if p.strip()] - ground_truths = [g.strip() for g in str(ground_truth).split(',') if g.strip()] - if not predictions or not ground_truths: - return 0.0 - def _f1(a: str, b: str) -> float: - return loc_f1_score(a, b) - vals = [] - for gt in ground_truths: - vals.append(max(_f1(pred, gt) for pred in predictions)) - return sum(vals) / len(vals) - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 8000) -> str: - """基于问题关键词智能选择上下文""" - if not contexts: - return "" - - # 提取问题关键词(只保留有意义的词) - question_lower = question.lower() - stop_words = {'what', 'when', 'where', 'who', 'why', 'how', 'did', 'do', 'does', 'is', 'are', 'was', 'were', 'the', 'a', 'an', 'and', 'or', 'but'} - question_words = set(re.findall(r'\b\w+\b', question_lower)) - question_words = {word for word in question_words if word not in stop_words and len(word) > 2} - - print(f"🔍 问题关键词: {question_words}") - - # 给每个上下文打分 - scored_contexts = [] - for i, context in enumerate(contexts): - context_lower = context.lower() - score = 0 - - # 关键词匹配得分 - keyword_matches = 0 - for word in question_words: - if word in context_lower: - keyword_matches += 1 - # 关键词出现次数越多,得分越高 - score += context_lower.count(word) * 2 - - # 上下文长度得分(适中的长度更好) - context_len = len(context) - if 100 < context_len < 2000: # 理想长度范围 - score += 5 - elif context_len >= 2000: # 太长可能包含无关信息 - score += 2 - - # 如果是前几个上下文,给予额外分数(通常相关性更高) - if i < 3: - score += 3 - - scored_contexts.append((score, context, keyword_matches)) - - # 按得分排序 - scored_contexts.sort(key=lambda x: x[0], reverse=True) - - # 选择高得分的上下文,直到达到字符限制 - selected = [] - total_chars = 0 - selected_count = 0 - - print("📊 上下文相关性分析:") - for score, context, matches in scored_contexts[:5]: # 只显示前5个 - print(f" - 得分: {score}, 关键词匹配: {matches}, 长度: {len(context)}") - - for score, context, matches in scored_contexts: - if total_chars + len(context) <= max_chars: - selected.append(context) - total_chars += len(context) - selected_count += 1 - else: - # 如果这个上下文得分很高但放不下,尝试截取 - if score > 10 and total_chars < max_chars - 500: - remaining = max_chars - total_chars - # 找到包含关键词的部分 - lines = context.split('\n') - relevant_lines = [] - current_chars = 0 - - for line in lines: - line_lower = line.lower() - line_relevance = any(word in line_lower for word in question_words) - - if line_relevance and current_chars < remaining - 100: - relevant_lines.append(line) - current_chars += len(line) - - if relevant_lines: - truncated = '\n'.join(relevant_lines) - if len(truncated) > 100: # 确保有足够内容 - selected.append(truncated + "\n[相关内容截断...]") - total_chars += len(truncated) - selected_count += 1 - break # 不再尝试添加更多上下文 - - result = "\n\n".join(selected) - print(f"✅ 智能选择: {selected_count}个上下文, 总长度: {total_chars}字符") - return result - - -def get_dynamic_search_params(question: str, question_index: int, total_questions: int): - """根据问题复杂度和进度动态调整检索参数""" - - # 分析问题复杂度 - word_count = len(question.split()) - has_temporal = any(word in question.lower() for word in ['when', 'date', 'time', 'ago']) - has_multi_hop = any(word in question.lower() for word in ['and', 'both', 'also', 'while']) - - # 根据进度调整 - 后期问题可能需要更精确的检索 - progress_factor = question_index / total_questions - - base_limit = 12 - if has_temporal and has_multi_hop: - base_limit = 20 - elif word_count > 8: - base_limit = 16 - - # 随着测试进行,逐渐收紧检索范围 - adjusted_limit = max(8, int(base_limit * (1 - progress_factor * 0.3))) - - # 动态调整最大字符数 - max_chars = 8000 + 4000 * (1 - progress_factor) - - return { - "limit": adjusted_limit, - "max_chars": int(max_chars) - } - - -class EnhancedEvaluationMonitor: - def __init__(self, reset_interval=5, performance_threshold=0.6): - self.question_count = 0 - self.reset_interval = reset_interval - self.performance_threshold = performance_threshold - self.consecutive_low_scores = 0 - self.performance_history = [] - self.recent_f1_scores = [] - - def should_reset_connections(self, current_f1=None): - """基于计数和性能双重判断""" - # 定期重置 - if self.question_count % self.reset_interval == 0: - return True - - # 性能驱动的重置 - if current_f1 is not None and current_f1 < self.performance_threshold: - self.consecutive_low_scores += 1 - if self.consecutive_low_scores >= 2: # 连续2个低分就重置 - print("🚨 连续低分,触发紧急重置") - self.consecutive_low_scores = 0 - return True - else: - self.consecutive_low_scores = 0 - - return False - - def record_performance(self, question_index, metrics, context_length, retrieved_docs): - """记录性能指标,检测衰减""" - self.performance_history.append({ - 'index': question_index, - 'metrics': metrics, - 'context_length': context_length, - 'retrieved_docs': retrieved_docs, - 'timestamp': time.time() - }) - - # 记录最近的F1分数 - self.recent_f1_scores.append(metrics['f1']) - if len(self.recent_f1_scores) > 5: - self.recent_f1_scores.pop(0) - - def get_recent_performance(self): - """获取近期平均性能""" - if not self.recent_f1_scores: - return 0.5 - return sum(self.recent_f1_scores) / len(self.recent_f1_scores) - - def get_performance_trend(self): - """分析性能趋势""" - if len(self.performance_history) < 2: - return "stable" - - recent_metrics = [item['metrics']['f1'] for item in self.performance_history[-5:]] - earlier_metrics = [item['metrics']['f1'] for item in self.performance_history[-10:-5]] - - if len(recent_metrics) < 2 or len(earlier_metrics) < 2: - return "stable" - - recent_avg = sum(recent_metrics) / len(recent_metrics) - earlier_avg = sum(earlier_metrics) / len(earlier_metrics) - - if recent_avg < earlier_avg * 0.8: - return "degrading" - elif recent_avg > earlier_avg * 1.1: - return "improving" - else: - return "stable" - - -def get_enhanced_search_params(question: str, question_index: int, total_questions: int, recent_performance: float): - """基于问题复杂度和近期性能动态调整检索参数""" - - # 基础参数 - base_params = get_dynamic_search_params(question, question_index, total_questions) - - # 性能自适应调整 - if recent_performance < 0.5: # 近期表现差 - # 增加检索范围,尝试获取更多上下文 - base_params["limit"] = min(base_params["limit"] + 5, 25) - base_params["max_chars"] = min(base_params["max_chars"] + 2000, 12000) - print(f"📈 性能自适应:增加检索范围 (limit={base_params['limit']}, max_chars={base_params['max_chars']})") - - elif recent_performance > 0.8: # 近期表现好 - # 收紧检索,提高精度 - base_params["limit"] = max(base_params["limit"] - 2, 8) - base_params["max_chars"] = max(base_params["max_chars"] - 1000, 6000) - print(f"🎯 性能自适应:提高检索精度 (limit={base_params['limit']}, max_chars={base_params['max_chars']})") - - # 中间阶段特殊处理 - mid_sequence_factor = abs(question_index / total_questions - 0.5) - if mid_sequence_factor < 0.2: # 在中间30%的问题 - print("🎯 中间阶段:使用更精确的检索策略") - base_params["limit"] = max(base_params["limit"] - 2, 10) # 减少数量,提高质量 - base_params["max_chars"] = max(base_params["max_chars"] - 1000, 7000) - - return base_params - - -def enhanced_context_selection(contexts: List[str], question: str, question_index: int, total_questions: int, max_chars: int = 8000) -> str: - """考虑问题序列位置的智能选择""" - - if not contexts: - return "" - - # 在序列中间阶段使用更严格的筛选 - mid_sequence_factor = abs(question_index / total_questions - 0.5) # 距离中心的距离 - - if mid_sequence_factor < 0.2: # 在中间30%的问题 - print("🎯 中间阶段:使用严格上下文筛选") - - # 提取问题关键词 - question_lower = question.lower() - stop_words = {'what', 'when', 'where', 'who', 'why', 'how', 'did', 'do', 'does', 'is', 'are', 'was', 'were', 'the', 'a', 'an', 'and', 'or', 'but'} - question_words = set(re.findall(r'\b\w+\b', question_lower)) - question_words = {word for word in question_words if word not in stop_words and len(word) > 2} - - # 只保留高度相关的上下文 - filtered_contexts = [] - for context in contexts: - context_lower = context.lower() - relevance_score = sum(3 if word in context_lower else 0 for word in question_words) - - # 额外加分给包含数字、日期的上下文(对事实性问题更重要) - if any(char.isdigit() for char in context): - relevance_score += 2 - - # 提高阈值:只有得分>=3的上下文才保留 - if relevance_score >= 3: - filtered_contexts.append(context) - else: - print(f" - 过滤低分上下文: 得分={relevance_score}") - - contexts = filtered_contexts - print(f"🔍 严格筛选后保留 {len(contexts)} 个上下文") - - # 使用原有的智能选择逻辑 - return smart_context_selection(contexts, question, max_chars) - - -async def run_enhanced_evaluation(): - """使用增强方法进行完整评估 - 解决中间性能衰减问题""" - from dotenv import load_dotenv - from uuid import UUID - from datetime import datetime - from dataclasses import dataclass - - # 修正导入路径:使用 app.core.memory.src 前缀 - from app.repositories.neo4j.neo4j_connector import Neo4jConnector - from app.repositories.neo4j.graph_search import search_graph_by_embedding - from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient - from app.core.models.base import RedBearModelConfig - from app.core.memory.utils.llm.llm_utils import get_llm_client - from app.core.memory.utils.config.config_utils import get_embedder_config - from app.schemas.memory_config_schema import MemoryConfig - from app.services.memory_config_service import MemoryConfigService - - # Get model IDs from config - llm_id = os.getenv("EVAL_LLM_ID", "6dc52e1b-9cec-4194-af66-a74c6307fc3f") - embedding_id = os.getenv("EVAL_EMBEDDING_ID", "e2a6392d-ca63-4d59-a523-647420b59cb2") - - # 加载数据 - 使用统一的 dataset 目录 - data_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "dataset", "locomo10.json") - - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 locomo10.json 放置在: api/app/core/memory/evaluation/dataset/" - ) - - print(f"✅ 找到数据文件: {data_path}") - - with open(data_path, "r", encoding="utf-8") as f: - raw = json.load(f) - - qa_items = [] - if isinstance(raw, list): - for entry in raw: - qa_items.extend(entry.get("qa", [])) - else: - qa_items.extend(raw.get("qa", [])) - - # 测试多少个问题 - 可通过环境变量设置 - sample_size = int(os.getenv("LOCOMO_SAMPLE_SIZE", "20")) - items = qa_items[:sample_size] - print(f"📊 将测试 {len(items)} 个问题(总共 {len(qa_items)} 个可用)") - - # 初始化增强监控器 - monitor = EnhancedEvaluationMonitor(reset_interval=5, performance_threshold=0.6) - - # 获取数据库会话并初始化 LLM 客户端 - from app.db import get_db - db = next(get_db()) - - try: - llm = get_llm_client(llm_id, db) - - # 初始化embedder - cfg_dict = get_embedder_config(embedding_id, db) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - - # 🔧 创建 MemoryConfig 对象用于搜索 - # 方案1:如果有配置ID,从数据库加载 - config_id = os.getenv("EVAL_CONFIG_ID") - if config_id: - print(f"📋 从数据库加载配置 ID: {config_id}") - memory_config_service = MemoryConfigService(db) - memory_config = memory_config_service.load_memory_config(config_id, service_name="locomo_test") - else: - # 方案2:创建临时配置对象用于测试 - print(f"📋 创建临时测试配置") - from uuid import UUID - from datetime import datetime - - # 将字符串 ID 转换为 UUID - try: - embedding_uuid = UUID(embedding_id) - llm_uuid = UUID(llm_id) - except ValueError as e: - raise ValueError(f"无效的 UUID 格式: {e}") - - memory_config = MemoryConfig( - config_id=1, # 临时 ID - config_name="locomo_test_config", - workspace_id=UUID("00000000-0000-0000-0000-000000000000"), # 临时 workspace - workspace_name="test_workspace", - tenant_id=UUID("00000000-0000-0000-0000-000000000000"), # 临时 tenant - embedding_model_id=embedding_uuid, - embedding_model_name="test_embedding", - llm_model_id=llm_uuid, - llm_model_name="test_llm", - storage_type="neo4j", - chunker_strategy="RecursiveChunker", - reflexion_enabled=False, - reflexion_iteration_period=3, - reflexion_range="partial", - reflexion_baseline="Time", - loaded_at=datetime.now() - ) - - print(f"✅ MemoryConfig 已准备: embedding_id={memory_config.embedding_model_id}, llm_id={memory_config.llm_model_id}") - - # 初始化连接器 - connector = Neo4jConnector() - - # 初始化结果字典 - results = { - "questions": [], - "overall_metrics": {"f1": 0.0, "b1": 0.0, "j": 0.0, "loc_f1": 0.0}, - "category_metrics": {}, - "retrieval_stats": {"total_questions": len(items), "avg_context_length": 0, "avg_retrieved_docs": 0}, - "performance_trend": "stable", - "timestamp": datetime.now().isoformat(), - "enhanced_strategy": True - } - - total_f1 = 0.0 - total_bleu1 = 0.0 - total_jaccard = 0.0 - total_loc_f1 = 0.0 - total_context_length = 0 - total_retrieved_docs = 0 - category_stats = {} - - try: - for i, item in enumerate(items): - monitor.question_count += 1 - - # 获取近期性能用于重置判断 - recent_performance = monitor.get_recent_performance() - - # 增强的重置判断 - should_reset = monitor.should_reset_connections(current_f1=recent_performance) - if should_reset and i > 0: - print(f"🔄 重置Neo4j连接 (问题 {i+1}/{len(items)}, 近期性能: {recent_performance:.3f})...") - await connector.close() - connector = Neo4jConnector() # 创建新连接 - print("✅ 连接重置完成") - - q = item.get("question", "") - ref = item.get("answer", "") - ref_str = str(ref) if ref is not None else "" - - print(f"\n🔍 [{i+1}/{len(items)}] 问题: {q}") - print(f"✅ 真实答案: {ref_str}") - - # 分类别统计 - category = "Unknown" - if item.get("category") == 1: - category = "Multi-Hop" - elif item.get("category") == 2: - category = "Temporal" - elif item.get("category") == 3: - category = "Open Domain" - elif item.get("category") == 4: - category = "Single-Hop" - - # 增强的检索参数 - search_params = get_enhanced_search_params(q, i, len(items), recent_performance) - search_limit = search_params["limit"] - max_chars = search_params["max_chars"] - - print(f"🏷️ 类别: {category}, 检索参数: limit={search_limit}, max_chars={max_chars}") - - # 使用项目标准的混合检索方法 - t0 = time.time() - contexts_all = [] - - try: - # 使用旧版本的搜索服务(重构前的版本) - from app.core.memory.src.search import run_hybrid_search - - print(f"🔀 使用混合搜索服务(旧版本)...") - print(f"📍 检索参数: group_id={group_id}, limit=20, search_type=hybrid") - print(f"📍 查询文本: {q}") - - search_results = await run_hybrid_search( - query_text=q, - search_type="hybrid", - end_user_id="locomo_sk", - limit=20, - include=["statements", "chunks", "entities", "summaries"], - output_path=None, - memory_config=memory_config, # 🔧 添加必需的 memory_config 参数 - rerank_alpha=0.6, # BM25权重 - use_forgetting_rerank=False, - use_llm_rerank=False - ) - - # 处理搜索结果 - 旧版本返回包含 reranked_results 的结构 - # 对于 hybrid 搜索,使用 reranked_results - if "reranked_results" in search_results: - reranked = search_results["reranked_results"] - chunks = reranked.get("chunks", []) - statements = reranked.get("statements", []) - entities = reranked.get("entities", []) - summaries = reranked.get("summaries", []) - else: - # 单一搜索类型的结果 - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - print(f"✅ 混合检索成功: {len(chunks)} chunks, {len(statements)} 条陈述, {len(entities)} 个实体, {len(summaries)} 个摘要") - - # 构建上下文:优先使用 chunks、statements 和 summaries - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # 实体摘要:最多加入前3个高分实体,避免噪声 - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + ' '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - print(f"📊 有效上下文数量: {len(contexts_all)}") - except Exception as e: - print(f"❌ 检索失败: {e}") - import traceback - print(f"详细错误信息:\n{traceback.format_exc()}") - contexts_all = [] - - t1 = time.time() - search_time = (t1 - t0) * 1000 - - # 增强的上下文选择 - context_text = "" - if contexts_all: - # 使用增强的上下文选择 - context_text = enhanced_context_selection(contexts_all, q, i, len(items), max_chars=max_chars) - - # 如果智能选择后仍然过长,进行最终保护性截断 - if len(context_text) > max_chars: - print(f"⚠️ 智能选择后仍然过长 ({len(context_text)}字符),进行最终截断") - context_text = context_text[:max_chars] + "\n\n[最终截断...]" - - # 时间解析 - anchor_date = datetime(2023, 5, 8) # 使用固定日期确保一致性 - context_text = _resolve_relative_times(context_text, anchor_date) - - context_text = f"Reference date: {anchor_date.date().isoformat()}\n\n" + context_text - - print(f"📝 最终上下文长度: {len(context_text)} 字符") - - # 显示不同上下文的预览(不只是第一条) - print("🔍 上下文预览:") - for j, context in enumerate(contexts_all[:3]): # 显示前3个上下文 - preview = context[:150].replace('\n', ' ') - print(f" 上下文{j+1}: {preview}...") - - # 🔍 调试:检查答案是否在上下文中 - if ref_str and ref_str.strip(): - answer_found = any(ref_str.lower() in ctx.lower() for ctx in contexts_all) - print(f"🔍 调试:答案 '{ref_str}' 是否在检索到的上下文中? {'✅ 是' if answer_found else '❌ 否'}") - - else: - print("❌ 没有检索到有效上下文") - context_text = "No relevant context found." - - # LLM 回答 - messages = [ - {"role": "system", "content": ( - "You are a precise QA assistant. Answer following these rules:\n" - "1) Extract the EXACT information mentioned in the context\n" - "2) For time questions: calculate actual dates from relative times\n" - "3) Return ONLY the answer text in simplest form\n" - "4) For dates, use format 'DD Month YYYY' (e.g., '7 May 2023')\n" - "5) If no clear answer found, respond with 'Unknown'" - )}, - {"role": "user", "content": f"Question: {q}\n\nContext:\n{context_text}"}, - ] - - t2 = time.time() - try: - # 使用异步调用 - resp = await llm.chat(messages=messages) - # 兼容不同的响应格式 - pred = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else "Unknown") - except Exception as e: - print(f"❌ LLM 生成失败: {e}") - pred = "Unknown" - t3 = time.time() - llm_time = (t3 - t2) * 1000 - - # 计算指标 - 使用导入的指标函数 - f1_val = f1_score(pred, ref_str) - bleu1_val = bleu1(pred, ref_str) - jaccard_val = jaccard(pred, ref_str) - loc_f1_val = loc_f1_score(pred, ref_str) - - print(f"🤖 LLM 回答: {pred}") - print(f"📈 指标 - F1: {f1_val:.3f}, BLEU-1: {bleu1_val:.3f}, Jaccard: {jaccard_val:.3f}, LoCoMo F1: {loc_f1_val:.3f}") - print(f"⏱️ 时间 - 检索: {search_time:.1f}ms, LLM: {llm_time:.1f}ms") - - # 更新统计 - total_f1 += f1_val - total_bleu1 += bleu1_val - total_jaccard += jaccard_val - total_loc_f1 += loc_f1_val - total_context_length += len(context_text) - total_retrieved_docs += len(contexts_all) - - if category not in category_stats: - category_stats[category] = {"count": 0, "f1_sum": 0.0, "b1_sum": 0.0, "j_sum": 0.0, "loc_f1_sum": 0.0} - - category_stats[category]["count"] += 1 - category_stats[category]["f1_sum"] += f1_val - category_stats[category]["b1_sum"] += bleu1_val - category_stats[category]["j_sum"] += jaccard_val - category_stats[category]["loc_f1_sum"] += loc_f1_val - - # 记录性能指标 - metrics = {"f1": f1_val, "bleu1": bleu1_val, "jaccard": jaccard_val, "loc_f1": loc_f1_val} - monitor.record_performance(i, metrics, len(context_text), len(contexts_all)) - - # 保存结果 - question_result = { - "question": q, - "ground_truth": ref_str, - "prediction": pred, - "category": category, - "metrics": metrics, - "retrieval": { - "retrieved_documents": len(contexts_all), - "context_length": len(context_text), - "search_limit": search_limit, - "max_chars": max_chars, - "recent_performance": recent_performance - }, - "timing": { - "search_ms": search_time, - "llm_ms": llm_time - } - } - - results["questions"].append(question_result) - - print("="*60) - - except Exception as e: - print(f"❌ 评估过程中发生错误: {e}") - # 即使出错,也返回已有的结果 - import traceback - traceback.print_exc() - - finally: - await connector.close() - - finally: - db.close() # 关闭数据库会话 - - # 计算总体指标 - n = len(items) - if n > 0: - results["overall_metrics"] = { - "f1": total_f1 / n, - "b1": total_bleu1 / n, - "j": total_jaccard / n, - "loc_f1": total_loc_f1 / n - } - - for category, stats in category_stats.items(): - count = stats["count"] - results["category_metrics"][category] = { - "count": count, - "f1": stats["f1_sum"] / count, - "bleu1": stats["b1_sum"] / count, - "jaccard": stats["j_sum"] / count, - "loc_f1": stats["loc_f1_sum"] / count - } - - results["retrieval_stats"]["avg_context_length"] = total_context_length / n - results["retrieval_stats"]["avg_retrieved_docs"] = total_retrieved_docs / n - - # 分析性能趋势 - results["performance_trend"] = monitor.get_performance_trend() - results["reset_interval"] = monitor.reset_interval - results["total_questions_processed"] = monitor.question_count - - return results - - -if __name__ == "__main__": - print("🚀 运行增强版完整评估(解决中间性能衰减问题)...") - print("📋 增强特性:") - print(" - 双重重置策略:定期重置 + 性能驱动重置") - print(" - 动态检索参数:基于近期性能自适应调整") - print(" - 中间阶段严格筛选:提高上下文质量要求") - print(" - 连续性能监控:实时检测性能衰减") - - result = asyncio.run(run_enhanced_evaluation()) - - print("\n📊 最终评估结果:") - print("总体指标:") - print(f" F1: {result['overall_metrics']['f1']:.4f}") - print(f" BLEU-1: {result['overall_metrics']['b1']:.4f}") - print(f" Jaccard: {result['overall_metrics']['j']:.4f}") - print(f" LoCoMo F1: {result['overall_metrics']['loc_f1']:.4f}") - - print("\n分类别指标:") - for category, metrics in result['category_metrics'].items(): - print(f" {category}: F1={metrics['f1']:.4f}, BLEU-1={metrics['bleu1']:.4f}, Jaccard={metrics['jaccard']:.4f}, LoCoMo F1={metrics['loc_f1']:.4f} (样本数: {metrics['count']})") - - print("\n检索统计:") - stats = result['retrieval_stats'] - print(f" 平均上下文长度: {stats['avg_context_length']:.0f} 字符") - print(f" 平均检索文档数: {stats['avg_retrieved_docs']:.1f}") - - print(f"\n性能趋势: {result['performance_trend']}") - print(f"重置间隔: 每{result['reset_interval']}个问题") - print(f"处理问题总数: {result['total_questions_processed']}") - print(f"增强策略: {'启用' if result.get('enhanced_strategy', False) else '未启用'}") - - - # 保存结果到指定目录 - # 使用代码文件所在目录的绝对路径 - current_file_dir = os.path.dirname(os.path.abspath(__file__)) - output_dir = os.path.join(current_file_dir, "results") - os.makedirs(output_dir, exist_ok=True) - output_file = os.path.join(output_dir, "enhanced_evaluation_results.json") - with open(output_file, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n详细结果已保存到: {output_file}") diff --git a/api/app/core/memory/evaluation/locomo/locomo_utils.py b/api/app/core/memory/evaluation/locomo/locomo_utils.py deleted file mode 100644 index 6ad68470..00000000 --- a/api/app/core/memory/evaluation/locomo/locomo_utils.py +++ /dev/null @@ -1,687 +0,0 @@ -""" -LoCoMo Utilities Module - -This module provides helper functions for the LoCoMo benchmark evaluation: -- Data loading from JSON files -- Conversation extraction for ingestion -- Temporal reference resolution -- Context selection and formatting -- Retrieval wrapper functions -- Ingestion wrapper functions -""" - -import os -import json -import re -from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - -from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline - - -def load_locomo_data( - data_path: str, - sample_size: int, - conversation_index: int = 0 -) -> List[Dict[str, Any]]: - """ - Load LoCoMo dataset from JSON file. - - The LoCoMo dataset structure is a list of conversation objects, where each - object contains a "qa" list of question-answer pairs. - - Args: - data_path: Path to locomo10.json file - sample_size: Number of QA pairs to load (limits total QA items returned) - conversation_index: Which conversation to load QA pairs from (default: 0 for first) - - Returns: - List of QA item dictionaries, each containing: - - question: str - - answer: str - - category: int (1-4) - - evidence: List[str] - - Raises: - FileNotFoundError: If data_path does not exist - json.JSONDecodeError: If file is not valid JSON - IndexError: If conversation_index is out of range - """ - if not os.path.exists(data_path): - raise FileNotFoundError(f"LoCoMo data file not found: {data_path}") - - with open(data_path, "r", encoding="utf-8") as f: - raw = json.load(f) - - # LoCoMo data structure: list of objects, each with a "qa" list - qa_items: List[Dict[str, Any]] = [] - - if isinstance(raw, list): - # Only load QA pairs from the specified conversation - if conversation_index < len(raw): - entry = raw[conversation_index] - if isinstance(entry, dict) and "qa" in entry: - qa_items.extend(entry.get("qa", [])) - else: - raise IndexError( - f"Conversation index {conversation_index} out of range. " - f"Dataset has {len(raw)} conversations." - ) - else: - # Fallback: single object with qa list - if conversation_index == 0: - qa_items.extend(raw.get("qa", [])) - else: - raise IndexError( - f"Conversation index {conversation_index} out of range. " - f"Dataset has only 1 conversation." - ) - - # Return only the requested sample size - return qa_items[:sample_size] - - -def extract_conversations(data_path: str, max_dialogues: int = 1, max_messages_per_dialogue: Optional[int] = None) -> List[str]: - """ - Extract conversation texts from LoCoMo data for ingestion. - - This function extracts the raw conversation dialogues from the LoCoMo dataset - so they can be ingested into the memory system. Each conversation is formatted - as a multi-line string with "role: message" format. - - Args: - data_path: Path to locomo10.json file - max_dialogues: Maximum number of dialogues to extract (default: 1) - max_messages_per_dialogue: Maximum messages per dialogue (default: None = all messages) - - Returns: - List of conversation strings formatted for ingestion. - Each string contains multiple lines in format "role: message" - - Example output: - [ - "User: I went to the store yesterday.\\nAI: What did you buy?\\n...", - "User: I love hiking.\\nAI: Where do you like to hike?\\n..." - ] - """ - if not os.path.exists(data_path): - raise FileNotFoundError(f"LoCoMo data file not found: {data_path}") - - with open(data_path, "r", encoding="utf-8") as f: - raw = json.load(f) - - # Ensure we have a list of entries - entries = raw if isinstance(raw, list) else [raw] - - contents: List[str] = [] - - for i, entry in enumerate(entries[:max_dialogues]): - if not isinstance(entry, dict): - continue - - conv = entry.get("conversation", {}) - - if not isinstance(conv, dict): - continue - - lines: List[str] = [] - - # Collect all session_* messages - for key, val in sorted(conv.items()): - if isinstance(val, list) and key.startswith("session_"): - for msg in val: - if not isinstance(msg, dict): - continue - - role = msg.get("speaker") or "User" - text = msg.get("text") or "" - text = str(text).strip() - - if not text: - continue - - lines.append(f"{role}: {text}") - - # Limit messages if specified - if max_messages_per_dialogue and len(lines) >= max_messages_per_dialogue: - break - - # Break outer loop if we've reached the message limit - if max_messages_per_dialogue and len(lines) >= max_messages_per_dialogue: - break - - if lines: - contents.append("\n".join(lines)) - - return contents - -# 时间解析:将相对时间表达转换为绝对日期 -def resolve_temporal_references(text: str, anchor_date: datetime) -> str: - """ - Resolve relative temporal references to absolute dates. - - This function converts relative time expressions (like "today", "yesterday", - "3 days ago") into absolute ISO date strings based on an anchor date. - - Supported patterns: - - today, yesterday, tomorrow - - X days ago, in X days - - last week, next week - - Args: - text: Text containing temporal references - anchor_date: Reference date for resolution (datetime object) - - Returns: - Text with temporal references replaced by ISO dates (YYYY-MM-DD format) - - Example: - >>> anchor = datetime(2023, 5, 8) - >>> resolve_temporal_references("I saw him yesterday", anchor) - "I saw him 2023-05-07" - """ - # Ensure input is a string - t = str(text) if text is not None else "" - - # today / yesterday / tomorrow - t = re.sub( - r"\btoday\b", - anchor_date.date().isoformat(), - t, - flags=re.IGNORECASE - ) - t = re.sub( - r"\byesterday\b", - (anchor_date - timedelta(days=1)).date().isoformat(), - t, - flags=re.IGNORECASE - ) - t = re.sub( - r"\btomorrow\b", - (anchor_date + timedelta(days=1)).date().isoformat(), - t, - flags=re.IGNORECASE - ) - - # X days ago - def _ago_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor_date - timedelta(days=n)).date().isoformat() - - # in X days - def _in_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor_date + timedelta(days=n)).date().isoformat() - - t = re.sub( - r"\b(\d+)\s+days?\s+ago\b", - _ago_repl, - t, - flags=re.IGNORECASE - ) - t = re.sub( - r"\bin\s+(\d+)\s+days?\b", - _in_repl, - t, - flags=re.IGNORECASE - ) - - # last week / next week (approximate as 7 days) - t = re.sub( - r"\blast\s+week\b", - (anchor_date - timedelta(days=7)).date().isoformat(), - t, - flags=re.IGNORECASE - ) - - # 中文支持 - t = re.sub( - r"\bnext\s+week\b", - (anchor_date + timedelta(days=7)).date().isoformat(), - t, - flags=re.IGNORECASE - ) - - return t - - -def select_and_format_information( - retrieved_info: List[str], - question: str, - max_chars: int = 8000 -) -> str: - """ - Intelligently select and format most relevant retrieved information for LLM prompt. - - This function scores each piece of retrieved information based on keyword matching - with the question, then selects the highest-scoring pieces up to the character limit. - - Scoring criteria: - - Keyword matches (higher weight for multiple occurrences) - - Context length (moderate length preferred) - - Position (earlier contexts get bonus points) - - Args: - retrieved_info: List of retrieved information strings (chunks, statements, entities) - question: Question being answered - max_chars: Maximum total characters to include in final prompt - - Returns: - Formatted string combining the most relevant information for LLM prompt. - Contexts are separated by double newlines. - - Example: - >>> contexts = ["Alice went to Paris", "Bob likes pizza", "Alice visited the Eiffel Tower"] - >>> question = "Where did Alice go?" - >>> select_and_format_information(contexts, question, max_chars=100) - "Alice went to Paris\\n\\nAlice visited the Eiffel Tower" - """ - if not retrieved_info: - return "" - - # Extract question keywords (filter out stop words and short words) - question_lower = question.lower() - stop_words = { - 'what', 'when', 'where', 'who', 'why', 'how', - 'did', 'do', 'does', 'is', 'are', 'was', 'were', - 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at' - } - question_words = set(re.findall(r'\b\w+\b', question_lower)) - question_words = { - word for word in question_words - if word not in stop_words and len(word) > 2 - } - - # Score each context - scored_contexts = [] - for i, context in enumerate(retrieved_info): - context_lower = context.lower() - score = 0 - - # Keyword matching score - keyword_matches = 0 - for word in question_words: - if word in context_lower: - keyword_matches += 1 - # Multiple occurrences increase score - score += context_lower.count(word) * 2 - - # Length score (prefer moderate length) - context_len = len(context) - if 100 < context_len < 2000: - score += 5 - elif context_len >= 2000: - score += 2 - - # Position bonus (earlier contexts often more relevant) - if i < 3: - score += 3 - - scored_contexts.append((score, context, keyword_matches)) - - # Sort by score (descending) - scored_contexts.sort(key=lambda x: x[0], reverse=True) - - # Select contexts up to character limit - selected = [] - total_chars = 0 - - for score, context, matches in scored_contexts: - if total_chars + len(context) <= max_chars: - selected.append(context) - total_chars += len(context) - else: - # Try to include high-scoring context by truncating - if score > 10 and total_chars < max_chars - 500: - remaining = max_chars - total_chars - # Find lines with keywords - lines = context.split('\n') - relevant_lines = [] - current_chars = 0 - - for line in lines: - line_lower = line.lower() - line_relevance = any(word in line_lower for word in question_words) - - if line_relevance and current_chars < remaining - 100: - relevant_lines.append(line) - current_chars += len(line) - - if relevant_lines and len('\n'.join(relevant_lines)) > 100: - truncated = '\n'.join(relevant_lines) - selected.append(truncated + "\n[Content truncated...]") - total_chars += len(truncated) - break - - return "\n\n".join(selected) - -# 记忆系统核心能力:写入与读取 -async def ingest_conversations_if_needed( - conversations: List[str], - end_user_id: str, - reset: bool = False -) -> bool: - """ - Wrapper for conversation ingestion using external extraction pipeline. - - This function populates the Neo4j database with processed conversation data - (chunks, statements, entities) so that the retrieval system has memory to search. - - The ingestion process: - 1. Parses conversation text into dialogue messages - 2. Chunks the dialogues into semantic units - 3. Extracts statements and entities using LLM - 4. Generates embeddings for all content - 5. Stores everything in Neo4j graph database - - Args: - conversations: List of raw conversation texts from LoCoMo dataset - Example: ["User: I went to Paris. AI: When was that?", ...] - end_user_id: Target end_user ID for database storage - reset: Whether to clear existing data first (not implemented in wrapper) - - Returns: - True if successful, False otherwise - - Note: - The external function uses "contexts" to mean "conversation texts". - This runs the full extraction pipeline: chunking → entity extraction → - statement extraction → embedding → Neo4j storage. - """ - try: - success = await ingest_contexts_via_full_pipeline( - contexts=conversations, - end_user_id=end_user_id, - save_chunk_output=True, - reset_group=reset - ) - return success - except Exception as e: - print(f"[Ingestion] Failed to ingest conversations: {e}") - return False - -async def retrieve_relevant_information( - question: str, - end_user_id: str, - search_type: str, - search_limit: int, - connector: Any, - embedder: Any -) -> List[str]: - """ - Retrieve relevant information from memory graph for a question. - - This function searches the Neo4j memory graph (populated during ingestion) and - returns relevant chunks, statements, and entity information that might help - answer the question. - - The function supports three search types: - - "keyword": Full-text search using Cypher queries - - "embedding": Vector similarity search using embeddings - - "hybrid": Combination of keyword and embedding search with reranking - - Args: - question: Question to search for - end_user_id: Database group ID (identifies which conversation memory to search) - search_type: "keyword", "embedding", or "hybrid" - search_limit: Max memory pieces to retrieve - connector: Neo4j connector instance - embedder: Embedder client instance - - Returns: - List of text strings (chunks, statements, entity summaries) from memory graph. - Each string represents a piece of retrieved information. - - Raises: - Exception: If search fails (caught and returns empty list) - """ - from app.repositories.neo4j.graph_search import ( - search_graph, - search_graph_by_embedding - ) - from app.core.memory.src.search import run_hybrid_search - - contexts_all: List[str] = [] - - try: - if search_type == "embedding": - # Embedding-based search - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], - ) - - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - # Build context from chunks - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - - # Add statements - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - # Add summaries - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # Add top entities (limit to 3 to avoid noise) - if entities: - scored = [e for e in entities if e.get("score") is not None] - top_entities = ( - sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] - if scored else entities[:3] - ) - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append( - f"EntitySummary: {name}" - f"{(' [' + '; '.join(meta) + ']') if meta else ''}" - ) - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - elif search_type == "keyword": - # Keyword-based search - search_results = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit - ) - - dialogs = search_results.get("dialogues", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - - # Build context from dialogues - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - - # Add statements - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - # Add entity names - if entities: - entity_names = [ - str(e.get("name", "")).strip() - for e in entities[:5] - if e.get("name") - ] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - else: # hybrid - # Hybrid search with fallback to embedding - try: - search_results = await run_hybrid_search( - query_text=question, - search_type=search_type, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], - output_path=None, - ) - - # Handle flat structure (new API format) - if search_results and isinstance(search_results, dict): - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - # Check if we got results - if not (chunks or statements or entities or summaries): - # Try nested structure (backward compatibility) - reranked = search_results.get("reranked_results", {}) - if reranked and isinstance(reranked, dict): - chunks = reranked.get("chunks", []) - statements = reranked.get("statements", []) - entities = reranked.get("entities", []) - summaries = reranked.get("summaries", []) - else: - raise ValueError("Hybrid search returned empty results") - else: - raise ValueError("Hybrid search returned empty results") - - except Exception as e: - # Fallback to embedding search - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], - ) - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - # Build context (same for both hybrid and fallback) - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # Add top entities - if entities: - scored = [e for e in entities if e.get("score") is not None] - top_entities = ( - sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] - if scored else entities[:3] - ) - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append( - f"EntitySummary: {name}" - f"{(' [' + '; '.join(meta) + ']') if meta else ''}" - ) - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - except Exception as e: - # Return empty list on error - contexts_all = [] - - return contexts_all - - -async def ingest_conversations_if_needed( - conversations: List[str], - end_user_id: str, - reset: bool = False -) -> bool: - """ - Wrapper for conversation ingestion using external extraction pipeline. - - This function populates the Neo4j database with processed conversation data - (chunks, statements, entities) so that the retrieval system has memory to search. - - The ingestion process: - 1. Parses conversation text into dialogue messages - 2. Chunks the dialogues into semantic units - 3. Extracts statements and entities using LLM - 4. Generates embeddings for all content - 5. Stores everything in Neo4j graph database - - Args: - conversations: List of raw conversation texts from LoCoMo dataset - Example: ["User: I went to Paris. AI: When was that?", ...] - end_user_id: Target group ID for database storage - reset: Whether to clear existing data first (not implemented in wrapper) - - Returns: - True if successful, False otherwise - - Note: - The external function uses "contexts" to mean "conversation texts". - This runs the full extraction pipeline: chunking → entity extraction → - statement extraction → embedding → Neo4j storage. - """ - try: - success = await ingest_contexts_via_full_pipeline( - contexts=conversations, - end_user_id=end_user_id, - save_chunk_output=True - ) - return success - except Exception as e: - print(f"[Ingestion] Failed to ingest conversations: {e}") - return False diff --git a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py b/api/app/core/memory/evaluation/locomo/qwen_search_eval.py deleted file mode 100644 index 889c5065..00000000 --- a/api/app/core/memory/evaluation/locomo/qwen_search_eval.py +++ /dev/null @@ -1,874 +0,0 @@ -import argparse -import asyncio -import json -import os -import time -from datetime import datetime, timedelta -from typing import List, Dict, Any -import statistics -import re -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.models.base import RedBearModelConfig -from app.core.memory.utils.config.config_utils import get_embedder_config -from app.core.memory.src.search import run_hybrid_search # 使用旧版本(重构前) -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline -from app.core.memory.evaluation.common.metrics import f1_score as common_f1, bleu1, jaccard, latency_stats, avg_context_tokens - - -# 参考 evaluation/locomo/evaluation.py 的 F1 计算逻辑(移除外部依赖,内联实现) -def _loc_normalize(text: str) -> str: - import re - # 确保输入是字符串 - text = str(text) if text is not None else "" - text = text.lower() - text = re.sub(r"[\,]", " ", text) # 去掉逗号 - text = re.sub(r"\b(a|an|the|and)\b", " ", text) - text = re.sub(r"[^\w\s]", " ", text) - text = " ".join(text.split()) - return text - -# 追加:相对时间归一化为绝对日期(有限支持:today/yesterday/tomorrow/X days ago/in X days/last week/next week) -def _resolve_relative_times(text: str, anchor: datetime) -> str: - import re - # 确保输入是字符串 - t = str(text) if text is not None else "" - # today / yesterday / tomorrow - t = re.sub(r"\btoday\b", anchor.date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\byesterday\b", (anchor - timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\btomorrow\b", (anchor + timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - # X days ago / in X days - def _ago_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor - timedelta(days=n)).date().isoformat() - def _in_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor + timedelta(days=n)).date().isoformat() - t = re.sub(r"\b(\d+)\s+days\s+ago\b", _ago_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\bin\s+(\d+)\s+days\b", _in_repl, t, flags=re.IGNORECASE) - # last week / next week(以7天近似) - t = re.sub(r"\blast\s+week\b", (anchor - timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\bnext\s+week\b", (anchor + timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - return t - -def loc_f1_score(prediction: str, ground_truth: str) -> float: - # 单答案 F1:按词集合计算(近似原始实现,去除词干依赖) - # 确保输入是字符串 - pred_str = str(prediction) if prediction is not None else "" - truth_str = str(ground_truth) if ground_truth is not None else "" - - p_tokens = _loc_normalize(pred_str).split() - g_tokens = _loc_normalize(truth_str).split() - if not p_tokens or not g_tokens: - return 0.0 - p = set(p_tokens) - g = set(g_tokens) - tp = len(p & g) - precision = tp / len(p) if p else 0.0 - recall = tp / len(g) if g else 0.0 - return (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0 - -def loc_multi_f1(prediction: str, ground_truth: str) -> float: - # 多答案 F1:prediction 与 ground_truth 以逗号分隔,逐一匹配取最大,再对多个 GT 取平均 - # 确保输入是字符串 - pred_str = str(prediction) if prediction is not None else "" - truth_str = str(ground_truth) if ground_truth is not None else "" - - predictions = [p.strip() for p in str(pred_str).split(',') if p.strip()] - ground_truths = [g.strip() for g in str(truth_str).split(',') if g.strip()] - if not predictions or not ground_truths: - return 0.0 - def _f1(a: str, b: str) -> float: - return loc_f1_score(a, b) - vals = [] - for gt in ground_truths: - vals.append(max(_f1(pred, gt) for pred in predictions)) - return sum(vals) / len(vals) - -# 标准化 LoCoMo 类别名:支持数字 category 与字符串 cat/type -CATEGORY_MAP_NUM_TO_NAME = { - 4: "Single-Hop", - 1: "Multi-Hop", - 3: "Open Domain", - 2: "Temporal", -} - -_TYPE_ALIASES = { - "single-hop": "Single-Hop", - "singlehop": "Single-Hop", - "single hop": "Single-Hop", - "multi-hop": "Multi-Hop", - "multihop": "Multi-Hop", - "multi hop": "Multi-Hop", - "open domain": "Open Domain", - "opendomain": "Open Domain", - "temporal": "Temporal", -} - -def get_category_label(item: Dict[str, Any]) -> str: - # 1) 直接用字符串 cat - cat = item.get("cat") - if isinstance(cat, str) and cat.strip(): - name = cat.strip() - lower = name.lower() - return _TYPE_ALIASES.get(lower, name) - # 2) 数字 category 转名称 - cat_num = item.get("category") - if isinstance(cat_num, int): - return CATEGORY_MAP_NUM_TO_NAME.get(cat_num, "unknown") - # 3) 备用 type 字段 - t = item.get("type") - if isinstance(t, str) and t.strip(): - lower = t.strip().lower() - return _TYPE_ALIASES.get(lower, t.strip()) - return "unknown" - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 12000) -> str: - """基于问题关键词智能选择上下文""" - if not contexts: - return "" - - # 提取问题关键词(只保留有意义的词) - question_lower = question.lower() - stop_words = {'what', 'when', 'where', 'who', 'why', 'how', 'did', 'do', 'does', 'is', 'are', 'was', 'were', 'the', 'a', 'an', 'and', 'or', 'but'} - question_words = set(re.findall(r'\b\w+\b', question_lower)) - question_words = {word for word in question_words if word not in stop_words and len(word) > 2} - - print(f"🔍 问题关键词: {question_words}") - - # 给每个上下文打分 - scored_contexts = [] - for i, context in enumerate(contexts): - context_lower = context.lower() - score = 0 - - # 关键词匹配得分 - keyword_matches = 0 - for word in question_words: - if word in context_lower: - keyword_matches += 1 - # 关键词出现次数越多,得分越高 - score += context_lower.count(word) * 2 - - # 上下文长度得分(适中的长度更好) - context_len = len(context) - if 100 < context_len < 2000: # 理想长度范围 - score += 5 - elif context_len >= 2000: # 太长可能包含无关信息 - score += 2 - - # 如果是前几个上下文,给予额外分数(通常相关性更高) - if i < 3: - score += 3 - - scored_contexts.append((score, context, keyword_matches)) - - # 按得分排序 - scored_contexts.sort(key=lambda x: x[0], reverse=True) - - # 选择高得分的上下文,直到达到字符限制 - selected = [] - total_chars = 0 - selected_count = 0 - - print("📊 上下文相关性分析:") - for score, context, matches in scored_contexts[:5]: # 只显示前5个 - print(f" - 得分: {score}, 关键词匹配: {matches}, 长度: {len(context)}") - - for score, context, matches in scored_contexts: - if total_chars + len(context) <= max_chars: - selected.append(context) - total_chars += len(context) - selected_count += 1 - else: - # 如果这个上下文得分很高但放不下,尝试截取 - if score > 10 and total_chars < max_chars - 500: - remaining = max_chars - total_chars - # 找到包含关键词的部分 - lines = context.split('\n') - relevant_lines = [] - current_chars = 0 - - for line in lines: - line_lower = line.lower() - line_relevance = any(word in line_lower for word in question_words) - - if line_relevance and current_chars < remaining - 100: - relevant_lines.append(line) - current_chars += len(line) - - if relevant_lines: - truncated = '\n'.join(relevant_lines) - if len(truncated) > 100: # 确保有足够内容 - selected.append(truncated + "\n[相关内容截断...]") - total_chars += len(truncated) - selected_count += 1 - break # 不再尝试添加更多上下文 - - result = "\n\n".join(selected) - print(f"✅ 智能选择: {selected_count}个上下文, 总长度: {total_chars}字符") - return result - - -def get_search_params_by_category(category: str): - """根据问题类别调整检索参数""" - params_map = { - "Multi-Hop": {"limit": 20, "max_chars": 15000}, - "Temporal": {"limit": 16, "max_chars": 10000}, - "Open Domain": {"limit": 24, "max_chars": 18000}, - "Single-Hop": {"limit": 12, "max_chars": 8000}, - } - return params_map.get(category, {"limit": 16, "max_chars": 12000}) - - -async def run_locomo_eval( - sample_size: int = 1, - end_user_id: str | None = None, - search_limit: int = 8, - context_char_budget: int = 4000, # 保持默认值不变 - llm_temperature: float = 0.0, - llm_max_tokens: int = 32, - search_type: str = "hybrid", # 保持默认值不变 - output_path: str | None = None, - skip_ingest_if_exists: bool = True, - llm_timeout: float = 10.0, - llm_max_retries: int = 1 -) -> Dict[str, Any]: - - # 函数内部使用三路检索逻辑,但保持参数签名不变 - end_user_id = end_user_id or SELECTED_end_user_id - data_path = os.path.join(PROJECT_ROOT, "data", "locomo10.json") - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 locomo10.json 放置在: {dataset_dir}" - ) - with open(data_path, "r", encoding="utf-8") as f: - raw = json.load(f) - # LoCoMo 数据结构:顶层为若干对象,每个对象下有 qa 列表 - qa_items: List[Dict[str, Any]] = [] - if isinstance(raw, list): - for entry in raw: - qa_items.extend(entry.get("qa", [])) - else: - qa_items.extend(raw.get("qa", [])) - items: List[Dict[str, Any]] = qa_items[:sample_size] - - # === 保持原来的数据摄入逻辑 === - entries = raw if isinstance(raw, list) else [raw] - - # 只摄入前1条对话(保持原样) - max_dialogues_to_ingest = 1 - contents: List[str] = [] - print(f"📊 找到 {len(entries)} 个对话对象,只摄入前 {max_dialogues_to_ingest} 条") - - for i, entry in enumerate(entries[:max_dialogues_to_ingest]): - if not isinstance(entry, dict): - continue - - conv = entry.get("conversation", {}) - sample_id = entry.get("sample_id", f"unknown_{i}") - - print(f"🔍 处理对话 {i+1}: {sample_id}") - - lines: List[str] = [] - if isinstance(conv, dict): - # 收集所有 session_* 的消息 - session_count = 0 - for key, val in conv.items(): - if isinstance(val, list) and key.startswith("session_"): - session_count += 1 - for msg in val: - role = msg.get("speaker") or "用户" - text = msg.get("text") or "" - text = str(text).strip() - if not text: - continue - lines.append(f"{role}: {text}") - - print(f" - 包含 {session_count} 个session, {len(lines)} 条消息") - - if not lines: - print(f"⚠️ 警告: 对话 {sample_id} 没有对话内容,跳过摄入") - continue - - contents.append("\n".join(lines)) - - print(f"📥 总共摄入 {len(contents)} 个对话的conversation内容") - - # 选择要评测的QA对(从所有对话中选取) - indexed_items: List[tuple[int, Dict[str, Any]]] = [] - if isinstance(raw, list): - for e_idx, entry in enumerate(raw): - for qa in entry.get("qa", []): - indexed_items.append((e_idx, qa)) - else: - for qa in raw.get("qa", []): - indexed_items.append((0, qa)) - - # 这里使用sample_size来限制评测的QA数量 - selected = indexed_items[:sample_size] - items: List[Dict[str, Any]] = [qa for _, qa in selected] - - print(f"🎯 将评测 {len(items)} 个QA对,数据库中只包含 {len(contents)} 个对话") - # === 修改结束 === - - connector = Neo4jConnector() - - # 关键修复:强制重新摄入纯净的对话数据 - print("🔄 强制重新摄入纯净的对话数据...") - await ingest_contexts_via_full_pipeline(contents, end_user_id, save_chunk_output=True) - - # 使用异步LLM客户端 - llm_client = get_llm_client(llm_id) - # 初始化embedder用于直接调用 - cfg_dict = get_embedder_config(embedding_id) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - - # connector initialized above - latencies_llm: List[float] = [] - latencies_search: List[float] = [] - # 上下文诊断收集 - per_query_context_counts: List[int] = [] - per_query_context_avg_tokens: List[float] = [] - per_query_context_chars: List[int] = [] - per_query_context_tokens_total: List[int] = [] - # 详细样本调试信息 - samples: List[Dict[str, Any]] = [] - # 通用指标 - f1s: List[float] = [] - b1s: List[float] = [] - jss: List[float] = [] - # 参考 LoCoMo 评测的类别专用 F1(multi-hop 使用多答案 F1) - loc_f1s: List[float] = [] - # Per-category aggregation - cat_counts: Dict[str, int] = {} - cat_f1s: Dict[str, List[float]] = {} - cat_b1s: Dict[str, List[float]] = {} - cat_jss: Dict[str, List[float]] = {} - cat_loc_f1s: Dict[str, List[float]] = {} - try: - for item in items: - q = item.get("question", "") - ref = item.get("answer", "") - # 确保答案是字符串 - ref_str = str(ref) if ref is not None else "" - cat = get_category_label(item) - - print(f"\n=== 处理问题: {q} ===") - - # 根据类别调整检索参数 - search_params = get_search_params_by_category(cat) - adjusted_limit = search_params["limit"] - max_chars = search_params["max_chars"] - - print(f"🏷️ 类别: {cat}, 检索参数: limit={adjusted_limit}, max_chars={max_chars}") - - # 改进的检索逻辑:使用三路检索(statements, dialogues, entities) - t0 = time.time() - contexts_all: List[str] = [] - search_results = None # 保存完整的检索结果 - - try: - if search_type == "embedding": - # 直接调用嵌入检索,包含三路数据 - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=q, - end_user_id=end_user_id, - limit=adjusted_limit, - include=["chunks", "statements", "entities", "summaries"], # 修复:使用正确的类型 - ) - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - print(f"✅ 嵌入检索成功: {len(chunks)} chunks, {len(statements)} 条陈述, {len(entities)} 个实体, {len(summaries)} 个摘要") - - # 构建上下文:优先使用 chunks、statements 和 summaries - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # 实体摘要:最多加入前3个高分实体,避免噪声 - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - elif search_type == "keyword": - # 直接调用关键词检索 - search_results = await search_graph( - connector=connector, - q=q, - end_user_id=end_user_id, - limit=adjusted_limit - ) - dialogs = search_results.get("dialogues", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - print(f"🔤 关键词检索找到 {len(dialogs)} 条对话, {len(statements)} 条陈述, {len(entities)} 个实体") - - # 构建上下文 - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - # 实体处理(关键词检索的实体可能没有分数) - if entities: - entity_names = [str(e.get("name", "")).strip() for e in entities[:5] if e.get("name")] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - else: # hybrid - # 使用旧版本的混合检索(重构前) - print("🔀 使用混合检索(旧版本)...") - try: - search_results = await run_hybrid_search( - query_text=q, - search_type=search_type, - end_user_id=end_user_id, - limit=adjusted_limit, - include=["chunks", "statements", "entities", "summaries"], - output_path=None, - rerank_alpha=0.6, - use_forgetting_rerank=False, - use_llm_rerank=False - ) - - # 处理旧版本的返回结构(包含 reranked_results) - if search_results and isinstance(search_results, dict): - # 对于 hybrid 搜索,使用 reranked_results - if "reranked_results" in search_results: - reranked = search_results["reranked_results"] - chunks = reranked.get("chunks", []) - statements = reranked.get("statements", []) - entities = reranked.get("entities", []) - summaries = reranked.get("summaries", []) - else: - # 单一搜索类型的结果 - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - # 检查是否有有效结果 - if chunks or statements or entities or summaries: - print(f"✅ 混合检索成功: {len(chunks)} chunks, {len(statements)} 陈述, {len(entities)} 实体, {len(summaries)} 摘要") - else: - # 如果顶层没有结果,尝试旧的嵌套结构(向后兼容) - reranked = search_results.get("reranked_results", {}) - if reranked and isinstance(reranked, dict): - chunks = reranked.get("chunks", []) - statements = reranked.get("statements", []) - entities = reranked.get("entities", []) - summaries = reranked.get("summaries", []) - print(f"✅ 混合检索成功(使用旧格式reranked结果): {len(chunks)} chunks, {len(statements)} 陈述") - else: - raise ValueError("混合检索返回空结果") - else: - raise ValueError("混合检索返回空结果") - - except Exception as e: - print(f"❌ 混合检索失败: {e},回退到嵌入检索") - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=q, - end_user_id=end_user_id, - limit=adjusted_limit, - include=["chunks", "statements", "entities", "summaries"], - ) - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - print(f"✅ 回退嵌入检索成功: {len(chunks)} chunks, {len(statements)} 陈述") - - # 🎯 统一处理:构建上下文(所有检索类型共用) - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # 实体摘要:最多加入前3个高分实体 - if entities: - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - # 关键修复:过滤掉包含当前问题答案的上下文 - filtered_contexts = [] - for context in contexts_all: - content = str(context) - # 排除包含当前问题标准答案的上下文 - if ref_str and ref_str.strip() and ref_str.strip() in content: - print("🚫 过滤掉包含标准答案的上下文") - continue - filtered_contexts.append(context) - - print(f"📊 过滤后保留 {len(filtered_contexts)} 个上下文 (原 {len(contexts_all)} 个)") - contexts_all = filtered_contexts - - # 输出完整的检索结果信息 - print("🔍 检索结果详情:") - if search_results: - output_data = { - "statements": [ - { - "statement": s.get("statement", "")[:200] + "..." if len(s.get("statement", "")) > 200 else s.get("statement", ""), - "score": s.get("score", 0.0) - } - for s in (statements[:2] if 'statements' in locals() else []) - ], - "dialogues": [ - { - "uuid": d.get("uuid", ""), - "end_user_id": d.get("end_user_id", ""), - "content": d.get("content", "")[:200] + "..." if len(d.get("content", "")) > 200 else d.get("content", ""), - "score": d.get("score", 0.0) - } - for d in (dialogs[:2] if 'dialogs' in locals() else []) - ], - "entities": [ - { - "name": e.get("name", ""), - "entity_type": e.get("entity_type", ""), - "score": e.get("score", 0.0) - } - for e in (entities[:2] if 'entities' in locals() else []) - ] - } - print(json.dumps(output_data, ensure_ascii=False, indent=2)) - else: - print(" 无检索结果") - - except Exception as e: - print(f"❌ {search_type}检索失败: {e}") - contexts_all = [] - search_results = None - - t1 = time.time() - latencies_search.append((t1 - t0) * 1000) - - # 使用智能上下文选择 - context_text = "" - if contexts_all: - context_text = smart_context_selection(contexts_all, q, max_chars=max_chars) - - # 如果智能选择后仍然过长,进行最终保护性截断 - if len(context_text) > max_chars: - print(f"⚠️ 智能选择后仍然过长 ({len(context_text)}字符),进行最终截断") - context_text = context_text[:max_chars] + "\n\n[最终截断...]" - - # 时间解析 - anchor_date = datetime(2023, 5, 8) # 使用固定日期确保一致性 - context_text = _resolve_relative_times(context_text, anchor_date) - - context_text = f"Reference date: {anchor_date.date().isoformat()}\n\n" + context_text - - print(f"📝 最终上下文长度: {len(context_text)} 字符") - - # 显示不同上下文的预览 - print("🔍 上下文预览:") - for j, context in enumerate(contexts_all[:3]): # 显示前3个上下文 - preview = context[:150].replace('\n', ' ') - print(f" 上下文{j+1}: {preview}...") - - else: - print("❌ 没有检索到有效上下文") - context_text = "No relevant context found." - - # 记录上下文诊断信息 - per_query_context_counts.append(len(contexts_all)) - per_query_context_avg_tokens.append(avg_context_tokens([context_text])) - per_query_context_chars.append(len(context_text)) - per_query_context_tokens_total.append(len(_loc_normalize(context_text).split())) - - # LLM 提示词 - messages = [ - {"role": "system", "content": ( - "You are a precise QA assistant. Answer following these rules:\n" - "1) Extract the EXACT information mentioned in the context\n" - "2) For time questions: calculate actual dates from relative times\n" - "3) Return ONLY the answer text in simplest form\n" - "4) For dates, use format 'DD Month YYYY' (e.g., '7 May 2023')\n" - "5) If no clear answer found, respond with 'Unknown'" - )}, - {"role": "user", "content": f"Question: {q}\n\nContext:\n{context_text}"}, - ] - - t2 = time.time() - # 使用异步调用 - resp = await llm_client.chat(messages=messages) - t3 = time.time() - latencies_llm.append((t3 - t2) * 1000) - - # 兼容不同的响应格式 - pred = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else "Unknown") - - # 计算指标(确保使用字符串) - f1_val = common_f1(str(pred), ref_str) - b1_val = bleu1(str(pred), ref_str) - j_val = jaccard(str(pred), ref_str) - - f1s.append(f1_val) - b1s.append(b1_val) - jss.append(j_val) - - # Accumulate by category - cat_counts[cat] = cat_counts.get(cat, 0) + 1 - cat_f1s.setdefault(cat, []).append(f1_val) - cat_b1s.setdefault(cat, []).append(b1_val) - cat_jss.setdefault(cat, []).append(j_val) - - # LoCoMo 专用 F1:multi-hop(1) 使用多答案 F1,其它(2/3/4)使用单答案 F1 - if item.get("category") in [2, 3, 4]: - loc_val = loc_f1_score(str(pred), ref_str) - elif item.get("category") in [1]: - loc_val = loc_multi_f1(str(pred), ref_str) - else: - loc_val = loc_f1_score(str(pred), ref_str) - loc_f1s.append(loc_val) - cat_loc_f1s.setdefault(cat, []).append(loc_val) - - # 保存完整的检索结果信息 - samples.append({ - "question": q, - "answer": ref_str, - "category": cat, - "prediction": pred, - "metrics": { - "f1": f1_val, - "b1": b1_val, - "j": j_val, - "loc_f1": loc_val - }, - "retrieval": { - "retrieved_documents": len(contexts_all), - "context_length": len(context_text), - "search_limit": adjusted_limit, - "max_chars": max_chars - }, - "timing": { - "search_ms": (t1 - t0) * 1000, - "llm_ms": (t3 - t2) * 1000 - } - }) - - print(f"🤖 LLM 回答: {pred}") - print(f"✅ 正确答案: {ref_str}") - print(f"📈 当前指标 - F1: {f1_val:.3f}, BLEU-1: {b1_val:.3f}, Jaccard: {j_val:.3f}, LoCoMo F1: {loc_val:.3f}") - - # Compute per-category averages and dispersion (std, iqr) - def _percentile(sorted_vals: List[float], p: float) -> float: - if not sorted_vals: - return 0.0 - if len(sorted_vals) == 1: - return sorted_vals[0] - k = (len(sorted_vals) - 1) * p - f = int(k) - c = f + 1 if f + 1 < len(sorted_vals) else f - if f == c: - return sorted_vals[f] - return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) - - by_category: Dict[str, Dict[str, float | int]] = {} - for c in cat_counts: - f_list = cat_f1s.get(c, []) - b_list = cat_b1s.get(c, []) - j_list = cat_jss.get(c, []) - lf_list = cat_loc_f1s.get(c, []) - j_sorted = sorted(j_list) - j_std = statistics.stdev(j_list) if len(j_list) > 1 else 0.0 - j_q75 = _percentile(j_sorted, 0.75) - j_q25 = _percentile(j_sorted, 0.25) - by_category[c] = { - "count": cat_counts[c], - "f1": (sum(f_list) / max(len(f_list), 1)) if f_list else 0.0, - "b1": (sum(b_list) / max(len(b_list), 1)) if b_list else 0.0, - "j": (sum(j_list) / max(len(j_list), 1)) if j_list else 0.0, - "j_std": j_std, - "j_iqr": (j_q75 - j_q25) if j_list else 0.0, - # 参考 LoCoMo 评测的类别专用 F1 - "loc_f1": (sum(lf_list) / max(len(lf_list), 1)) if lf_list else 0.0, - } - - # 累加命中(cum accuracy by category):与 evaluation_stats.py 输出形式相仿 - cum_accuracy_by_category = {c: sum(cat_loc_f1s.get(c, [])) for c in cat_counts} - - result = { - "dataset": "locomo", - "items": len(items), - "metrics": { - "f1": sum(f1s) / max(len(f1s), 1), - "b1": sum(b1s) / max(len(b1s), 1), - "j": sum(jss) / max(len(jss), 1), - # LoCoMo 类别专用 F1 的总体 - "loc_f1": sum(loc_f1s) / max(len(loc_f1s), 1), - }, - "by_category": by_category, - "category_counts": cat_counts, - "cum_accuracy_by_category": cum_accuracy_by_category, - "context": { - "avg_tokens": (sum(per_query_context_avg_tokens) / max(len(per_query_context_avg_tokens), 1)) if per_query_context_avg_tokens else 0.0, - "avg_chars": (sum(per_query_context_chars) / max(len(per_query_context_chars), 1)) if per_query_context_chars else 0.0, - "count_avg": (sum(per_query_context_counts) / max(len(per_query_context_counts), 1)) if per_query_context_counts else 0.0, - "avg_memory_tokens": (sum(per_query_context_tokens_total) / max(len(per_query_context_tokens_total), 1)) if per_query_context_tokens_total else 0.0, - }, - "latency": { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm), - }, - "samples": samples, - "params": { - "end_user_id": end_user_id, - "search_limit": search_limit, - "context_char_budget": context_char_budget, - "search_type": search_type, - "llm_id": llm_id, - "retrieval_embedding_id": embedding_id, - "chunker_strategy": os.getenv("EVAL_CHUNKER_STRATEGY", "RecursiveChunker"), - "skip_ingest_if_exists": skip_ingest_if_exists, - "llm_timeout": llm_timeout, - "llm_max_retries": llm_max_retries, - "llm_temperature": llm_temperature, - "llm_max_tokens": llm_max_tokens - }, - "timestamp": datetime.now().isoformat() - } - if output_path: - try: - os.makedirs(os.path.dirname(output_path), exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"✅ 结果已保存到: {output_path}") - except Exception as e: - print(f"❌ 保存结果失败: {e}") - return result - finally: - await connector.close() - - -def main(): - parser = argparse.ArgumentParser(description="Run LoCoMo evaluation with Qwen search") - parser.add_argument("--sample_size", type=int, default=1, help="Number of samples to evaluate") - parser.add_argument("--end_user_id", type=str, default=None, help="Group ID for retrieval") - parser.add_argument("--search_limit", type=int, default=8, help="Search limit per query") - parser.add_argument("--context_char_budget", type=int, default=12000, help="Max characters for context") - parser.add_argument("--llm_temperature", type=float, default=0.0, help="LLM temperature") - parser.add_argument("--llm_max_tokens", type=int, default=32, help="LLM max tokens") - parser.add_argument("--search_type", type=str, default="embedding", choices=["keyword", "embedding", "hybrid"], help="Search type") - parser.add_argument("--output_path", type=str, default=None, help="Output path for results") - parser.add_argument("--skip_ingest_if_exists", action="store_true", help="Skip ingest if group exists") - parser.add_argument("--llm_timeout", type=float, default=10.0, help="LLM timeout in seconds") - parser.add_argument("--llm_max_retries", type=int, default=1, help="LLM max retries") - args = parser.parse_args() - - load_dotenv() - - result = asyncio.run(run_locomo_eval( - sample_size=args.sample_size, - end_user_id=args.end_user_id, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - llm_temperature=args.llm_temperature, - llm_max_tokens=args.llm_max_tokens, - search_type=args.search_type, - output_path=args.output_path, - skip_ingest_if_exists=args.skip_ingest_if_exists, - llm_timeout=args.llm_timeout, - llm_max_retries=args.llm_max_retries - )) - - print("\n" + "="*50) - print("📊 最终评测结果:") - print(f" 样本数量: {result['items']}") - print(f" F1: {result['metrics']['f1']:.3f}") - print(f" BLEU-1: {result['metrics']['b1']:.3f}") - print(f" Jaccard: {result['metrics']['j']:.3f}") - print(f" LoCoMo F1: {result['metrics']['loc_f1']:.3f}") - print(f" 平均上下文长度: {result['context']['avg_chars']:.0f} 字符") - print(f" 平均检索延迟: {result['latency']['search']['mean']:.1f}ms") - print(f" 平均LLM延迟: {result['latency']['llm']['mean']:.1f}ms") - - if result['by_category']: - print("\n📈 按类别细分:") - for cat, metrics in result['by_category'].items(): - print(f" {cat}:") - print(f" 样本数: {metrics['count']}") - print(f" F1: {metrics['f1']:.3f}") - print(f" LoCoMo F1: {metrics['loc_f1']:.3f}") - print(f" Jaccard: {metrics['j']:.3f} (±{metrics['j_std']:.3f}, IQR={metrics['j_iqr']:.3f})") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py b/api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py deleted file mode 100644 index aaf46e35..00000000 --- a/api/app/core/memory/evaluation/longmemeval/longmemeval_benchmark.py +++ /dev/null @@ -1,1339 +0,0 @@ -import argparse -import asyncio -import json -import os -import time -import re -import statistics -from datetime import datetime, timedelta -from typing import List, Dict, Any -from pathlib import Path - -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.models.base import RedBearModelConfig -from app.core.memory.utils.config.config_utils import get_embedder_config -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME -from app.core.memory.evaluation.common.metrics import f1_score as common_f1, jaccard, latency_stats, avg_context_tokens -from app.core.memory.evaluation.common.metrics import exact_match - - -def load_dataset_any(path: str) -> List[Dict[str, Any]]: - """健壮地加载数据集,支持三种格式: - 1. 标准 JSON 数组: [{...}, {...}] - 2. 单个 JSON 对象: {...} - 3. JSONL 格式(每行一个 JSON): {...}\n{...}\n{...} - """ - with open(path, "r", encoding="utf-8") as f: - content = f.read().strip() - - # 尝试标准 JSON 解析 - try: - data = json.loads(content) - if isinstance(data, list): - return [item for item in data if isinstance(item, dict)] - elif isinstance(data, dict): - return [data] - except json.JSONDecodeError: - pass - - # 尝试 JSONL 格式(每行一个 JSON 对象) - items = [] - for line in content.splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - if isinstance(obj, dict): - items.append(obj) - elif isinstance(obj, list): - items.extend(item for item in obj if isinstance(item, dict)) - except json.JSONDecodeError: - continue - - return items - - -def is_chinese_text(s: str) -> bool: - return bool(re.search(r"[\u4e00-\u9fff]", s or "")) - - -def build_context_from_sessions(item: Dict[str, Any]) -> List[str]: - """从数据项的 haystack_sessions 构建上下文片段。 - - 优先返回包含 has_answer 的消息 - - 其次返回拼接后的整段会话 - """ - contexts: List[str] = [] - sessions = item.get("haystack_sessions", []) or item.get("sessions", []) - for session in sessions: - parts: List[str] = [] - if isinstance(session, list): - for msg in session: - role = msg.get("role", "") - content = msg.get("content", "") or msg.get("text", "") - if content: - parts.append(f"{role}: {content}" if role else str(content)) - if msg.get("has_answer", False): - contexts.append(f"{role}: {content}" if role else str(content)) - elif isinstance(session, dict): - role = session.get("role", "") - content = session.get("content", "") or session.get("text", "") - if content: - parts.append(f"{role}: {content}" if role else str(content)) - if session.get("has_answer", False): - contexts.append(f"{role}: {content}" if role else str(content)) - if parts: - contexts.append("\n".join(parts)) - # 兜底:存在单字段上下文 - if not contexts: - single_ctx = item.get("context") or item.get("dialogue") or item.get("conversation") - if isinstance(single_ctx, str) and single_ctx.strip(): - contexts.append(single_ctx.strip()) - return contexts - - -def extract_candidate_options(question: str) -> List[str]: - """从问题中提取候选选项(A-or-B 类问题)。""" - q = (question or "").strip() - options: List[str] = [] - - # 1) 引号包裹的片段 - for pat in [r"'([^']+)'", r'\"([^\"]+)\"', r'“([^”]+)”', r'‘([^’]+)’']: - for m in re.findall(pat, q): - val = (m or "").strip() - if val: - options.append(val) - - # 2) or/还是/或者 连接词 - if len(options) < 2: - pats = [ - r"([^,;,;]+?)\s+or\s+([^,;,;\?\.!.。!]+)", - r"([^,;,;]+?)\s+还是\s+([^,;,;\?\.!.。!]+)", - r"([^,;,;]+?)\s+或者\s+([^,;,;\?\.!.。!]+)", - ] - for pat in pats: - matches = list(re.finditer(pat, q, flags=re.IGNORECASE)) - if matches: - m = matches[-1] - cand1 = m.group(1).strip().strip("??.,,;; ") - cand2 = m.group(2).strip().strip("??.,,;; ") - options.extend([cand1, cand2]) - break - - # 去重 - seen = set() - uniq: List[str] = [] - for o in options: - o2 = o.strip() - key = o2.lower() if not is_chinese_text(o2) else o2 - if o2 and key not in seen: - uniq.append(o2) - seen.add(key) - return uniq - - -def extract_time_entities(text: str) -> List[Dict[str, Any]]: - """增强时间实体提取,专门用于时间推理问题""" - time_entities = [] - - # 日期模式 - date_patterns = [ - (r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b', 'date'), # YYYY-MM-DD - (r'\b(\d{1,2})月(\d{1,2})日\b', 'date'), # 中文日期 - (r'\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})?', 'date'), # 英文月份 - (r'\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})?', 'date'), # 英文月份缩写 - ] - - # 时间间隔模式 - duration_patterns = [ - (r'(\d+)\s*天', 'days'), - (r'(\d+)\s*周', 'weeks'), - (r'(\d+)\s*个月', 'months'), - (r'(\d+)\s*年', 'years'), - (r'(\d+)\s*days?', 'days'), - (r'(\d+)\s*weeks?', 'weeks'), - (r'(\d+)\s*months?', 'months'), - (r'(\d+)\s*years?', 'years'), - ] - - # 事件时间关系模式 - temporal_relation_patterns = [ - (r'(之前|以前|前)\s*(\d+)\s*天', 'days_before'), - (r'(之后|以后|后)\s*(\d+)\s*天', 'days_after'), - (r'(\d+)\s*天\s*(之前|以前|前)', 'days_before'), - (r'(\d+)\s*天\s*(之后|以后|后)', 'days_after'), - (r'(\d+)\s*days?\s*(before|ago)', 'days_before'), - (r'(\d+)\s*days?\s*(after|later)', 'days_after'), - ] - - # 提取日期 - for pattern, entity_type in date_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'start': match.start(), - 'end': match.end() - }) - - # 提取时间间隔 - for pattern, entity_type in duration_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'value': int(match.group(1)), - 'start': match.start(), - 'end': match.end() - }) - - # 提取时间关系 - for pattern, entity_type in temporal_relation_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'value': int(match.group(2)) if match.groups() >= 2 else int(match.group(1)), - 'start': match.start(), - 'end': match.end() - }) - - return time_entities - - -def calculate_time_difference(date1: str, date2: str) -> int: - """计算两个日期之间的天数差""" - try: - # 解析日期格式 - def parse_date(date_str: str) -> datetime: - # 尝试多种日期格式 - formats = [ - '%Y-%m-%d', - '%m月%d日', - '%B %d, %Y', - '%b %d, %Y', - '%Y年%m月%d日' - ] - - for fmt in formats: - try: - return datetime.strptime(date_str, fmt) - except ValueError: - continue - - # 如果都无法解析,返回当前日期 - return datetime.now() - - d1 = parse_date(date1) - d2 = parse_date(date2) - - # 计算天数差(绝对值) - return abs((d2 - d1).days) - except Exception: - return -1 # 表示计算失败 - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: - """增强版上下文选择:特别优化时间推理问题的处理""" - if not contexts: - return "" - - # 检测是否为时间推理问题 - is_temporal_question = any(keyword in question.lower() for keyword in - ['days', 'day', 'before', 'after', 'first', '先后', '顺序', '间隔', '多久', '多少天']) - - # 提取时间实体从问题中 - question_time_entities = extract_time_entities(question) - - # 英文关键词(去停用词) - question_lower = question.lower() - stop_words = { - 'what','when','where','who','why','how','did','do','does','is','are','was','were', - 'the','a','an','and','or','but','many','which','first' - } - eng_words = [w for w in set(re.findall(r'\b\w+\b', question_lower)) - if w not in stop_words and len(w) > 2] - - # 中文片段与候选选项 - cn_tokens = generate_query_keywords_cn(question) - options = extract_candidate_options(question) - - # 时间推理问题的特殊处理 - if is_temporal_question: - # 为时间问题添加时间相关关键词 - time_keywords = ['天', '日', '月', '年', 'before', 'after', 'days', 'first', '先后'] - eng_words = [w for w in eng_words if w not in ['days', 'first']] # 避免重复 - cn_tokens.extend([kw for kw in time_keywords if kw not in cn_tokens]) - - # 限制关键词数量,优先时间相关 - tokens = time_keywords[:2] + cn_tokens[:2] + eng_words[:1] + options[:1] - else: - # 常规问题处理 - tokens = cn_tokens[:3] + options[:2] + eng_words[:1] - - # 去重 - seen = set() - final_tokens: List[str] = [] - for t in tokens: - t2 = t.strip() - if t2 and t2 not in seen: - final_tokens.append(t2) - seen.add(t2) - - scored_contexts: List[tuple[float, str]] = [] - - # 时间推理问题的权重映射 - temporal_weight_map = { - "天": 2.0, "日": 2.0, "月": 1.8, "年": 1.8, "days": 2.0, - "before": 1.5, "after": 1.5, "first": 1.5, "先后": 1.5 - } - - # 常规问题的权重映射 - normal_weight_map = { - "问题": 2.0, "故障": 2.0, "异常": 1.8, "不正常": 1.8, "坏了": 1.8, - "系统": 1.3, "GPS": 1.5, "保养": 1.4, "设备": 1.2, "模块": 1.2, "功能": 1.1 - } - - weight_map = temporal_weight_map if is_temporal_question else normal_weight_map - - for i, context in enumerate(contexts): - context_str = str(context) - lines = re.split(r'[\r\n]+', context_str) - hit_lines: List[str] = [] - kw_hits: float = 0.0 - time_entity_count = 0 - - for line in lines: - ln = line.strip() - if not ln: - continue - - has_keyword = False - # 关键词匹配 - for tok in final_tokens: - if tok and tok in ln: - w = weight_map.get(tok, 1.0) - kw_hits += ln.count(tok) * w - has_keyword = True - - # 时间实体检测(特别针对时间推理问题) - if is_temporal_question: - time_entities = extract_time_entities(ln) - time_entity_count += len(time_entities) - if time_entities: - has_keyword = True - - if has_keyword: - # 对于时间推理问题,保留包含时间信息的完整行 - hit_lines.append(ln) - - snippet = "\n".join(hit_lines) if hit_lines else context_str.strip() - - # 限制单段长度,但对时间推理问题稍微放宽限制 - max_snippet_len = 600 if is_temporal_question else 500 - if len(snippet) > max_snippet_len: - snippet = snippet[:max_snippet_len] - - # 评分逻辑 - has_number = 1 if re.search(r'\d', snippet) else 0 - has_date = 1 if (re.search(r'\b\d{4}-\d{1,2}-\d{1,2}\b', snippet) or - re.search(r'\d{1,2}月\d{1,2}日', snippet)) else 0 - - # 时间推理问题的特殊评分 - if is_temporal_question: - time_bonus = time_entity_count * 2.0 # 时间实体奖励 - temporal_coherence = 3 if (has_date and time_entity_count >= 2) else 0 - else: - time_bonus = 0 - temporal_coherence = 0 - - length_bonus = 5 if 50 < len(snippet) < 1000 else (2 if len(snippet) >= 1000 else 0) - pos_bonus = 3 if i < 3 else 0 - - score = (kw_hits * 0.8 + (has_number + has_date) * 1.5 + - length_bonus + pos_bonus + time_bonus + temporal_coherence) - - scored_contexts.append((score, snippet)) - - # 选择累计至总字符预算 - scored_contexts.sort(key=lambda x: x[0], reverse=True) - selected: List[str] = [] - total_chars = 0 - - for score, snippet in scored_contexts: - if total_chars + len(snippet) <= max_chars: - selected.append(snippet) - total_chars += len(snippet) - else: - if not selected and len(snippet) > max_chars: - selected.append(snippet[:max_chars]) - break - - final_context = "\n\n".join(selected) - - # 对于时间推理问题,添加时间计算提示 - if is_temporal_question and question_time_entities: - time_prompt = "\n\n[时间推理提示:请仔细分析上述上下文中的日期和时间关系,计算时间间隔或确定事件顺序]" - if total_chars + len(time_prompt) <= max_chars: - final_context += time_prompt - - return final_context - - -# 中文关键词提取(短语级,含数词/日期/常见领域词) -def _extract_cn_tokens(text: str) -> List[str]: - if not text: - return [] - t = str(text) - # 去掉常见功能词(粗略,不依赖分词库) - stop_words = [ - "我","我们","你","他","她","它","这","那","哪","一个","一次","一些","什么","怎么","是否","吗","呢", - "很","更","最","已经","正在","将要","马上","尽快","最近","关于","有关","以及","并且","或者","还是", - "因为","所以","如果","但是","而且","然后","之后","之前","同时","另外","并","但","却","被","把","让","给", - "和","与","跟","及","还有","就","都","在","对","对于","的","了","着","过","到","于","从","以","为","向","至","是" - ] - for sw in stop_words: - t = t.replace(sw, " ") - # 去标点 - t = re.sub(r"[,。!?、;:,.!?;:\"'()()[]\[\]\-—…·]", " ", t) - # 基础中文片段(>=2) - base = re.findall(r"[\u4e00-\u9fff]{2,}", t) - # 特殊组合:第X次XXXX - specials = re.findall(r"第[一二三四五六七八九十]+次[\u4e00-\u9fff]{2,6}", text) - # 领域词(简单词典) - # 日期与数字 - dates = re.findall(r"\d{4}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}", text) - numbers = re.findall(r"\b\d+\b", text) - - tokens: List[str] = specials + base + dates + numbers - - generic = {"建议","推荐","帮助","提升","技能","有效","团队","参与度","喜欢","开始"} - tokens: List[str] = specials + base + dates + numbers - uniq: List[str] = [] - seen = set() - for tok in tokens: - tok2 = tok.strip() - if len(tok2) < 2 or len(tok2) > 6: - continue - if tok2 in generic: - continue - if tok2 not in seen: - uniq.append(tok2) - seen.add(tok2) - # 排除常见疑问型短语 - blacklist_exact = {"是什么","多少","多少天","哪个","哪些","之间","先","后","之前","之后"} - uniq2: List[str] = [u for u in uniq if u not in blacklist_exact] - return uniq2[:12] - - -# 面向检索的中文关键词生成:强调"短语、核心名词、问题/故障" -def generate_query_keywords_cn(question: str) -> List[str]: - if not question: - return [] - raw = _extract_cn_tokens(question) - core: List[str] = [] - seen = set() - - def push(x: str): - x2 = x.strip() - if not x2: - return - if 2 <= len(x2) <= 6 and x2 not in seen: - core.append(x2) - seen.add(x2) - - # 检测时间推理问题 - is_temporal = any(keyword in question for keyword in ['天', '日', 'before', 'after', 'first', '先后', '间隔']) - if is_temporal: - push("天") - push("日") - push("先后") - - # 明确优先的核心词 - if "新车" in question: - push("新车") - # 第X次保养/维修 - specials = re.findall(r"第[一二三四五六七八九十]+次[\u4e00-\u9fff]{2,6}", question) - for s in specials: - if "保养" in s or "维修" in s: - push(s) - if "保养" in question: - push("保养") - # 问题/故障类词,如题含"问题"则扩展同义词 - if "问题" in question: - for w in ["问题","故障","异常","不正常"]: - push(w) - - # 补充:从原始片段筛更短的名词短语(过滤疑问型词) - blacklist = {"是什么","多少","哪个","还是","或者","之间","先","后","之前","之后"} - for tok in raw: - if tok in blacklist: - continue - push(tok) - - # 限制数量,避免过长列表影响检索稳定性 - return core[:4] # 稍微增加限制 - - -# 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: - results: List[Dict[str, Any]] = [] - try: - for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) - if rows: - results.extend(rows) - except Exception: - pass - - # 按 name 去重 - deduped: List[Dict[str, Any]] = [] - seen = set() - for r in results: - k = str(r.get("name", "")) - if k and k not in seen: - deduped.append(r) - seen.add(k) - return deduped - - -# 通过对话/陈述中的entity_ids反查实体名称 -_FETCH_ENTITIES_BY_IDS = """ -MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) -RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type -""" - -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: - if not ids: - return [] - try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) - return rows or [] - except Exception: - return [] - - -# 增强的时间实体检索 -_TIME_ENTITY_SEARCH = """ -MATCH (e:ExtractedEntity) -WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) -RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type -LIMIT $limit -""" - -async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: - """专门搜索时间相关的实体""" - try: - date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" - rows = await connector.execute_query(_TIME_ENTITY_SEARCH, - date_pattern=date_pattern, - end_user_id=end_user_id, - limit=limit) - return rows or [] - except Exception: - return [] - - -# 中英相对时间解析:today/昨天/上周/3天后 等简单归一化为日期 -def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: - t = str(text) if text is not None else "" - # 英文 today/yesterday/tomorrow - t = re.sub(r"\btoday\b", anchor.date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\byesterday\b", (anchor - timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\btomorrow\b", (anchor + timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - - # 英文 X days ago / in X days - def _ago_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor - timedelta(days=n)).date().isoformat() - def _in_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor + timedelta(days=n)).date().isoformat() - t = re.sub(r"\b(\d+)\s+days\s+ago\b", _ago_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\bin\s+(\d+)\s+days\b", _in_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\blast\s+week\b", (anchor - timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\bnext\s+week\b", (anchor + timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - - # 中文 今天/昨天/明天 - t = re.sub(r"今天", anchor.date().isoformat(), t) - t = re.sub(r"昨日|昨天", (anchor - timedelta(days=1)).date().isoformat(), t) - t = re.sub(r"明天", (anchor + timedelta(days=1)).date().isoformat(), t) - # 中文 X天前 / X天后 - t = re.sub(r"(\d+)天前", lambda m: (anchor - timedelta(days=int(m.group(1)))).date().isoformat(), t) - t = re.sub(r"(\d+)天后", lambda m: (anchor + timedelta(days=int(m.group(1)))).date().isoformat(), t) - # 中文 上周 / 下周(近似7天) - t = re.sub(r"上周", (anchor - timedelta(days=7)).date().isoformat(), t) - t = re.sub(r"下周", (anchor + timedelta(days=7)).date().isoformat(), t) - # 中文 月日(无年份)补全年份 - def _md_repl(m: re.Match[str]) -> str: - mon = int(m.group(1)); day = int(m.group(2)) - return f"{anchor.year}-{mon:02d}-{day:02d}" - t = re.sub(r"(\d{1,2})月(\d{1,2})日", _md_repl, t) - return t - - -async def run_longmemeval_test( - sample_size: int = 3, - end_user_id: str | None = None, - search_limit: int = 8, - context_char_budget: int = 4000, - llm_temperature: float = 0.0, - llm_max_tokens: int = 16, - search_type: str = "hybrid", - data_path: str | None = None, - start_index: int = 0, - max_contexts_per_item: int = 2, - save_chunk_output: bool = True, - save_chunk_output_path: str | None = None, - reset_group_before_ingest: bool = False, - skip_ingest: bool = False, -) -> Dict[str, Any]: - """LongMemEval 评估测试:增强时间推理能力""" - - # Use environment variable with fallback chain - if end_user_id is None: - end_user_id = os.getenv("LONGMEMEVAL_END_USER_ID") or os.getenv("EVAL_END_USER_ID", "longmemeval_zh_bak_3") - - # 数据路径 - if not data_path: - # 固定使用中文数据集:dataset/longmemeval_oracle_zh.json - dataset_dir = Path(__file__).resolve().parent.parent / "dataset" - data_path = str(dataset_dir / "longmemeval_oracle_zh.json") - - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 longmemeval_oracle_zh.json 放置在: {dataset_dir}" - ) - - qa_list: List[Dict[str, Any]] = load_dataset_any(data_path) - # 支持评估全部样本:当 sample_size <= 0 时,取从 start_index 到末尾 - if sample_size is None or sample_size <= 0: - items = qa_list[start_index:] - else: - items = qa_list[start_index:start_index + sample_size] - - # 可选:摄入上下文(默认启用) - if not skip_ingest: - # 选择上下文并限量 - contexts: List[str] = [] - for it in items: - built = build_context_from_sessions(it) - full_transcripts = [c for c in built if "\n" in c] - evidence_msgs = [c for c in built if "\n" not in c] - selected: List[str] = [] - take_e = min(len(evidence_msgs), max_contexts_per_item) - selected.extend(evidence_msgs[:take_e]) - remain = max_contexts_per_item - len(selected) - if remain > 0 and full_transcripts: - selected.extend(full_transcripts[:remain]) - if not selected and built: - selected.append(built[0]) - contexts.extend(selected) - - print(f"📥 摄入 {len(contexts)} 个上下文到数据库") - if reset_group_before_ingest and end_user_id: - try: - _tmp_conn = Neo4jConnector() - await _tmp_conn.delete_group(end_user_id) - print(f"🧹 已清空组 {end_user_id} 的历史图数据") - except Exception as _e: - print(f"⚠️ 清空组数据失败(忽略继续): {end_user_id} - {_e}") - finally: - try: - await _tmp_conn.close() - except Exception: - pass - _ingest_fn = ingest_contexts_via_full_pipeline - if _ingest_fn is None: - print("⚠️ 摄入函数不可用,已跳过摄入。请确认 PYTHONPATH 包含 'src' 或从项目根运行。") - else: - await _ingest_fn( - contexts, - end_user_id, - save_chunk_output=save_chunk_output, - save_chunk_output_path=save_chunk_output_path, - ) - - # 初始化组件(摄入后再初始化连接器)- 使用异步LLM客户端 - from app.db import get_db - - db = next(get_db()) - try: - llm_client = get_llm_client(os.getenv("EVAL_LLM_ID"), db) - cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID"), db) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - finally: - db.close() - - connector = Neo4jConnector() - - # 指标收集 - latencies_llm: List[float] = [] - latencies_search: List[float] = [] - per_query_context_counts: List[int] = [] - per_query_context_avg_tokens: List[float] = [] - per_query_context_chars: List[int] = [] - - type_correct: Dict[str, List[float]] = {} - type_f1: Dict[str, List[float]] = {} - type_jacc: Dict[str, List[float]] = {} - - samples: List[Dict[str, Any]] = [] - # 统计重复的上下文预览(跨样本),便于诊断"相同上下文"问题 - preview_counter: Dict[str, int] = {} - - try: - for item in items: - question = item.get("question", "") - reference = item.get("answer", "") - qtype = item.get("question_type") or item.get("type", "unknown") - - print(f"\n=== 处理问题: {question} ===") - - # 检测问题类型 - is_temporal = any(keyword in question.lower() for keyword in - ['days', 'day', 'before', 'after', 'first', '先后', '顺序', '间隔', '多久', '多少天']) - - # 检索 - t0 = time.time() - contexts_all: List[str] = [] - dialogs, statements, entities = [], [], [] - - try: - if search_type == "embedding": - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], - ) - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - - # 实体摘要(最多3个) - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - elif search_type == "keyword": - search_results = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit, - ) - chunks = search_results.get("chunks", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - summaries = search_results.get("summaries", []) - - for c in chunks: - content = str(c.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - for sm in summaries: - summary_text = str(sm.get("summary", "")).strip() - if summary_text: - contexts_all.append(summary_text) - if entities: - entity_names = [str(e.get("name", "")).strip() for e in entities[:5] if e.get("name")] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - else: # hybrid(增强版:特别优化时间推理问题) - emb_chunks, emb_statements, emb_entities, emb_summaries, emb_dialogs = [], [], [], [], [] - kw_dialogs, kw_statements, kw_entities = [], [], [] - - # 1) 嵌入检索 - try: - emb_res = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], - ) - if isinstance(emb_res, dict): - emb_chunks = emb_res.get("chunks", []) or [] - emb_statements = emb_res.get("statements", []) or [] - emb_entities = emb_res.get("entities", []) or [] - emb_summaries = emb_res.get("summaries", []) or [] - emb_dialogs = emb_res.get("dialogues", []) or [] - except Exception as e: - print(f"⚠️ 嵌入检索失败,将继续进行关键词检索: {e}") - - # 2) 关键词检索(增强版) - try: - kw_res = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit, - ) - if isinstance(kw_res, dict): - kw_dialogs = kw_res.get("dialogues", []) or [] - kw_statements = kw_res.get("statements", []) or [] - kw_entities = kw_res.get("entities", []) or [] - - # 时间推理问题的特殊处理 - if is_temporal: - # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) - if time_entities: - kw_entities.extend(time_entities) - # 添加时间相关关键词检索 - time_keywords = ['天', '日', '月', '年', 'before', 'after', 'first'] - for tk in time_keywords: - try: - time_res = await search_graph( - connector=connector, - q=tk, - end_user_id=end_user_id, - limit=2, - ) - if isinstance(time_res, dict): - kw_dialogs.extend(time_res.get("dialogues", []) or []) - kw_statements.extend(time_res.get("statements", []) or []) - except Exception: - pass - - # 中文关键词拆分后做别名匹配 - cn_tokens = _extract_cn_tokens(question) - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) - if alias_entities: - kw_entities.extend(alias_entities) - - # 从对话/陈述中的 entity_ids 反查实体 - ids = [] - try: - for d in kw_dialogs: - ids.extend(d.get("entity_ids", []) or []) - for s in kw_statements: - ids.extend(s.get("entity_ids", []) or []) - except Exception: - pass - if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) - if id_entities: - kw_entities.extend(id_entities) - - # 多关键词检索 - try: - eng_words = [w for w in set(re.findall(r"\b\w+\b", question.lower())) if len(w) > 2] - kw_list = generate_query_keywords_cn(question)[:3] + eng_words[:1] - for kw in kw_list: - if not kw: - continue - sub_res = await search_graph( - connector=connector, - q=str(kw), - end_user_id=end_user_id, - limit=max(3, search_limit // 2), - ) - if isinstance(sub_res, dict): - kw_dialogs.extend(sub_res.get("dialogues", []) or []) - kw_statements.extend(sub_res.get("statements", []) or []) - kw_entities.extend(sub_res.get("entities", []) or []) - except Exception: - pass - - # 选项参与关键词检索 - try: - opt_list = extract_candidate_options(question)[:2] - for opt in opt_list: - if not opt: - continue - opt_res = await search_graph( - connector=connector, - q=str(opt), - end_user_id=end_user_id, - limit=max(3, search_limit // 2), - ) - if isinstance(opt_res, dict): - kw_dialogs.extend(opt_res.get("dialogues", []) or []) - kw_statements.extend(opt_res.get("statements", []) or []) - kw_entities.extend(opt_res.get("entities", []) or []) - except Exception: - pass - except Exception as e: - print(f"❌ 关键词检索失败: {e}") - - # 3) 合并、排序并去重 - all_dialogs = emb_dialogs + kw_dialogs - all_statements = emb_statements + kw_statements - all_entities = emb_entities + kw_entities - - def dedup(items: List[Dict[str, Any]], key_field: str = "uuid") -> List[Dict[str, Any]]: - seen = set() - out = [] - for it in items: - key = str(it.get(key_field, "")) + str(it.get("content", "") + str(it.get("statement", ""))) - if key not in seen: - out.append(it) - seen.add(key) - return out - - # 时间推理问题优先排序包含时间信息的文档 - if is_temporal: - def temporal_score(item: Dict[str, Any]) -> float: - base_score = float(item.get("score", 0.0)) - content = str(item.get("content", "") + str(item.get("statement", ""))) - time_entities = extract_time_entities(content) - time_bonus = len(time_entities) * 0.5 - return base_score + time_bonus - - dialogs = dedup(sorted(all_dialogs, key=temporal_score, reverse=True)) - statements = dedup(sorted(all_statements, key=temporal_score, reverse=True)) - else: - dialogs = dedup(sorted(all_dialogs, key=lambda d: float(d.get("score", 0.0)), reverse=True)) - statements = dedup(sorted(all_statements, key=lambda s: float(s.get("score", 0.0)), reverse=True)) - - entities = dedup(all_entities, key_field="name") - - # 4) 构建上下文 - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - # 实体摘要 - try: - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - except Exception: - pass - - # 全局回退 - if not contexts_all and search_type in ("embedding", "hybrid"): - try: - print("🔁 检索为空,回退到关键词检索...") - kw_fallback = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=max(search_limit, 5), - ) - fb_dialogs = kw_fallback.get("dialogues", []) or [] - fb_statements = kw_fallback.get("statements", []) or [] - fb_entities = kw_fallback.get("entities", []) or [] - - for d in fb_dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in fb_statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - if fb_entities: - entity_names = [str(e.get("name", "")).strip() for e in fb_entities[:5] if e.get("name")] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - dialogs = fb_dialogs if fb_dialogs else dialogs - statements = fb_statements if fb_statements else statements - entities = fb_entities if fb_entities else entities - print(f"↩️ 回退到关键词检索: {len(fb_dialogs)} 对话, {len(fb_statements)} 条陈述, {len(fb_entities)} 个实体") - except Exception as fe: - print(f"❌ 关键词回退失败: {fe}") - - ent_count = len(entities) if isinstance(entities, list) else 0 - print(f"✅ {search_type}检索成功: {len(dialogs)} 对话, {len(statements)} 条陈述, {ent_count} 个实体") - if is_temporal: - print("⏰ 检测为时间推理问题,已启用时间优化检索") - - except Exception as e: - print(f"❌ {search_type}检索失败: {e}") - contexts_all = [] - - t1 = time.time() - latencies_search.append((t1 - t0) * 1000) - - # 智能上下文选择 - context_text = "" - if contexts_all: - context_text = smart_context_selection(contexts_all, question, max_chars=context_char_budget) - # 相对时间解析 - try: - context_text = _resolve_relative_times_cn_en(context_text, anchor=datetime.now()) - except Exception: - pass - # 诊断信息 - try: - cn_diag = generate_query_keywords_cn(question)[:3] - opts = extract_candidate_options(question)[:2] - qlw = [w for w in set(re.findall(r'\b\w+\b', question.lower())) if len(w) > 2][:1] - diag_tokens: List[str] = [] - for t in cn_diag + opts + qlw: - if t and t not in diag_tokens: - diag_tokens.append(t) - print(f"🔍 关键词/选项: {', '.join(diag_tokens)}") - preview = context_text[:200].replace('\n', ' ') - print(f"🔎 上下文预览: {preview}...") - key_preview = preview.strip() - if key_preview: - preview_counter[key_preview] = preview_counter.get(key_preview, 0) + 1 - except Exception: - pass - else: - print("❌ 没有检索到有效上下文") - context_text = "No relevant context found." - - # 记录上下文诊断信息 - per_query_context_counts.append(len(contexts_all)) - per_query_context_avg_tokens.append(avg_context_tokens([context_text])) - per_query_context_chars.append(len(context_text)) - - # LLM 推理(增强时间推理提示) - options = extract_candidate_options(question) - if len(options) >= 2: - opt_lines = "\n".join(f"- {o}" for o in options) - # 时间推理问题的特殊提示 - if is_temporal: - system_prompt = ( - "You are a QA assistant specializing in temporal reasoning. Analyze the dates and time relationships in the context carefully. " - "Return ONLY one string: exactly one option from the provided candidates. If the context is insufficient, respond with 'Unknown'. " - "Pay special attention to date sequences and time intervals." - ) - else: - system_prompt = ( - "You are a QA assistant. Respond in the same language as the question. Return ONLY one string: exactly one option from the provided candidates. " - "If the context is insufficient, respond with 'Unknown'. If the context expresses a synonym or paraphrase of a candidate, return the closest candidate. " - "Do not include explanations." - ) - - messages = [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": ( - f"Question: {question}\n\nCandidates:\n{opt_lines}\n\nContext:\n{context_text}\n\nReturn EXACTLY one candidate string (or 'Unknown')." - ), - }, - ] - else: - # 时间推理问题的特殊提示 - if is_temporal: - system_prompt = ( - "You are a QA assistant specializing in temporal reasoning. Analyze the dates and time relationships in the context carefully. " - "If the context contains the answer, return a concise answer phrase focusing on temporal information. " - "If the answer cannot be determined from the context, respond with 'Unknown'. Return ONLY the final answer string, no explanations." - ) - else: - system_prompt = ( - "You are a QA assistant. Respond in the same language as the question. If the context contains the answer, return a concise answer phrase. " - "If the answer cannot be determined from the context, respond with 'Unknown'. Return ONLY the final answer string, no explanations." - ) - - messages = [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": f"Question: {question}\n\nContext:\n{context_text}\n\nReturn ONLY the answer (or 'Unknown').", - }, - ] - - t2 = time.time() - # 使用异步调用 - resp = await llm_client.chat(messages=messages) - t3 = time.time() - latencies_llm.append((t3 - t2) * 1000) - - # 兼容不同的响应格式 - pred_raw = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else "Unknown") - - # 选项题输出规范化 - pred = pred_raw - if len(options) >= 2 and not pred_raw.lower().startswith("unknown"): - def _basic_norm(s: str) -> str: - s = s.lower().strip() - return re.sub(r"[^\w\s]", " ", s) - def _jaccard(a: str, b: str) -> float: - ta = set(t for t in _basic_norm(a).split() if t) - tb = set(t for t in _basic_norm(b).split() if t) - if not ta and not tb: - return 1.0 - if not ta or not tb: - return 0.0 - return len(ta & tb) / len(ta | tb) - best = None - best_score = -1.0 - for o in options: - score = _jaccard(pred_raw, o) - if score > best_score: - best = o - best_score = score - if best is not None and best_score > 0.0: - pred = best - - # 指标 - flag = exact_match(pred, reference) - f1_val = common_f1(str(pred), str(reference)) - j_val = jaccard(str(pred), str(reference)) - - type_correct.setdefault(qtype, []).append(flag) - type_f1.setdefault(qtype, []).append(f1_val) - type_jacc.setdefault(qtype, []).append(j_val) - - samples.append({ - "question": question, - "prediction": pred, - "answer": reference, - "question_type": qtype, - "is_temporal": is_temporal, - "question_id": item.get("question_id"), - "options": options, - "context_count": len(contexts_all), - "context_chars": len(context_text), - "retrieved_dialogue_count": len(dialogs), - "retrieved_statement_count": len(statements), - "metrics": { - "exact_match": bool(flag), - "f1": f1_val, - "jaccard": j_val - }, - "timing": { - "search_ms": (t1 - t0) * 1000, - "llm_ms": (t3 - t2) * 1000 - } - }) - - print(f"🤖 LLM 回答: {pred}") - print(f"✅ 正确答案: {reference}") - print(f"📈 当前指标 - Exact Match: {flag}, F1: {f1_val:.3f}, Jaccard: {j_val:.3f}") - - # 聚合结果 - type_acc = {t: (sum(v) / max(len(v), 1)) for t, v in type_correct.items()} - f1_by_type = {t: (sum(v) / max(len(v), 1)) for t, v in type_f1.items()} - jacc_by_type = {t: (sum(v) / max(len(v), 1)) for t, v in type_jacc.items()} - - result = { - "dataset": "longmemeval", - "items": len(items), - "accuracy_by_type": type_acc, - "f1_by_type": f1_by_type, - "jaccard_by_type": jacc_by_type, - "samples": samples, - "latency": { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm), - }, - "context": { - "avg_tokens": statistics.mean(per_query_context_avg_tokens) if per_query_context_avg_tokens else 0.0, - "avg_chars": statistics.mean(per_query_context_chars) if per_query_context_chars else 0.0, - "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, - }, - "params": { - "end_user_id": end_user_id, - "search_limit": search_limit, - "context_char_budget": context_char_budget, - "search_type": search_type, - "llm_id": os.getenv("EVAL_LLM_ID"), - "embedding_id": os.getenv("EVAL_EMBEDDING_ID"), - "sample_size": sample_size, - "start_index": start_index, - }, - "timestamp": datetime.now().isoformat() - } - - # 计算汇总指标 - try: - total_items = max(len(samples), 1) - correct_count = sum(1 for s in samples if s.get("metrics", {}).get("exact_match")) - score_accuracy = (correct_count / total_items) * 100.0 - - total_latencies_ms = [] - for s in samples: - t = s.get("timing", {}) - total_latencies_ms.append(float(t.get("search_ms", 0.0)) + float(t.get("llm_ms", 0.0))) - total_lat_stats = latency_stats(total_latencies_ms) if total_latencies_ms else {"p50": 0.0, "iqr": 0.0} - latency_median_s = total_lat_stats.get("p50", 0.0) / 1000.0 - latency_iqr_s = total_lat_stats.get("iqr", 0.0) / 1000.0 - - avg_ctx_tokens = statistics.mean(per_query_context_avg_tokens) if per_query_context_avg_tokens else 0.0 - avg_ctx_tokens_k = avg_ctx_tokens / 1000.0 - - result["metric_summary"] = { - "score_accuracy": score_accuracy, - "latency_median_s": latency_median_s, - "latency_iqr_s": latency_iqr_s, - "avg_context_tokens_k": avg_ctx_tokens_k, - } - except Exception: - result["metric_summary"] = { - "score_accuracy": 0.0, - "latency_median_s": 0.0, - "latency_iqr_s": 0.0, - "avg_context_tokens_k": 0.0, - } - - # 诊断信息 - try: - dups = sorted([(k, c) for k, c in preview_counter.items() if c > 1], key=lambda x: -x[1])[:5] - result["diagnostics"] = { - "duplicate_previews_top": [{"count": c, "preview": k[:120]} for k, c in dups], - "unique_preview_count": len(preview_counter), - } - except Exception: - pass - - return result - - finally: - await connector.close() - -def main(): - load_dotenv() - parser = argparse.ArgumentParser(description="LongMemEval 评估测试脚本(增强时间推理版)") - parser.add_argument("--sample-size", type=int, default=3, help="样本数量(<=0 表示全部)") - parser.add_argument("--all", action="store_true", help="评估全部样本(覆盖 --sample-size)") - parser.add_argument("--start-index", type=int, default=0, help="起始样本索引") - parser.add_argument("--end-user-id", type=str, default=None, help="图数据库 End User ID,默认使用环境变量") - parser.add_argument("--search-limit", type=int, default=8, help="检索条数上限") - parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") - parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") - parser.add_argument("--llm-max-tokens", type=int, default=16, help="LLM 最大输出 token") - parser.add_argument("--search-type", type=str, default="hybrid", choices=["embedding","keyword","hybrid"], help="检索类型") - parser.add_argument("--data-path", type=str, default=None, help="数据集路径") - parser.add_argument("--max-contexts-per-item", type=int, default=2, help="每条样本最多摄入的上下文段数") - parser.add_argument("--no-save-chunk-output", action="store_true", help="不保存分块结果(默认保存)") - parser.add_argument("--save-chunk-output-path", type=str, default=None, help="自定义分块输出路径") - parser.add_argument("--reset-group-before-ingest", action="store_true", help="摄入前清空该 Group 在图数据库中的历史数据") - parser.add_argument("--skip-ingest", action="store_true", help="跳过摄入,仅检索评估") - args = parser.parse_args() - - sample_size = 0 if args.all else args.sample_size - - result = asyncio.run( - run_longmemeval_test( - sample_size=sample_size, - end_user_id=args.end_user_id, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - llm_temperature=args.llm_temperature, - llm_max_tokens=args.llm_max_tokens, - search_type=args.search_type, - data_path=args.data_path, - start_index=args.start_index, - max_contexts_per_item=args.max_contexts_per_item, - save_chunk_output=(not args.no_save_chunk_output), - save_chunk_output_path=args.save_chunk_output_path, - reset_group_before_ingest=args.reset_group_before_ingest, - skip_ingest=args.skip_ingest, - ) - ) - - # 打印结果 - print("\n" + "="*50) - print("📊 LongMemEval 测试结果:") - print(f" 样本数量: {result['items']}") - - if result['accuracy_by_type']: - print("\n📈 按问题类型细分:") - for qtype, acc in result['accuracy_by_type'].items(): - print(f" {qtype}:") - print(f" Score (Accuracy): {acc:.3f}") - - print(f"\n📊 指标总览:") - ms = result.get('metric_summary', {}) - print(f" Score (Accuracy): {ms.get('score_accuracy', 0.0):.1f}%") - print(f" Latency (s): median {ms.get('latency_median_s', 0.0):.3f}s") - print(f" Latency IQR (s): {ms.get('latency_iqr_s', 0.0):.3f}s") - print(f" Avg Context Tokens (k): {ms.get('avg_context_tokens_k', 0.0):.3f}k") - - print(f"\n⏱️ 细分性能指标:") - print(f" 检索延迟(均值): {result['latency']['search']['mean']:.1f}ms") - print(f" LLM延迟(均值): {result['latency']['llm']['mean']:.1f}ms") - print(f" 上下文长度(均值): {result['context']['avg_chars']:.0f} 字符") - - - # 保存结果到文件 - try: - # 使用相对路径而不是 PROJECT_ROOT - out_dir = Path(__file__).resolve().parent / "results" - os.makedirs(out_dir, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = os.path.join(out_dir, f"longmemeval_{result['params']['search_type']}_{ts}.json") - with open(out_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n💾 结果已保存: {out_path}") - except Exception as e: - print(f"⚠️ 结果保存失败: {e}") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/longmemeval/test_eval.py b/api/app/core/memory/evaluation/longmemeval/test_eval.py deleted file mode 100644 index 08daa890..00000000 --- a/api/app/core/memory/evaluation/longmemeval/test_eval.py +++ /dev/null @@ -1,1312 +0,0 @@ -import argparse -import asyncio -import json -import os -import time -import re -import statistics -from datetime import datetime, timedelta -from typing import List, Dict, Any -from pathlib import Path - -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -# 与现有评估脚本保持一致的导入方式 -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.repositories.neo4j.graph_search import search_graph, search_graph_by_embedding -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.models.base import RedBearModelConfig -from app.core.memory.utils.config.config_utils import get_embedder_config -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.dialogue_queries import SEARCH_ENTITIES_BY_NAME -from app.core.memory.evaluation.common.metrics import f1_score as common_f1, jaccard, latency_stats, avg_context_tokens -from app.core.memory.evaluation.common.metrics import exact_match - - -def load_dataset_any(path: str) -> List[Dict[str, Any]]: - """健壮地加载数据集,支持三种格式: - 1. 标准 JSON 数组: [{...}, {...}] - 2. 单个 JSON 对象: {...} - 3. JSONL 格式(每行一个 JSON): {...}\n{...}\n{...} - """ - with open(path, "r", encoding="utf-8") as f: - content = f.read().strip() - - # 尝试标准 JSON 解析 - try: - data = json.loads(content) - if isinstance(data, list): - return [item for item in data if isinstance(item, dict)] - elif isinstance(data, dict): - return [data] - except json.JSONDecodeError: - pass - - # 尝试 JSONL 格式(每行一个 JSON 对象) - items = [] - for line in content.splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - if isinstance(obj, dict): - items.append(obj) - elif isinstance(obj, list): - items.extend(item for item in obj if isinstance(item, dict)) - except json.JSONDecodeError: - continue - - return items - - -def is_chinese_text(s: str) -> bool: - return bool(re.search(r"[\u4e00-\u9fff]", s or "")) - - -def extract_candidate_options(question: str) -> List[str]: - """从问题中提取候选选项(A-or-B 类问题)。""" - q = (question or "").strip() - options: List[str] = [] - - # 1) 引号包裹的片段 - for pat in [r"'([^']+)'", r'\"([^\"]+)\"', r'“([^”]+)”', r'‘([^’]+)’']: - for m in re.findall(pat, q): - val = (m or "").strip() - if val: - options.append(val) - - # 2) or/还是/或者 连接词 - if len(options) < 2: - pats = [ - r"([^,;,;]+?)\s+or\s+([^,;,;\?\.!.。!]+)", - r"([^,;,;]+?)\s+还是\s+([^,;,;\?\.!.。!]+)", - r"([^,;,;]+?)\s+或者\s+([^,;,;\?\.!.。!]+)", - ] - for pat in pats: - matches = list(re.finditer(pat, q, flags=re.IGNORECASE)) - if matches: - m = matches[-1] - cand1 = m.group(1).strip().strip("??.,,;; ") - cand2 = m.group(2).strip().strip("??.,,;; ") - options.extend([cand1, cand2]) - break - - # 去重 - seen = set() - uniq: List[str] = [] - for o in options: - o2 = o.strip() - key = o2.lower() if not is_chinese_text(o2) else o2 - if o2 and key not in seen: - uniq.append(o2) - seen.add(key) - return uniq - - -def extract_time_entities(text: str) -> List[Dict[str, Any]]: - """增强时间实体提取,专门用于时间推理问题""" - time_entities = [] - - # 日期模式 - date_patterns = [ - (r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b', 'date'), # YYYY-MM-DD - (r'\b(\d{1,2})月(\d{1,2})日\b', 'date'), # 中文日期 - (r'\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})?', 'date'), # 英文月份 - (r'\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})?', 'date'), # 英文月份缩写 - ] - - # 时间间隔模式 - duration_patterns = [ - (r'(\d+)\s*天', 'days'), - (r'(\d+)\s*周', 'weeks'), - (r'(\d+)\s*个月', 'months'), - (r'(\d+)\s*年', 'years'), - (r'(\d+)\s*days?', 'days'), - (r'(\d+)\s*weeks?', 'weeks'), - (r'(\d+)\s*months?', 'months'), - (r'(\d+)\s*years?', 'years'), - ] - - # 事件时间关系模式 - temporal_relation_patterns = [ - (r'(之前|以前|前)\s*(\d+)\s*天', 'days_before'), - (r'(之后|以后|后)\s*(\d+)\s*天', 'days_after'), - (r'(\d+)\s*天\s*(之前|以前|前)', 'days_before'), - (r'(\d+)\s*天\s*(之后|以后|后)', 'days_after'), - (r'(\d+)\s*days?\s*(before|ago)', 'days_before'), - (r'(\d+)\s*days?\s*(after|later)', 'days_after'), - ] - - # 提取日期 - for pattern, entity_type in date_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'start': match.start(), - 'end': match.end() - }) - - # 提取时间间隔 - for pattern, entity_type in duration_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'value': int(match.group(1)), - 'start': match.start(), - 'end': match.end() - }) - - # 提取时间关系 - for pattern, entity_type in temporal_relation_patterns: - matches = re.finditer(pattern, text, re.IGNORECASE) - for match in matches: - time_entities.append({ - 'text': match.group(), - 'type': entity_type, - 'value': int(match.group(2)) if match.groups() >= 2 else int(match.group(1)), - 'start': match.start(), - 'end': match.end() - }) - - return time_entities - - -def calculate_time_difference(date1: str, date2: str) -> int: - """计算两个日期之间的天数差""" - try: - # 解析日期格式 - def parse_date(date_str: str) -> datetime: - # 尝试多种日期格式 - formats = [ - '%Y-%m-%d', - '%m月%d日', - '%B %d, %Y', - '%b %d, %Y', - '%Y年%m月%d日' - ] - - for fmt in formats: - try: - return datetime.strptime(date_str, fmt) - except ValueError: - continue - - # 如果都无法解析,返回当前日期 - return datetime.now() - - d1 = parse_date(date1) - d2 = parse_date(date2) - - # 计算天数差(绝对值) - return abs((d2 - d1).days) - except Exception: - return -1 # 表示计算失败 - - -def _extract_cn_tokens(text: str) -> List[str]: - """中文关键词提取(短语级,含数词/日期/常见领域词)""" - if not text: - return [] - t = str(text) - # 去掉常见功能词(粗略,不依赖分词库) - stop_words = [ - "我","我们","你","他","她","它","这","那","哪","一个","一次","一些","什么","怎么","是否","吗","呢", - "很","更","最","已经","正在","将要","马上","尽快","最近","关于","有关","以及","并且","或者","还是", - "因为","所以","如果","但是","而且","然后","之后","之前","同时","另外","并","但","却","被","把","让","给", - "和","与","跟","及","还有","就","都","在","对","对于","的","了","着","过","到","于","从","以","为","向","至","是" - ] - for sw in stop_words: - t = t.replace(sw, " ") - # 去标点 - t = re.sub(r"[,。!?、;:,.!?;:\"'()()[]\[\]\-—…·]", " ", t) - # 基础中文片段(>=2) - base = re.findall(r"[\u4e00-\u9fff]{2,}", t) - # 特殊组合:第X次XXXX - specials = re.findall(r"第[一二三四五六七八九十]+次[\u4e00-\u9fff]{2,6}", text) - # 日期与数字 - dates = re.findall(r"\d{4}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}", text) - numbers = re.findall(r"\b\d+\b", text) - - generic = {"建议","推荐","帮助","提升","技能","有效","团队","参与度","喜欢","开始"} - tokens: List[str] = specials + base + dates + numbers - uniq: List[str] = [] - seen = set() - for tok in tokens: - tok2 = tok.strip() - if len(tok2) < 2 or len(tok2) > 6: - continue - if tok2 in generic: - continue - if tok2 not in seen: - uniq.append(tok2) - seen.add(tok2) - # 排除常见疑问型短语 - blacklist_exact = {"是什么","多少","多少天","哪个","哪些","之间","先","后","之前","之后"} - uniq2: List[str] = [u for u in uniq if u not in blacklist_exact] - return uniq2[:12] - - -def generate_query_keywords_cn(question: str) -> List[str]: - """增强版关键词提取,特别关注技术术语和专有名词""" - if not question: - return [] - - # 提取专有名词(带引号的内容) - quoted_terms = re.findall(r'["""]([^"""]+)["""]', question) - - # 提取技术术语(中英文混合) - tech_terms = re.findall(r'[A-Z][a-zA-Z]+\s+[A-Z][a-zA-Z]+|[A-Za-z]+[\u4e00-\u9fff]+|[\u4e00-\u9fff]+[A-Za-z]+', question) - - # 提取核心名词短语 - core_nouns = re.findall(r'[\u4e00-\u9fff]{2,5}系统|[\u4e00-\u9fff]{2,5}管理|[\u4e00-\u9fff]{2,5}分析|[\u4e00-\u9fff]{2,5}工作坊|[\u4e00-\u9fff]{2,5}研讨会', question) - - # 基础中文片段 - base_tokens = _extract_cn_tokens(question) - - # 特定领域关键词增强 - domain_keywords = [] - # GPS相关 - if any(term in question for term in ["GPS", "导航", "定位系统", "系统运行"]): - domain_keywords.extend(["GPS", "导航系统", "定位", "系统故障", "功能异常"]) - # 活动相关 - if any(term in question for term in ["工作坊", "研讨会", "网络研讨会", "活动"]): - domain_keywords.extend(["工作坊", "研讨会", "参加", "参与", "活动"]) - # 时间顺序相关 - if any(term in question for term in ["先", "后", "第一个", "之前", "首先"]): - domain_keywords.extend(["先", "后", "之前", "之后", "第一次", "首先"]) - # 设备相关 - if any(term in question for term in ["设备", "手机", "电脑", "笔记本电脑"]): - domain_keywords.extend(["设备", "手机", "电脑", "笔记本电脑", "购买"]) - - # 合并并去重 - all_tokens = quoted_terms + tech_terms + core_nouns + base_tokens + domain_keywords - seen = set() - final_tokens = [] - - for token in all_tokens: - token = token.strip() - if len(token) >= 2 and token not in seen: - final_tokens.append(token) - seen.add(token) - - return final_tokens[:8] - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: - """增强版上下文选择:特别优化技术术语和精确匹配""" - if not contexts: - return "" - - # 检测是否为时间推理问题 - is_temporal_question = any(keyword in question.lower() for keyword in - ['days', 'day', 'before', 'after', 'first', '先后', '顺序', '间隔', '多久', '多少天']) - - # 提取时间实体从问题中 - question_time_entities = extract_time_entities(question) - - # 提取关键技术实体 - key_entities = [] - # GPS相关 - if any(term in question for term in ["GPS", "导航", "定位系统", "系统运行"]): - key_entities.extend(["GPS", "导航", "定位", "系统", "功能", "问题", "故障"]) - # 活动相关 - if any(term in question for term in ["工作坊", "研讨会", "网络研讨会", "活动"]): - key_entities.extend(["工作坊", "研讨会", "参加", "参与", "活动", "时间"]) - # 时间顺序相关 - if any(term in question for term in ["先", "后", "第一个", "之前", "首先"]): - key_entities.extend(["先", "后", "之前", "之后", "第一次", "首先"]) - - # 英文关键词(去停用词) - question_lower = question.lower() - stop_words = { - 'what','when','where','who','why','how','did','do','does','is','are','was','were', - 'the','a','an','and','or','but','many','which','first' - } - eng_words = [w for w in set(re.findall(r'\b\w+\b', question_lower)) - if w not in stop_words and len(w) > 2] - - # 中文片段与候选选项 - cn_tokens = generate_query_keywords_cn(question) - options = extract_candidate_options(question) - - # 时间推理问题的特殊处理 - if is_temporal_question: - # 为时间问题添加时间相关关键词 - time_keywords = ['天', '日', '月', '年', 'before', 'after', 'days', 'first', '先后'] - eng_words = [w for w in eng_words if w not in ['days', 'first']] # 避免重复 - cn_tokens.extend([kw for kw in time_keywords if kw not in cn_tokens]) - - # 限制关键词数量,优先时间相关 - tokens = time_keywords[:2] + key_entities[:3] + cn_tokens[:2] + eng_words[:1] + options[:1] - else: - # 常规问题处理,优先关键技术实体 - tokens = key_entities[:4] + cn_tokens[:3] + options[:2] + eng_words[:1] - - # 去重 - seen = set() - final_tokens: List[str] = [] - for t in tokens: - t2 = t.strip() - if t2 and t2 not in seen: - final_tokens.append(t2) - seen.add(t2) - - scored_contexts: List[tuple[float, str]] = [] - - # 关键技术实体权重映射 - key_entity_weights = { - "GPS": 3.0, "导航": 2.5, "系统": 2.0, "功能": 2.0, "问题": 2.0, "故障": 2.5, - "工作坊": 2.5, "研讨会": 2.5, "参加": 2.0, "参与": 2.0, - "先": 2.0, "后": 2.0, "之前": 2.0, "之后": 2.0, "第一次": 2.5 - } - - # 时间推理问题的权重映射 - temporal_weight_map = { - "天": 2.0, "日": 2.0, "月": 1.8, "年": 1.8, "days": 2.0, - "before": 1.5, "after": 1.5, "first": 1.5, "先后": 1.5 - } - - # 常规问题的权重映射 - normal_weight_map = { - "问题": 2.0, "故障": 2.0, "异常": 1.8, "不正常": 1.8, "坏了": 1.8, - "系统": 1.3, "GPS": 1.5, "保养": 1.4, "设备": 1.2, "模块": 1.2, "功能": 1.1 - } - - # 合并权重映射 - weight_map = {**normal_weight_map, **temporal_weight_map, **key_entity_weights} - - for i, context in enumerate(contexts): - context_str = str(context) - lines = re.split(r'[\r\n]+', context_str) - hit_lines: List[str] = [] - kw_hits: float = 0.0 - time_entity_count = 0 - key_entity_hits = 0 - - for line in lines: - ln = line.strip() - if not ln: - continue - - has_keyword = False - # 关键词匹配 - for tok in final_tokens: - if tok and tok in ln: - w = weight_map.get(tok, 1.0) - hit_count = ln.count(tok) - kw_hits += hit_count * w - # 关键技术实体额外奖励 - if tok in key_entity_weights: - key_entity_hits += hit_count - has_keyword = True - - # 时间实体检测(特别针对时间推理问题) - if is_temporal_question: - time_entities = extract_time_entities(ln) - time_entity_count += len(time_entities) - if time_entities: - has_keyword = True - - # 精确匹配奖励(完整问题关键词出现在上下文中) - for q_word in question.split(): - if len(q_word) > 3 and q_word in ln: - kw_hits += 0.5 # 精确匹配奖励 - - if has_keyword: - # 对于包含关键信息的行,保留完整行 - hit_lines.append(ln) - - snippet = "\n".join(hit_lines) if hit_lines else context_str.strip() - - # 限制单段长度,但对包含关键信息的上下文稍微放宽限制 - max_snippet_len = 600 if (key_entity_hits > 0 or time_entity_count > 0) else 500 - if len(snippet) > max_snippet_len: - snippet = snippet[:max_snippet_len] - - # 评分逻辑 - has_number = 1 if re.search(r'\d', snippet) else 0 - has_date = 1 if (re.search(r'\b\d{4}-\d{1,2}-\d{1,2}\b', snippet) or - re.search(r'\d{1,2}月\d{1,2}日', snippet)) else 0 - - # 关键技术实体奖励 - key_entity_bonus = key_entity_hits * 1.0 - - # 时间推理问题的特殊评分 - if is_temporal_question: - time_bonus = time_entity_count * 2.0 # 时间实体奖励 - temporal_coherence = 3 if (has_date and time_entity_count >= 2) else 0 - else: - time_bonus = 0 - temporal_coherence = 0 - - length_bonus = 5 if 50 < len(snippet) < 1000 else (2 if len(snippet) >= 1000 else 0) - pos_bonus = 3 if i < 3 else 0 - - score = (kw_hits * 0.8 + (has_number + has_date) * 1.5 + - length_bonus + pos_bonus + time_bonus + temporal_coherence + key_entity_bonus) - - scored_contexts.append((score, snippet)) - - # 选择累计至总字符预算 - scored_contexts.sort(key=lambda x: x[0], reverse=True) - selected: List[str] = [] - total_chars = 0 - - for score, snippet in scored_contexts: - if total_chars + len(snippet) <= max_chars: - selected.append(snippet) - total_chars += len(snippet) - else: - if not selected and len(snippet) > max_chars: - selected.append(snippet[:max_chars]) - break - - final_context = "\n\n".join(selected) - - # 对于时间推理问题,添加时间计算提示 - if is_temporal_question and question_time_entities: - time_prompt = "\n\n[时间推理提示:请仔细分析上述上下文中的日期和时间关系,计算时间间隔或确定事件顺序]" - if total_chars + len(time_prompt) <= max_chars: - final_context += time_prompt - - return final_context - - -# 通过别名匹配进行实体关键词检索(多token合并) -async def _search_entities_by_aliases(connector: Neo4jConnector, tokens: List[str], end_user_id: str | None, limit: int) -> List[Dict[str, Any]]: - results: List[Dict[str, Any]] = [] - try: - for tok in tokens: - rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q=tok, end_user_id=end_user_id, limit=limit) - if rows: - results.extend(rows) - except Exception: - pass - - # 按 name 去重 - deduped: List[Dict[str, Any]] = [] - seen = set() - for r in results: - k = str(r.get("name", "")) - if k and k not in seen: - deduped.append(r) - seen.add(k) - return deduped - - -# 通过对话/陈述中的entity_ids反查实体名称 -_FETCH_ENTITIES_BY_IDS = """ -MATCH (e:ExtractedEntity) -WHERE e.id IN $ids AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) -RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type -""" - -async def _fetch_entities_by_ids(connector: Neo4jConnector, ids: List[str], end_user_id: str | None) -> List[Dict[str, Any]]: - if not ids: - return [] - try: - rows = await connector.execute_query(_FETCH_ENTITIES_BY_IDS, ids=list({i for i in ids if i}), end_user_id=end_user_id) - return rows or [] - except Exception: - return [] - - -# 增强的时间实体检索 -_TIME_ENTITY_SEARCH = """ -MATCH (e:ExtractedEntity) -WHERE e.entity_type CONTAINS "TIME" OR e.entity_type CONTAINS "DATE" OR e.name =~ $date_pattern -AND ($end_user_id IS NULL OR e.end_user_id = $end_user_id) -RETURN e.id AS id, e.name AS name, e.end_user_id AS end_user_id, e.entity_type AS entity_type -LIMIT $limit -""" - -async def _search_time_entities(connector: Neo4jConnector, end_user_id: str | None, limit: int = 5) -> List[Dict[str, Any]]: - """专门搜索时间相关的实体""" - try: - date_pattern = r".*\d{4}.*|.*\d{1,2}月\d{1,2}日.*" - rows = await connector.execute_query(_TIME_ENTITY_SEARCH, - date_pattern=date_pattern, - end_user_id=end_user_id, - limit=limit) - return rows or [] - except Exception: - return [] - - -# 技术术语专门检索 -async def _search_tech_terms(connector: Neo4jConnector, question: str, end_user_id: str | None, limit: int = 3) -> List[Dict[str, Any]]: - """专门搜索技术术语相关的实体""" - tech_entities = [] - try: - # GPS相关 - if any(term in question for term in ["GPS", "导航", "定位系统"]): - gps_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="GPS", end_user_id=end_user_id, limit=limit) - if gps_rows: - tech_entities.extend(gps_rows) - - # 活动相关 - if any(term in question for term in ["工作坊", "研讨会", "网络研讨会"]): - workshop_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="工作坊", end_user_id=end_user_id, limit=limit) - if workshop_rows: - tech_entities.extend(workshop_rows) - - # 时间顺序相关 - if any(term in question for term in ["先", "后", "第一个"]): - time_rows = await connector.execute_query(SEARCH_ENTITIES_BY_NAME, q="第一次", end_user_id=end_user_id, limit=limit) - if time_rows: - tech_entities.extend(time_rows) - - except Exception: - pass - - return tech_entities - - -# 中英相对时间解析:today/昨天/上周/3天后 等简单归一化为日期 -def _resolve_relative_times_cn_en(text: str, anchor: datetime) -> str: - t = str(text) if text is not None else "" - # 英文 today/yesterday/tomorrow - t = re.sub(r"\btoday\b", anchor.date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\byesterday\b", (anchor - timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\btomorrow\b", (anchor + timedelta(days=1)).date().isoformat(), t, flags=re.IGNORECASE) - - # 英文 X days ago / in X days - def _ago_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor - timedelta(days=n)).date().isoformat() - def _in_repl(m: re.Match[str]) -> str: - n = int(m.group(1)) - return (anchor + timedelta(days=n)).date().isoformat() - t = re.sub(r"\b(\d+)\s+days\s+ago\b", _ago_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\bin\s+(\d+)\s+days\b", _in_repl, t, flags=re.IGNORECASE) - t = re.sub(r"\blast\s+week\b", (anchor - timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - t = re.sub(r"\bnext\s+week\b", (anchor + timedelta(days=7)).date().isoformat(), t, flags=re.IGNORECASE) - - # 中文 今天/昨天/明天 - t = re.sub(r"今天", anchor.date().isoformat(), t) - t = re.sub(r"昨日|昨天", (anchor - timedelta(days=1)).date().isoformat(), t) - t = re.sub(r"明天", (anchor + timedelta(days=1)).date().isoformat(), t) - # 中文 X天前 / X天后 - t = re.sub(r"(\d+)天前", lambda m: (anchor - timedelta(days=int(m.group(1)))).date().isoformat(), t) - t = re.sub(r"(\d+)天后", lambda m: (anchor + timedelta(days=int(m.group(1)))).date().isoformat(), t) - # 中文 上周 / 下周(近似7天) - t = re.sub(r"上周", (anchor - timedelta(days=7)).date().isoformat(), t) - t = re.sub(r"下周", (anchor + timedelta(days=7)).date().isoformat(), t) - # 中文 月日(无年份)补全年份 - def _md_repl(m: re.Match[str]) -> str: - mon = int(m.group(1)); day = int(m.group(2)) - return f"{anchor.year}-{mon:02d}-{day:02d}" - t = re.sub(r"(\d{1,2})月(\d{1,2})日", _md_repl, t) - return t - - -async def run_longmemeval_test( - sample_size: int = 3, - end_user_id: str = "longmemeval_zh_bak_2", - search_limit: int = 8, - context_char_budget: int = 4000, - llm_temperature: float = 0.0, - llm_max_tokens: int = 16, - search_type: str = "hybrid", - data_path: str | None = None, - start_index: int = 0, -) -> Dict[str, Any]: - """LongMemEval 评估测试:增强技术术语检索能力""" - - # 数据路径 - if not data_path: - # 固定使用中文数据集:dataset/longmemeval_oracle_zh.json - dataset_dir = Path(__file__).resolve().parent.parent / "dataset" - data_path = str(dataset_dir / "longmemeval_oracle_zh.json") - - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 longmemeval_oracle_zh.json 放置在: {dataset_dir}" - ) - - qa_list: List[Dict[str, Any]] = load_dataset_any(data_path) - # 支持评估全部样本:当 sample_size <= 0 时,取从 start_index 到末尾 - if sample_size is None or sample_size <= 0: - items = qa_list[start_index:] - else: - items = qa_list[start_index:start_index + sample_size] - - # 初始化组件 - 使用异步LLM客户端 - llm_client = get_llm_client(os.getenv("EVAL_LLM_ID")) - connector = Neo4jConnector() - cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID")) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - - # 指标收集 - latencies_llm: List[float] = [] - latencies_search: List[float] = [] - per_query_context_counts: List[int] = [] - per_query_context_avg_tokens: List[float] = [] - per_query_context_chars: List[int] = [] - - type_correct: Dict[str, List[float]] = {} - type_f1: Dict[str, List[float]] = {} - type_jacc: Dict[str, List[float]] = {} - - samples: List[Dict[str, Any]] = [] - # 统计重复的上下文预览(跨样本),便于诊断"相同上下文"问题 - preview_counter: Dict[str, int] = {} - - try: - for item in items: - question = item.get("question", "") - reference = item.get("answer", "") - qtype = item.get("question_type") or item.get("type", "unknown") - - print(f"\n=== 处理问题: {question} ===") - - # 检测问题类型 - is_temporal = any(keyword in question.lower() for keyword in - ['days', 'day', 'before', 'after', 'first', '先后', '顺序', '间隔', '多久', '多少天']) - - # 检索 - t0 = time.time() - contexts_all: List[str] = [] - dialogs, statements, entities = [], [], [] - - try: - if search_type == "embedding": - search_results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["dialogues", "statements", "entities"], - ) - dialogs = search_results.get("dialogues", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - # 实体摘要(最多3个) - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - elif search_type == "keyword": - search_results = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit, - ) - dialogs = search_results.get("dialogues", []) - statements = search_results.get("statements", []) - entities = search_results.get("entities", []) - - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - if entities: - entity_names = [str(e.get("name", "")).strip() for e in entities[:5] if e.get("name")] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - else: # hybrid(增强版:特别优化技术术语检索) - emb_dialogs, emb_statements, emb_entities = [], [], [] - kw_dialogs, kw_statements, kw_entities = [], [], [] - - # 1) 嵌入检索 - try: - emb_res = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["dialogues", "statements", "entities"], - ) - if isinstance(emb_res, dict): - emb_dialogs = emb_res.get("dialogues", []) or [] - emb_statements = emb_res.get("statements", []) or [] - emb_entities = emb_res.get("entities", []) or [] - except Exception as e: - print(f"⚠️ 嵌入检索失败,将继续进行关键词检索: {e}") - - # 2) 关键词检索(增强版) - try: - kw_res = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit, - ) - if isinstance(kw_res, dict): - kw_dialogs = kw_res.get("dialogues", []) or [] - kw_statements = kw_res.get("statements", []) or [] - kw_entities = kw_res.get("entities", []) or [] - - # 技术术语专门检索 - tech_entities = await _search_tech_terms(connector, question, end_user_id, search_limit//2) - if tech_entities: - kw_entities.extend(tech_entities) - - # 时间推理问题的特殊处理 - if is_temporal: - # 专门搜索时间实体 - time_entities = await _search_time_entities(connector, end_user_id, search_limit//2) - if time_entities: - kw_entities.extend(time_entities) - # 添加时间相关关键词检索 - time_keywords = ['天', '日', '月', '年', 'before', 'after', 'first'] - for tk in time_keywords: - try: - time_res = await search_graph( - connector=connector, - q=tk, - end_user_id=end_user_id, - limit=2, - ) - if isinstance(time_res, dict): - kw_dialogs.extend(time_res.get("dialogues", []) or []) - kw_statements.extend(time_res.get("statements", []) or []) - except Exception: - pass - - # 中文关键词拆分后做别名匹配 - cn_tokens = generate_query_keywords_cn(question) # 使用增强版关键词提取 - alias_entities = await _search_entities_by_aliases(connector, cn_tokens, end_user_id, search_limit) - if alias_entities: - kw_entities.extend(alias_entities) - - # 从对话/陈述中的 entity_ids 反查实体 - ids = [] - try: - for d in kw_dialogs: - ids.extend(d.get("entity_ids", []) or []) - for s in kw_statements: - ids.extend(s.get("entity_ids", []) or []) - except Exception: - pass - if ids: - id_entities = await _fetch_entities_by_ids(connector, ids, end_user_id) - if id_entities: - kw_entities.extend(id_entities) - - # 多关键词检索(使用增强版关键词) - try: - eng_words = [w for w in set(re.findall(r"\b\w+\b", question.lower())) if len(w) > 2] - kw_list = generate_query_keywords_cn(question)[:4] # 使用更多关键词 - for kw in kw_list: - if not kw: - continue - sub_res = await search_graph( - connector=connector, - q=str(kw), - end_user_id=end_user_id, - limit=max(3, search_limit // 2), - ) - if isinstance(sub_res, dict): - kw_dialogs.extend(sub_res.get("dialogues", []) or []) - kw_statements.extend(sub_res.get("statements", []) or []) - kw_entities.extend(sub_res.get("entities", []) or []) - except Exception: - pass - - # 选项参与关键词检索 - try: - opt_list = extract_candidate_options(question)[:2] - for opt in opt_list: - if not opt: - continue - opt_res = await search_graph( - connector=connector, - q=str(opt), - end_user_id=end_user_id, - limit=max(3, search_limit // 2), - ) - if isinstance(opt_res, dict): - kw_dialogs.extend(opt_res.get("dialogues", []) or []) - kw_statements.extend(opt_res.get("statements", []) or []) - kw_entities.extend(opt_res.get("entities", []) or []) - except Exception: - pass - except Exception as e: - print(f"❌ 关键词检索失败: {e}") - - # 3) 合并、排序并去重 - all_dialogs = emb_dialogs + kw_dialogs - all_statements = emb_statements + kw_statements - all_entities = emb_entities + kw_entities - - def dedup(items: List[Dict[str, Any]], key_field: str = "uuid") -> List[Dict[str, Any]]: - seen = set() - out = [] - for it in items: - key = str(it.get(key_field, "")) + str(it.get("content", "") + str(it.get("statement", ""))) - if key not in seen: - out.append(it) - seen.add(key) - return out - - # 关键技术实体优先排序 - def enhanced_score(item: Dict[str, Any]) -> float: - score_val = item.get("score", 0.0) - base_score = float(score_val) if score_val is not None else 0.0 - content = str(item.get("content", "") + str(item.get("statement", ""))) - - # 关键技术实体奖励 - key_entities = [] - if any(term in question for term in ["GPS", "导航", "系统"]): - key_entities.extend(["GPS", "导航", "系统", "功能"]) - if any(term in question for term in ["工作坊", "研讨会", "活动"]): - key_entities.extend(["工作坊", "研讨会", "参加"]) - - key_bonus = 0 - for key_ent in key_entities: - if key_ent in content: - key_bonus += 1.0 - - # 时间实体奖励 - time_bonus = 0 - if is_temporal: - time_entities = extract_time_entities(content) - time_bonus = len(time_entities) * 0.5 - - return base_score + key_bonus + time_bonus - - dialogs = dedup(sorted(all_dialogs, key=enhanced_score, reverse=True)) - statements = dedup(sorted(all_statements, key=enhanced_score, reverse=True)) - entities = dedup(all_entities, key_field="name") - - # 4) 构建上下文 - for d in dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - # 实体摘要 - try: - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - except Exception: - pass - - # 全局回退 - if not contexts_all and search_type in ("embedding", "hybrid"): - try: - print("🔁 检索为空,回退到关键词检索...") - kw_fallback = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=max(search_limit, 5), - ) - fb_dialogs = kw_fallback.get("dialogues", []) or [] - fb_statements = kw_fallback.get("statements", []) or [] - fb_entities = kw_fallback.get("entities", []) or [] - - for d in fb_dialogs: - content = str(d.get("content", "")).strip() - if content: - contexts_all.append(content) - for s in fb_statements: - stmt_text = str(s.get("statement", "")).strip() - if stmt_text: - contexts_all.append(stmt_text) - if fb_entities: - entity_names = [str(e.get("name", "")).strip() for e in fb_entities[:5] if e.get("name")] - if entity_names: - contexts_all.append(f"EntitySummary: {', '.join(entity_names)}") - - dialogs = fb_dialogs if fb_dialogs else dialogs - statements = fb_statements if fb_statements else statements - entities = fb_entities if fb_entities else entities - print(f"↩️ 回退到关键词检索: {len(fb_dialogs)} 对话, {len(fb_statements)} 条陈述, {len(fb_entities)} 个实体") - except Exception as fe: - print(f"❌ 关键词回退失败: {fe}") - - ent_count = len(entities) if isinstance(entities, list) else 0 - print(f"✅ {search_type}检索成功: {len(dialogs)} 对话, {len(statements)} 条陈述, {ent_count} 个实体") - if is_temporal: - print("⏰ 检测为时间推理问题,已启用时间优化检索") - - except Exception as e: - print(f"❌ {search_type}检索失败: {e}") - contexts_all = [] - - t1 = time.time() - latencies_search.append((t1 - t0) * 1000) - - # 智能上下文选择 - context_text = "" - if contexts_all: - context_text = smart_context_selection(contexts_all, question, max_chars=context_char_budget) - # 相对时间解析 - try: - context_text = _resolve_relative_times_cn_en(context_text, anchor=datetime.now()) - except Exception: - pass - # 诊断信息 - try: - cn_diag = generate_query_keywords_cn(question)[:4] # 显示更多关键词 - opts = extract_candidate_options(question)[:2] - qlw = [w for w in set(re.findall(r'\b\w+\b', question.lower())) if len(w) > 2][:1] - diag_tokens: List[str] = [] - for t in cn_diag + opts + qlw: - if t and t not in diag_tokens: - diag_tokens.append(t) - print(f"🔍 关键词/选项: {', '.join(diag_tokens)}") - preview = context_text[:200].replace('\n', ' ') - print(f"🔎 上下文预览: {preview}...") - key_preview = preview.strip() - if key_preview: - preview_counter[key_preview] = preview_counter.get(key_preview, 0) + 1 - except Exception: - pass - else: - print("❌ 没有检索到有效上下文") - context_text = "No relevant context found." - - # 记录上下文诊断信息 - per_query_context_counts.append(len(contexts_all)) - per_query_context_avg_tokens.append(avg_context_tokens([context_text])) - per_query_context_chars.append(len(context_text)) - - # LLM 推理(增强技术术语提示) - options = extract_candidate_options(question) - if len(options) >= 2: - opt_lines = "\n".join(f"- {o}" for o in options) - # 技术术语问题的特殊提示 - if any(term in question for term in ["GPS", "系统", "功能", "工作坊", "研讨会"]): - system_prompt = ( - "You are a QA assistant specializing in technical and activity-related questions. " - "Pay special attention to technical terms like GPS, systems, functions, workshops, and seminars. " - "Return ONLY one string: exactly one option from the provided candidates. If the context is insufficient, respond with 'Unknown'. " - "Focus on matching technical details and activity sequences accurately." - ) - elif is_temporal: - system_prompt = ( - "You are a QA assistant specializing in temporal reasoning. Analyze the dates and time relationships in the context carefully. " - "Return ONLY one string: exactly one option from the provided candidates. If the context is insufficient, respond with 'Unknown'. " - "Pay special attention to date sequences and time intervals." - ) - else: - system_prompt = ( - "You are a QA assistant. Respond in the same language as the question. Return ONLY one string: exactly one option from the provided candidates. " - "If the context is insufficient, respond with 'Unknown'. If the context expresses a synonym or paraphrase of a candidate, return the closest candidate. " - "Do not include explanations." - ) - - messages = [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": ( - f"Question: {question}\n\nCandidates:\n{opt_lines}\n\nContext:\n{context_text}\n\nReturn EXACTLY one candidate string (or 'Unknown')." - ), - }, - ] - else: - # 技术术语问题的特殊提示 - if any(term in question for term in ["GPS", "系统", "功能", "工作坊", "研讨会"]): - system_prompt = ( - "You are a QA assistant specializing in technical and activity-related questions. " - "Pay special attention to technical terms like GPS, systems, functions, workshops, and seminars. " - "If the context contains the answer, return a concise answer phrase focusing on technical details. " - "If the answer cannot be determined from the context, respond with 'Unknown'. Return ONLY the final answer string, no explanations." - ) - elif is_temporal: - system_prompt = ( - "You are a QA assistant specializing in temporal reasoning. Analyze the dates and time relationships in the context carefully. " - "If the context contains the answer, return a concise answer phrase focusing on temporal information. " - "If the answer cannot be determined from the context, respond with 'Unknown'. Return ONLY the final answer string, no explanations." - ) - else: - system_prompt = ( - "You are a QA assistant. Respond in the same language as the question. If the context contains the answer, return a concise answer phrase. " - "If the answer cannot be determined from the context, respond with 'Unknown'. Return ONLY the final answer string, no explanations." - ) - - messages = [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": f"Question: {question}\n\nContext:\n{context_text}\n\nReturn ONLY the answer (or 'Unknown').", - }, - ] - - t2 = time.time() - # 使用异步调用 - resp = await llm_client.chat(messages=messages) - t3 = time.time() - latencies_llm.append((t3 - t2) * 1000) - - # 兼容不同的响应格式 - pred_raw = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else "Unknown") - - # 选项题输出规范化 - pred = pred_raw - if len(options) >= 2 and not pred_raw.lower().startswith("unknown"): - def _basic_norm(s: str) -> str: - s = s.lower().strip() - return re.sub(r"[^\w\s]", " ", s) - def _jaccard(a: str, b: str) -> float: - ta = set(t for t in _basic_norm(a).split() if t) - tb = set(t for t in _basic_norm(b).split() if t) - if not ta and not tb: - return 1.0 - if not ta or not tb: - return 0.0 - return len(ta & tb) / len(ta | tb) - best = None - best_score = -1.0 - for o in options: - score = _jaccard(pred_raw, o) - if score > best_score: - best = o - best_score = score - if best is not None and best_score > 0.0: - pred = best - - # 指标 - flag = exact_match(pred, reference) - f1_val = common_f1(str(pred), str(reference)) - j_val = jaccard(str(pred), str(reference)) - - type_correct.setdefault(qtype, []).append(flag) - type_f1.setdefault(qtype, []).append(f1_val) - type_jacc.setdefault(qtype, []).append(j_val) - - samples.append({ - "question": question, - "prediction": pred, - "answer": reference, - "question_type": qtype, - "is_temporal": is_temporal, - "question_id": item.get("question_id"), - "options": options, - "context_count": len(contexts_all), - "context_chars": len(context_text), - "retrieved_dialogue_count": len(dialogs), - "retrieved_statement_count": len(statements), - "metrics": { - "exact_match": bool(flag), - "f1": f1_val, - "jaccard": j_val - }, - "timing": { - "search_ms": (t1 - t0) * 1000, - "llm_ms": (t3 - t2) * 1000 - } - }) - - print(f"🤖 LLM 回答: {pred}") - print(f"✅ 正确答案: {reference}") - print(f"📈 当前指标 - Exact Match: {flag}, F1: {f1_val:.3f}, Jaccard: {j_val:.3f}") - - # 聚合结果 - type_acc = {t: (sum(v) / max(len(v), 1)) for t, v in type_correct.items()} - f1_by_type = {t: (sum(v) / max(len(v), 1)) for t, v in type_f1.items()} - jacc_by_type = {t: (sum(v) / max(len(v), 1)) for t, v in type_jacc.items()} - - result = { - "dataset": "longmemeval", - "items": len(items), - "accuracy_by_type": type_acc, - "f1_by_type": f1_by_type, - "jaccard_by_type": jacc_by_type, - "samples": samples, - "latency": { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm), - }, - "context": { - "avg_tokens": statistics.mean(per_query_context_avg_tokens) if per_query_context_avg_tokens else 0.0, - "avg_chars": statistics.mean(per_query_context_chars) if per_query_context_chars else 0.0, - "count_avg": statistics.mean(per_query_context_counts) if per_query_context_counts else 0.0, - }, - "params": { - "end_user_id": end_user_id, - "search_limit": search_limit, - "context_char_budget": context_char_budget, - "search_type": search_type, - "llm_id": os.getenv("EVAL_LLM_ID"), - "embedding_id": os.getenv("EVAL_EMBEDDING_ID"), - "sample_size": sample_size, - "start_index": start_index, - }, - "timestamp": datetime.now().isoformat() - } - - # 计算汇总指标 - try: - total_items = max(len(samples), 1) - correct_count = sum(1 for s in samples if s.get("metrics", {}).get("exact_match")) - score_accuracy = (correct_count / total_items) * 100.0 - - total_latencies_ms = [] - for s in samples: - t = s.get("timing", {}) - total_latencies_ms.append(float(t.get("search_ms", 0.0)) + float(t.get("llm_ms", 0.0))) - total_lat_stats = latency_stats(total_latencies_ms) if total_latencies_ms else {"p50": 0.0, "iqr": 0.0} - latency_median_s = total_lat_stats.get("p50", 0.0) / 1000.0 - latency_iqr_s = total_lat_stats.get("iqr", 0.0) / 1000.0 - - avg_ctx_tokens = statistics.mean(per_query_context_avg_tokens) if per_query_context_avg_tokens else 0.0 - avg_ctx_tokens_k = avg_ctx_tokens / 1000.0 - - result["metric_summary"] = { - "score_accuracy": score_accuracy, - "latency_median_s": latency_median_s, - "latency_iqr_s": latency_iqr_s, - "avg_context_tokens_k": avg_ctx_tokens_k, - } - except Exception: - result["metric_summary"] = { - "score_accuracy": 0.0, - "latency_median_s": 0.0, - "latency_iqr_s": 0.0, - "avg_context_tokens_k": 0.0, - } - - # 诊断信息 - try: - dups = sorted([(k, c) for k, c in preview_counter.items() if c > 1], key=lambda x: -x[1])[:5] - result["diagnostics"] = { - "duplicate_previews_top": [{"count": c, "preview": k[:120]} for k, c in dups], - "unique_preview_count": len(preview_counter), - } - except Exception: - pass - - return result - - finally: - await connector.close() - - -def main(): - load_dotenv() - parser = argparse.ArgumentParser(description="LongMemEval 评估测试脚本(增强技术术语检索版)") - parser.add_argument("--sample-size", type=int, default=3, help="样本数量(<=0 表示全部)") - parser.add_argument("--all", action="store_true", help="评估全部样本(覆盖 --sample-size)") - parser.add_argument("--start-index", type=int, default=0, help="起始样本索引") - parser.add_argument("--group-id", type=str, default="longmemeval_zh_bak_3", help="图数据库 Group ID") - parser.add_argument("--search-limit", type=int, default=8, help="检索条数上限") - parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") - parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") - parser.add_argument("--llm-max-tokens", type=int, default=16, help="LLM 最大输出 token") - parser.add_argument("--search-type", type=str, default="hybrid", choices=["embedding","keyword","hybrid"], help="检索类型") - parser.add_argument("--data-path", type=str, default=None, help="数据集路径") - args = parser.parse_args() - - sample_size = 0 if args.all else args.sample_size - - result = asyncio.run( - run_longmemeval_test( - sample_size=sample_size, - end_user_id=args.end_user_id, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - llm_temperature=args.llm_temperature, - llm_max_tokens=args.llm_max_tokens, - search_type=args.search_type, - data_path=args.data_path, - start_index=args.start_index, - ) - ) - - # 打印结果 - print("\n" + "="*50) - print("📊 LongMemEval 测试结果:") - print(f" 样本数量: {result['items']}") - - if result['accuracy_by_type']: - print("\n📈 按问题类型细分:") - for qtype, acc in result['accuracy_by_type'].items(): - print(f" {qtype}:") - print(f" Score (Accuracy): {acc:.3f}") - - print(f"\n📊 指标总览:") - ms = result.get('metric_summary', {}) - print(f" Score (Accuracy): {ms.get('score_accuracy', 0.0):.1f}%") - print(f" Latency (s): median {ms.get('latency_median_s', 0.0):.3f}s") - print(f" Latency IQR (s): {ms.get('latency_iqr_s', 0.0):.3f}s") - print(f" Avg Context Tokens (k): {ms.get('avg_context_tokens_k', 0.0):.3f}k") - - print(f"\n⏱️ 细分性能指标:") - print(f" 检索延迟(均值): {result['latency']['search']['mean']:.1f}ms") - print(f" LLM延迟(均值): {result['latency']['llm']['mean']:.1f}ms") - print(f" 上下文长度(均值): {result['context']['avg_chars']:.0f} 字符") - - - # 保存结果到文件 - try: - out_dir = os.path.join(PROJECT_ROOT, "evaluation", "longmemeval", "results") - os.makedirs(out_dir, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = os.path.join(out_dir, f"longmemeval_{result['params']['search_type']}_{ts}.json") - with open(out_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n💾 结果已保存: {out_path}") - except Exception as e: - print(f"⚠️ 结果保存失败: {e}") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py b/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py deleted file mode 100644 index e07b0cab..00000000 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa-test.py +++ /dev/null @@ -1,559 +0,0 @@ -import argparse -import asyncio -import json -import os -import time -from datetime import datetime -from typing import List, Dict, Any -import re -from pathlib import Path - -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - print(f"✅ 加载评估配置: {eval_config_path}") - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.src.search import run_hybrid_search # 使用与 evaluate_qa.py 相同的检索函数 -from app.core.memory.llm_tools.openai_embedder import OpenAIEmbedderClient -from app.core.models.base import RedBearModelConfig -from app.core.memory.utils.config.config_utils import get_embedder_config - -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.common.metrics import exact_match, latency_stats, avg_context_tokens - -from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: - """基于问题关键词对上下文进行评分选择,并在预算内拼接文本。 - - 参考 evaluation/memsciqa/evaluate_qa.py 的实现,避免路径导入带来的不稳定。 - """ - if not contexts: - return "" - question_lower = (question or "").lower() - stop_words = { - 'what','when','where','who','why','how','did','do','does','is','are','was','were', - 'the','a','an','and','or','but' - } - question_words = set(re.findall(r"\b\w+\b", question_lower)) - question_words = {w for w in question_words if w not in stop_words and len(w) > 2} - - scored = [] - for i, ctx in enumerate(contexts): - ctx_lower = (ctx or "").lower() - score = 0 - matches = 0 - for w in question_words: - if w in ctx_lower: - matches += 1 - score += ctx_lower.count(w) * 2 - length = len(ctx) - if 100 < length < 2000: - score += 5 - elif length >= 2000: - score += 2 - if i < 3: - score += 3 - scored.append((score, ctx, matches)) - - scored.sort(key=lambda x: x[0], reverse=True) - - selected: List[str] = [] - total = 0 - for score, ctx, _ in scored: - if total + len(ctx) <= max_chars: - selected.append(ctx) - total += len(ctx) - else: - if score > 10 and total < max_chars - 200: - remaining = max_chars - total - lines = ctx.split('\n') - rel_lines: List[str] = [] - cur = 0 - for line in lines: - l = line.lower() - if any(w in l for w in question_words) and cur < remaining - 50: - rel_lines.append(line) - cur += len(line) - if rel_lines: - truncated = '\n'.join(rel_lines) - if len(truncated) > 50: - selected.append(truncated + "\n[相关内容截断...]") - total += len(truncated) - break - return "\n\n".join(selected) - - -def extract_question_keywords(question: str, max_keywords: int = 8) -> List[str]: - """提取问题中的关键词(简单英文分词,去停用词,长度>=3)。""" - ql = (question or "").lower() - stop_words = { - 'what','when','where','who','why','how','did','do','does','is','are','was','were', - 'the','a','an','and','or','but','of','to','in','on','for','with','from','that','this' - } - words = re.findall(r"\b[\w-]+\b", ql) - kws = [w for w in words if w not in stop_words and len(w) >= 3] - # 去重保序 - seen = set() - uniq = [] - for w in kws: - if w not in seen: - uniq.append(w) - seen.add(w) - if len(uniq) >= max_keywords: - break - return uniq - - -def analyze_contexts_simple(contexts: List[str], keywords: List[str], top_n: int = 5) -> List[Dict[str, int | float]]: - """对上下文进行简单相关性打分,仅用于控制台可视化。 - - 评分: score = match_count*200 + min(len(text), 100000)/100 - """ - results = [] - for ctx in contexts: - tl = (ctx or "").lower() - match_count = sum(1 for k in keywords if k in tl) - length = len(ctx) - score = match_count * 200 + min(length, 100000) / 100.0 - results.append({"score": float(f"{score:.0f}"), "match": match_count, "length": length}) - results.sort(key=lambda x: (x["score"], x["match"], x["length"]), reverse=True) - return results[:max(top_n, 0)] - - -# 纯测试脚本不进行摄入;若需摄入请使用 evaluate_qa.py - - -def load_dataset_memsciqa(data_path: str) -> List[Dict[str, Any]]: - if not os.path.exists(data_path): - raise FileNotFoundError(f"未找到数据集: {data_path}") - items: List[Dict[str, Any]] = [] - with open(data_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - items.append(json.loads(line)) - except Exception: - # 跳过坏行但不中断 - continue - return items - - -async def run_memsciqa_test( - sample_size: int = 3, - end_user_id: str | None = None, - search_limit: int = 8, - context_char_budget: int = 4000, - llm_temperature: float = 0.0, - llm_max_tokens: int = 64, - search_type: str = "embedding", - data_path: str | None = None, - start_index: int = 0, - verbose: bool = True, -) -> Dict[str, Any]: - """memsciqa 增强测试脚本:结合 evaluate_qa 的三路检索与智能上下文选择。 - - - 支持从指定索引开始与评估全部样本(sample_size<=0) - - 支持在摄入前重置组(清空图)与跳过摄入 - - 支持 keyword / embedding / hybrid 三种检索 - """ - - # 默认使用指定的 memsci 组 ID - end_user_id = end_user_id or "group_memsci" - - # 数据路径解析 - if not data_path: - dataset_dir = Path(__file__).resolve().parent.parent / "dataset" - data_path = str(dataset_dir / "msc_self_instruct.jsonl") - - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 msc_self_instruct.jsonl 放置在: {dataset_dir}" - ) - - # 加载数据 - all_items = load_dataset_memsciqa(data_path) - if sample_size is None or sample_size <= 0: - items = all_items[start_index:] - else: - items = all_items[start_index:start_index + sample_size] - - # 初始化 LLM(纯测试:不进行摄入) - llm = get_llm_client(os.getenv("EVAL_LLM_ID")) - - # 初始化 Neo4j 连接与向量检索 Embedder(对齐 locomo_test) - connector = Neo4jConnector() - embedder = None - if search_type in ("embedding", "hybrid"): - cfg_dict = get_embedder_config(os.getenv("EVAL_EMBEDDING_ID")) - embedder = OpenAIEmbedderClient( - model_config=RedBearModelConfig.model_validate(cfg_dict) - ) - - # 评估循环 - latencies_llm: List[float] = [] - latencies_search: List[float] = [] - # 存储完整上下文文本用于统计 - contexts_used: List[str] = [] - per_query_context_chars: List[int] = [] - per_query_context_counts: List[int] = [] - correct_flags: List[float] = [] - f1s: List[float] = [] - b1s: List[float] = [] - jss: List[float] = [] - samples: List[Dict[str, Any]] = [] - - total_items = len(items) - for idx, item in enumerate(items): - if verbose: - print(f"\n🧪 评估样本: {idx+1}/{total_items}") - question = item.get("self_instruct", {}).get("B", "") or item.get("question", "") - reference = item.get("self_instruct", {}).get("A", "") or item.get("answer", "") - - # 检索:使用与 evaluate_qa.py 相同的 run_hybrid_search - t0 = time.time() - results = None - try: - if search_type in ("embedding", "hybrid"): - # 使用嵌入检索(与 qwen_search_eval 对齐) - results = await search_graph_by_embedding( - connector=connector, - embedder_client=embedder, - query_text=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues - ) - elif search_type == "keyword": - # 关键词检索(直接调用 graph_search) - results = await search_graph( - connector=connector, - q=question, - end_user_id=end_user_id, - limit=search_limit, - include=["chunks", "statements", "entities", "summaries"], # 使用 chunks 而不是 dialogues - ) - except Exception: - results = None - t1 = time.time() - search_ms = (t1 - t0) * 1000 - latencies_search.append(search_ms) - - # 构建上下文:与 evaluate_qa.py 完全一致的逻辑 - contexts_all: List[str] = [] - retrieved_counts: Dict[str, int] = {} - if results: - # 处理 hybrid 搜索结果 - if search_type == "hybrid": - emb = results.get("embedding_search", {}) if isinstance(results.get("embedding_search"), dict) else {} - kw = results.get("keyword_search", {}) if isinstance(results.get("keyword_search"), dict) else {} - emb_dialogs = emb.get("dialogues", []) - emb_statements = emb.get("statements", []) - emb_entities = emb.get("entities", []) - kw_dialogs = kw.get("dialogues", []) - kw_statements = kw.get("statements", []) - kw_entities = kw.get("entities", []) - all_dialogs = emb_dialogs + kw_dialogs - all_statements = emb_statements + kw_statements - all_entities = emb_entities + kw_entities - - # 简单去重 - seen_dialog = set() - dialogues = [] - for d in all_dialogs: - key = (str(d.get("uuid", "")), str(d.get("content", ""))) - if key not in seen_dialog: - dialogues.append(d) - seen_dialog.add(key) - - seen_stmt = set() - statements = [] - for s in all_statements: - key = str(s.get("statement", "")) - if key not in seen_stmt: - statements.append(s) - seen_stmt.add(key) - - seen_ent = set() - entities = [] - for e in all_entities: - key = str(e.get("name", "")) - if key not in seen_ent: - entities.append(e) - seen_ent.add(key) - else: - # embedding 或 keyword 单独搜索 - dialogues = results.get("dialogues", []) - statements = results.get("statements", []) - entities = results.get("entities", []) - - retrieved_counts = { - "dialogues": len(dialogues), - "statements": len(statements), - "entities": len(entities), - } - - # 构建上下文文本 - for d in dialogues: - text = str(d.get("content", "")).strip() - if text: - contexts_all.append(text) - - for s in statements: - text = str(s.get("statement", "")).strip() - if text: - contexts_all.append(text) - - # 实体摘要 - if entities: - scored = [e for e in entities if e.get("score") is not None] - top_entities = sorted(scored, key=lambda x: x.get("score", 0), reverse=True)[:3] if scored else entities[:3] - if top_entities: - summary_lines = [] - for e in top_entities: - name = str(e.get("name", "")).strip() - etype = str(e.get("entity_type", "")).strip() - score = e.get("score") - if name: - meta = [] - if etype: - meta.append(f"type={etype}") - if isinstance(score, (int, float)): - meta.append(f"score={score:.3f}") - summary_lines.append(f"EntitySummary: {name}{(' [' + '; '.join(meta) + ']') if meta else ''}") - if summary_lines: - contexts_all.append("\n".join(summary_lines)) - - if verbose: - if retrieved_counts: - print(f"✅ 检索成功: {retrieved_counts.get('dialogues',0)} dialogues, {retrieved_counts.get('statements',0)} 条陈述, {retrieved_counts.get('entities',0)} 个实体, {retrieved_counts.get('summaries',0)} 个摘要") - print(f"📊 有效上下文数量: {len(contexts_all)}") - q_keywords = extract_question_keywords(question, max_keywords=8) - if q_keywords: - print(f"🔍 问题关键词: {set(q_keywords)}") - if contexts_all: - analysis = analyze_contexts_simple(contexts_all, q_keywords, top_n=5) - if analysis: - print("📊 上下文相关性分析:") - for a in analysis: - print(f" - 得分: {int(a['score'])}, 关键词匹配: {a['match']}, 长度: {a['length']}") - # 打印检索到的上下文预览,便于定位为何为 Unknown - print("🔎 上下文预览(最多前10条,每条截断展示):") - for i, ctx in enumerate(contexts_all[:10]): - preview = str(ctx).replace("\n", " ") - if len(preview) > 300: - preview = preview[:300] + "..." - print(f" [{i+1}] 长度: {len(ctx)} | 片段: {preview}") - # 标注参考答案是否出现在任一上下文中 - ref_lower = (str(reference) or "").lower() - if ref_lower: - hits = [] - for i, ctx in enumerate(contexts_all): - if ref_lower in str(ctx).lower(): - hits.append(i+1) - print(f"🔗 参考答案命中上下文条数: {len(hits)}" + (f" | 命中索引: {hits}" if hits else "")) - - context_text = smart_context_selection(contexts_all, question, max_chars=context_char_budget) if contexts_all else "" - if not context_text: - context_text = "No relevant context found." - contexts_used.append(context_text) - per_query_context_chars.append(len(context_text)) - per_query_context_counts.append(len(contexts_all)) - - if verbose: - selected_count = (context_text.count("\n\n") + 1) if context_text else 0 - print(f"✅ 智能选择: {selected_count}个上下文, 总长度: {len(context_text)}字符") - # 展示拼接后的上下文片段,便于核查是否包含答案 - concat_preview = context_text.replace("\n", " ") - if len(concat_preview) > 600: - concat_preview = concat_preview[:600] + "..." - print(f"🧵 拼接上下文预览: {concat_preview}") - - messages = [ - { - "role": "system", - "content": ( - "You are a QA assistant. Answer in English. Follow these guidelines:\n" - "1) If the context contains information to answer the question, provide a concise answer based on the context;\n" - "2) If the context does not contain enough information to answer the question, respond with 'Unknown';\n" - "3) Keep your answer brief and to the point;\n" - "4) Do not add explanations or additional text beyond the answer." - ), - }, - {"role": "user", "content": f"Question: {question}\n\nContext:\n{context_text}"}, - ] - - t2 = time.time() - try: - # 使用异步调用 - resp = await llm.chat(messages=messages) - # 更健壮的响应解析,处理不同的LLM响应格式 - if hasattr(resp, 'content'): - pred = resp.content.strip() - elif isinstance(resp, dict) and "choices" in resp and len(resp["choices"]) > 0: - pred = resp["choices"][0]["message"]["content"].strip() - elif isinstance(resp, dict) and "content" in resp: - pred = resp["content"].strip() - elif isinstance(resp, str): - pred = resp.strip() - else: - pred = "Unknown" - print(f"⚠️ LLM响应格式异常: {type(resp)} - {resp}") - - # 检查预测是否为"Unknown"或空,如果是则检查上下文是否真的没有答案 - if pred.lower() in ["unknown", ""]: - # 如果参考答案在上下文中存在,但LLM返回Unknown,可能是提示词问题 - ref_lower = (str(reference) or "").lower() - if ref_lower and any(ref_lower in ctx.lower() for ctx in contexts_all): - print("⚠️ 参考答案在上下文中存在但LLM返回Unknown,检查提示词") - except Exception as e: - # 更详细的错误处理 - pred = "Unknown" - print(f"⚠️ LLM调用异常: {e}") - t3 = time.time() - llm_ms = (t3 - t2) * 1000 - latencies_llm.append(llm_ms) - - exact = exact_match(pred, reference) - correct_flags.append(exact) - f1_val = f1_score(str(pred), str(reference)) - b1_val = bleu1(str(pred), str(reference)) - j_val = jaccard(str(pred), str(reference)) - f1s.append(f1_val) - b1s.append(b1_val) - jss.append(j_val) - - if verbose: - print(f"🤖 LLM 回答: {pred}") - print(f"✅ 正确答案: {reference}") - print(f"📈 当前指标 - F1: {f1_val:.3f}, BLEU-1: {b1_val:.3f}, Jaccard: {j_val:.3f}") - print(f"⏱️ 延迟 - 检索: {search_ms:.0f}ms, LLM: {llm_ms:.0f}ms") - - # 对齐 locomo/qwen_search_eval.py 的样本输出结构 - samples.append({ - "question": str(question), - "answer": str(reference), - "prediction": str(pred), - "metrics": { - "f1": f1_val, - "b1": b1_val, - "j": j_val - }, - "retrieval": { - "retrieved_documents": len(contexts_all), - "context_length": len(context_text), - "search_limit": search_limit, - "max_chars": context_char_budget - }, - "timing": { - "search_ms": search_ms, - "llm_ms": llm_ms - } - }) - - # 计算总体指标与聚合 - acc = sum(correct_flags) / max(len(correct_flags), 1) - ctx_avg_tokens = avg_context_tokens(contexts_used) - result = { - "dataset": "memsciqa", - "items": len(items), - "metrics": { - "f1": (sum(f1s) / max(len(f1s), 1)) if f1s else 0.0, - "b1": (sum(b1s) / max(len(b1s), 1)) if b1s else 0.0, - "j": (sum(jss) / max(len(jss), 1)) if jss else 0.0, - }, - "context": { - "avg_tokens": ctx_avg_tokens, - "avg_chars": (sum(per_query_context_chars) / max(len(per_query_context_chars), 1)) if per_query_context_chars else 0.0, - "count_avg": (sum(per_query_context_counts) / max(len(per_query_context_counts), 1)) if per_query_context_counts else 0.0, - "avg_memory_tokens": 0.0 - }, - "latency": { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm), - }, - "samples": samples, - "params": { - "end_user_id": end_user_id, - "search_limit": search_limit, - "context_char_budget": context_char_budget, - "llm_temperature": llm_temperature, - "llm_max_tokens": llm_max_tokens, - "search_type": search_type, - "start_index": start_index, - "llm_id": os.getenv("EVAL_LLM_ID"), - "retrieval_embedding_id": os.getenv("EVAL_EMBEDDING_ID") - }, - "timestamp": datetime.now().isoformat(), - } - try: - await connector.close() - except Exception: - pass - return result - - -def main(): - load_dotenv() - parser = argparse.ArgumentParser(description="memsciqa 测试脚本(三路检索 + 智能上下文选择)") - parser.add_argument("--sample-size", type=int, default=10, help="样本数量(<=0 表示全部)") - parser.add_argument("--all", action="store_true", help="评估全部样本(覆盖 --sample-size)") - parser.add_argument("--start-index", type=int, default=0, help="起始样本索引") - parser.add_argument("--group-id", type=str, default="group_memsci", help="图数据库 Group ID(默认 group_memsci)") - parser.add_argument("--search-limit", type=int, default=8, help="检索条数上限") - parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") - parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") - parser.add_argument("--llm-max-tokens", type=int, default=64, help="LLM 最大输出 token") - parser.add_argument("--search-type", type=str, default="embedding", choices=["embedding","keyword","hybrid"], help="检索类型(hybrid 等同于 embedding)") - parser.add_argument("--data-path", type=str, default=None, help="数据集路径(默认 data/msc_self_instruct.jsonl)") - parser.add_argument("--output", type=str, default=None, help="将评估结果保存到指定文件路径(JSON)") - parser.add_argument("--verbose", action="store_true", default=True, help="打印过程日志(默认开启)") - parser.add_argument("--quiet", action="store_true", help="关闭过程日志") - args = parser.parse_args() - - sample_size = 0 if args.all else args.sample_size - - verbose_flag = False if args.quiet else args.verbose - result = asyncio.run( - run_memsciqa_test( - sample_size=sample_size, - end_user_id=args.end_user_id, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - llm_temperature=args.llm_temperature, - llm_max_tokens=args.llm_max_tokens, - search_type=args.search_type, - data_path=args.data_path, - start_index=args.start_index, - verbose=verbose_flag, - ) - ) - - print(json.dumps(result, ensure_ascii=False, indent=2)) - - # 结果保存 - out_path = args.output - if not out_path: - eval_dir = os.path.dirname(os.path.abspath(__file__)) - dataset_results_dir = os.path.join(eval_dir, "results") - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - out_path = os.path.join(dataset_results_dir, f"memsciqa_{result['params']['search_type']}_{ts}.json") - try: - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n💾 结果已保存: {out_path}") - except Exception as e: - print(f"⚠️ 结果保存失败: {e}") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py b/api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py deleted file mode 100644 index 40684f4c..00000000 --- a/api/app/core/memory/evaluation/memsciqa/memsciqa_benchmark.py +++ /dev/null @@ -1,369 +0,0 @@ -import argparse -import asyncio -import json -import os -import time -from datetime import datetime -from typing import List, Dict, Any -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent.parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector -from app.core.memory.src.search import run_hybrid_search # 使用旧版本(重构前) -from app.core.memory.utils.llm.llm_utils import get_llm_client -from app.core.memory.evaluation.extraction_utils import ingest_contexts_via_full_pipeline -from app.core.memory.evaluation.common.metrics import exact_match, latency_stats, avg_context_tokens - - -def smart_context_selection(contexts: List[str], question: str, max_chars: int = 4000) -> str: - """基于问题关键词对上下文进行评分选择,并在预算内拼接文本。""" - if not contexts: - return "" - import re - # 提取问题关键词(移除停用词) - question_lower = (question or "").lower() - stop_words = { - 'what','when','where','who','why','how','did','do','does','is','are','was','were', - 'the','a','an','and','or','but' - } - question_words = set(re.findall(r"\b\w+\b", question_lower)) - question_words = {w for w in question_words if w not in stop_words and len(w) > 2} - - # 评分 - scored = [] - for i, ctx in enumerate(contexts): - ctx_lower = (ctx or "").lower() - score = 0 - matches = 0 - for w in question_words: - if w in ctx_lower: - matches += 1 - score += ctx_lower.count(w) * 2 - length = len(ctx) - if 100 < length < 2000: - score += 5 - elif length >= 2000: - score += 2 - if i < 3: - score += 3 - scored.append((score, ctx, matches)) - - scored.sort(key=lambda x: x[0], reverse=True) - - # 选择直到达到字符限制,必要时截断包含关键词的段落 - selected: List[str] = [] - total = 0 - for score, ctx, _ in scored: - if total + len(ctx) <= max_chars: - selected.append(ctx) - total += len(ctx) - else: - if score > 10 and total < max_chars - 200: - remaining = max_chars - total - lines = ctx.split('\n') - rel_lines: List[str] = [] - cur = 0 - for line in lines: - l = line.lower() - if any(w in l for w in question_words) and cur < remaining - 50: - rel_lines.append(line) - cur += len(line) - if rel_lines: - truncated = '\n'.join(rel_lines) - if len(truncated) > 50: - selected.append(truncated + "\n[相关内容截断...]") - total += len(truncated) - break - return "\n\n".join(selected) - - -def build_context_from_dialog(dialog_obj: Dict[str, Any]) -> str: - """Compose a text context from `dialog` list in msc_self_instruct item.""" - parts: List[str] = [] - for turn in dialog_obj.get("dialog", []): - speaker = turn.get("speaker", "") - text = turn.get("text", "") - if text: - parts.append(f"{speaker}: {text}") - return "\n".join(parts) - - -def _combine_dialogues_for_hybrid(results: Dict[str, Any]) -> List[Dict[str, Any]]: - """Combine dialogues from embedding and keyword searches (embedding first).""" - if results is None: - return [] - emb = [] - kw = [] - if isinstance(results.get("embedding_search"), dict): - emb = results.get("embedding_search", {}).get("dialogues", []) or [] - elif isinstance(results.get("dialogues"), list): - emb = results.get("dialogues", []) or [] - if isinstance(results.get("keyword_search"), dict): - kw = results.get("keyword_search", {}).get("dialogues", []) or [] - seen = set() - merged: List[Dict[str, Any]] = [] - for d in emb: - k = (str(d.get("uuid", "")), str(d.get("content", ""))) - if k not in seen: - merged.append(d) - seen.add(k) - for d in kw: - k = (str(d.get("uuid", "")), str(d.get("content", ""))) - if k not in seen: - merged.append(d) - seen.add(k) - return merged - - - -async def run_memsciqa_eval(sample_size: int = 1, end_user_id: str | None = None, search_limit: int = 8, context_char_budget: int = 4000, llm_temperature: float = 0.0, llm_max_tokens: int = 64, search_type: str = "hybrid", memory_config: "MemoryConfig" = None) -> Dict[str, Any]: - end_user_id = end_user_id or SELECTED_GROUP_ID - - # Load data - dataset_dir = Path(__file__).resolve().parent.parent / "dataset" - data_path = dataset_dir / "msc_self_instruct.jsonl" - - if not os.path.exists(data_path): - raise FileNotFoundError( - f"数据集文件不存在: {data_path}\n" - f"请将 msc_self_instruct.jsonl 放置在: {dataset_dir}" - ) - with open(data_path, "r", encoding="utf-8") as f: - lines = f.readlines() - items: List[Dict[str, Any]] = [json.loads(l) for l in lines[:sample_size]] - - - # 改为:每条样本仅摄入一个上下文(完整对话转录),避免多上下文摄入 - # 说明:memsciqa 数据集的每个样本天然只有一个对话,保持按样本一上下文的策略 - contexts: List[str] = [build_context_from_dialog(item) for item in items] - await ingest_contexts_via_full_pipeline(contexts, end_user_id) - - # LLM client (使用异步调用) - from app.db import get_db - - db = next(get_db()) - try: - llm_client = get_llm_client(os.getenv("EVAL_LLM_ID"), db) - finally: - db.close() - - # Evaluate each item - connector = Neo4jConnector() - latencies_llm: List[float] = [] - latencies_search: List[float] = [] - contexts_used: List[str] = [] - correct_flags: List[float] = [] - f1s: List[float] = [] - b1s: List[float] = [] - jss: List[float] = [] - try: - for item in items: - question = item.get("self_instruct", {}).get("B", "") or item.get("question", "") - reference = item.get("self_instruct", {}).get("A", "") or item.get("answer", "") - # 检索:对齐 locomo 的三路检索(dialogues/statements/entities) - t0 = time.time() - try: - results = await run_hybrid_search( - query_text=question, - search_type=search_type, - end_user_id=end_user_id, - limit=search_limit, - include=["dialogues", "statements", "entities"], - output_path=None, - ) - except Exception: - results = None - t1 = time.time() - latencies_search.append((t1 - t0) * 1000) - - # 构建上下文:包含对话、陈述和实体摘要,并智能选择 - contexts_all: List[str] = [] - if results: - if search_type == "hybrid": - emb = results.get("embedding_search", {}) if isinstance(results.get("embedding_search"), dict) else {} - kw = results.get("keyword_search", {}) if isinstance(results.get("keyword_search"), dict) else {} - emb_dialogs = emb.get("dialogues", []) - emb_statements = emb.get("statements", []) - emb_entities = emb.get("entities", []) - kw_dialogs = kw.get("dialogues", []) - kw_statements = kw.get("statements", []) - kw_entities = kw.get("entities", []) - all_dialogs = emb_dialogs + kw_dialogs - all_statements = emb_statements + kw_statements - all_entities = emb_entities + kw_entities - - # 简单去重与限制 - seen_texts = set() - for d in all_dialogs: - text = str(d.get("content", "")).strip() - if text and text not in seen_texts: - contexts_all.append(text) - seen_texts.add(text) - if len(contexts_all) >= search_limit: - break - for s in all_statements: - text = str(s.get("statement", "")).strip() - if text and text not in seen_texts: - contexts_all.append(text) - seen_texts.add(text) - if len(contexts_all) >= search_limit: - break - # 实体摘要(最多3个) - names = [] - merged_entities = all_entities[:] - for e in merged_entities: - name = str(e.get("name", "")).strip() - if name and name not in names: - names.append(name) - if len(names) >= 3: - break - if names: - contexts_all.append("EntitySummary: " + ", ".join(names)) - else: - dialogs = results.get("dialogues", []) - statements = results.get("statements", []) - entities = results.get("entities", []) - for d in dialogs: - text = str(d.get("content", "")).strip() - if text: - contexts_all.append(text) - for s in statements: - text = str(s.get("statement", "")).strip() - if text: - contexts_all.append(text) - names = [str(e.get("name", "")).strip() for e in entities[:3] if e.get("name")] - if names: - contexts_all.append("EntitySummary: " + ", ".join(names)) - - # 智能选择并截断到预算 - context_text = smart_context_selection(contexts_all, question, max_chars=context_char_budget) if contexts_all else "" - if not context_text: - context_text = "No relevant context found." - contexts_used.append(context_text[:200]) - - # Call LLM (使用异步调用) - messages = [ - {"role": "system", "content": "You are a QA assistant. Answer in English. Strictly follow: 1) If the context contains the answer, copy the shortest exact span from the context as the answer; 2) If the answer cannot be determined from the context, respond with 'Unknown'; 3) Return ONLY the answer text, no explanations."}, - {"role": "user", "content": f"Question: {question}\n\nContext:\n{context_text}"}, - ] - t2 = time.time() - resp = await llm_client.chat(messages=messages) - t3 = time.time() - latencies_llm.append((t3 - t2) * 1000) - pred = resp.content.strip() if hasattr(resp, 'content') else (resp["choices"][0]["message"]["content"].strip() if isinstance(resp, dict) else str(resp).strip()) - # Metrics: F1, BLEU-1, Jaccard; keep exact match for reference - correct_flags.append(exact_match(pred, reference)) - from app.core.memory.evaluation.common.metrics import f1_score, bleu1, jaccard - f1s.append(f1_score(str(pred), str(reference))) - b1s.append(bleu1(str(pred), str(reference))) - jss.append(jaccard(str(pred), str(reference))) - - # Aggregate metrics - acc = sum(correct_flags) / max(len(correct_flags), 1) - ctx_avg_tokens = avg_context_tokens(contexts_used) - result = { - "dataset": "memsciqa", - "items": len(items), - "metrics": { - "accuracy": acc, - # Placeholders for extensibility - "f1": (sum(f1s) / max(len(f1s), 1)) if f1s else 0.0, - "bleu1": (sum(b1s) / max(len(b1s), 1)) if b1s else 0.0, - "jaccard": (sum(jss) / max(len(jss), 1)) if jss else 0.0, - }, - "latency": { - "search": latency_stats(latencies_search), - "llm": latency_stats(latencies_llm), - }, - "avg_context_tokens": ctx_avg_tokens, - } - return result - finally: - await connector.close() - - -def main(): - # Load environment variables first - load_dotenv() - - # Get defaults from environment variables - env_sample_size = os.getenv("MEMSCIQA_SAMPLE_SIZE") - env_search_limit = os.getenv("MEMSCIQA_SEARCH_LIMIT") - env_context_budget = os.getenv("MEMSCIQA_CONTEXT_CHAR_BUDGET") - env_llm_max_tokens = os.getenv("MEMSCIQA_LLM_MAX_TOKENS") - env_skip_ingest = os.getenv("MEMSCIQA_SKIP_INGEST", "false").lower() in ("true", "1", "yes") - env_output_dir = os.getenv("MEMSCIQA_OUTPUT_DIR") - - # Convert to appropriate types with fallback to code defaults - default_sample_size = int(env_sample_size) if env_sample_size else 1 - default_search_limit = int(env_search_limit) if env_search_limit else 8 - default_context_budget = int(env_context_budget) if env_context_budget else 4000 - default_llm_max_tokens = int(env_llm_max_tokens) if env_llm_max_tokens else 64 - default_output_dir = env_output_dir if env_output_dir else None - - parser = argparse.ArgumentParser(description="Evaluate DMR (memsciqa) with graph search and Qwen") - - parser.add_argument("--sample-size", type=int, default=1, help="评测样本数量") - parser.add_argument("--end-user-id", type=str, default=None, help="可选 end_user_id,默认使用环境变量") - parser.add_argument("--search-limit", type=int, default=8, help="每类检索最大返回数") - parser.add_argument("--context-char-budget", type=int, default=4000, help="上下文字符预算") - - parser.add_argument("--llm-temperature", type=float, default=0.0, help="LLM 温度") - parser.add_argument("--llm-max-tokens", type=int, default=default_llm_max_tokens, - help=f"LLM 最大生成长度 (env: MEMSCIQA_LLM_MAX_TOKENS={env_llm_max_tokens or 'not set'})") - parser.add_argument("--search-type", type=str, choices=["keyword","embedding","hybrid"], default="hybrid", help="检索类型") - parser.add_argument("--skip-ingest", action="store_true", default=env_skip_ingest, - help=f"跳过数据摄入,使用 Neo4j 中的现有数据 (env: MEMSCIQA_SKIP_INGEST={os.getenv('MEMSCIQA_SKIP_INGEST', 'false')})") - parser.add_argument("--output-dir", type=str, default=default_output_dir, - help=f"结果保存目录 (env: MEMSCIQA_OUTPUT_DIR={env_output_dir or 'not set'})") - args = parser.parse_args() - - result = asyncio.run( - run_memsciqa_eval( - sample_size=args.sample_size, - end_user_id=args.end_user_id, - search_limit=args.search_limit, - context_char_budget=args.context_char_budget, - llm_temperature=args.llm_temperature, - llm_max_tokens=args.llm_max_tokens, - search_type=args.search_type, - skip_ingest=args.skip_ingest, - ) - ) - - # Print results to console - print(json.dumps(result, ensure_ascii=False, indent=2)) - - # Save results to file - output_dir = args.output_dir - if output_dir is None: - # Use absolute path to ensure results are saved in the correct location - script_dir = Path(__file__).resolve().parent - output_dir = script_dir / "results" - elif not Path(output_dir).is_absolute(): - # If relative path, make it relative to this script's directory - script_dir = Path(__file__).resolve().parent - output_dir = script_dir / output_dir - else: - output_dir = Path(output_dir) - - output_dir.mkdir(parents=True, exist_ok=True) - - timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = output_dir / f"memsciqa_{timestamp_str}.json" - - try: - with open(output_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n✅ 结果已保存到: {output_path}") - except Exception as e: - print(f"\n❌ 保存结果失败: {e}") - - -if __name__ == "__main__": - main() diff --git a/api/app/core/memory/evaluation/run_eval.py b/api/app/core/memory/evaluation/run_eval.py deleted file mode 100644 index 56b2e790..00000000 --- a/api/app/core/memory/evaluation/run_eval.py +++ /dev/null @@ -1,147 +0,0 @@ -import argparse -import asyncio -import json -import os -from typing import Any, Dict -from pathlib import Path -from dotenv import load_dotenv - -# Load evaluation config -eval_config_path = Path(__file__).resolve().parent / ".env.evaluation" -if eval_config_path.exists(): - load_dotenv(eval_config_path, override=True) - -from app.repositories.neo4j.neo4j_connector import Neo4jConnector - -from app.core.memory.evaluation.memsciqa.evaluate_qa import run_memsciqa_eval -from app.core.memory.evaluation.longmemeval.qwen_search_eval import run_longmemeval_test -from app.core.memory.evaluation.locomo.qwen_search_eval import run_locomo_eval - - -async def run( - dataset: str, - sample_size: int, - reset_group: bool, - end_user_id: str | None, - judge_model: str | None = None, - search_limit: int | None = None, - context_char_budget: int | None = None, - llm_temperature: float | None = None, - llm_max_tokens: int | None = None, - search_type: str | None = None, - start_index: int | None = None, - max_contexts_per_item: int | None = None, -) -> Dict[str, Any]: - # Use environment variable with fallback chain if not provided - if end_user_id is None: - end_user_id = os.getenv("EVAL_END_USER_ID", "benchmark_default") - - if reset_group: - connector = Neo4jConnector() - try: - await connector.delete_group(end_user_id) - finally: - await connector.close() - - if dataset == "locomo": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} - if search_limit is not None: - kwargs["search_limit"] = search_limit - if context_char_budget is not None: - kwargs["context_char_budget"] = context_char_budget - if llm_temperature is not None: - kwargs["llm_temperature"] = llm_temperature - if llm_max_tokens is not None: - kwargs["llm_max_tokens"] = llm_max_tokens - if search_type is not None: - kwargs["search_type"] = search_type - return await run_locomo_eval(**kwargs) - - if dataset == "memsciqa": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} - if search_limit is not None: - kwargs["search_limit"] = search_limit - if context_char_budget is not None: - kwargs["context_char_budget"] = context_char_budget - if llm_temperature is not None: - kwargs["llm_temperature"] = llm_temperature - if llm_max_tokens is not None: - kwargs["llm_max_tokens"] = llm_max_tokens - if search_type is not None: - kwargs["search_type"] = search_type - return await run_memsciqa_eval(**kwargs) - - if dataset == "longmemeval": - kwargs: Dict[str, Any] = {"sample_size": sample_size, "end_user_id": end_user_id} - if search_limit is not None: - kwargs["search_limit"] = search_limit - if context_char_budget is not None: - kwargs["context_char_budget"] = context_char_budget - if llm_temperature is not None: - kwargs["llm_temperature"] = llm_temperature - if llm_max_tokens is not None: - kwargs["llm_max_tokens"] = llm_max_tokens - if search_type is not None: - kwargs["search_type"] = search_type - if start_index is not None: - kwargs["start_index"] = start_index - if max_contexts_per_item is not None: - kwargs["max_contexts_per_item"] = max_contexts_per_item - return await run_longmemeval_test(**kwargs) - raise ValueError(f"未知数据集: {dataset}") - - -def main(): - load_dotenv() - parser = argparse.ArgumentParser(description="统一评估入口:memsciqa / longmemeval / locomo") - parser.add_argument("--dataset", choices=["memsciqa", "longmemeval", "locomo"], required=True) - parser.add_argument("--sample-size", type=int, default=1, help="先用一条数据跑通") - parser.add_argument("--reset-group", action="store_true", help="运行前清空当前 end_user_id 的图数据") - parser.add_argument("--group-id", type=str, default=None, help="可选 end_user_id,默认取 runtime.json") - parser.add_argument("--judge-model", type=str, default=None, help="可选:longmemeval 判别式评测模型名") - parser.add_argument("--search-limit", type=int, default=None, help="检索返回的对话节点数量上限(不提供则使用各脚本默认)") - parser.add_argument("--context-char-budget", type=int, default=None, help="上下文字符预算(不提供则使用各脚本默认)") - parser.add_argument("--llm-temperature", type=float, default=None, help="生成温度(不提供则使用各脚本默认)") - parser.add_argument("--llm-max-tokens", type=int, default=None, help="最大生成 tokens(不提供则使用各脚本默认)") - parser.add_argument("--search-type", type=str, default=None, choices=["keyword", "embedding", "hybrid"], help="检索类型(不提供则使用各脚本默认)") - # 仅透传到 longmemeval;其他数据集忽略 - parser.add_argument("--start-index", type=int, default=None, help="仅 longmemeval:起始样本索引(不提供则用脚本默认)") - parser.add_argument("--max-contexts-per-item", type=int, default=None, help="仅 longmemeval:每条样本摄入的上下文数量上限(不提供则用脚本默认)") - parser.add_argument("--output", type=str, default=None, help="可选:将评估结果保存到指定文件路径(JSON);不提供时默认保存到 evaluation//results 目录") - args = parser.parse_args() - - result = asyncio.run(run( - args.dataset, - args.sample_size, - args.reset_group, - args.end_user_id, - args.judge_model, - args.search_limit, - args.context_char_budget, - args.llm_temperature, - args.llm_max_tokens, - args.search_type, - args.start_index, - args.max_contexts_per_item, - )) - print(json.dumps(result, ensure_ascii=False, indent=2)) - - # 结果输出逻辑保持不变 - if args.output: - out_path = args.output - else: - eval_dir = os.path.dirname(os.path.abspath(__file__)) - dataset_results_dir = os.path.join(eval_dir, args.dataset, "results") - out_filename = f"{args.dataset}_{args.sample_size}.json" - out_path = os.path.join(dataset_results_dir, out_filename) - - out_dir = os.path.dirname(out_path) - if out_dir and not os.path.exists(out_dir): - os.makedirs(out_dir, exist_ok=True) - with open(out_path, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(f"\n结果已保存到: {out_path}") - - -if __name__ == "__main__": - main() diff --git a/redbear-mem-benchmark b/redbear-mem-benchmark index d9a00be6..558c023d 160000 --- a/redbear-mem-benchmark +++ b/redbear-mem-benchmark @@ -1 +1 @@ -Subproject commit d9a00be62d974c0ad071c27e86f878b921c675b6 +Subproject commit 558c023dadb5327a05561b22d8fb363c6ee2be29 From 3af183f6c347d9ca0b279952ecbf812de0db56c1 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 11:24:04 +0800 Subject: [PATCH 085/175] feat(web): workflow add code node --- web/src/i18n/en.ts | 13 +- web/src/i18n/zh.ts | 14 +- .../Workflow/components/Editor/index.tsx | 47 +++-- .../plugin/JavaScriptHighlightPlugin.tsx | 164 ++++++++++++++++++ .../Editor/plugin/Python3HighlightPlugin.tsx | 159 +++++++++++++++++ .../Properties/CodeExecution/OutputList.tsx | 86 +++++++++ .../Properties/CodeExecution/index.tsx | 128 ++++++++++++++ .../Properties/HttpRequest/EditableTable.tsx | 3 +- .../Properties/HttpRequest/index.tsx | 1 + .../Properties/JinjaRender/index.tsx | 4 +- .../Properties/MappingList/index.tsx | 30 ++-- .../components/Properties/MessageEditor.tsx | 22 +-- .../Properties/hooks/useVariableList.ts | 7 +- .../Workflow/components/Properties/index.tsx | 10 +- .../Properties/properties.module.css | 3 + web/src/views/Workflow/constant.ts | 33 +++- .../views/Workflow/hooks/useWorkflowGraph.ts | 85 ++------- 17 files changed, 678 insertions(+), 131 deletions(-) create mode 100644 web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx create mode 100644 web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx create mode 100644 web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx create mode 100644 web/src/views/Workflow/components/Properties/CodeExecution/index.tsx diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1df2eb6d..3c4aa092 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -866,7 +866,7 @@ export const en = { minimumRetention: 'Minimum retention (λ_time)', minimumRetentionDesc: 'Controls the minimum retention threshold of memory retention', - forgettingRate: 'Forgetting rate (λ_mem)', + forgettingRate: 'Forgetting rate (λ_mem)', forgettingRateDesc: 'Control the speed of memory forgetting, the higher the value, the faster the forgetting', offset: 'Offset (offset)', offsetDesc: 'The offset of the minimum preservation degree', @@ -934,7 +934,7 @@ export const en = { number: 'Number', checkbox: 'Checkbox', apiVariable: 'API Variable', - + displayName: 'Display Name', maxLength: 'Max Length', required: 'Required', @@ -1765,7 +1765,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re externalInteraction: 'External Interaction', "http-request": 'HTTP Request', tool: 'Tools', - code_execution: 'Code Execution', + code: 'Code Execution', "jinja-render": 'Template Rendering', cognitiveUpgrading: 'Cognitive Upgrading (Innovation)', 'memory-read': 'Memory Retrieval', @@ -1858,6 +1858,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'array[number]': 'Array[Number]', 'array[boolean]': 'Array[Boolean]', 'array[object]': 'Array[Object]', + 'object': 'Object', addParams: 'Add Extract Variable', promptPlaceholder: 'Write prompts here, type "{" to insert variables, type "insert" to insert', }, @@ -1962,6 +1963,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re config_id: 'Memory Configuration', search_switch: 'Search Mode', }, + + 'code': { + input_variables: 'Input Variables', + output_variables: 'Output Variables', + refreshTip: '同步函数签名至代码', + }, name: 'Key', type: 'Type', value: 'Value', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 39908757..a79b9ae9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1609,11 +1609,6 @@ export const zh = { loadingEmpty: '内容正在加载中…', loadingEmptyDesc: '您的内容正在火箭运输中!很快就会降落在您的屏幕上' }, - count: '计数: {{count}}', - increment: '增加', - decrement: '减少', - reset: '重置', - switchLanguage: '切换语言', home: { title: '首页', @@ -1858,7 +1853,7 @@ export const zh = { externalInteraction: '外部交互', "http-request": 'HTTP请求', tool: '工具 (Tool)', - code_execution: '代码执行', + code: '代码执行', "jinja-render": '模板渲染', cognitiveUpgrading: '认知升级(创新)', 'memory-read': '记忆提取', @@ -1952,6 +1947,7 @@ export const zh = { 'array[number]': 'Array[Number]', 'array[boolean]': 'Array[Boolean]', 'array[object]': 'Array[Object]', + 'object': 'Object', addParams: '添加提取变量', promptPlaceholder: '在此处编写提示,输入“{”插入变量,输入“insert”插入', }, @@ -2056,6 +2052,12 @@ export const zh = { config_id: '记忆配置', search_switch: '检索模式', }, + + 'code': { + input_variables: '输入变量', + output_variables: '输出变量', + refreshTip: '同步函数签名至代码', + }, name: '键', type: '类型', value: '值', diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index fd3e937b..e37c71de 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -15,22 +15,24 @@ import CharacterCountPlugin from './plugin/CharacterCountPlugin' import InitialValuePlugin from './plugin/InitialValuePlugin'; import CommandPlugin from './plugin/CommandPlugin'; import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin'; +import Python3HighlightPlugin from './plugin/Python3HighlightPlugin'; +import JavaScriptHighlightPlugin from './plugin/JavaScriptHighlightPlugin'; import LineNumberPlugin from './plugin/LineNumberPlugin'; import BlurPlugin from './plugin/BlurPlugin'; import { VariableNode } from './nodes/VariableNode' -interface LexicalEditorProps { +export interface LexicalEditorProps { placeholder?: string; value?: string; onChange?: (value: string) => void; - options: Suggestion[]; + options?: Suggestion[]; variant?: 'outlined' | 'borderless'; height?: number; fontSize?: number; lineHeight?: number; - enableJinja2?: boolean; size?: 'default' | 'small'; - type?: 'input' | 'textarea' + type?: 'input' | 'textarea', + language?: 'string' | 'jinja2' | 'python3' | 'javascript' } const theme = { @@ -54,20 +56,25 @@ const Editor: FC =({ placeholder = "请输入内容...", value = "", onChange, - options, + options = [], variant = 'borderless', - enableJinja2 = false, size = 'default', - type = 'textarea' + type = 'textarea', + language = 'string' }) => { - const [_count, setCount] = useState(0); + const [enableJinja2, setEnableJinja2] = useState(false) + const [enableLineNumbers, setEnableLineNumbers] = useState(false) useEffect(() => { - if (enableJinja2) { - const styleId = 'jinja2-styles'; + const needsLineNumbers = language === 'jinja2' || language === 'python3' || language === 'javascript'; + setEnableJinja2(language === 'jinja2'); + setEnableLineNumbers(needsLineNumbers); + + if (needsLineNumbers) { + const styleId = 'code-editor-styles'; let existingStyle = document.getElementById(styleId); - + if (!existingStyle) { const style = document.createElement('style'); style.id = styleId; @@ -119,6 +126,7 @@ const Editor: FC =({ } .editor-content-with-numbers { white-space: pre-wrap; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; } .editor-content-with-numbers p { margin: 0; @@ -128,7 +136,8 @@ const Editor: FC =({ document.head.appendChild(style); } } - }, [enableJinja2]); + }, [language]) + const initialConfig = { namespace: 'AutocompleteEditor', theme: enableJinja2 ? jinja2Theme : theme, @@ -168,7 +177,7 @@ const Editor: FC =({
=({ style={{ minHeight: placeHolderMinheight, position: 'absolute', - top: enableJinja2 ? '4px' : variant === 'borderless' ? '0' : '6px', - left: enableJinja2 ? '16px' : (variant === 'borderless' ? '0' : '11px'), + top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px', + left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'), color: '#A8A9AA', fontSize: fontSize, lineHeight: placeHolderMinheight, @@ -227,12 +236,14 @@ const Editor: FC =({ /> - {enableJinja2 && } - {enableJinja2 && } + {language === 'jinja2' && } + {language === 'python3' && } + {language === 'javascript' && } + {enableLineNumbers && } { setCount(count) }} onChange={onChange} /> - {enableJinja2 && } + {enableLineNumbers && }
); diff --git a/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx new file mode 100644 index 00000000..90053646 --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx @@ -0,0 +1,164 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { TextNode, $createTextNode, $getSelection, $isRangeSelection } from 'lexical'; + +const JS_KEYWORDS = new Set([ + 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', + 'in', 'instanceof', 'let', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', + 'typeof', 'var', 'void', 'while', 'with', 'yield', 'true', 'false', 'null', 'undefined' +]); + +const JavaScriptHighlightPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerNodeTransform(TextNode, (textNode: TextNode) => { + const text = textNode.getTextContent(); + + if (textNode.hasFormat('code')) return; + if (!needsHighlight(text)) return; + + const parent = textNode.getParent(); + if (!parent) return; + + const selection = $getSelection(); + let selectionOffset = null; + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + if (anchor.getNode() === textNode) { + selectionOffset = anchor.offset; + } + } + + const tokens = tokenizeJavaScript(text); + if (tokens.length <= 1) return; + + const newNodes = tokens.map(token => { + const newNode = $createTextNode(token.text); + newNode.toggleFormat('code'); + + switch (token.type) { + case 'keyword': + newNode.setStyle('color: #d73a49; font-weight: 600;'); + break; + case 'string': + newNode.setStyle('color: #032f62;'); + break; + case 'comment': + newNode.setStyle('color: #6a737d; font-style: italic;'); + break; + case 'number': + newNode.setStyle('color: #005cc5; font-weight: 500;'); + break; + case 'function': + newNode.setStyle('color: #6f42c1; font-weight: 500;'); + break; + } + + return newNode; + }); + + if (newNodes.length > 1) { + textNode.replace(newNodes[0]); + for (let i = 1; i < newNodes.length; i++) { + newNodes[i - 1].insertAfter(newNodes[i]); + } + + if (selectionOffset !== null && $isRangeSelection(selection)) { + let currentOffset = 0; + for (const node of newNodes) { + const nodeLength = node.getTextContent().length; + if (currentOffset + nodeLength >= selectionOffset) { + node.select(selectionOffset - currentOffset, selectionOffset - currentOffset); + break; + } + currentOffset += nodeLength; + } + } + } + }); + }, [editor]); + + return null; +}; + +function needsHighlight(text: string): boolean { + return /[a-zA-Z0-9_/"'`]/.test(text); +} + +function tokenizeJavaScript(text: string): Array<{text: string, type: string}> { + const tokens: Array<{text: string, type: string}> = []; + let i = 0; + + while (i < text.length) { + // Single-line comments + if (text.slice(i, i + 2) === '//') { + let start = i; + while (i < text.length && text[i] !== '\n') i++; + tokens.push({ text: text.slice(start, i), type: 'comment' }); + continue; + } + + // Multi-line comments + if (text.slice(i, i + 2) === '/*') { + let start = i; + i += 2; + while (i < text.length && text.slice(i, i + 2) !== '*/') i++; + if (i < text.length) i += 2; + tokens.push({ text: text.slice(start, i), type: 'comment' }); + continue; + } + + // Strings + if (text[i] === '"' || text[i] === "'" || text[i] === '`') { + const quote = text[i]; + let start = i++; + + while (i < text.length) { + if (text[i] === quote && text[i - 1] !== '\\') { + i++; + break; + } + i++; + } + tokens.push({ text: text.slice(start, i), type: 'string' }); + continue; + } + + // Numbers + if (/\d/.test(text[i])) { + let start = i; + while (i < text.length && /[\d.]/.test(text[i])) i++; + tokens.push({ text: text.slice(start, i), type: 'number' }); + continue; + } + + // Keywords and identifiers + if (/[a-zA-Z_$]/.test(text[i])) { + let start = i; + while (i < text.length && /[a-zA-Z0-9_$]/.test(text[i])) i++; + const word = text.slice(start, i); + + if (JS_KEYWORDS.has(word)) { + tokens.push({ text: word, type: 'keyword' }); + } else if (i < text.length && text[i] === '(') { + tokens.push({ text: word, type: 'function' }); + } else { + tokens.push({ text: word, type: 'text' }); + } + continue; + } + + // Other characters + let start = i; + while (i < text.length && !/[a-zA-Z0-9_$/"'`]/.test(text[i])) i++; + if (start < i) { + tokens.push({ text: text.slice(start, i), type: 'text' }); + } + } + + return tokens; +} + +export default JavaScriptHighlightPlugin; diff --git a/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx new file mode 100644 index 00000000..387160ed --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx @@ -0,0 +1,159 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { TextNode, $createTextNode, $getSelection, $isRangeSelection } from 'lexical'; + +const PYTHON_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', + 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', + 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', + 'with', 'yield' +]); + +const Python3HighlightPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerNodeTransform(TextNode, (textNode: TextNode) => { + const text = textNode.getTextContent(); + + if (textNode.hasFormat('code')) return; + if (!needsHighlight(text)) return; + + const parent = textNode.getParent(); + if (!parent) return; + + const selection = $getSelection(); + let selectionOffset = null; + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + if (anchor.getNode() === textNode) { + selectionOffset = anchor.offset; + } + } + + const tokens = tokenizePython(text); + if (tokens.length <= 1) return; + + const newNodes = tokens.map(token => { + const newNode = $createTextNode(token.text); + newNode.toggleFormat('code'); + + switch (token.type) { + case 'keyword': + newNode.setStyle('color: #d73a49; font-weight: 600;'); + break; + case 'string': + newNode.setStyle('color: #032f62;'); + break; + case 'comment': + newNode.setStyle('color: #6a737d; font-style: italic;'); + break; + case 'number': + newNode.setStyle('color: #005cc5; font-weight: 500;'); + break; + case 'function': + newNode.setStyle('color: #6f42c1; font-weight: 500;'); + break; + } + + return newNode; + }); + + if (newNodes.length > 1) { + textNode.replace(newNodes[0]); + for (let i = 1; i < newNodes.length; i++) { + newNodes[i - 1].insertAfter(newNodes[i]); + } + + if (selectionOffset !== null && $isRangeSelection(selection)) { + let currentOffset = 0; + for (const node of newNodes) { + const nodeLength = node.getTextContent().length; + if (currentOffset + nodeLength >= selectionOffset) { + node.select(selectionOffset - currentOffset, selectionOffset - currentOffset); + break; + } + currentOffset += nodeLength; + } + } + } + }); + }, [editor]); + + return null; +}; + +function needsHighlight(text: string): boolean { + return /[a-zA-Z0-9_#"']/.test(text); +} + +function tokenizePython(text: string): Array<{text: string, type: string}> { + const tokens: Array<{text: string, type: string}> = []; + let i = 0; + + while (i < text.length) { + // Comments + if (text[i] === '#') { + let start = i; + while (i < text.length && text[i] !== '\n') i++; + tokens.push({ text: text.slice(start, i), type: 'comment' }); + continue; + } + + // Strings + if (text[i] === '"' || text[i] === "'") { + const quote = text[i]; + let start = i++; + const isTriple = text.slice(start, start + 3) === quote.repeat(3); + if (isTriple) i += 2; + + while (i < text.length) { + if (isTriple && text.slice(i, i + 3) === quote.repeat(3)) { + i += 3; + break; + } else if (!isTriple && text[i] === quote && text[i - 1] !== '\\') { + i++; + break; + } + i++; + } + tokens.push({ text: text.slice(start, i), type: 'string' }); + continue; + } + + // Numbers + if (/\d/.test(text[i])) { + let start = i; + while (i < text.length && /[\d.]/.test(text[i])) i++; + tokens.push({ text: text.slice(start, i), type: 'number' }); + continue; + } + + // Keywords and identifiers + if (/[a-zA-Z_]/.test(text[i])) { + let start = i; + while (i < text.length && /[a-zA-Z0-9_]/.test(text[i])) i++; + const word = text.slice(start, i); + + if (PYTHON_KEYWORDS.has(word)) { + tokens.push({ text: word, type: 'keyword' }); + } else if (i < text.length && text[i] === '(') { + tokens.push({ text: word, type: 'function' }); + } else { + tokens.push({ text: word, type: 'text' }); + } + continue; + } + + // Other characters + let start = i; + while (i < text.length && !/[a-zA-Z0-9_#"']/.test(text[i])) i++; + if (start < i) { + tokens.push({ text: text.slice(start, i), type: 'text' }); + } + } + + return tokens; +} + +export default Python3HighlightPlugin; diff --git a/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx b/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx new file mode 100644 index 00000000..8be8d97e --- /dev/null +++ b/web/src/views/Workflow/components/Properties/CodeExecution/OutputList.tsx @@ -0,0 +1,86 @@ +import { type FC, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next' +import { Button, Form, Input, Divider, Space, Select } from 'antd'; + +interface OutputListProps { + label: string; + name: string; + extra?: ReactNode; +} + +const types = [ + 'string', + 'number', + 'boolean', + 'array[string]', + 'array[number]', + 'array[boolean]', + 'array[object]', + 'object' +] +const OutputList: FC = ({ label, name, extra }) => { + const { t } = useTranslation() + return ( + <> + + {(fields, { add, remove }) => ( + <> +
+
+ {label} +
+ + + {extra} + + +
+ {fields.map(({ key, name, ...restField }) => ( +
+ + + + + + + + + + + + + + + + + + + ) +} + +export default CodeExecution diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx index 671ae074..d1383f45 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx @@ -144,6 +144,7 @@ const EditableTable: React.FC = ({ icon={block ? undefined : } onClick={() => add(createNewRow())} size="small" + block={block} className={block ? "rb:mt-1 rb:text-[12px]! rb:bg-transparent!" : "rb:text-[12px]!"} > {block && `+${t('common.add')}`} @@ -155,7 +156,7 @@ const EditableTable: React.FC = ({ {title && (
{title}
- +
)} diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index 7fcd333e..a6b50e33 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -196,6 +196,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an placeholder={t('common.pleaseSelect')} options={options.filter(vo => vo.dataType.includes('file'))} filterBooleanType={true} + size="small" /> } diff --git a/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx index d1a392ae..7b466310 100644 --- a/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx +++ b/web/src/views/Workflow/components/Properties/JinjaRender/index.tsx @@ -175,7 +175,7 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti return ( <> - + @@ -184,7 +184,7 @@ const JinjaRender: FC = ({ selectedNode, options, templateOpti title={t('workflow.config.jinja-render.template')} isArray={false} parentName="template" - enableJinja2={true} + language="jinja2" options={templateOptions} titleVariant="borderless" size="small" diff --git a/web/src/views/Workflow/components/Properties/MappingList/index.tsx b/web/src/views/Workflow/components/Properties/MappingList/index.tsx index 4da1f3c3..d0f56e1c 100644 --- a/web/src/views/Workflow/components/Properties/MappingList/index.tsx +++ b/web/src/views/Workflow/components/Properties/MappingList/index.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import { type FC, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next' -import { Button, Form, Input, Divider } from 'antd'; +import { Button, Form, Input, Divider, Space } from 'antd'; import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import VariableSelect from '../VariableSelect' interface MappingListProps { + label: string; name: string; options: Suggestion[]; + extra?: ReactNode; + valueKey?: string; } -const MappingList: React.FC = ({ name, options }) => { +const MappingList: FC = ({ label, name, options, extra, valueKey = 'value' }) => { const { t } = useTranslation() return ( <> @@ -17,16 +20,19 @@ const MappingList: React.FC = ({ name, options }) => { <>
- {t('workflow.config.jinja-render.mapping')} + {label}
- + + {extra} + +
{fields.map(({ key, name, ...restField }) => (
@@ -43,7 +49,7 @@ const MappingList: React.FC = ({ name, options }) => { void; size?: 'small' | 'default' } @@ -29,8 +29,8 @@ const MessageEditor: FC = ({ isArray = true, parentName = 'messages', placeholder, - options, - enableJinja2 = false, + options = [], + language, size = 'default' }) => { const { t } = useTranslation() @@ -81,13 +81,15 @@ const MessageEditor: FC = ({ -
{title ?? t('workflow.answerDesc')}
+ : title}
- +
); @@ -132,7 +134,7 @@ const MessageEditor: FC = ({ )} - + ); diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index 37574f75..11d91d98 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -68,7 +68,7 @@ const processNodeVariables = ( if (p?.name) addVariable(variableList, addedKeys, `${dataNodeId}_${p.name}`, p.name, p.type || 'string', `${dataNodeId}.${p.name}`, nodeData); }); break; - + case 'var-aggregator': if (config.group.defaultValue) { (config.group_variables.defaultValue || []).forEach((gv: any) => { @@ -106,6 +106,11 @@ const processNodeVariables = ( if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); }); break; + case 'code': + (config.output_variables.defaultValue || []).forEach((cv: any) => { + if (cv.name?.trim()) addVariable(variableList, addedKeys, `${dataNodeId}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${dataNodeId}.${cv.name}`, nodeData); + }); + break; } }; diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 38fd3005..aa757275 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -26,9 +26,10 @@ import MemoryConfig from './MemoryConfig' import VariableList from './VariableList' import { useVariableList, getCurrentNodeVariables, getChildNodeVariables } from './hooks/useVariableList' import styles from './properties.module.css' -import Editor from "../Editor"; +import Editor, { type LexicalEditorProps } from "../Editor"; import RbSlider from './RbSlider' import JinjaRender from './JinjaRender' +import CodeExecution from './CodeExecution' interface PropertiesProps { selectedNode?: Node | null; @@ -364,6 +365,11 @@ const Properties: FC = ({ options={getFilteredVariableList(selectedNode?.data?.type, 'mapping')} templateOptions={getFilteredVariableList(selectedNode?.data?.type, 'template')} /> + : selectedNode?.data?.type === 'code' + ? : configs && Object.keys(configs).length > 0 && Object.keys(configs).map((key) => { const config = configs[key] || {} @@ -438,7 +444,7 @@ const Properties: FC = ({ title={t(`workflow.config.${selectedNode?.data?.type}.${key}`)} isArray={!!config.isArray} parentName={key} - enableJinja2={config.enableJinja2 as boolean} + language={config.language as LexicalEditorProps['language']} options={getFilteredVariableList(selectedNode?.data?.type, key)} titleVariant={config.titleVariant} size="small" diff --git a/web/src/views/Workflow/components/Properties/properties.module.css b/web/src/views/Workflow/components/Properties/properties.module.css index 292a13e4..4820788f 100644 --- a/web/src/views/Workflow/components/Properties/properties.module.css +++ b/web/src/views/Workflow/components/Properties/properties.module.css @@ -87,4 +87,7 @@ .properties :global(.ant-select .ant-select-arrow) { font-size: 10px; inset-inline-end: 6px; +} +.properties :global(.ant-input-sm) { + padding: 3.6px 7px; } \ No newline at end of file diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index e250e184..2cdd60d9 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -284,7 +284,7 @@ export const nodeLibrary: NodeLibrary[] = [ config: { input: { type: 'variableList', - filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop'], + filterNodeTypes: ['knowledge-retrieval', 'iteration', 'loop', 'parameter-extractor', 'code'], filterVariableNames: ['message'] }, parallel: { @@ -431,7 +431,32 @@ export const nodeLibrary: NodeLibrary[] = [ } } }, - // { type: "code_execution", icon: codeExecutionIcon }, + { type: "code", icon: codeExecutionIcon, + config: { + input_variables: { + type: 'inputList', + defaultValue: [{ name: 'arg1' }, { name: 'arg2' }] + }, + language: { + type: 'select', + defaultValue: 'python3' + }, + code: { + type: 'messageEditor', + isArray: false, + language: ['python3', 'javascript'], + titleVariant: 'borderless', + defaultValue: `def main(arg1: str, arg2: str): + return { + "result": arg1 + arg2, + }` + }, + output_variables: { + type: 'outputList', + defaultValue: [{name: 'result', type: 'string'}] + }, + } + }, { type: "jinja-render", icon: templateRenderingIcon, config: { mapping: { @@ -441,12 +466,12 @@ export const nodeLibrary: NodeLibrary[] = [ template: { type: 'messageEditor', isArray: false, - enableJinja2: true, + language: 'jinja2', titleVariant: 'borderless', defaultValue: "{{arg1}}" }, } - } + }, ] }, // { diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 0cc69fea..48cd6652 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -109,6 +109,12 @@ export const useWorkflowGraph = ({ : group_variables } else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value })) + } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { + try { + nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) + } catch { + nodeLibraryConfig.config[key].defaultValue = config[key] + } } else if (nodeLibraryConfig.config && nodeLibraryConfig.config[key] && config[key]) { nodeLibraryConfig.config[key].defaultValue = config[key] } @@ -588,77 +594,6 @@ export const useWorkflowGraph = ({ graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight); } }; - - const nodeChangePosition = ({ node, options }: { node: Node; options: { skipParentHandler?: boolean } }) => { - const embedPadding = 50; // Define the embed padding constant - if (options.skipParentHandler) { - return - } - - const children = node.getChildren() - if (children && children.length) { - node.prop('originPosition', node.getPosition()) - } - - const parent = node.getParent() - if (parent && parent.isNode()) { - let originSize = parent.prop('originSize') - if (originSize == null) { - originSize = parent.getSize() - parent.prop('originSize', originSize) - } - - let originPosition = parent.prop('originPosition') - if (originPosition == null) { - originPosition = parent.getPosition() - parent.prop('originPosition', originPosition) - } - - let x = originPosition.x - let y = originPosition.y - let cornerX = originPosition.x + originSize.width - let cornerY = originPosition.y + originSize.height - let hasChange = false - - const children = parent.getChildren() - if (children) { - children.forEach((child) => { - const bbox = child.getBBox().inflate(embedPadding) - const corner = bbox.getCorner() - - if (bbox.x < x) { - x = bbox.x - hasChange = true - } - - if (bbox.y < y) { - y = bbox.y - hasChange = true - } - - if (corner.x > cornerX) { - cornerX = corner.x - hasChange = true - } - - if (corner.y > cornerY) { - cornerY = corner.y - hasChange = true - } - }) - } - - if (hasChange) { - parent.prop( - { - position: { x, y }, - size: { width: cornerX - x, height: cornerY - y }, - }, - { skipParentHandler: true }, - ) - } - } - } // 初始化 const init = () => { @@ -912,7 +847,13 @@ export const useWorkflowGraph = ({ if (data.config) { Object.keys(data.config).forEach(key => { - if (key === 'memory' && data.config[key] && 'defaultValue' in data.config[key]) { + if (data.type === 'code' && key === 'code' && data.config[key] && 'defaultValue' in data.config[key]) { + const code = data.config[key].defaultValue || '' + itemConfig = { + ...itemConfig, + code: btoa(encodeURIComponent(code || '')) + } + } else if (key === 'memory' && data.config[key] && 'defaultValue' in data.config[key]) { const { messages, ...rest } = data.config[key].defaultValue let memoryMessage = { role: 'USER', content: data.config[key].defaultValue.messages } itemConfig = { From a53be317656c1c2f17db96e143be4ea29bba9a31 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 11:41:16 +0800 Subject: [PATCH 086/175] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E7=9A=84=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../forgetting_engine/forgetting_scheduler.py | 2 +- api/app/services/memory_storage_service.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py index 5a178fc2..072d587c 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py @@ -343,7 +343,7 @@ class ForgettingScheduler: params = {} if end_user_id: - end_user_id['end_user_id'] = end_user_id + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 0ede7bd3..784288de 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -182,13 +182,21 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # 将 ORM 对象转换为字典列表 data_list = [] for config in configs: + # 安全地转换 user_id 为 int + config_id_old = None + if config.user_id: + try: + config_id_old = int(config.user_id) + except (ValueError, TypeError): + config_id_old = None + config_dict = { "config_id": config.config_id, "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": int(config.user_id), + "config_id_old": config_id_old, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, @@ -268,7 +276,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) try: config_service = MemoryConfigService(self.db) memory_config = config_service.load_memory_config( - config_id=int(cid), + config_id=str(cid), service_name="MemoryStorageService.pilot_run_stream" ) logger.info(f"Configuration loaded successfully: {memory_config.config_name}") From d160076267ff28653c39f94aafdbaee5082f69b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=90=E5=8A=9B=E9=BD=90?= <162269739+lanceyq@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:44:50 +0800 Subject: [PATCH 087/175] Fix/redbear benchmark (#205) * Refactor: Move evaluation folder to redbear-mem-benchmark submodule * [changes]Update submodule reference * Refactor: Move evaluation folder to redbear-mem-benchmark submodule * [changes]Update submodule reference * Remove duplicate evaluation submodule, use redbear-mem-benchmark instead --- redbear-mem-benchmark | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redbear-mem-benchmark b/redbear-mem-benchmark index 558c023d..4b0257bb 160000 --- a/redbear-mem-benchmark +++ b/redbear-mem-benchmark @@ -1 +1 @@ -Subproject commit 558c023dadb5327a05561b22d8fb363c6ee2be29 +Subproject commit 4b0257bb4e7dc384b2aaf849b0bd6eae4b39835d From 73c78103108ff3b3a7cd4c053b48bf58b0f80150 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:45:14 +0800 Subject: [PATCH 088/175] Fix/memory bug fix (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * 检查需要更改的格式问题 --------- Co-authored-by: lanceyq <1982376970@qq.com> --- .../forgetting_engine/forgetting_scheduler.py | 2 +- api/app/services/memory_storage_service.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py index 5a178fc2..072d587c 100644 --- a/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py +++ b/api/app/core/memory/storage_services/forgetting_engine/forgetting_scheduler.py @@ -343,7 +343,7 @@ class ForgettingScheduler: params = {} if end_user_id: - end_user_id['end_user_id'] = end_user_id + params['end_user_id'] = end_user_id results = await self.connector.execute_query(query, **params) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 0ede7bd3..784288de 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -182,13 +182,21 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # 将 ORM 对象转换为字典列表 data_list = [] for config in configs: + # 安全地转换 user_id 为 int + config_id_old = None + if config.user_id: + try: + config_id_old = int(config.user_id) + except (ValueError, TypeError): + config_id_old = None + config_dict = { "config_id": config.config_id, "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, "end_user_id": config.end_user_id, - "config_id_old": int(config.user_id), + "config_id_old": config_id_old, "apply_id": config.apply_id, "llm_id": config.llm_id, "embedding_id": config.embedding_id, @@ -268,7 +276,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) try: config_service = MemoryConfigService(self.db) memory_config = config_service.load_memory_config( - config_id=int(cid), + config_id=str(cid), service_name="MemoryStorageService.pilot_run_stream" ) logger.info(f"Configuration loaded successfully: {memory_config.config_name}") From 1324ba3a494e9f2f7e311f095ed17f9f82dcd1c0 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 13:47:55 +0800 Subject: [PATCH 089/175] fix(web): remove URI decode and encode --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 48cd6652..4c010de0 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -111,7 +111,7 @@ export const useWorkflowGraph = ({ nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value })) } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { try { - nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) + nodeLibraryConfig.config[key].defaultValue = atob(config[key] as string) } catch { nodeLibraryConfig.config[key].defaultValue = config[key] } @@ -851,7 +851,7 @@ export const useWorkflowGraph = ({ const code = data.config[key].defaultValue || '' itemConfig = { ...itemConfig, - code: btoa(encodeURIComponent(code || '')) + code: btoa(code || '') } } else if (key === 'memory' && data.config[key] && 'defaultValue' in data.config[key]) { const { messages, ...rest } = data.config[key].defaultValue From 2694576a32c80b5746f0bf8b1dc428469dced72a Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 27 Jan 2026 14:04:44 +0800 Subject: [PATCH 090/175] [add] plugin system and base sso module --- api/app/core/config.py | 23 +++++++++++ api/app/models/tenant_model.py | 4 ++ api/app/models/user_model.py | 4 ++ api/app/plugins/__init__.py | 74 ++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 api/app/plugins/__init__.py diff --git a/api/app/core/config.py b/api/app/core/config.py index 59c6ff5f..85add288 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -9,6 +9,25 @@ load_dotenv() class Settings: + # ======================================================================== + # Deployment Mode Configuration + # ======================================================================== + # community: 社区版(开源,功能受限) + # cloud: SaaS 云服务版(全功能,按量计费) + # enterprise: 企业私有化版(License 控制) + DEPLOYMENT_MODE: str = os.getenv("DEPLOYMENT_MODE", "community") + + # License 配置(企业版) + LICENSE_FILE: str = os.getenv("LICENSE_FILE", "/etc/app/license.json") + LICENSE_SERVER_URL: str = os.getenv("LICENSE_SERVER_URL", "https://license.yourcompany.com") + + # 计费服务配置(SaaS 版) + BILLING_SERVICE_URL: str = os.getenv("BILLING_SERVICE_URL", "") + + # 基础 URL(用于 SSO 回调等) + BASE_URL: str = os.getenv("BASE_URL", "http://localhost:8000") + FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:3000") + ENABLE_SINGLE_WORKSPACE: bool = os.getenv("ENABLE_SINGLE_WORKSPACE", "true").lower() == "true" # API Keys Configuration OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "") @@ -72,6 +91,10 @@ class Settings: # Single Sign-On configuration ENABLE_SINGLE_SESSION: bool = os.getenv("ENABLE_SINGLE_SESSION", "false").lower() == "true" + + # SSO 免登配置 + SSO_TOKEN_EXPIRE_SECONDS: int = int(os.getenv("SSO_TOKEN_EXPIRE_SECONDS", "300")) + SSO_TRUSTED_SOURCES_CONFIG: str = os.getenv("SSO_TRUSTED_SOURCES_CONFIG", "{}") # File Upload MAX_FILE_SIZE: int = int(os.getenv("MAX_FILE_SIZE", "52428800")) diff --git a/api/app/models/tenant_model.py b/api/app/models/tenant_model.py index 552e87b5..54a3e347 100644 --- a/api/app/models/tenant_model.py +++ b/api/app/models/tenant_model.py @@ -16,6 +16,10 @@ class Tenants(Base): updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) is_active = Column(Boolean, default=True) + # SSO 外部关联字段 + external_id = Column(String(100), nullable=True, index=True) # 外部企业ID + external_source = Column(String(50), nullable=True) # 来源系统 + # Relationship to users - one tenant has many users users = relationship("User", back_populates="tenant") diff --git a/api/app/models/user_model.py b/api/app/models/user_model.py index 89971a3a..663bfc71 100644 --- a/api/app/models/user_model.py +++ b/api/app/models/user_model.py @@ -18,6 +18,10 @@ class User(Base): updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now) last_login_at = Column(DateTime, nullable=True) # 最后登录时间,可为空 + # SSO 外部关联字段 + external_id = Column(String(100), nullable=True) # 外部用户ID + external_source = Column(String(50), nullable=True) # 来源系统 + current_workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), nullable=True) # 当前工作空间ID,可为空 # Foreign key to tenant - each user belongs to exactly one tenant diff --git a/api/app/plugins/__init__.py b/api/app/plugins/__init__.py new file mode 100644 index 00000000..e9ef92fd --- /dev/null +++ b/api/app/plugins/__init__.py @@ -0,0 +1,74 @@ +# app/plugins/__init__.py +""" +插件系统 - 支持开源核心 + 闭源增值模块 + +使用方式: +1. 开源版(community):基础功能 +2. 商业版(enterprise):加载 premium 包中的高级实现 +""" +import os +from typing import Dict, Any, Optional +from app.core.logging_config import get_logger + +logger = get_logger(__name__) + +# 版本标识 +EDITION = os.environ.get("EDITION", "community") +IS_ENTERPRISE = EDITION == "enterprise" + +# 插件注册表 +_plugins: Dict[str, Any] = {} + +# 路由注册表(用于动态注册闭源模块的路由) +_routers: list = [] + + +def is_enterprise() -> bool: + """是否为商业版""" + return IS_ENTERPRISE + + +def list_plugins() -> list: + """列出所有已注册插件""" + return list(_plugins.keys()) + + +def register_plugin(name: str, instance: Any): + """注册插件""" + _plugins[name] = instance + logger.info(f"插件已注册: {name}") + + +def get_plugin(name: str) -> Optional[Any]: + """获取插件实例""" + return _plugins.get(name) + + +def register_router(router, prefix: str = "", tags: list = None): + """注册路由(供闭源模块使用)""" + _routers.append({ + "router": router, + "prefix": prefix, + "tags": tags or [] + }) + logger.info(f"路由已注册: {prefix}") + + +def get_registered_routers() -> list: + """获取所有注册的路由""" + return _routers + + +def register_premium_routers(app): + """ + 注册 premium 模块的路由到 FastAPI app + + 在商业版 main.py 中调用 + """ + for router_info in _routers: + app.include_router( + router_info["router"], + prefix=f"/api{router_info['prefix']}", + tags=router_info["tags"] + ) + logger.info(f"Premium 路由已挂载: /api{router_info['prefix']}") From a047cf2e91d3973442b67ae285730e1040d2a146 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 14:32:48 +0800 Subject: [PATCH 091/175] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=8E=B7=E5=8F=96memory=5Fconfig=5FidBUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 6e72a53f..09a14c32 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1263,7 +1263,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) result[user_id] = {"memory_config_id": None, "memory_config_name": None} # 2. 批量获取所有相关应用的最新发布版本 - app_ids = list(user_to_app.values()) + app_ids = list(set(user_to_app.values())) if not app_ids: return result @@ -1295,8 +1295,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 批量查询 memory_config_name config_id_to_name = {} if memory_config_ids: - memory_configs = db.query(MemoryConfig).filter(MemoryConfig.id.in_(memory_config_ids)).all() - config_id_to_name = {str(mc.id): mc.config_name for mc in memory_configs} + memory_configs = db.query(MemoryConfig).filter(MemoryConfig.config_id.in_(memory_config_ids)).all() + config_id_to_name = {str(mc.config_id): mc.config_name for mc in memory_configs} # 4. 构建最终结果 for end_user_id, app_id in user_to_app.items(): From 8847039d76b96c286c0c81e00f4ff466c3561c2a Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:36:37 +0800 Subject: [PATCH 092/175] Fix/memory bug fix (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * 检查需要更改的格式问题 * 修复宿主列表获取memory_config_idBUG --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/services/memory_agent_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 6e72a53f..09a14c32 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1263,7 +1263,7 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) result[user_id] = {"memory_config_id": None, "memory_config_name": None} # 2. 批量获取所有相关应用的最新发布版本 - app_ids = list(user_to_app.values()) + app_ids = list(set(user_to_app.values())) if not app_ids: return result @@ -1295,8 +1295,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 批量查询 memory_config_name config_id_to_name = {} if memory_config_ids: - memory_configs = db.query(MemoryConfig).filter(MemoryConfig.id.in_(memory_config_ids)).all() - config_id_to_name = {str(mc.id): mc.config_name for mc in memory_configs} + memory_configs = db.query(MemoryConfig).filter(MemoryConfig.config_id.in_(memory_config_ids)).all() + config_id_to_name = {str(mc.config_id): mc.config_name for mc in memory_configs} # 4. 构建最终结果 for end_user_id, app_id in user_to_app.items(): From 28e6939884435b9da0d5d73e53fff55b14d79aef Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 27 Jan 2026 15:06:50 +0800 Subject: [PATCH 093/175] [modify] file local server url --- api/app/controllers/file_storage_controller.py | 2 +- api/app/core/config.py | 1 + api/app/core/storage/url_signer.py | 2 +- api/env.example | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api/app/controllers/file_storage_controller.py b/api/app/controllers/file_storage_controller.py index c28ffe6c..1a7e8ad2 100644 --- a/api/app/controllers/file_storage_controller.py +++ b/api/app/controllers/file_storage_controller.py @@ -310,7 +310,7 @@ async def get_file_url( try: if permanent: # Generate permanent URL (no expiration check) - server_url = f"http://{settings.SERVER_IP}:8000/api" + server_url = settings.FILE_LOCAL_SERVER_URL url = f"{server_url}/storage/permanent/{file_id}" return success( data={ diff --git a/api/app/core/config.py b/api/app/core/config.py index 85add288..44d29c8f 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -130,6 +130,7 @@ class Settings: # Server Configuration SERVER_IP: str = os.getenv("SERVER_IP", "127.0.0.1") + FILE_LOCAL_SERVER_URL : str = os.getenv("FILE_LOCAL_SERVER_URL", "http://localhost:8000/api") # ======================================================================== # Internal Configuration (not in .env, used by application code) diff --git a/api/app/core/storage/url_signer.py b/api/app/core/storage/url_signer.py index 480c8ef4..712b298e 100644 --- a/api/app/core/storage/url_signer.py +++ b/api/app/core/storage/url_signer.py @@ -36,7 +36,7 @@ def generate_signed_url( """ if base_url is None: # Use SERVER_IP or default to localhost - server_url = f"http://{settings.SERVER_IP}:8000/api" + server_url = settings.FILE_LOCAL_SERVER_URL base_url = server_url # Calculate expiration timestamp diff --git a/api/env.example b/api/env.example index 45ab6c70..274049b9 100644 --- a/api/env.example +++ b/api/env.example @@ -75,6 +75,7 @@ ENABLE_SINGLE_SESSION= MAX_FILE_SIZE=52428800 # 50MB:10 * 1024 * 1024 FILE_PATH=/files +FILE_LOCAL_SERVER_URL="http://localhost:8000/api" # Storage Backend Configuration # Supported values: local, oss, s3 # Default: local From 93c9e76c4b63182b1897afe042b606e77d599e70 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 27 Jan 2026 15:31:29 +0800 Subject: [PATCH 094/175] [add] migration script --- api/app/models/memory_config_model.py | 2 +- .../versions/75f0ec80e50b_202601271517.py | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 api/migrations/versions/75f0ec80e50b_202601271517.py diff --git a/api/app/models/memory_config_model.py b/api/app/models/memory_config_model.py index b468e2a2..454b1b48 100644 --- a/api/app/models/memory_config_model.py +++ b/api/app/models/memory_config_model.py @@ -10,7 +10,7 @@ class MemoryConfig(Base): # 主键 config_id = Column(UUID(as_uuid=True), primary_key=True, comment="配置ID") - + config_id_old = Column(Integer, nullable=True, comment="备份的配置ID") # 基本信息 config_name = Column(String, nullable=False, comment="配置名称") config_desc = Column(String, nullable=True, comment="配置描述") diff --git a/api/migrations/versions/75f0ec80e50b_202601271517.py b/api/migrations/versions/75f0ec80e50b_202601271517.py new file mode 100644 index 00000000..a70d7315 --- /dev/null +++ b/api/migrations/versions/75f0ec80e50b_202601271517.py @@ -0,0 +1,57 @@ +"""202601271517 + +Revision ID: 75f0ec80e50b +Revises: 325b759cd66b +Create Date: 2026-01-27 15:26:48.696600 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '75f0ec80e50b' +down_revision: Union[str, None] = '325b759cd66b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('memory_config', 'config_id', + existing_type=sa.UUID(), + comment='配置ID', + existing_nullable=False) + op.alter_column('memory_config', 'config_id_old', + existing_type=sa.INTEGER(), + comment='备份的配置ID', + existing_comment='配置ID', + existing_nullable=True) + op.add_column('tenants', sa.Column('external_id', sa.String(length=100), nullable=True)) + op.add_column('tenants', sa.Column('external_source', sa.String(length=50), nullable=True)) + op.create_index(op.f('ix_tenants_external_id'), 'tenants', ['external_id'], unique=False) + op.add_column('users', sa.Column('external_id', sa.String(length=100), nullable=True)) + op.add_column('users', sa.Column('external_source', sa.String(length=50), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'external_source') + op.drop_column('users', 'external_id') + op.drop_index(op.f('ix_tenants_external_id'), table_name='tenants') + op.drop_column('tenants', 'external_source') + op.drop_column('tenants', 'external_id') + op.alter_column('memory_config', 'config_id_old', + existing_type=sa.INTEGER(), + comment='配置ID', + existing_comment='备份的配置ID', + existing_nullable=True) + op.alter_column('memory_config', 'config_id', + existing_type=sa.UUID(), + comment=None, + existing_comment='配置ID', + existing_nullable=False) + # ### end Alembic commands ### From 2eff8d1962b603be3df7336958994f90366e6f1e Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 17:23:53 +0800 Subject: [PATCH 095/175] fix(workflow): fix activation and branch control issues in streaming output --- api/app/core/workflow/executor.py | 375 +++++++++-------------- api/app/core/workflow/graph_builder.py | 338 ++++++++++++++++---- api/app/core/workflow/nodes/base_node.py | 88 +----- api/app/core/workflow/nodes/end/node.py | 291 +----------------- api/app/core/workflow/nodes/llm/node.py | 49 +-- api/docker-compose.yml | 12 + 6 files changed, 458 insertions(+), 695 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index a465846b..a98a9c9a 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -11,16 +11,12 @@ from typing import Any from langchain_core.runnables import RunnableConfig from langgraph.graph.state import CompiledStateGraph -from app.core.workflow.graph_builder import GraphBuilder +from app.core.workflow.expression_evaluator import evaluate_expression +from app.core.workflow.graph_builder import GraphBuilder, StreamOutputConfig from app.core.workflow.nodes import WorkflowState from app.core.workflow.nodes.base_config import VariableType from app.core.workflow.nodes.enums import NodeType - -# from app.core.tools.registry import ToolRegistry -# from app.core.tools.executor import ToolExecutor -# from app.core.tools.langchain_adapter import LangchainAdapter -# TOOL_MANAGEMENT_AVAILABLE = True -# from app.db import get_db +from app.core.workflow.template_renderer import render_template logger = logging.getLogger(__name__) @@ -55,6 +51,8 @@ class WorkflowExecutor: self.execution_config = workflow_config.get("execution_config", {}) self.start_node_id = None + self.end_outputs: dict[str, StreamOutputConfig] = {} + self.activate_end: str | None = None self.checkpoint_config = RunnableConfig( configurable={ @@ -127,7 +125,6 @@ class WorkflowExecutor: "user_id": self.user_id, "error": None, "error_node": None, - "streaming_buffer": {}, # 流式缓冲区 "cycle_nodes": [ node.get("id") for node in self.workflow_config.get("nodes") @@ -139,9 +136,8 @@ class WorkflowExecutor: } } - def _build_final_output(self, result, elapsed_time): + def _build_final_output(self, result, elapsed_time, final_output): node_outputs = result.get("node_outputs", {}) - final_output = self._extract_final_output(node_outputs) token_usage = self._aggregate_token_usage(node_outputs) conversation_id = None for node_id, node_output in node_outputs.items(): @@ -161,6 +157,12 @@ class WorkflowExecutor: "error": result.get("error"), } + def _update_end_activate(self, node_id): + for node in self.end_outputs.keys(): + self.end_outputs[node].update_activate(node_id) + if self.end_outputs[node].activate and self.activate_end is None: + self.activate_end = node + def build_graph(self, stream=False) -> CompiledStateGraph: """构建 LangGraph @@ -173,6 +175,7 @@ class WorkflowExecutor: stream=stream, ) self.start_node_id = builder.start_node_id + self.end_outputs = builder.end_node_map graph = builder.build() logger.info(f"工作流图构建完成: execution_id={self.execution_id}") @@ -205,14 +208,34 @@ class WorkflowExecutor: try: result = await graph.ainvoke(initial_state, config=self.checkpoint_config) - + full_content = '' + for end_info in self.end_outputs.values(): + output_template = "".join([output.literal for output in end_info.outputs]) + full_content += render_template( + output_template, + result.get("variables", {}), + result.get("runtime_vars", {}), + strict=False + ) + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "assistant", + "content": full_content + } + ] + ) # 计算耗时 end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() logger.info(f"工作流执行完成: execution_id={self.execution_id}, elapsed_time={elapsed_time:.2f}s") - return self._build_final_output(result, elapsed_time) + return self._build_final_output(result, elapsed_time, full_content) except Exception as e: # 计算耗时(即使失败也记录) @@ -273,6 +296,7 @@ class WorkflowExecutor: # 3. Execute workflow try: chunk_count = 0 + full_content = '' async for event in graph.astream( initial_state, @@ -293,21 +317,25 @@ class WorkflowExecutor: # Handle custom streaming events (chunks from nodes via stream writer) chunk_count += 1 event_type = data.get("type", "node_chunk") # "message" or "node_chunk" - if event_type in ("message", "node_chunk"): + if event_type == "node_chunk": + node_id = data.get("node_id") + if self.activate_end: + end_info = self.end_outputs[self.activate_end] + current_output = end_info.outputs[end_info.cursor] + if current_output.is_variable and node_id in current_output.literal: + if data.get("done"): + end_info.cursor += 1 + else: + full_content += data.get("chunk") + yield { + "event": "message", + "data": { + "chunk": data.get("chunk") + } + } logger.info(f"[CUSTOM] ✅ 收到 {event_type} #{chunk_count} from {data.get('node_id')}" f"- execution_id: {self.execution_id}") - yield { - "event": event_type, # "message" or "node_chunk" - "data": { - "node_id": data.get("node_id"), - "chunk": data.get("chunk"), - "full_content": data.get("full_content"), - "chunk_index": data.get("chunk_index"), - "is_prefix": data.get("is_prefix"), - "is_suffix": data.get("is_suffix"), - "conversation_id": input_data.get("conversation_id"), - } - } + elif event_type == "node_error": yield { "event": event_type, # "message" or "node_chunk" @@ -376,14 +404,107 @@ class WorkflowExecutor: elif mode == "updates": # Handle state updates - store final state - # TODO:流式输出点 + for node_id in data.keys(): + self._update_end_activate(node_id) + wait = False + state = graph.get_state(config=self.checkpoint_config) + node_outputs = state.values.get("runtime_vars", {}) + for _ in data.keys(): + node_outputs = node_outputs | data.get(_).get("runtime_vars", {}) + + while self.activate_end and not wait: + message = '' + logger.info(self.activate_end) + end_info = self.end_outputs[self.activate_end] + content = end_info.outputs[end_info.cursor] + while content.activate: + if not content.is_variable: + full_content += content.literal + message += content.literal + else: + try: + chunk = evaluate_expression( + content.literal, + variables={}, + node_outputs=node_outputs + ) + message += chunk + full_content += chunk + except ValueError: + pass + end_info.cursor += 1 + if end_info.cursor == len(end_info.outputs): + break + content = end_info.outputs[end_info.cursor] + if end_info.cursor != len(end_info.outputs): + wait = True + else: + self.end_outputs.pop(self.activate_end) + self.activate_end = None + for node_id in data.keys(): + self._update_end_activate(node_id) + if message: + yield { + "event": "message", + "data": { + "chunk": message + } + } + logger.debug(f"[UPDATES] 收到 state 更新 from {list(data.keys())} " f"- execution_id: {self.execution_id}") + result = graph.get_state(self.checkpoint_config).values + while self.activate_end: + message = '' + end_info = self.end_outputs[self.activate_end] + content = end_info.outputs[end_info.cursor] + if not content.is_variable: + message += content.literal + else: + node_outputs = result.get("runtime_vars", {}) + variables = result.get("variables", {}) + try: + chunk = evaluate_expression( + content.literal, + variables=variables, + node_outputs=node_outputs + ) + message += chunk + full_content += chunk + except ValueError: + pass + end_info.cursor += 1 + if end_info.cursor == len(end_info.outputs): + self.end_outputs.pop(self.activate_end) + self.activate_end = None + if self.end_outputs: + self.activate_end = list(self.end_outputs.keys())[0] + if message: + yield { + "event": "message", + "data": { + "chunk": message + } + } + # 计算耗时 end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() result = graph.get_state(self.checkpoint_config).values + logger.info(result) + result["messages"].extend( + [ + { + "role": "user", + "content": input_data.get("message", '') + }, + { + "role": "assistant", + "content": full_content + } + ] + ) logger.info( f"Workflow execution completed (streaming), " f"total chunks: {chunk_count}, elapsed: {elapsed_time:.2f}s, execution_id: {self.execution_id}" @@ -392,7 +513,7 @@ class WorkflowExecutor: # 发送 workflow_end 事件 yield { "event": "workflow_end", - "data": self._build_final_output(result, elapsed_time) + "data": self._build_final_output(result, elapsed_time, full_content) } except Exception as e: @@ -414,31 +535,6 @@ class WorkflowExecutor: } } - @staticmethod - def _extract_final_output(node_outputs: dict[str, Any]) -> str | None: - """从节点输出中提取最终输出 - - 优先级: - 1. 最后一个执行的非 start/end 节点的 output - 2. 如果没有节点输出,返回 None - - Args: - node_outputs: 所有节点的输出 - - Returns: - 最终输出字符串或 None - """ - if not node_outputs: - return None - - # 获取最后一个节点的输出 - last_node_output = list(node_outputs.values())[-1] if node_outputs else None - - if last_node_output and isinstance(last_node_output, dict): - return last_node_output.get("output") - - return None - @staticmethod def _aggregate_token_usage(node_outputs: dict[str, Any]) -> dict[str, int] | None: """聚合所有节点的 token 使用情况 @@ -529,178 +625,3 @@ async def execute_workflow_stream( ) async for event in executor.execute_stream(input_data): yield event - -# ==================== 工具管理系统集成 ==================== - -# def get_workflow_tools(workspace_id: str, user_id: str) -> list: -# """获取工作流可用的工具列表 -# -# Args: -# workspace_id: 工作空间ID -# user_id: 用户ID -# -# Returns: -# 可用工具列表 -# """ -# if not TOOL_MANAGEMENT_AVAILABLE: -# logger.warning("工具管理系统不可用") -# return [] -# -# try: -# db = next(get_db()) -# -# # 创建工具注册表 -# registry = ToolRegistry(db) -# -# # 注册内置工具类 -# from app.core.tools.builtin import ( -# DateTimeTool, JsonTool, BaiduSearchTool, MinerUTool, TextInTool -# ) -# registry.register_tool_class(DateTimeTool) -# registry.register_tool_class(JsonTool) -# registry.register_tool_class(BaiduSearchTool) -# registry.register_tool_class(MinerUTool) -# registry.register_tool_class(TextInTool) -# -# # 获取活跃的工具 -# import uuid -# tools = registry.list_tools(workspace_id=uuid.UUID(workspace_id)) -# active_tools = [tool for tool in tools if tool.status.value == "active"] -# -# # 转换为Langchain工具 -# langchain_tools = [] -# for tool_info in active_tools: -# try: -# tool_instance = registry.get_tool(tool_info.id) -# if tool_instance: -# langchain_tool = LangchainAdapter.convert_tool(tool_instance) -# langchain_tools.append(langchain_tool) -# except Exception as e: -# logger.error(f"转换工具失败: {tool_info.name}, 错误: {e}") -# -# logger.info(f"为工作流获取了 {len(langchain_tools)} 个工具") -# return langchain_tools -# -# except Exception as e: -# logger.error(f"获取工作流工具失败: {e}") -# return [] -# -# -# class ToolWorkflowNode: -# """工具工作流节点 - 在工作流中执行工具""" -# -# def __init__(self, node_config: dict, workflow_config: dict): -# """初始化工具节点 -# -# Args: -# node_config: 节点配置 -# workflow_config: 工作流配置 -# """ -# self.node_config = node_config -# self.workflow_config = workflow_config -# self.tool_id = node_config.get("tool_id") -# self.tool_parameters = node_config.get("parameters", {}) -# -# async def run(self, state: WorkflowState) -> WorkflowState: -# """执行工具节点""" -# if not TOOL_MANAGEMENT_AVAILABLE: -# logger.error("工具管理系统不可用") -# state["error"] = "工具管理系统不可用" -# return state -# -# try: -# from sqlalchemy.orm import Session -# db = next(get_db()) -# -# # 创建工具执行器 -# registry = ToolRegistry(db) -# executor = ToolExecutor(db, registry) -# -# # 准备参数(支持变量替换) -# parameters = self._prepare_parameters(state) -# -# # 执行工具 -# result = await executor.execute_tool( -# tool_id=self.tool_id, -# parameters=parameters, -# user_id=uuid.UUID(state["user_id"]), -# workspace_id=uuid.UUID(state["workspace_id"]) -# ) -# -# # 更新状态 -# node_id = self.node_config.get("id") -# if result.success: -# state["node_outputs"][node_id] = { -# "type": "tool", -# "tool_id": self.tool_id, -# "output": result.data, -# "execution_time": result.execution_time, -# "token_usage": result.token_usage -# } -# -# # 更新运行时变量 -# if isinstance(result.data, dict): -# for key, value in result.data.items(): -# state["runtime_vars"][f"{node_id}.{key}"] = value -# else: -# state["runtime_vars"][f"{node_id}.result"] = result.data -# else: -# state["error"] = result.error -# state["error_node"] = node_id -# state["node_outputs"][node_id] = { -# "type": "tool", -# "tool_id": self.tool_id, -# "error": result.error, -# "execution_time": result.execution_time -# } -# -# return state -# -# except Exception as e: -# logger.error(f"工具节点执行失败: {e}") -# state["error"] = str(e) -# state["error_node"] = self.node_config.get("id") -# return state -# -# def _prepare_parameters(self, state: WorkflowState) -> dict: -# """准备工具参数(支持变量替换)""" -# parameters = {} -# -# for key, value in self.tool_parameters.items(): -# if isinstance(value, str) and value.startswith("${") and value.endswith("}"): -# # 变量替换 -# var_path = value[2:-1] -# -# # 支持多层级变量访问,如 ${sys.message} 或 ${node1.result} -# if "." in var_path: -# parts = var_path.split(".") -# current = state.get("variables", {}) -# -# for part in parts: -# if isinstance(current, dict) and part in current: -# current = current[part] -# else: -# # 尝试从运行时变量获取 -# runtime_key = ".".join(parts) -# current = state.get("runtime_vars", {}).get(runtime_key, value) -# break -# -# parameters[key] = current -# else: -# # 简单变量 -# variables = state.get("variables", {}) -# parameters[key] = variables.get(var_path, value) -# else: -# parameters[key] = value -# -# return parameters -# -# -# # 注册工具节点到NodeFactory(如果存在) -# try: -# from app.core.workflow.nodes import NodeFactory -# if hasattr(NodeFactory, 'register_node_type'): -# NodeFactory.register_node_type("tool", ToolWorkflowNode) -# logger.info("工具节点已注册到工作流系统") -# except Exception as e: -# logger.warning(f"注册工具节点失败: {e}") diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index 5b9388fc..a8ca19fe 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -1,12 +1,15 @@ import logging +import re import uuid from collections import defaultdict +from functools import lru_cache from typing import Any from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import START, END from langgraph.graph.state import CompiledStateGraph, StateGraph from langgraph.types import Send +from pydantic import BaseModel, Field from app.core.workflow.expression_evaluator import evaluate_condition from app.core.workflow.nodes import WorkflowState, NodeFactory @@ -15,6 +18,115 @@ from app.core.workflow.nodes.enums import NodeType, BRANCH_NODES logger = logging.getLogger(__name__) +class OutputContent(BaseModel): + """ + Represents a single output segment of an End node. + + An output segment can be either: + - literal text (static string) + - a variable placeholder (e.g. {{ node.field }}) + + Each segment has its own activation state, which is especially + important in stream mode. + """ + + literal: str = Field( + ..., + description="Raw output content. Can be literal text or a variable placeholder." + ) + + activate: bool = Field( + ..., + description=( + "Whether this output segment is currently active.\n" + "- True: allowed to be emitted/output\n" + "- False: blocked until activated by branch control" + ) + ) + + is_variable: bool = Field( + ..., + description=( + "Whether this segment represents a variable placeholder.\n" + "True -> variable (e.g. {{ node.field }})\n" + "False -> literal text" + ) + ) + + +class StreamOutputConfig(BaseModel): + """ + Streaming output configuration for an End node. + + This structure controls: + - whether the End node output is globally active + - which upstream branch nodes are responsible for activation + - how each output segment behaves in streaming mode + """ + + activate: bool = Field( + ..., + description=( + "Global activation state of the End node output.\n" + "If False, no output should be emitted until all control nodes are resolved." + ) + ) + + control_nodes: list[str] = Field( + ..., + description=( + "List of upstream branch node IDs that control this End node.\n" + "Each node must signal completion before output becomes active." + ) + ) + + outputs: list[OutputContent] = Field( + ..., + description="Ordered list of output segments parsed from the output template." + ) + + cursor: int = Field( + ..., + description=( + "Streaming cursor index.\n" + "Indicates how many output segments have already been emitted." + ) + ) + + def update_activate(self, node_id): + """ + Update activation state based on an upstream node completion. + + This method is typically called when a branch/control node finishes execution. + + Behavior: + 1. If the node is a control node: + - Remove it from `control_nodes` + - If all control nodes are resolved, activate the entire output + + 2. Activate variable output segments that depend on this node: + - If an output segment is a variable + - And its literal references the completed node_id + - Mark that segment as active + """ + + # Case 1: resolve control branch dependency + if node_id in self.control_nodes: + self.control_nodes.remove(node_id) + + # All branch constraints resolved → enable output + if not self.control_nodes: + self.activate = True + + # Case 2: activate variable segments related to this node + for i in range(len(self.outputs)): + if ( + self.outputs[i].is_variable + and node_id in self.outputs[i].literal + ): + self.outputs[i].activate = True + + class GraphBuilder: def __init__( self, @@ -29,6 +141,12 @@ class GraphBuilder: self.start_node_id = None self.end_node_ids = [] + self.node_map = {node["id"]: node for node in self.nodes} + self.end_node_map: dict[str, StreamOutputConfig] = {} + self._find_upstream_branch_node = lru_cache( + maxsize=len(self.nodes) * 2 + )(self._find_upstream_branche_node) + self._analyze_end_node_output() self.graph = StateGraph(WorkflowState) self.add_nodes() @@ -43,79 +161,182 @@ class GraphBuilder: def edges(self) -> list[dict[str, Any]]: return self.workflow_config.get("edges", []) - def _analyze_end_node_prefixes(self) -> tuple[dict[str, str], set[str]]: - """ - Analyze the prefix configuration for End nodes. + def get_node_type(self, node_id: str) -> str: + """Retrieve the type of node given its ID. - This function scans each End node's output template, identifies - references to its direct upstream nodes, and extracts the prefix - string appearing before the first reference. + Args: + node_id (str): The unique identifier of the node. Returns: - tuple: - - dict[str, str]: Mapping from upstream node ID to its End node prefix - - set[str]: Set of node IDs that are directly adjacent to End nodes and referenced + str: The type of the node. + + Raises: + RuntimeError: If no node with the given `node_id` exists. """ - import re + try: + return self.node_map[node_id]["type"] + except KeyError: + raise RuntimeError(f"Node not found: Id={node_id}") - prefixes = {} - adjacent_and_referenced = set() # Record nodes directly adjacent to End and referenced + def _find_upstream_branche_node(self, target_node: str) -> tuple[bool, tuple[str]]: + """Find upstream branch nodes for a given target node in the workflow graph. - # 找到所有 End 节点 + This method identifies all upstream control (branch) nodes that can affect + the execution of `target_node`. If `target_node` is reachable from a start + node (i.e., a node with no upstream nodes), the method returns an empty tuple. + + The function distinguishes between branch nodes (defined in `BRANCH_NODES`) + and non-branch nodes, recursively traversing upstream through non-branch + nodes. If any non-branch upstream path does not lead to a branch node, + the result will indicate that no valid upstream branch node exists. + + Args: + target_node (str): The identifier of the target node. + + Returns: + tuple[bool, tuple[str]]: + - has_branch (bool): True if all upstream non-branch paths lead to at least + one branch node; False if any path reaches a start node without a branch. + - branch_nodes (tuple[str]): A deduplicated tuple of upstream branch node IDs + affecting `target_node`. Returns an empty tuple if `has_branch` is False. + """ + source_nodes = [ + edge.get("source") + for edge in self.edges + if edge.get("target") == target_node + ] + if not source_nodes and self.get_node_type(target_node) in [NodeType.START, NodeType.CYCLE_START]: + return False, tuple() + + branch_nodes = [] + non_branch_nodes = [] + + for node_id in source_nodes: + if self.get_node_type(node_id) in BRANCH_NODES: + branch_nodes.append(node_id) + else: + non_branch_nodes.append(node_id) + + has_branch = True + for node_id in non_branch_nodes: + node_has_branch, nodes = self._find_upstream_branche_node(node_id) + has_branch = has_branch and node_has_branch + if not has_branch: + break + branch_nodes.extend(nodes) + if not has_branch: + branch_nodes = [] + + return has_branch, tuple(set(branch_nodes)) + + def _analyze_end_node_output(self): + """ + Analyze output templates of all End nodes and generate StreamOutputConfig. + + This method is responsible for parsing the `output` field of End nodes, + splitting literal text and variable placeholders (e.g. {{ node.field }}), + and determining whether each output segment should be activated immediately + or controlled by upstream branch nodes. + + In stream mode: + - If the End node is controlled by any upstream branch node, the output + will be initially inactive and controlled by those branch nodes. + - Otherwise, the output is activated immediately. + + In non-stream mode: + - All outputs are activated by default. + """ + + # Collect all End nodes in the workflow end_nodes = [node for node in self.nodes if node.get("type") == "end"] logger.info(f"[Prefix Analysis] Found {len(end_nodes)} End nodes") + # Iterate through each End node to analyze its output for end_node in end_nodes: end_node_id = end_node.get("id") - output_template = end_node.get("config", {}).get("output") + config = end_node.get("config", {}) + output = config.get("output") - logger.info(f"[Prefix Analysis] End node {end_node_id} template: {output_template}") - - if not output_template: + # Skip End nodes without output configuration + if not output: continue - # Find all node references in the template - # Matches {{node_id.xxx}} or {{ node_id.xxx }} format (allowing spaces) - pattern = r'\{\{\s*([a-zA-Z0-9_-]+)\.[a-zA-Z0-9_]+\s*\}\}' - matches = list(re.finditer(pattern, output_template)) + # Regex to split output into: + # - variable placeholders: {{ ... }} + # - normal literal text + # + # Example: + # "Hello {{user.name}}!" -> + # ["Hello ", "{{user.name}}", "!"] + pattern = r'\{\{.*?\}\}|[^{}]+' - logger.info(f"[Prefix Analysis] 模板中找到 {len(matches)} 个节点引用") + # Strict variable format: {{ node_id.field_name }} + variable_pattern_string = r'\{\{\s*[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\}\}' + variable_pattern = re.compile(variable_pattern_string) - # Identify all direct upstream nodes connected to the End node - direct_upstream_nodes = [] - for edge in self.edges: - if edge.get("target") == end_node_id: - source_node_id = edge.get("source") - direct_upstream_nodes.append(source_node_id) + # Split output into ordered segments + output_template = list(re.findall(pattern, output)) - logger.info(f"[Prefix Analysis] Direct upstream nodes of End node: {direct_upstream_nodes}") + # Determine whether each segment is literal text + # True -> literal (can be directly output) + # False -> variable placeholder (needs runtime value) + output_flag = [ + not bool(variable_pattern.match(item)) + for item in output_template + ] - # 找到第一个直接上游节点的引用 - for match in matches: - referenced_node_id = match.group(1) - logger.info(f"[Prefix Analysis] Checking reference: {referenced_node_id}") + # Stream mode: output activation depends on upstream branch nodes + if self.stream: + # Find upstream branch nodes that can control this End node + has_branch, control_nodes = self._find_upstream_branche_node(end_node_id) - if referenced_node_id in direct_upstream_nodes: - # 这是直接上游节点的引用,提取前缀 - prefix = output_template[:match.start()] + # Build StreamOutputConfig for this End node + self.end_node_map[end_node_id] = StreamOutputConfig( + # If there is no upstream branch, output is active immediately + activate=not has_branch, - logger.info(f"[Prefix Analysis] " - f"✅ Found reference to direct upstream node {referenced_node_id}, prefix: '{prefix}'") + # Branch nodes that control activation of this End node + control_nodes=list(control_nodes), - # 标记这个节点为"相邻且被引用" - adjacent_and_referenced.add(referenced_node_id) + # Convert output segments into OutputContent objects + outputs=list( + [ + OutputContent( + literal=output_string, + # Literal text can be activated immediately unless blocked by branch + activate=activate, + # Variable segments are marked explicitly + is_variable=not activate + ) + for output_string, activate in zip(output_template, output_flag) + ] + ), + # Cursor for streaming output (initially 0) + cursor=0 + ) + logger.info(f"[Stream Analysis] end_id: {end_node_id}, " + f"activate: {not has_branch}, " + f"control_nodes: {control_nodes}," + f"output: {output_template}," + f"output_activate: {output_flag}") - if prefix: - prefixes[referenced_node_id] = prefix - logger.info(f"[Prefix Analysis] " - f"✅ Assign prefix for node {referenced_node_id}: '{prefix[:50]}...'") - - # 只处理第一个直接上游节点的引用 - break - - logger.info(f"[Prefix Analysis] Final prefixes: {prefixes}") - logger.info(f"[Prefix Analysis] Nodes adjacent to End and referenced: {adjacent_and_referenced}") - return prefixes, adjacent_and_referenced + # Non-stream mode: all outputs are activated by default + else: + self.end_node_map[end_node_id] = StreamOutputConfig( + activate=True, + control_nodes=[], + outputs=list( + [ + OutputContent( + literal=output_string, + activate=True, + is_variable=not activate + ) + for output_string, activate in zip(output_template, output_flag) + ] + ), + cursor=0 + ) def add_nodes(self): """Add all nodes from the workflow configuration to the state graph. @@ -135,9 +356,6 @@ class GraphBuilder: Returns: None """ - # Analyze End node prefixes if in stream mode - end_prefixes, adjacent_and_referenced = self._analyze_end_node_prefixes() if self.stream else ({}, set()) - for node in self.nodes: node_type = node.get("type") node_id = node.get("id") @@ -171,17 +389,6 @@ class GraphBuilder: related_edge[idx]['condition'] = f"node.{node_id}.output == '{related_edge[idx]['label']}'" if node_instance: - # Inject End node prefix configuration if in stream mode - if self.stream and node_id in end_prefixes: - node_instance._end_node_prefix = end_prefixes[node_id] - logger.info(f"Injected End prefix for node {node_id}") - - # Mark nodes as adjacent and referenced to End node in stream mode - if self.stream: - node_instance._is_adjacent_to_end = node_id in adjacent_and_referenced - if node_id in adjacent_and_referenced: - logger.info(f"Node {node_id} marked as adjacent and referenced to End node") - # Wrap node's run method to avoid closure issues if self.stream: # Stream mode: create an async generator function @@ -261,6 +468,7 @@ class GraphBuilder: for source_node, branches in conditional_edges.items(): def make_router(src, branch_list): """reate a router function for each source node that routes to a NOP node for later merging.""" + def make_branch_node(node_name, targets): def node(s): # NOTE: NOP NODE MUST NOT MODIFY STATE diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index 8a85bb0d..4dcdf2bb 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -67,10 +67,6 @@ class WorkflowState(TypedDict): error: str | None error_node: str | None - # Streaming buffer (stores real-time streaming output of nodes) - # Format: {node_id: {"chunks": [...], "full_content": "..."}} - streaming_buffer: Annotated[dict[str, Any], lambda x, y: {**x, **y}] - # node activate status activate: Annotated[dict[str, bool], merge_activate_state] @@ -300,7 +296,7 @@ class BaseNode(ABC): """ if not self.check_activate(state): yield self.trans_activate(state) - logger.info(f"跳过节点{self.node_id}") + logger.info(f"jump node: {self.node_id}") return import time @@ -313,19 +309,6 @@ class BaseNode(ABC): # Get LangGraph's stream writer for sending custom data writer = get_stream_writer() - # Check if this is an End node - # End nodes CAN send chunks (for suffix), but only after LLM content - is_end_node = self.node_type == "end" - - # Check if this node is adjacent to End node (for message type) - is_adjacent_to_end = getattr(self, '_is_adjacent_to_end', False) - - # Determine chunk type: "message" for End and adjacent nodes, "node_chunk" for others - chunk_type = "message" if (is_end_node or is_adjacent_to_end) else "node_chunk" - - logger.debug( - f"节点 {self.node_id} chunk 类型: {chunk_type} (is_end={is_end_node}, adjacent={is_adjacent_to_end})") - # Accumulate complete result (for final wrapping) chunks = [] final_result = None @@ -340,66 +323,25 @@ class BaseNode(ABC): raise TimeoutError() # Check if it's a completion marker - if isinstance(item, dict) and item.get("__final__"): + if item.get("__final__"): final_result = item["result"] - elif isinstance(item, str): - # String is a chunk + else: chunk_count += 1 - chunks.append(item) - full_content = "".join(chunks) + content = str(item.get("chunk")) + done = item.get("done", False) + chunks.append(content) # Send chunks for all nodes (including End nodes for suffix) - logger.debug(f"节点 {self.node_id} 发送 chunk #{chunk_count}: {item[:50]}...") + logger.debug(f"节点 {self.node_id} 发送 chunk #{chunk_count}: {content[:50]}...") # 1. Send via stream writer (for real-time client updates) writer({ - "type": chunk_type, # "message" or "node_chunk" + "type": "node_chunk", "node_id": self.node_id, - "chunk": item, - "full_content": full_content, - "chunk_index": chunk_count + "chunk": content, + "done": done }) - # 2. Update streaming buffer in state (for downstream nodes) - # Only non-End nodes need streaming buffer - if not is_end_node: - yield { - "streaming_buffer": { - self.node_id: { - "full_content": full_content, - "chunk_count": chunk_count, - "is_complete": False - } - } - } - else: - # Other types are also treated as chunks - chunk_count += 1 - chunk_str = str(item) - chunks.append(chunk_str) - full_content = "".join(chunks) - - # Send chunks for all nodes - writer({ - "type": chunk_type, # "message" or "node_chunk" - "node_id": self.node_id, - "chunk": chunk_str, - "full_content": full_content, - "chunk_index": chunk_count - }) - - # Only non-End nodes need streaming buffer - if not is_end_node: - yield { - "streaming_buffer": { - self.node_id: { - "full_content": full_content, - "chunk_count": chunk_count, - "is_complete": False - } - } - } - elapsed_time = time.time() - start_time logger.info(f"节点 {self.node_id} 流式执行完成,耗时: {elapsed_time:.2f}s, chunks: {chunk_count}") @@ -426,16 +368,6 @@ class BaseNode(ABC): "looping": state["looping"] } - # Add streaming buffer for non-End nodes - if not is_end_node: - state_update["streaming_buffer"] = { - self.node_id: { - "full_content": "".join(chunks), - "chunk_count": chunk_count, - "is_complete": True # Mark as complete - } - } - # Finally yield state update # LangGraph will merge this into state yield state_update | self.trans_activate(state) diff --git a/api/app/core/workflow/nodes/end/node.py b/api/app/core/workflow/nodes/end/node.py index 0cbd9e8e..3a5153a9 100644 --- a/api/app/core/workflow/nodes/end/node.py +++ b/api/app/core/workflow/nodes/end/node.py @@ -5,10 +5,8 @@ End 节点实现 """ import logging -import re from app.core.workflow.nodes.base_node import BaseNode, WorkflowState -from app.core.workflow.nodes.enums import NodeType logger = logging.getLogger(__name__) @@ -37,24 +35,8 @@ class EndNode(BaseNode): # 如果配置了输出模板,使用模板渲染;否则使用默认输出 if output_template: output = self._render_template(output_template, state, strict=False) - state['messages'].extend([ - { - "role": "user", - "content": self.get_variable("sys.message", state) - }, - { - "role": "assistant", - "content": output - } - ]) else: - state['messages'].extend([ - { - "role": "user", - "content": self.get_variable("sys.message", state) - }, - ]) - output = "工作流已完成" + output = "" # 统计信息(用于日志) node_outputs = state.get("node_outputs", {}) @@ -63,274 +45,3 @@ class EndNode(BaseNode): logger.info(f"节点 {self.node_id} (End) 执行完成,共执行 {total_nodes} 个节点") return output - - def _extract_referenced_nodes(self, template: str) -> list[str]: - """从模板中提取引用的节点 ID - - 例如:'结果:{{llm_qa.output}}' -> ['llm_qa'] - - Args: - template: 模板字符串 - - Returns: - 引用的节点 ID 列表 - """ - # 匹配 {{node_id.xxx}} 格式 - pattern = r'\{\{([a-zA-Z0-9_]+)\.[a-zA-Z0-9_]+\}\}' - matches = re.findall(pattern, template) - return list(set(matches)) # 去重 - - def _parse_template_parts(self, template: str, state: WorkflowState) -> list[dict]: - """解析模板,分离静态文本和动态引用 - - 例如:'你好 {{llm.output}}, 这是后缀' - 返回:[ - {"type": "static", "content": "你好 "}, - {"type": "dynamic", "node_id": "llm", "field": "output"}, - {"type": "static", "content": ", 这是后缀"} - ] - - Args: - template: 模板字符串 - state: 工作流状态 - - Returns: - 模板部分列表 - """ - import re - - parts = [] - last_end = 0 - - # 匹配 {{xxx}} 或 {{ xxx }} 格式(支持空格) - pattern = r'\{\{\s*([^}]+?)\s*\}\}' - - for match in re.finditer(pattern, template): - start, end = match.span() - - # 添加前面的静态文本 - if start > last_end: - static_text = template[last_end:start] - if static_text: - parts.append({"type": "static", "content": static_text}) - - # 解析动态引用 - ref = match.group(1).strip() - - # 检查是否是节点引用(如 llm.output 或 llm_qa.output) - if '.' in ref: - node_id, field = ref.split('.', 1) - parts.append({ - "type": "dynamic", - "node_id": node_id, - "field": field, - "raw": ref - }) - else: - # 其他引用(如 {{var.xxx}}),当作静态处理 - # 直接渲染这部分 - rendered = self._render_template(f"{{{{{ref}}}}}", state) - parts.append({"type": "static", "content": rendered}) - - last_end = end - - # 添加最后的静态文本 - if last_end < len(template): - static_text = template[last_end:] - if static_text: - parts.append({"type": "static", "content": static_text}) - - return parts - - async def execute_stream(self, state: WorkflowState): - """Execute End node business logic (streaming) - - Smart output strategy: - 1. Check if template references a direct upstream LLM node - 2. If yes, only output the part AFTER that reference (suffix) - 3. Prefix and LLM content have already been sent during LLM node streaming - - Note: Only LLM nodes get this special treatment. Other node types output normally. - - Example: '{{start.test}}hahaha {{ llm_qa.output }} lalalalala a' - - Direct upstream LLM node is llm_qa - - Prefix '{{start.test}}hahaha ' was sent before LLM node streaming - - LLM content was streamed during LLM node execution - - End node only outputs ' lalalalala a' (suffix, sent as one chunk) - - Args: - state: Workflow state - - Yields: - Completion marker - """ - logger.info(f"节点 {self.node_id} (End) 开始执行(流式)") - - # 获取配置的输出模板 - output_template = self.config.get("output") - - if not output_template: - output = "工作流已完成" - from langgraph.config import get_stream_writer - writer = get_stream_writer() - writer({ - "type": "message", # End node output uses message type - "node_id": self.node_id, - "chunk": "", - "full_content": output, - "chunk_index": 1, - "is_suffix": False - }) - state['messages'].extend([ - { - "role": "user", - "content": self.get_variable("sys.message", state) - } - ]) - yield {"__final__": True, "result": output} - return - - # Find direct upstream LLM nodes - direct_upstream_llm_nodes = [] - for edge in self.workflow_config.get("edges", []): - if edge.get("target") == self.node_id: - source_node_id = edge.get("source") - # Check if the source node is an LLM node - for node in self.workflow_config.get("nodes", []): - logger.info(f"节点 {self.node_id} 的类型 {node.get("type")}") - if node.get("id") == source_node_id and node.get("type") == NodeType.LLM: - direct_upstream_llm_nodes.append(source_node_id) - break - - logger.info(f"节点 {self.node_id} 的直接上游 LLM 节点: {direct_upstream_llm_nodes}") - - # Parse template parts - parts = self._parse_template_parts(output_template, state) - logger.info(f"节点 {self.node_id} 解析模板,共 {len(parts)} 个部分") - for i, part in enumerate(parts): - logger.info(f"[模板解析] part[{i}]: {part}") - - # Find the first reference to a direct upstream LLM node - upstream_llm_ref_index = None - for i, part in enumerate(parts): - if part["type"] == "dynamic" and part["node_id"] in direct_upstream_llm_nodes: - upstream_llm_ref_index = i - logger.info(f"节点 {self.node_id} 找到直接上游 LLM 节点 {part['node_id']} 的引用,索引: {i}") - break - - if upstream_llm_ref_index is None: - # No reference to direct upstream LLM node, output complete template content - output = self._render_template(output_template, state, strict=False) - logger.info(f"节点 {self.node_id} 没有引用直接上游 LLM 节点,输出完整内容: '{output[:50]}...'") - - # Send complete content via writer (as a single message chunk) - from langgraph.config import get_stream_writer - writer = get_stream_writer() - writer({ - "type": "message", # End node output uses message type - "node_id": self.node_id, - "chunk": output, - "full_content": output, - "chunk_index": 1, - "is_suffix": False - }) - logger.info(f"节点 {self.node_id} 已通过 writer 发送完整内容") - - state['messages'].extend([ - { - "role": "user", - "content": self.get_variable("sys.message", state) - }, - { - "role": "assistant", - "content": output - } - ]) - - # yield completion marker - yield {"__final__": True, "result": output} - return - - # Has reference to direct upstream LLM node, only output the part after that reference (suffix) - logger.info( - f"节点 {self.node_id} 检测到直接上游 LLM 节点引用,只输出后缀部分(从索引 {upstream_llm_ref_index + 1} 开始)") - - # Collect suffix parts - suffix_parts = [] - logger.info(f"[后缀调试] 开始收集后缀,从索引 {upstream_llm_ref_index + 1} 到 {len(parts) - 1}") - for i in range(upstream_llm_ref_index + 1, len(parts)): - part = parts[i] - logger.info(f"[后缀调试] 处理 part[{i}]: {part}") - if part["type"] == "static": - # 静态文本 - logger.info(f"[后缀调试] 添加静态文本: '{part['content']}'") - suffix_parts.append(part["content"]) - - elif part["type"] == "dynamic": - # Other dynamic references (if there are multiple references) - node_id = part["node_id"] - field = part["field"] - - # Use VariablePool to get variable value - pool = self.get_variable_pool(state) - try: - # Try to get variable value with default empty string - content = pool.get([node_id, field], default="") - logger.info(f"[后缀调试] 获取变量 {node_id}.{field} 成功: '{content}'") - except Exception as e: - logger.warning(f"[后缀调试] 获取变量 {node_id}.{field} 失败: {e}") - content = "" - - # Convert to string if not None - suffix_parts.append(str(content) if content is not None else "") - - # 拼接后缀 - suffix = "".join(suffix_parts) - - # 构建完整输出(用于返回,包含前缀 + 动态内容 + 后缀) - full_output = self._render_template(output_template, state, strict=False) - - state['messages'].extend([ - { - "role": "user", - "content": self.get_variable("sys.message", state) - }, - { - "role": "assistant", - "content": full_output - } - ]) - - logger.info(f"[后缀调试] 节点 {self.node_id} 后缀部分数量: {len(suffix_parts)}") - logger.info(f"[后缀调试] 后缀内容: '{suffix}'") - logger.info(f"[后缀调试] 后缀长度: {len(suffix)}") - logger.info(f"[后缀调试] 后缀是否为空: {not suffix}") - - if suffix: - logger.info(f"节点 {self.node_id} 输出后缀: '{suffix}...' (长度: {len(suffix)})") - # 一次性输出后缀(作为单个 chunk) - # 注意:不要直接 yield 字符串,因为 base_node 会逐字符处理 - # 而是通过 writer 直接发送 - from langgraph.config import get_stream_writer - writer = get_stream_writer() - writer({ - "type": "message", # End 节点的输出使用 message 类型 - "node_id": self.node_id, - "chunk": suffix, - "full_content": full_output, # full_content 是完整的渲染结果(前缀+LLM+后缀) - "chunk_index": 1, - "is_suffix": True - }) - logger.info(f"节点 {self.node_id} 已通过 writer 发送后缀,full_content 长度: {len(full_output)}") - else: - logger.warning(f"[后缀调试] 节点 {self.node_id} 后缀为空,不发送!" - f"upstream_llm_ref_index={upstream_llm_ref_index}, parts数量={len(parts)}") - - # 统计信息 - node_outputs = state.get("node_outputs", {}) - total_nodes = len(node_outputs) - - logger.info(f"节点 {self.node_id} (End) 执行完成(流式),共执行了 {total_nodes} 个节点") - - # yield 完成标记(包含完整输出) - yield {"__final__": True, "result": full_output} diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index a74e0b60..f315b238 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -7,18 +7,18 @@ LLM 节点实现 import logging import re from typing import Any -from langchain_core.messages import AIMessage, SystemMessage, HumanMessage -from app.core.workflow.nodes.base_node import BaseNode, WorkflowState +from langchain_core.messages import AIMessage + +from app.core.error_codes import BizCode +from app.core.exceptions import BusinessException from app.core.models import RedBearLLM, RedBearModelConfig +from app.core.workflow.nodes.base_node import BaseNode, WorkflowState from app.core.workflow.nodes.llm.config import LLMNodeConfig from app.db import get_db_context from app.models import ModelType from app.services.model_service import ModelConfigService -from app.core.exceptions import BusinessException -from app.core.error_codes import BizCode - logger = logging.getLogger(__name__) @@ -231,42 +231,14 @@ class LLMNode(BaseNode): 文本片段(chunk)或完成标记 """ self.typed_config = LLMNodeConfig(**self.config) - from langgraph.config import get_stream_writer llm, prompt_or_messages = self._prepare_llm(state, True) logger.info(f"节点 {self.node_id} 开始执行 LLM 调用(流式)") logger.debug(f"LLM 配置: streaming={getattr(llm._model, 'streaming', 'unknown')}") - # 检查是否有注入的 End 节点前缀配置 - writer = get_stream_writer() - end_prefix = getattr(self, '_end_node_prefix', None) - - logger.info(f"[LLM前缀] 节点 {self.node_id} 检查前缀配置: {end_prefix is not None}") - if end_prefix: - logger.info(f"[LLM前缀] 前缀内容: '{end_prefix}'") - - if end_prefix: - # 渲染前缀(可能包含其他变量) - try: - rendered_prefix = self._render_template(end_prefix, state) - logger.info(f"节点 {self.node_id} 提前发送 End 节点前缀: '{rendered_prefix[:50]}...'") - - # 提前发送 End 节点的前缀(使用 "message" 类型) - writer({ - "type": "message", # End 相关的内容都是 message 类型 - "node_id": "end", # 标记为 end 节点的输出 - "chunk": rendered_prefix, - "full_content": rendered_prefix, - "chunk_index": 0, - "is_prefix": True # 标记这是前缀 - }) - except Exception as e: - logger.warning(f"渲染/发送 End 节点前缀失败: {e}") - # 累积完整响应 full_response = "" - last_chunk = None chunk_count = 0 # 调用 LLM(流式,支持字符串或消息列表) @@ -284,12 +256,19 @@ class LLMNode(BaseNode): # 只有当内容不为空时才处理 if content: full_response += content - last_chunk = chunk chunk_count += 1 # 流式返回每个文本片段 - yield content + yield { + "__final__": False, + "chunk": content + } + yield { + "__final__": False, + "chunk": "", + "done": True + } logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(full_response)}, 总 chunks: {chunk_count}") # 构建完整的 AIMessage(包含元数据) diff --git a/api/docker-compose.yml b/api/docker-compose.yml index a7337689..f30220cb 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -15,6 +15,7 @@ services: networks: - default - celery + - sandbox depends_on: - worker-memory - worker-document @@ -63,5 +64,16 @@ services: depends_on: - worker-memory + sandbox: + image: redbear_sandbox:latest + container_name: sandbox + ports: + - "8194" + command: /code/.venv/bin/python main.py + restart: unless-stopped + networks: + - sandbox + networks: celery: + sandbox: From 166d05afe9ed142e3f2d03e347d6b9d4a74625e6 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 17:37:36 +0800 Subject: [PATCH 096/175] fix(workflow): fix function cache not taking effect and potential list index overflow --- api/app/core/workflow/executor.py | 4 +++- api/app/core/workflow/graph_builder.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index a98a9c9a..11bb5bac 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -320,7 +320,9 @@ class WorkflowExecutor: if event_type == "node_chunk": node_id = data.get("node_id") if self.activate_end: - end_info = self.end_outputs[self.activate_end] + end_info = self.end_outputs.get(self.activate_end) + if not end_info or end_info.cursor >= len(end_info.outputs): + continue current_output = end_info.outputs[end_info.cursor] if current_output.is_variable and node_id in current_output.literal: if data.get("done"): diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index a8ca19fe..3576c576 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -145,7 +145,7 @@ class GraphBuilder: self.end_node_map: dict[str, StreamOutputConfig] = {} self._find_upstream_branch_node = lru_cache( maxsize=len(self.nodes) * 2 - )(self._find_upstream_branche_node) + )(self._find_upstream_branch_node) self._analyze_end_node_output() self.graph = StateGraph(WorkflowState) @@ -178,7 +178,7 @@ class GraphBuilder: except KeyError: raise RuntimeError(f"Node not found: Id={node_id}") - def _find_upstream_branche_node(self, target_node: str) -> tuple[bool, tuple[str]]: + def _find_upstream_branch_node(self, target_node: str) -> tuple[bool, tuple[str]]: """Find upstream branch nodes for a given target node in the workflow graph. This method identifies all upstream control (branch) nodes that can affect @@ -219,7 +219,7 @@ class GraphBuilder: has_branch = True for node_id in non_branch_nodes: - node_has_branch, nodes = self._find_upstream_branche_node(node_id) + node_has_branch, nodes = self._find_upstream_branch_node(node_id) has_branch = has_branch and node_has_branch if not has_branch: break @@ -288,7 +288,7 @@ class GraphBuilder: # Stream mode: output activation depends on upstream branch nodes if self.stream: # Find upstream branch nodes that can control this End node - has_branch, control_nodes = self._find_upstream_branche_node(end_node_id) + has_branch, control_nodes = self._find_upstream_branch_node(end_node_id) # Build StreamOutputConfig for this End node self.end_node_map[end_node_id] = StreamOutputConfig( From 2a10e9f7eed723183a5b1f920c31942f5963ee52 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 17:51:27 +0800 Subject: [PATCH 097/175] style(workflow): enforce PEP8 style and remove redundant imports --- api/app/core/workflow/nodes/code/__init__.py | 2 +- api/app/core/workflow/nodes/code/node.py | 1 - .../workflow/nodes/cycle_graph/iteration.py | 1 - .../core/workflow/nodes/cycle_graph/node.py | 1 - api/app/core/workflow/nodes/if_else/node.py | 2 +- api/app/core/workflow/nodes/memory/config.py | 2 -- api/app/core/workflow/nodes/memory/node.py | 2 +- .../nodes/question_classifier/config.py | 3 ++- .../nodes/question_classifier/node.py | 19 ++++++++++--------- api/app/core/workflow/nodes/tool/__init__.py | 2 +- api/app/core/workflow/nodes/tool/node.py | 18 +++++++++--------- 11 files changed, 25 insertions(+), 28 deletions(-) diff --git a/api/app/core/workflow/nodes/code/__init__.py b/api/app/core/workflow/nodes/code/__init__.py index e42af93d..758ab3a5 100644 --- a/api/app/core/workflow/nodes/code/__init__.py +++ b/api/app/core/workflow/nodes/code/__init__.py @@ -1,3 +1,3 @@ from app.core.workflow.nodes.code.node import CodeNode -__all__ = ["CodeNode"] \ No newline at end of file +__all__ = ["CodeNode"] diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index 5262a7e2..b2a4da32 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -7,7 +7,6 @@ from textwrap import dedent from typing import Any import httpx -from sympy.physics.vector import vlatex from app.core.workflow.nodes import BaseNode, WorkflowState from app.core.workflow.nodes.base_config import VariableType diff --git a/api/app/core/workflow/nodes/cycle_graph/iteration.py b/api/app/core/workflow/nodes/cycle_graph/iteration.py index e9174df8..cd63d233 100644 --- a/api/app/core/workflow/nodes/cycle_graph/iteration.py +++ b/api/app/core/workflow/nodes/cycle_graph/iteration.py @@ -1,5 +1,4 @@ import asyncio -import copy import logging import re from typing import Any diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index 1f550b0b..82782658 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -6,7 +6,6 @@ from langgraph.graph.state import CompiledStateGraph from app.core.workflow.nodes import WorkflowState from app.core.workflow.nodes.base_node import BaseNode -from app.core.workflow.nodes.cycle_graph.config import LoopNodeConfig, IterationNodeConfig from app.core.workflow.nodes.cycle_graph.iteration import IterationRuntime from app.core.workflow.nodes.cycle_graph.loop import LoopRuntime from app.core.workflow.nodes.enums import NodeType diff --git a/api/app/core/workflow/nodes/if_else/node.py b/api/app/core/workflow/nodes/if_else/node.py index 41f1138b..cf5a1499 100644 --- a/api/app/core/workflow/nodes/if_else/node.py +++ b/api/app/core/workflow/nodes/if_else/node.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) class IfElseNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config: IfElseNodeConfig | None= None + self.typed_config: IfElseNodeConfig | None = None @staticmethod def _evaluate(operator, instance: CompareOperatorInstance) -> Any: diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 4c8c43eb..853ba882 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -1,8 +1,6 @@ -import uuid from uuid import UUID from pydantic import Field -from typing import Literal from app.core.workflow.nodes.base_config import BaseNodeConfig diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index 0589cc82..f71c70ee 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -24,7 +24,7 @@ class MemoryReadNode(BaseNode): return await MemoryAgentService().read_memory( end_user_id=end_user_id, message=self._render_template(self.typed_config.message, state), - config_id=str(self.typed_config.config_id), + config_id=self.typed_config.config_id, search_switch=self.typed_config.search_switch, history=[], db=db, diff --git a/api/app/core/workflow/nodes/question_classifier/config.py b/api/app/core/workflow/nodes/question_classifier/config.py index 998e2fb4..2dd8d28a 100644 --- a/api/app/core/workflow/nodes/question_classifier/config.py +++ b/api/app/core/workflow/nodes/question_classifier/config.py @@ -5,6 +5,7 @@ from pydantic import Field, BaseModel from app.core.workflow.nodes.base_config import BaseNodeConfig + class ClassifierConfig(BaseModel): """分类器节点配置""" @@ -13,7 +14,7 @@ class ClassifierConfig(BaseModel): class QuestionClassifierNodeConfig(BaseNodeConfig): """问题分类器节点配置""" - + model_id: uuid.UUID = Field(..., description="LLM模型ID") input_variable: str = Field(default="{{sys.message}}", description="输入变量选择器(用户问题)") user_supplement_prompt: Optional[str] = Field(default=None, description="用户补充提示词,额外分类指令") diff --git a/api/app/core/workflow/nodes/question_classifier/node.py b/api/app/core/workflow/nodes/question_classifier/node.py index aee72eda..6df410cb 100644 --- a/api/app/core/workflow/nodes/question_classifier/node.py +++ b/api/app/core/workflow/nodes/question_classifier/node.py @@ -18,30 +18,30 @@ DEFAULT_EMPTY_QUESTION_CASE = f"{DEFAULT_CASE_PREFIX}1" class QuestionClassifierNode(BaseNode): """问题分类器节点""" - + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) self.typed_config: QuestionClassifierNodeConfig | None = None self.category_to_case_map = {} - + def _get_llm_instance(self) -> RedBearLLM: """获取LLM实例""" with get_db_read() as db: config = ModelConfigService.get_model_by_id(db=db, model_id=self.typed_config.model_id) - + if not config: raise BusinessException("配置的模型不存在", BizCode.NOT_FOUND) - + if not config.api_keys or len(config.api_keys) == 0: raise BusinessException("模型配置缺少 API Key", BizCode.INVALID_PARAMETER) - + api_config = config.api_keys[0] model_name = api_config.model_name provider = api_config.provider api_key = api_config.api_key base_url = api_config.api_base model_type = config.type - + return RedBearLLM( RedBearModelConfig( model_name=model_name, @@ -64,7 +64,7 @@ class QuestionClassifierNode(BaseNode): case_tag = f"{DEFAULT_CASE_PREFIX}{idx}" category_map[category_name] = case_tag return category_map - + async def execute(self, state: WorkflowState) -> dict: """执行问题分类""" self.typed_config = QuestionClassifierNodeConfig(**self.config) @@ -74,11 +74,12 @@ class QuestionClassifierNode(BaseNode): categories = self.typed_config.categories or [] category_names = [class_item.class_name.strip() for class_item in categories] category_count = len(category_names) - + if not question: logger.warning( f"节点 {self.node_id} 未获取到输入问题,使用默认分支" - f"(默认分支:{DEFAULT_EMPTY_QUESTION_CASE},分类总数:{category_count})" + f"(默认分支:{DEFAULT_EMPTY_QUESTION_CASE}" + f"分类总数: {category_count})" ) # 若分类列表为空,返回默认unknown分支,否则返回CASE1 if category_count > 0: diff --git a/api/app/core/workflow/nodes/tool/__init__.py b/api/app/core/workflow/nodes/tool/__init__.py index 8392f05c..a311139e 100644 --- a/api/app/core/workflow/nodes/tool/__init__.py +++ b/api/app/core/workflow/nodes/tool/__init__.py @@ -1,4 +1,4 @@ from app.core.workflow.nodes.tool.config import ToolNodeConfig from app.core.workflow.nodes.tool.node import ToolNode -__all__ = ["ToolNode", "ToolNodeConfig"] \ No newline at end of file +__all__ = ["ToolNode", "ToolNodeConfig"] diff --git a/api/app/core/workflow/nodes/tool/node.py b/api/app/core/workflow/nodes/tool/node.py index 3e79b075..aba96303 100644 --- a/api/app/core/workflow/nodes/tool/node.py +++ b/api/app/core/workflow/nodes/tool/node.py @@ -16,11 +16,11 @@ TEMPLATE_PATTERN = re.compile(r"\{\{.*?\}\}") class ToolNode(BaseNode): """工具节点""" - + def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) self.typed_config: ToolNodeConfig | None = None - + async def execute(self, state: WorkflowState) -> dict[str, Any]: """执行工具""" self.typed_config = ToolNodeConfig(**self.config) @@ -28,21 +28,21 @@ class ToolNode(BaseNode): tenant_id = self.get_variable("sys.tenant_id", state) user_id = self.get_variable("sys.user_id", state) workspace_id = self.get_variable("sys.workspace_id", state) - + # 如果没有租户ID,尝试从工作流ID获取 if not tenant_id: if workspace_id: from app.repositories.tool_repository import ToolRepository with get_db_read() as db: tenant_id = ToolRepository.get_tenant_id_by_workspace_id(db, workspace_id) - + if not tenant_id: logger.error(f"节点 {self.node_id} 缺少租户ID") return { "success": False, "data": "缺少租户ID" } - + # 渲染工具参数 rendered_parameters = {} for param_name, param_template in self.typed_config.tool_parameters.items(): @@ -55,9 +55,9 @@ class ToolNode(BaseNode): # 非模板参数(数字/布尔/普通字符串)直接保留原值 rendered_value = param_template rendered_parameters[param_name] = rendered_value - + logger.info(f"节点 {self.node_id} 执行工具 {self.typed_config.tool_id},参数: {rendered_parameters}") - + # 执行工具 with get_db_read() as db: tool_service = ToolService(db) @@ -79,7 +79,7 @@ class ToolNode(BaseNode): else: logger.error(f"节点 {self.node_id} 工具执行失败: {result.error}") return { - "data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False), + "data": result.error if isinstance(result.error, str) else json.dumps(result.error, ensure_ascii=False), "error_code": result.error_code, "execution_time": result.execution_time - } \ No newline at end of file + } From 2abbd5a7fb0565ca5169b18c150adbd61a774e7b Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 18:16:53 +0800 Subject: [PATCH 098/175] fix(workflow): fix streaming output error when variable is not a string --- api/app/core/workflow/executor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 11bb5bac..17adbdf2 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -163,6 +163,15 @@ class WorkflowExecutor: if self.end_outputs[node].activate and self.activate_end is None: self.activate_end = node + @staticmethod + def _trans_output_string(content): + if isinstance(content, str): + return content + elif isinstance(content, list): + return "\n".join(content) + else: + return str(content) + def build_graph(self, stream=False) -> CompiledStateGraph: """构建 LangGraph @@ -430,6 +439,7 @@ class WorkflowExecutor: variables={}, node_outputs=node_outputs ) + chunk = self._trans_output_string(chunk) message += chunk full_content += chunk except ValueError: @@ -472,6 +482,7 @@ class WorkflowExecutor: variables=variables, node_outputs=node_outputs ) + chunk = self._trans_output_string(chunk) message += chunk full_content += chunk except ValueError: From d93d52cf1071170c31b21d4343589d6d13ee4a33 Mon Sep 17 00:00:00 2001 From: lixiangcheng1 Date: Tue, 27 Jan 2026 18:30:27 +0800 Subject: [PATCH 099/175] [fix]remove aspose-slides --- api/app/core/rag/app/presentation.py | 165 --------------------------- api/pyproject.toml | 1 - api/requirements.txt | 1 - 3 files changed, 167 deletions(-) delete mode 100644 api/app/core/rag/app/presentation.py diff --git a/api/app/core/rag/app/presentation.py b/api/app/core/rag/app/presentation.py deleted file mode 100644 index d62e0096..00000000 --- a/api/app/core/rag/app/presentation.py +++ /dev/null @@ -1,165 +0,0 @@ -import copy -import re -from io import BytesIO -from PIL import Image - -from app.core.rag.nlp import tokenize, is_english -from app.core.rag.nlp import rag_tokenizer -from app.core.rag.deepdoc.parser import PdfParser, PlainParser -from app.core.rag.deepdoc.parser.ppt_parser import RAGPptParser as PptParser -from PyPDF2 import PdfReader as pdf2_read -from app.core.rag.app.naive import by_plaintext, PARSERS - -class Ppt(PptParser): - def __call__(self, fnm, from_page, to_page, callback=None): - txts = super().__call__(fnm, from_page, to_page) - - callback(0.5, "Text extraction finished.") - import aspose.slides as slides - import aspose.pydrawing as drawing - imgs = [] - with slides.Presentation(BytesIO(fnm)) as presentation: - for i, slide in enumerate(presentation.slides[from_page: to_page]): - try: - with BytesIO() as buffered: - slide.get_thumbnail( - 0.1, 0.1).save( - buffered, drawing.imaging.ImageFormat.jpeg) - buffered.seek(0) - imgs.append(Image.open(buffered).copy()) - except RuntimeError as e: - raise RuntimeError(f'ppt parse error at page {i+1}, original error: {str(e)}') from e - assert len(imgs) == len( - txts), "Slides text and image do not match: {} vs. {}".format(len(imgs), len(txts)) - callback(0.9, "Image extraction finished") - self.is_english = is_english(txts) - return [(txts[i], imgs[i]) for i in range(len(txts))] - -class Pdf(PdfParser): - def __init__(self): - super().__init__() - - def __garbage(self, txt): - txt = txt.lower().strip() - if re.match(r"[0-9\.,%/-]+$", txt): - return True - if len(txt) < 3: - return True - return False - - def __call__(self, filename, binary=None, from_page=0, - to_page=100000, zoomin=3, callback=None): - from timeit import default_timer as timer - start = timer() - callback(msg="OCR started") - self.__images__(filename if not binary else binary, - zoomin, from_page, to_page, callback) - callback(msg="Page {}~{}: OCR finished ({:.2f}s)".format(from_page, min(to_page, self.total_page), timer() - start)) - assert len(self.boxes) == len(self.page_images), "{} vs. {}".format( - len(self.boxes), len(self.page_images)) - res = [] - for i in range(len(self.boxes)): - lines = "\n".join([b["text"] for b in self.boxes[i] - if not self.__garbage(b["text"])]) - res.append((lines, self.page_images[i])) - callback(0.9, "Page {}~{}: Parsing finished".format( - from_page, min(to_page, self.total_page))) - return res, [] - - -class PlainPdf(PlainParser): - def __call__(self, filename, binary=None, from_page=0, - to_page=100000, callback=None, **kwargs): - self.pdf = pdf2_read(filename if not binary else BytesIO(binary)) - page_txt = [] - for page in self.pdf.pages[from_page: to_page]: - page_txt.append(page.extract_text()) - callback(0.9, "Parsing finished") - return [(txt, None) for txt in page_txt], [] - - -def chunk(filename, binary=None, from_page=0, to_page=100000, - lang="Chinese", callback=None, vision_model=None, parser_config=None, **kwargs): - """ - The supported file formats are pdf, pptx. - Every page will be treated as a chunk. And the thumbnail of every page will be stored. - PPT file will be parsed by using this method automatically, setting-up for every PPT file is not necessary. - """ - if parser_config is None: - parser_config = {} - eng = lang.lower() == "english" - doc = { - "docnm_kwd": filename, - "title_tks": rag_tokenizer.tokenize(re.sub(r"\.[a-zA-Z]+$", "", filename)) - } - doc["title_sm_tks"] = rag_tokenizer.fine_grained_tokenize(doc["title_tks"]) - res = [] - if re.search(r"\.pptx?$", filename, re.IGNORECASE): - if not binary: - with open(filename, "rb") as f: - binary = f.read() - ppt_parser = Ppt() - for pn, (txt, img) in enumerate(ppt_parser( - filename if not binary else binary, from_page, 1000000, callback)): - d = copy.deepcopy(doc) - pn += from_page - d["image"] = img - d["doc_type_kwd"] = "image" - d["page_num_int"] = [pn + 1] - d["top_int"] = [0] - d["position_int"] = [(pn + 1, 0, img.size[0], 0, img.size[1])] - tokenize(d, txt, eng) - res.append(d) - return res - elif re.search(r"\.pdf$", filename, re.IGNORECASE): - layout_recognizer = parser_config.get("layout_recognize", "DeepDOC") - - if isinstance(layout_recognizer, bool): - layout_recognizer = "DeepDOC" if layout_recognizer else "Plain Text" - - name = layout_recognizer.strip().lower() - parser = PARSERS.get(name, by_plaintext) - callback(0.1, "Start to parse.") - - sections, _, _ = parser( - filename=filename, - binary=binary, - from_page=from_page, - to_page=to_page, - lang=lang, - callback=callback, - vision_model=vision_model, - pdf_cls=Pdf, - **kwargs - ) - - if not sections: - return [] - - if name in ["tcadp", "docling", "mineru"]: - parser_config["chunk_token_num"] = 0 - - callback(0.8, "Finish parsing.") - - for pn, (txt, img) in enumerate(sections): - d = copy.deepcopy(doc) - pn += from_page - if img: - d["image"] = img - d["page_num_int"] = [pn + 1] - d["top_int"] = [0] - d["position_int"] = [(pn + 1, 0, img.size[0] if img else 0, 0, img.size[1] if img else 0)] - tokenize(d, txt, eng) - res.append(d) - return res - - raise NotImplementedError( - "file type not supported yet(pptx, pdf supported)") - - -if __name__ == "__main__": - import sys - - def dummy(a, b): - pass - chunk(sys.argv[1], callback=dummy) diff --git a/api/pyproject.toml b/api/pyproject.toml index 2dcc706d..e14495e2 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -88,7 +88,6 @@ dependencies = [ "cachetools==6.2.1", "ruamel.yaml==0.18.10", "strenum==0.4.15", - "aspose-slides==24.12.0", "opencv-python==4.10.0.84", "numpy>=1.26.0,<2.0.0", "huggingface-hub==0.25.2", diff --git a/api/requirements.txt b/api/requirements.txt index 99252e09..0fc47979 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -82,7 +82,6 @@ olefile==0.47 cachetools==6.2.1 ruamel.yaml==0.18.10 strenum==0.4.15 -aspose-slides==24.12.0 opencv-python==4.10.0.84 numpy>=1.26.0,<2.0.0 huggingface-hub==0.25.2 From c5a794f1b5cfd4c3317603e570e8d30b5fe73d17 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 18:39:47 +0800 Subject: [PATCH 100/175] perf(workflow): enhance streaming output node activation performance --- api/app/core/workflow/executor.py | 2 +- api/app/core/workflow/graph_builder.py | 40 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 17adbdf2..f0411ae3 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -333,7 +333,7 @@ class WorkflowExecutor: if not end_info or end_info.cursor >= len(end_info.outputs): continue current_output = end_info.outputs[end_info.cursor] - if current_output.is_variable and node_id in current_output.literal: + if current_output.is_variable and current_output.depends_on_node(node_id): if data.get("done"): end_info.cursor += 1 else: diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index 3576c576..9fa89fd2 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -53,6 +53,44 @@ class OutputContent(BaseModel): ) ) + def depends_on_node(self, node_id: str) -> bool: + """ + Check if this output segment depends on a specific node's variable. + + This method examines the `literal` of the output segment to see if it + contains a variable placeholder referencing the given node in the form: + + {{ node_id.field_name }} + + It uses a regular expression to match the exact node ID, avoiding + false positives from substring matches (e.g., 'node1' should not match 'node10'). + + Args: + node_id (str): The ID of the node to check for in this segment's variable placeholders. + + Returns: + bool: + - True if the segment contains a variable referencing the given node. + - False otherwise. + + Example: + literal = "{{node1.name}}" + + depends_on_node("node1") -> True + depends_on_node("node2") -> False + + Usage: + This method is primarily used in stream mode to determine whether + a particular variable output segment should be activated when a + specific upstream node completes execution. + """ + variable_pattern = rf"\{{\{{\s*{re.escape(node_id)}\.[a-zA-Z0-9_]+\s*\}}\}}" + pattern = re.compile(variable_pattern) + match = pattern.search(self.literal) + if match: + return True + return False + class StreamOutputConfig(BaseModel): """ @@ -122,7 +160,7 @@ class StreamOutputConfig(BaseModel): for i in range(len(self.outputs)): if ( self.outputs[i].is_variable - and node_id in self.outputs[i].literal + and self.outputs[i].depends_on_node(node_id) ): self.outputs[i].activate = True From 8fb9e779a647d753fecaf25a1b764853449712a2 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 18:52:51 +0800 Subject: [PATCH 101/175] feat(workflow): store token usage in message table --- api/app/services/workflow_service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index f9426c87..2958f4f9 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -528,7 +528,8 @@ class WorkflowService: self.conversation_service.add_message( conversation_id=conversation_id_uuid, role=message["role"], - content=message["content"] + content=message["content"], + meta_data=None if message["role"] == "user" else {"usage": token_usage} ) logger.info(f"Workflow Run Success, " f"execution_id: {execution.execution_id}, message count: {len(final_messages)}") @@ -678,7 +679,8 @@ class WorkflowService: self.conversation_service.add_message( conversation_id=conversation_id_uuid, role=message["role"], - content=message["content"] + content=message["content"], + meta_data=None if message["role"] == "user" else {"usage": token_usage} ) logger.info(f"Workflow Run Success, " f"execution_id: {execution.execution_id}, message count: {len(final_messages)}") From 3a0f07d36ff0fc9fe51fe5e4adf2ca6e25ecc85b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 19:17:11 +0800 Subject: [PATCH 102/175] feat(web): add PageEmpty component --- web/src/assets/images/empty/pageEmpty.png | Bin 0 -> 161041 bytes web/src/components/Empty/PageEmpty.tsx | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 web/src/assets/images/empty/pageEmpty.png create mode 100644 web/src/components/Empty/PageEmpty.tsx diff --git a/web/src/assets/images/empty/pageEmpty.png b/web/src/assets/images/empty/pageEmpty.png new file mode 100644 index 0000000000000000000000000000000000000000..f78cc42d0d5dc1cf149a1686fda2548a81fe5dec GIT binary patch literal 161041 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
  • >m}U-p^$lczah`OzROG5j>b9J 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

    e;(m$vbP^Q z*1fQ?5#fHf=h*y>?Q-rXe9^weqArVTA1(6v_Gl7!>V{HW=MG=?3d>uf<|HNcs^Yxw zkQXK0UXQZd$NL;j4Y6s6u_2~FG9wqbZO-$+>y|jyGp6Ga^@WR6k3C7D3ZkG2G2+Hk z8s|Av)jYO6=2_QawC6_258g_-d;s5_!DShO5kie&v;d!!?w-BOw7M`Hf(VA;y{Er0 z$GFV7JRVbrg!nf29_n(uH5|6YEzZ;T9;<5nzP(iQ3rw%CQHFqX4!S+Ufg9zb&t}#K z+m}N~PrdNM7bm<%51m*`Z(f?Mi(rRJ;g}nZi^2;F3rlp00=gXzzyAXqefJM6BuBis ze&NJ#e)6+xd8Gdrf4!$ z4YfqlL`sPim5?H_34|C4p&^8TX+pg4@Kew5^z$zu&a-E3j`^8c%A&v{2!@Em;SdBt zKtKcp0R%8!VZ20mnHk_RntYMeTE?{(aIJYLaYdUf%keJ9mw75&in+Iw-4>r= zXZZNW#)XDm-;*+S?b`MAve&=t={uD zikan3GHklN)wYZqbKe(w`MXS47TwuqiA)>^9+||bb9loR>TCH z2~Lf@W#s*b7kFJSQB7;oq^5b{G(}Y*-l4`<2=%n$^m8xr)XY2^2X4pD?!)_>m=dN9 z`ND|S=0$Yv46I!yRy)+8VHyLqDRtp+?HoV90CV$5R&Y>+_iYXcQF-LT4uhn)yp1o~ z483rszRdMxEXPFY9qG&&WPYCF@CxaL=gAOqfjx_}+&DW|T%Anba`Ec*yMRvu#%o~r z-|rv3e0lq^OP8)Uqfu;PWVE?Se>!GuoA(ye@J8SS-=_Dy?|ofx zAF>pFwwhKqHcd@cO%UfOJgn?L;0rGlnb2vs@Xk|BD|QCkY;JC|wY|k~Jf^N{e4Znj zM+`(2ONwB41c%@eULjy62u8q20vZ!v`tm7$|BoJGe&&an>2%Sgm@3H>O-hOpO$keh zloFN<6jT$2VFs9@W|)CtV1{Wz0kecgLk%=V)RdSK786Mm7>En52qp_z`z>CspKRYEl-RTESk?RKB` zY>#%UL%ZF>Oh_RxN5uyYZnT9P?~qT2L4+ZY)pH3nd0za!fm%f9kFSHZEledNAz}vynZ;K z-)&PaCmtFaV_)FgeX1hoIU1gf>l{dtMPI^&i}Y{2k;&p7ro}n>p(c-!*;sS;EyuH} z$nQGwr4#RX+xzc-@^ioc;3nS!TU~X(_2iS?&u7`2+xrgxZ;=C z;RCdC&-V6!&8i;Kbnt1OWpD5}+u-c!^+U z7{X{Y;@|$x$C>Z<`I~QhE3G^urhq0xV?xzXRZ!z)YKj>zQNGGhP*79U6jLRsK}tkb zG(|Kj$r5IU7Y|Now>v2>%cR~XC)t;9?ny24Cs$UEe7XMIV?X1>eF$egRKP$)c$Hxy z2(Jc**lmUD()r`0z>B6z_L={~t%n4VPzEl6Btf!uXwyZ}%)2t-?(5 zg4XO3brVR@%A!@4ow=5YE70kXlp0cq_^hC8cWAtW5YT$ec&(ytwQ2WyJG2h zNH;}t5vFHO!~8t{=n*EDE|DKSL~CUqi~SCV_wDa)Zm!>Z>e7pM0H5aH!$2NdUGbx_1bkt!y#+a2^TM1WOH+q$z+7q5j5ctoC#nU;DMlu zbDmO!OdKy$F_Ib)BNzmXgrR`)RR$ugZEW!GKK^^m_50j;>n(UyG#RRjp_uX#!$c6{ zB?e-s@)AW2Oe0ARjS00xj1fx#Gcds6@XnDJMJn=Ej7SjgPEh23BIds!?e>?{xr-MD zgSDUgrC$oiZ@TGtC(G^?vJRq75HJA@!FYuj4iE%2K}0cLW?(`zrK)QN<1wRZ!nB%_ zc!}Qu9OfJ^bFI$Ux#j=;@Z$d8{@mw&e+w{nec#B~wd=2!7OV(dOh5#bzhVat9O$g9 zEU#SJu$3?w&nofUcr(8+x4ihU`OYnx@}I)HQKy*gO{QV5**N*4ZJH8I)8L9W-Nn63 zM?-5wD6IO_gLG|x+Zs!=LI5y17>x~J?+4Ljija~>&#-w7@Rpv z$9q&1Q9&_AF5}AF0=oAonLwCMNjn?J)>ZP+CPkRwEP)s*yu@L=Br_xy)Z?1<=S~t& zou%r{vC^Morf88eA(~+Zxg~DRGw$gL=SB^iu|ZN~Dh1EEF3+SL4ra!@gLLsK+3O>% z``$ww4rw+A44*s6@Wo5S`Q^MUT6ZqYEZuqk{r5le;DZk~{5foO)jcyBEw#>^f3HsC zPf2RtMvMz7MAV?y@6(@|XKONIy179;nd0^wpj%abx$uipmBr8f=0_)do6O98=))hH z(V+d*L{e1X!lL;0AW`;Kq9GD_}m5~4ks440^0VAprO^F5( zBLT`^z>F9p&!0NOM}OyI6d(RDHyk{GsDgoEyi9nB2w)fn$xt)Y6ibPaBB}{ZhFauh z4ndrwmFFoh${5YnD)MZ|vd?CD`B=-jQzKtpj;;BP2OfAJ@pX2p>aljWdnOY*pduwE zyh;GaS1E>I2!g3$B1DBrJz{G(Vl*97g+PpfKNSOCFIpIFUf6#A(hJWF0Attpm5g1x z{^JtB18!z!rPuEIUl@;@8|!-E^LeZM%w)%Z2`&a8-Fxo```500sX2T0YycA9p?5>p z{@}fx^>I17xf8lER4z$zYticebbDd3lVXAS`JTl%7wTqTjH+r*LS1DoS6B=UMV6CP z$Xh)mYm+o#&M;L{sA-~-6)mznqil6)bu&WU5Zx3>ftV7xH58qko4UfieI=XhpwW?9 z5v?Zp!3bBDm;hoJuu-(Q-d{rV7IC;u+Ps2Wzla}ikz3+rY9Qhe2N)QFi6{7s42OoA z@np=oYQk_hVE_C)%l%ora~OgFJx#o+m2u7z57$$MQ4#Z8mNrk>gge#fdcnsU-PuAi zj~k3}k3Y@K<~E@}NB2hP&CD+BTR!m4S{EJ$p5o8=_X zL$=2QCX*>mXh^Dj1L127L_CHY=a~q*zAt6$+V$P%fC9*i{^G$TdXJ0RO{8;)#XEeF ze#le37wp>1-rm#gV&$XE&wO$m@}qUJ^mh*)nSb!ox#5%WV$FA8c+Wk;w03#)u8HlH zI%e+;Rk$xiJKAq`OYyCIJgHU9iIN!_oh36dkChQsMV!MK#1Qd$LE&3yYKSobhJ`7U z;WmgPFA99CMNzb9ce}*8!eSuvj*gAFMFI!akZOv{OOhtS+6HYes5nFbNmSxk@AepW zI;8Oy>H0-v<2+flMV1tC-kf*=A(=y6hGhot~?<+%mC z%OEL+vf`C@cOu(q!WSy2EHQO0PNo(o(v*Wu(Z(m(qeSURyb;4I%_~=L57e4p-59oCGb21v;>zmNmq(lRULYBw6$aj*@ z9^TIEOu*irp?8O!9Uto&*D7(tNvnWc-;(zZ0Wb1x5D`0e?AWGvZZm|i5Mv`d+dGJJ zRMRPS2nYt|$V|x0_<9N?j0jXf0u+cq(1ZYn@CxJW7_Sn*00RUtzN%>gk3I861mPp^ zyN{KHWiktMdt^-r*C2o6TmPO5MCuDaSSFyHg>idPbbt(0E}-Sd@}); zWq?*yMFfmp-*+;0?fUNWD=RBEYQxVCr}6eg>qyJD7o!BPCgbs>n#HnQXfNaP<=*^( z+q=E~TN|r?H+cUWrQdV!KnH(wcF*Y7cEUaXlC39p00$_q(GyQR5mr|j{Pu6(Gal88 zJ#ywVSKQc4$DN(6LCQ-r-&t^$#3X{H$}~*SdQ6t(&@+-o@^%l=M2Z0?8ClT=sYrDo zIq(@+3^a8>VnfWPWa7XJuR^BG2 zNVs$gw|N0yjqoTq56+Q*=rS}fu~v_)+rt$(NQMF_2By;qwtXENZ9<$dXeusjT}Mor zpIatdSfaguAN_uh`<#$p-)8#kDZaQiCgQm)CC_TbU8)?|yM*<7_;VL2H#f<>L&PXk zMYwX6Emul$c>n%Zk$w33Ab)Okb@fyKY<1N>^DDoybo$Xpf7Gq5|NU$*xRYtfqbguH z@3qeIX~@gZxmNrCc0PP|ZM4;OO`I*F+}Uhw`jl$&ZkKXg`CQ~?LVW}9BHyNK*ETQo zI{9f;J=#=teswZtZef8e XR1EMM-4lfEOB!qwwFlvN|5fO}UWW3DyX2MGhA>>&e z%`C*2xt%?q2O)zwwy+psJ3GJ7pE>ZlWOBFj`BF8Gw}nRT%5?LVth4y|{DJ+;t;HMTbn{ZpYxK}VFmvNi z@Aa+rkBWCcfom;Dt0zb@B#^A z-FePrIopFx?D}c)dW=I5??EzD98F$|_2%$%3l#lXe7B3VT1Z~tiWX9~k!~OQ^Q63u zH4PGKECfPQT6^{|cf*ZjlR)<3dD<7RaS)+1*X43OW~*vw5ZbBY&`gW{?|eIDtB;;| zfxKz(B4p0t^8&ZBmup?m`Sq(_9dmQN?nJ8Mh3vohKrf!Z{4?RowV(If+qY81T-6Y9 zMplF-_s=w${7O@{fBDGXxrgum)n8rzk={(*OzSt4%3E{OZ0^RJ$@?=@8=J27A`^>G z{rU?hzreStsw^Stw$LT2SA7^5kult0tgNWKm|-OYM=oUf0pnn0R#}f!hqzQ ztK04N<$iDW|LgU-|D!qgvCuSc0G4@$@G2pt#MDKD~n)>{cLHxfg^89~EzV)v`KK1;@&Wr!x7k_c{;~)Qc{m?@X8Q-R=sYi@h6J?w98lhMUb@AuC?rX?5GC8BNN}$Z+1{GDsm1Cqt+P)YFkowzj0q zTfGB^ZoS9RgvrhqsH(UJ?|!VvLus+T6zi*}fd2zXe9Kndt?#aH&0P3L&g8?%wU;d~ z92(8Jh)GQd6{Gblv}csg+%mIsw@@uCQV&Mdb&cdx+TIhx6xB$KkrWzIs4<`@T1b}D znq8o6GlV)&*A;2(h;^dXYSGJ#TYW__91vn8JJoAM|Kh*bb>!#~{w^Ubur!|@!)sVz2;(Zn?@2=L){+&qv|46m- z+y{T_*T;OtmD8t(<9g{5=Y`@i0G&PtZrOHglS_`YItJ&)6YUJB8y%K9ZVNSMC zYbS%jXhgf!#(5znBLNeX8c;w11B{mmV0;r1FbpAKB=5W{ihR9n6^|E1>-T`q=6QbU zmRoMwdEtd8=0aUuXzJz`Gcqu~UI>A6moD-D{`SZD^q$AL{pK6E`?gzIIk11=a`%Xu z{Cdj#qdCRZ<>SY<@4N558ZiFDYN|SXF7K50Wn#T)6a2_!@SqxjPB5rPO8oR#lWbE4YS4$4R<3FD(Mq58L zp4tBKX4KqOw0k{@*_IYFtvv70Zk41aQjLbBI*@xucezU(_ej+yqn$O&vI23r9lqs` zd}-x~4R?k_>-DlM`$#*dO3l@0TYY<`8lHa`F#a;{Ud@i@%^ha(pEgl{Sc+b+5Vg=W zMiWrikaS=k|_2*D`k7U|3{Vab?`Ce+gkoI_NZjt0c(7(sA(#&|lx7X^7y z;IopvRg#xou!JeZlqe-|&?e|)LNHKqBolnAgES3NmKXv~FpLnVRKrb-00`hn2uVDt z-N*H3$hvKOUf{gPi6D}I3<10YoJVkY?{Im6v=5{G8LIPbzDEIHBFvrG)OY{a~+JK1hDn%^fHrCiV@kNG54|3P*Z;@WN zxbf-7AOAZiuhnaB@3mWID$zT|m*c6k&*1r03&m$1{hilstg@I!iVQN_$i zK;q95!1z-F7z!4>bJ1mf&AII9BFj#Ru;85k^`>b)f7e}|t%n{uTLX6K(j^WYI4~*l z;qQm0Ic{bvyg~rus{~;*9`VB2iJiNASwH*O<6&lI_S~KW`+s$1<;W)iE%LG z-{<&haqHgf?xHOIyLPAZp(tgqu9c<_;=nmf9O`mnr%N_FN8am_<+)_uAr1sV#35n`4g^FD0i4Tl zna39ezBfxYzl0h1(EwLfgcQiSU2>m8j9^NZ68*ZOzp+jC{6(6JmvDy;(Op_XuC3uz zaRLs*A($mnmT}{KKfv36^dsDU{5D_a`P^V@W2vaBUMs0Z?@pNTD=x~vSiW}Y^Y8lf z9}XYlA>$?YgP$BdS{^RTV_ARxZ?8rE?zzP6&qwm-MiWkK?C{0y0ncn~=g)6$-nKa& z{@_f%zt}GFtHbGZ3ou@T_q^vlYuDGVFRSTWVu~F#NSeS5GvgZxUn68$W@T9>Ac&K? zEQ-^uPWc<{R{1~8^k@HPQ~fCLmK5Vh>ZX2cyIo!x4zu&a;c&`V42Q$Gw6t{5EFCdp zzlc-kTrkWdI9`(HIhpsoWL?C6HW~kFS~tIZ>eT5!IdS5|kne)&bXp&tmq$ico?ls- zKQ`0pEapCQnRf_+ri7}JVnhHhj(SqjZnY`OlF4+6n(`MZih{CLe!ACN{M?>9ek|sT z`?6tqz_Wdpmngfw=XrMR`m2+1^E(&&iwi$$fq&%7&YfD$IhS=PyR%ldXK8o)^!xMV zWzJyZDm&LN;4HH5$PLUNIDjt-rh|2M)?P$4(C#kC_WGL4&(E`d?Ex0mej3ZqXyN|6^J4iQi%Tu*`1&6pq_#S?u_>U7Cw=O}tTeC}}~h!f!z zh5=?ujDfnYX_}g(0mN`VBP$B>RzcpIp;%Z#)$qe@d{yDglGfZTnSzDL|IZ$r2YZ^G zg?;}%=bZO_f7|`t{r29o_3RqWNE+=DNF$Jf0U1o#OvNS@8x?^H6(>|ug_DwlsW>4v zRQ_>Uf25qHDrJeq1`OCpNHP+O(2VxcwA9nn)9byr@A_Nc_dVx4Pr7HYC1R%vOoLUE z`}rV%f@p@^5++kZzehfG0v99Y;RrFqL=X%yqsrn$u^`IEC_HZ-1V! z-xSYJZxoxq{ElDw$jwtXZm5C$+Us6deA{r?zdBrfeP!}rOY%SEGv7gyyP{gzst~gh zvl5e$vx%vcL5el+!|R>*k94~It?_hx2{8VIE?l^fA9>`F>EYqwsTkvfhIz~|Ge7|0 z-zJENbvo7N;=+9C7|VH9`Jzi=f|%fzx#>RZuiH$b5A&<5^gbv6A=E08DM|`j;3v?sxDR4VK$$k zs(|qSCIa(%_UMUwAOD{I;^KD)-+iiwJ-)DZeC7C=ySH|4T$*#_70q+x$erg{x_hy= zdfz*H(f$2U^?tNkK7NlIEO{3yQxTeaLOmbROsB-GNYSO!?UL&`qbnELeEu`+UVDzz zj3~rN?UdPQlX^DByOPP_E=5r=o7_Zw4|5Bc=5CJ7a#>Y}DNeow7+>euyMMNS_o?u$ z){g(dbTsjUltgUnmKVcA!#I=pei_VNP-h-#=DCNy_7KwaM;EM;hvMspZ+Wz z&6sxxj_ibORbFT#GmIPp*6UL&EmQUua3LTf-~8`W%27Q*-)~yM}=P%C2|LgL>!EgOXANm*V```b5*Bi#cKe_kJ z>0aHw^TxdW$+~WTGSBsGmTZko0Zvo_5k$ba#Tg)o69gw-q%6IhEP|{r40@OM$Kxyf zDZKA}@7ww8XTQ+qoX(|`PXLauC;Tza`PAulc80@+5A}Nezjyum^?yE_)i2Cuv+;B~ zjezn;Qc7P5WqGKY?lrZOv)TN0v5l|m^@cCh_3Q?a0o&c(&AYq1(}&MI>>6txY}@vA zu}!CdLPRh#P&#V<8ME=1fC!YYIro~C>cU0}*h7h=74{ivbQqm6#IUP8a6_^QOft^AZ6>Km=7I#aOl)R`SaIr_$i~ z!{x%MHd3cMV@tIrpRD{5MA_Jiat_9zzI)CpeF& z($42Jb%Vx4P6_7&Wv@%s9pK9z<^w8%IgbD-W@2o~O^e2aHj&)6Xw#5lB@Ls6F8 zrZJ(hCAV|5opDlYo+{>awMEoWap>9#?#wCDb6>&FTFg7Zp(5;A=EYnyGeNsu!qO7u z!T?tkIOhPvfRtL9&t^>P8r6*R9wz}@iSq$*4g_*CVr)q{Xi7+9YnzpoPalF-nPv9J$iSYp|`k1e=v}{ zA2@gL+PA&!6NjfRJomR>_t@Yw2QS=v*o1fQ?bh$x+@1W*!-Mt>v9U$X<}pwV3`88a znG*~F@qj}R1VaEPp$Os@G2I_@t1ll+r&IofKK8MX*$@5D4_-Wf{`@%Se1Fb)14KZ0 zi2wo!+50eBSQvhMb#>*Zy4~(SzIgHCbsz)Azni9%o*fR?K4sb-FMM&TZrXEgY~I@M z_g`$=c5`KArQF!qc*OhSC$4T^`JbEV>_6+2<$c+#%tf&Y#-YI9`_J zk8d4r|LoS*jUV6N-~XPbX`XVvr0o66M>>bYOr82!e=E7A41y zonSavpswebDhN2?7RRiq=~P_?{UOmZb<<#GfPfenh;fT&KG~%#hvM9lOPYL2657ZE zMLT)6&%*i9=xXA~E0X8Pkvq#`cj&6H@^*U5Z=B6rmrTr+9Xh=sy}=OY9p(e(g!z2V z=-?*vn^&1`e}#7UB58aXm&O#<;Bv%~5fmkZDViH{JE3Xz!7^eFO$i@Lpa?llma(ik z&p!e0*JI!P?dzwO{5z{q{a9IsZ%<}kC0Nzz(&=;&AJ8^aPshy1`{;azHZ6uycDfWr zg=WyKB*2A$_XQZ_Sd;20xv9~%#!|$2D5@@@+o9@oDY`veQQkpbm};6dylZQkNJQg7{*xOYRv38p;%g`>@5;6MNo%n1mH2*PcGh~qYo5WK4d zPu6^XcRGH7KZU2Cep(-W^wIM-Z|+|du_w%I24o2)%5MXUau2E@t;n{)4vmpcMZFp)x~~4cuZo< z#GEid1k6mTs(iHH?f%esHho{WQ@!(^yH2g;tYJ2<9kA}4m!b%t%Q^3Hhitf|@06T> zKs28gaKdeZIAL{djaR+uF;1L3#qr}C96NTLt*uSk7!klsNhx7?s$QSP#Wh+jsB6U# zFb@bXGeFT;6Bbs$3bI86aj|Wd8>#;JZ0E}pj=UlTjvTqO{CJ*@X*oNA1#z7vI)f#; z{Y4OA@8%6CDvF}Qp~QMhJKZNuxAEhvlH0yRcMAd|=fCZ3!|IF}Gh zn2S`sW2WtJ(KJ5Q?VkRF6Yuh0sABUsv~AxvI=HzQ&0CYzK(LfBF`PKaiEJ5Mfmw^@ zIk7d`$pPXDLeZz}_bJK(oZv)IGNGy{ih>ktw5icH5$BP5KF8q^7jPmNnH&?Q5hscZ z4zxm4P^UCGLe3z9$W{8g+UU!&LWl9O?GaKQCTSGaod64U)Dlj#mZBrLBGRyK%d z?xi|7K<6zpC-`1Pr3sIK87hLAkug61(U0+)(_KEdwMpC5ys9_k2R2UfhQSI&f~B3C zj6U{pZX93b^b?O^hug>E&5J)a=v0;Vmxs?^eSzQk>;j;K($bBT(%-C`=AEWCEX(3X zw_ASD`||&~dGqEb|DMjDKR*V3W?^CBOQjG0P0HytDF3}niIskjst6dyyqPna%$e3T zh9Ls|UZ1k6ID7I$H+a{5@#-~p_x9=a`ox&I%?zCL?+(HJ?@iNO1QK_G>cg7SaWV5E z0yw}J42Ily?i~GY4{{w?F>y5znI#jT9BQH7`mka-b+&U(CK&s6>E5drwQ@*M|lSPu#QPh`$XZJR)BwgrTD zMqX34-81a8D}>G&0FIpK6gMU+eGr?jM$&z5Km;^tr@p8dUGz zTb*--oJDXIbCWC62xDxb5)3n0>}V{GK`9b%e0c8q9i?Nhga9nA*v4SUH|fh&Q@gj|00A7Sdif_@ zH~3lFy8t;+i=rvUFQKm%#0s6)B=;?!e!U8bP{=(IQoLhOxXdfu>_11X69X|hDI%n* zMuw`7VJB?lBYwQu)V+yWunozX7|VV&IzKt^z<_@DEXltx7z}m^Io`rHgJE=4RgCWx zB3>Nry*%98U1r}pxhxbEJQpNfHV95Q9^0|4vkn=*bM8KLR-tAZDdOde?NNU~7uU9D zz1G8#tk669Q9$83H5sdrv5=^fRfp5vxtm`TW(SE&duU^@3~&uvjOPb&x!RYYh_RE) zlj1J%Xrl61B7QPD5s487dKh6vG0)bw=W!(IL2pCT;JHOCrD0-jdk6sK8z&h+{>3^Q zdT8(J+&ZcKdc|(}`d??h|2FL-KkUe{@nH6_y^i$mSq*)7T##9D6)AkWK{{7#Wydt3 zL}x610V8NhJ4v9}-hcX!H@J*w7@xG=$z`IHR7JQL+=UhNk@X%dlku+{_1YVOr~(>z zqeTl{|qbTBzQpJ`mdAYVV#heE$lMdJoc9U!8@ z{)Y-@{YR18}Yav(ePs%HU}VHwxArF!@^?9H1vgGtNbm=ztiqOr3| zA%$_p+aF>!E6WejxN5=y+4MyfT*gV;`Hx*u6i?LSlmKHJav)>^0#r^kLz+AIR^}(n zG^UNTt*a1I_iv~!F-jzooSfoWp}Yd5V=d=hzWxV;1HFL}M_g(-m4(WXgPG)$=(T&i zw!=UJe}AWfv%SudftSCUFTXd8ix!NA#{0bnAgKZQ8SKwzXRSILdYCDf(zTx$;R8e{ z=u!5rSW;9@ma}kTz8DgZh7c6Ht0k8Esqw9aH^#7$WUYSd%X+MelJD=aCjH^;}KGWmA^Mg-`@@L87uyR_VETMLPF8O{4X4 zQbKaqT-rTAKS;-nZ{QQ}KG5O^!Gl)XS0pF~Z=@!2O8{iI%y2TYktj57MgBRp_mfTO z71!o9s-o$y*5+R!N+-vSX(#8i%(vhnL48PbJP{h9trF0^1;Rep4SJV&TaA!Di~W*1 zjWrT>*gRgPZj0`wY$Jp>dP4_da*#xCX=f6fh{QMY+4U&{vP05G)HbwrE1Z#_=9VwY z7w6xgG{5-f(HaEa%aNly_F>QA{MLJ0GdhCh%jHmhJ_i?kOU1G9;4FXlvF=T% z?Zt8KLjIhM8<#FtTuDinGUnxno`tPyNZ2Oe+Bb}B;86DQrw861HUxHo=&w>66djjx zS+&0%cj04rNEk*}i|4J0P8xJ-TC@q3@aY@ERPjBSV%JX}EQA1?RtQj;kf?NteB1mQ z?(}wG?A_BBYTNH__y0)b@zAx_4^~|cWj!BKs^hj?ve@(X4X3793!~dpyuAs({ON*y zHK>ogC1z#?;eLd_J{A=_YJZ@PE=SW{1by*5OsR3_%hO~2@*~ljO3OYtxbW|cL_*TQ zv{IsTPTrDi%sS<;J(3Ltdat%Lb`nQdQMb(Y`*$f93gJ#dZ@+Bjp*Po0vk1j<^lhg~ z_)h*Wbw~;E<{UopN+;%4S_h~r&oJAMDNDB1W$SUl_IPe0{Fl}>r7S$8T3Ux{=;UX0 z#x({S42fO9{sg3Qh6Lk_n}{DVkEug%_up#HTHp1RdEfax3E*&nCg317-$$dwhB?Zu z>E6E+Pv?7favP{iJR&O1CH`oHW`ymF^BcE>Y=$u+wh;I-R|%|Z!w^zwN|i9W8v;i| z*HwtdiY+J0`;EKbIuhj4=n1&tIj7*-42pMUR7{){A(X$xGAGjiX8kT5MsWKtAp2~2 zH^08Z57|{N^my!B;F}#?5WYHtg?u|E_-^KC^Y_tk=~fBY#a>_v4$R= z#(qqb{O%S5g~A=Ur&$g&`m95jD1AT*w!8=xC;M8D)TbzR=Z-dfkxXwNi*sADT|1-x z?#1YB_*!$@yMZI=_^}V$x8BC4#?N_ej3{T|CyOlOPtnR^zCX;Ks>!WN_Y(1F@Jsd1 zi_XMG=!h>nY9s~7xMb1J;RECFUL7R5zqXgV zi8nDC&(}A4Cs`&&Kk@6_(&7hY!rSQmn6>-ff&K5sD3R5*vQxi{_(1+9pwO34;m=$y z)D`&6zf?o(56oD#M&k`ILEGdx;tfKA83m(kUiJ@20VZFXv6`lzbwmHbFtYx!Cn5c_ zvt-!8c$$<_7&wFSu$T168K*ERz>8(Le=x^loMg+1CWjKR=&EJwN}mqV!2Gw#(a-O1 zw+QQgVR^ZszB2-#XctacZn=86a#CuQiKvBzgB*);dpwh!t4K1e-Vpwx9=h%a)eT6n^MpIBc&%sqMF1f>1H`PuPY% z>Prj&*wN4<(Nx0{t_%RuvLv#(d=Vy~_G>os!T4}L#P2shmIHEc$HSALH1zhlJa3;# z@ZiBA+FaJXvYH!sx33foI&SsaPJa8AbIVIJA&;p_21%^%%kUX`P?dZ5437~7PU7KGKeP(wU zEK_K)GJkm-FfQLrV!iy>yw>*?b9}HM#5gTCNm)stv!~@}X4o_g$$R!Clo9 z6tW#Q|H@Yv>t_U2bGAOd(e_}B{pHyhsKfryW#-`s`N;{mFzDp1`+LsHy?K?aB5i9a zas20M+-iQ9t$gtD$S-NEM%_Rb{_SVSNom^^9=FyU3Ippex=}H^Ja%cAT{n5?8wyYl zS$MO~haV|UdfLqVT)kA#_3)@2lZVV{r z`CfO;ts#jwD&F$2Tust1H!nS~8nlt(tK_oub>`q>chm)`o^%eh^QN1`X13SC*p+}` zULLkBHXf1j_wV1U%AK-2y$?AbDsHMhe`VSIjh$1xy`(1W3~&$r(?Suxj@Mv~ZfPDU zOdBOo;IA@xF%azhYLMtNo4E=G>>z*n z;-O)$bRx$>*od?^u=*+7+iEVJqfRS|L-P=!LZ=%1>Fc2^Uhvi~ zWw{Bgs#2Z#EoLLDJi{WIYp^hzJK;JT7Pa$=9E(1Bq^$GcypC;&F)ubrpFYcFnhSE@ zBb$Ug>pjbk>j6p-gVsZGaF9FFpx-bV?I&PfpQq$YTZ_N>3(#Yo}bL z7(<#EUT^|=41&FJis1p1zej!gM36?U=cgD3`dFM@xg;t|uz8UyN__-!^E0GYlBFUh z3)Uk=)ZQ|DFhW%2G*HhZCBph`S2${jhpBAqg-7KmXmyI=dFXn4oP}(Ck~F1}M53Z& zTQ5);>_a(1Y8Rrv78pCQdJJn=$R4%}Wf5L1cirzLxRXf!+#HGE|5Ja$ilom=eHr-WU2W>jAeEq~5|jF`_1*TuMLA6< zkUoy-HzM&;gguBY{0+8%jyS!^W#9DUBz;&()d01vkXd=*;^cBxKX9ao_S+#|bE}oQ zVO7xiYfiGI3wV|D399{JbXsXt02enKGvk@}uYW*MB<#j|_0Kp<`_1ZF$JemqR|W8| z|JH7M*OLC>Ut#@CHpUvp6>pgovPY`n8Q+$KTFkry>hF3l26bY zZKYET>)+gRQ#oLk#k_nkdR)ey1UI~Wq#OLDhy`w;`I&24v^rWa+NUtxqtVFGYeI(d z16iintm`)o2lCK;BrR^a7EzH{z$6mvZ`HYhWrQ!d*QhKL) zuQa-^K@1R!k>?N0gvAop-JdztcoU6HQMvK^Ifmx=&pSmvdp&sgU@B;AmXkW|^CI6q zZPaJueC6Lzw{EM4k_=)!>DK9pPmW195Su}bXgp$@me^nK9{x$%ki`o(LLzV-?Ux+^ z`5c<+0?K1BO3vpzFGwzZ=)zyYlBo^T^TjG0>7lsfz0%>9SW%ZrwJJ|zk8jeBkE3GB z6D?owFd7yqg=du?1gyFU?ut;vLt%!mdMFQAp%g2sVTb%LC)myLpYu7}Zq+T@^SF># zE1f27Y$B*X7*HmtJQ-C3CT3wdWLU%Kes5d?E(C+Rmsa%N&)4cbQujQbV`4ag4pd#EC?u%)s^x!0jBCWb82nfcmoeYd$#t_ z;Vx+>tH2NB!fK>P%m8ImZc<(`E2XPkNp&l|C&oqGuEgA!utVAUF}u*=YO~eLuKgo} z(9fK&+Y4{j2^BBH)-EE}vaSd|OYda8z(ky_e5(qs^|f5+LYv3%ze^2}4rO?_ZD|){ z@M7)uZ`EnxyV)t8Ncgge@5`St4^E#>N#2Fz`4?~Ak8?Gf?(_u1HUxnK0!Rb^YL9(* zk1cT)BQJL%-wVi1cQe`i_0cEa(S1u{I#iql+W$kRQ;%f^6%`LI7_K$BGtR$>sQADB z6eT1{WlrAOZmVgWc0$baETKw?6P%E+kJ0-%$gUn4#l4zf668 zPOdeY|J9qpQe#+22qxZK#5Tt6tm3C;s&ul8VDhP^g8PzcO~9|bD3%Unsy_M4i0lE}Hq~u7tN>i3#ld+N-6Xu!FKx zb5hl&^zZiX=67HO-~+iS=X$b8v?iU*t={=OT3(9MCjH3!+?&Yysq-|^@x)W~34L?J zz&0NprCiVFP3PueZcv)O{oi>;(|r-F2@@U!>gWohrxz0QF#-cf{{v5iX(kgbHFWpM zAN=4Ks<#f2QCCuV#qK;>;j$P_dFZEdKO%!6X>n&`+?1yxo|aBbbjI!r0P7QTML-YN z{^Sy7>sq}$G8zBe+7#!~BdcjdlZ}@Jwcx5w5fD=~Ok67Anx=V_l$E^!)UT~O#a0JQ z+Md1`>0+|S+FQ{qmN9c%VC^y4wt4xI_0dzS8_k~e!>g8!HZvRHKA8@4VxPuf}8twnB?7F@(AFmJ6_`oM&y#J>lU>!DuCH zr@0l8+ru0b^d*PoAiy=WYrl-0qztT_wnbT-tG5?fxOjsMmIBu=qh9R>b^9Yl_nQDfLq3yku{XM%9P+K^jS9nT)!7*`r;%2weOL^#t|~$mS3+ zyeLySo$zbYSo?v|k_7`*;Zp z$a@ACZlumBXgAOGj3_~}*>R!v*(iEc6b^276t_K_J$(@Z$x{KPC!A}hFX#jjatyu9 z^4T~hY<07yrX%=sD61Y^|5`$TTy+RR0T33~;CC@p*Hz}&j@eZsFw3M1-HvJvxx_To z6k7D;mwH3><<&tnSmehDMTu5V_bO7_mw#QL&|bpnSspLY@x1NVZ-dha1Np(hmuv&5 zev6%SI@iGEtI5uPzsDoj=M4LzHUobLeqLT!9#D8!_ONE3&wm_y|PwM zGYvlc{w?Ib`{i#*TUiwdlP5E2>2tQaQS6gU{%_S;yP9EZ%^Q9NTfvBG0X9ME%CZYr zZT$;-vh8>#^v%ZNN#xxS zkK>du1GBJ&jf2Aumg3yey)nzxj)8hhsBNVX4~*`!7rr=D*C&%*ZkMxnsvmaTsK`TJ zHM+8R5(6n{Zpj5<{>=3?>&&)A;>CL;1TU4c$a2RXzi&lmJblldYPi`p5X$a}Q&$8g zrjSnk7@H4rd3E&XlRkxj4h=r(2{{jOf)7OiK-__zi`;Z#deh2KtVYkm3&Ds&0PxZH zHWt0K%)J>f3Zzo4(Oil_EqKk3)skwe?j?S(H?@x!2l2){@oV2mybr4va}~4Z^iM;S zzbM{@rqO+AY5iGm&R7z=n~3)r;EJ%P?-n44MbH2?8oyGV5b_$F#(Vqj|iN5(^! zV81d#oeG93WVxD$l`gFvz8tyQw<-&!$^1|Glz<~1tFWoO}=rv-J!DT!~yLM+*ip@q~Y0OCK6au1m!fTNna=|;DFxu#kRs2fBO4L zh4+cux+5nL+qHR`!_qGHHG2g=^x4RTuh+kozM{^epNk8Q_k3BlAsg3IHn0B?9w)KB z)O;4|dsWwLbbq%0Ja;kZ?kp}Ytn>NZzbieZD-ODsTT+bc%~+${yBX83opSC=0}sC> z(|?(F^H`Ex@k+UKv+bNqRESK(>F;!i19byrFN5lRU~RAo`~knZbRZHu`Ks%+J{IURLJL$ zAdm5xFRELu+$?*zyI66`8nZUo2njC>z9I_{p2(cFD$+h;^FDioR z;%-8^y%W-69(QHPC-_I@dpx%-hR2r2JdUD~SDEa23@Gm9cf$JjZB4(3yO6tLGl^Zx}`y z^l~ib-1K3T$Y9i2HjfeY7zN0bRWB#VEY0{muiQn=U+D(9^^I1kkY8yOegTo)O-!ve z=#V#OcJ1HZjCv2z@8?hsu`nMA>t(wPecm1%VBL%lGl06smZP}T)1bLBu2+Aa_n%zk zwI9Ev++i9~Tv&a{67p{8{E|Yw;}`Rh1?i3FJ-i_Az9dI#E?4rlr=Z}RgHGt3;`V*d z?(S}x9vT?_+}+*%Va$a4lG%TI;$~0YElSUaCFh)9IVILT%fxPY;u(GB?VyL44cE!v zk7Ox`hsJW;M^a*Ov+*CQtk)?Wzn&hg42qZyWVUW>?%=5uORlwOM4~mM(ht1W)_WHM zwlBHO3-6(6?bzK37QG()gf);~7OIk1{qs_t!CRiPiPep&8H&ZccqQV`DH6 zA)bWfmpR+E)#$mf>;>uZHnF3P;1}<1I_iG?tg$21zONecF?3xUCmhJs3A(FO-2PXl zQxm5Yb|RCuBo=F0+!>OyKPwsimox$(0SLBzgiwQk^L@d9U_U}g@S$H94nBODbh+!z zGQ~Uw`DsYJROEZ(<(+~pnH0M?*(kbCk5!HrPT75$TruFo(0j&`#hE)cnVEp`s{rWv|0v!(NcEyepY4*S8>`{v`5iguMMRsFm_lHfM8)tjtiZf^Tqgbgz-+rlxeH}Y()_?1SrfT3r@J3)aGc-I}mK-gs zf@#O6IwW?R6V%!KX05&XEmcwW{^Obq`ftaDz)KhIf8Q$N|1_1lxm^6L&JVp5i530S z8c^`j^)SOUzx4(pDjYBQR7==oW_S$&MvuVGhMMe&IZvk78Sv*y$2E!XhP9Tb7 zR~`L@#@2$~U_AfqC21a{l-XMevh^LQRsv%YCB>gXW+cI=)M8+asiu$59)6>OKnP8y zfyZc^N}fp~!o`mdA%svMNWMTodqST>&m_IwPKdeU;rZZ(lySSQkyh62 zYfp3Oh;caU8;uYg!d{$sGzf`qd5zwJq43D*i|omL)(OaQ`F(#+uA%jK^_%OXn(P}Q zzG)kMU~2yYe^uSwQZMTyQ*e40tnjUQRqWwM@^9== zSc|iVDa{YPFFLmG7|etc5f3%*=F`A&2H~5(2X{JaXCjO2Mmmi%?-T`x?y|-TBdd<@ zr^425R>yRtU*5Mr>v8C?;$`tn*8~OIe;)0AA-I#!&@EnCp05?v#62OIXsk2GrQ*fC%g^t((-jJL2#+YU8FO-l zlQ4NxYFT{L66XYxa#WP`P%&kyMKwy6Sr)R@s{-KoVw?nyq$o8%_IOH1py_gA1udU^ z6Sw4_kkJq~qA4CKDiQG*wro`}h@Bl8!Z4pLvvyZ|bJew^@9)1yQ2MO1yK-sH@}{el zUs6&lD=39VWFrZ&&W6C-v`?y``TJ1P+k)vMHH`|$zT0gz;JoN~lT~h1^+;ymJyWqM zd(@d8Iq=ZW1|sa&F^GZKPzEgHn6@zz&ee*wK4%u`(GYkX=G@X`|Cg00wlarAm2{$nr|H>wJwxTwz%Khqypq#s7a1JRirkq7aQ-f8tXpLWy^ z8PxgTed(LHU-W3Xz9FoRu2aCOj=oZicK_b+=38Wnzm44zWU4pz?_a$nZ{N=?EsqEB zWSe~&!ag~hiUu|%>-0LR#yfp&8cpupT2cr(=wZG45qYQ6`RBqGkjN3--kZwdoAPU} zPUz92q}x)sHOr*v{fXSjRD9T4pCNBU0aMJN%$HyDMdE3c){n*=$=eB^;D%N6R33h2 zam`nG{07Vk1tdh7KI=5MEqFFo$7nko^-I)|cD8a#?|VqJWZ<-MeRfK%Dhh3?OQNr& zLPE*?N-j@X44j+9MPMWLg*P2mZkadHNS2fq2WZA^DjKGBY3kL*yz=?1chH|rTgAI; zzZ=5_90fjwzo_2nNnRPgnZ?Vavvc}$BQ*6}prQe9cTXsF6n!72ICM8mxA2X2gPf8L zC#lt>7dG*3!C##odeIl#bn-x*R*JArOVR0elU6dh4t~c+XK}Nhep1oX4xK!>Wft$0*Vu-fR6nvXW;cENQ(zCc{_l^hM1|MCA#d=ciSKA8jPwC!W zcjGHvi7gVl%bSdA$jPY80sEiYM~FJg_%I-+Bm{W=U`>|0`Y>d8DnBGuY?dG>N(G|u zw;aKp|HT(#cPJOx9=bl+nbDYUIp%YBJl143Cil-)%GM_Ia3?~JQOb9ISe zw^2pKJ3*`KNCA4Ou*4rC@Kdb*KOAn^l3$SLFckHM;k(SL57lC9W(?Y47%v?)@IV=S zs72{UDb`y+k8dErPMjGwbc*v)zJ%a+I(l`(gPT?WpVe&I;Z-I}@T2D(i_>f#ZXeTk z6kKT^lTG5)>L=hO=QEzst=n!Q^IeToqedpQA|@T>O$ZG!^;azFQDSVeANs|KY;~}N1I_z}aAw#k+!CLd072d= zDmVfbIPpp-w5%yf8ELku(zligPso*4x#yPqCP?vQ+n}cXd&eoOfy&t&`Gabi`D3Y@ z(7Ps~@GBBo#2LXiD3K}ACJ>JGJ_mwQa${z-K}aBo3WUoGNJW8yLC|!rrvB9C#Qy?X zGtFPOdhqLfJpRwxm*Cqb!ZVL!>b=#`!LTETi=U?Fb+)c@Ip4jRHEXDJvlHhAM^O&- zoBr?oJ!ejzFO)jy&kSkz(`cj%D{p~3t&nQVv;{}0!?;ySxlqr6_(==~9L^om*?S}H z7%N3o$MBPi)is30kJNqEy_Jkp0+w2pLxayn?Z#xxyk%+>pi4HFu!PWID<`*crw{ot zot@lqYs=Ei@H^{1W%7{Jy7p5I5KF8grw^uIZW}!w8TNWPM<^X~E!{62pdK^6^G=AI zPqOhe-cVW@KYzX3vB_RxWFEp?`js6N3(k_a&Z;vS_d2_>yO-~{iR;XAdeqDYqQUej zF{`DZT62zNf|r@6F|_8<9@|%4ANR%^?wjT;3iack_S&{<<$eUyEf>IkYo$?^zY^uA zl+wlMJQODq1IJkr_&sVQD|Yu2(-jMq*7PhZ?$PrD0BW9yQ&cczl1SDVigAKOIHxnF zxe{Zfd(%@=xW7PSP}NSZ+xdgiX&>L}l#f4nuU*5|)-;F}Ell=4pS@RmI>V}%J!)4g zasP3y*5-Ny`#dVON5<)m%G3jbaoGt}62#dlIl(5JWlCUW*fs{BjMaZbPQbSXIN{sh zn6^7^4w-ywS#LJ18}0~~eh%oNZo|j!MjzgW7rqhmb^1LL?dInyRa?K<-tTt9U&&nD zSN}cjD>+%bDP#S2>h%P+Y*}b*o?{R3De1$FAVik+`b6Wem}$ozZx73j6#~&*DnHJL zUUEoL`yNu(^OLW@aW=B=1#I3cs*5t1kYBJgxMppJgTe>|{B2Gjk!8fkTzla(}<` zg}Z*U@pCw)e8hley@Vx&_Cw5i5*JP-J-5h^Bm^LW&EEDUFZmm&%)8^h`?H4dA6sjK z*Sn0pIGb8vn=T2qOJ-g)8?2n5%0h%*R95Ke`n9^k0U<~IE$(eD=`z4uSl|Mv@uGUtE8bS`hb~h=a-*UrcpFieB5lbZI03=!&D*<33`Ue#E%f;QQEZv z!s_3N-pi7_Q8wk*SjIP3_QzaH{~+cDTXA|*l@GYhHs^RTbG+|$ltxI405`AG!5rC! z*DA~}mb;psiIHL3lFXyfOTb}kQ0irCC&n#O!_ZHx7Y`RLyO|WvQ3bPC!haDnN4^_= zsAIPkN@ODxm62>jf|iv9b|`KB2C(?!^Hz;v9LHw+yR2mKO-2CGKQaz~>V4_>LmRZ^ z6uu#KhoA^Qkj>G51ja8>ey-#P@9i6{Ld*EXW+eQ5wPG%G_Rlvm_V#Q}I&AGW(KD*9 zU2SoR5GcY<(l{`eTJQHMAevjhGQQ0s)j0b70~Ix${$;m3aw1A5(b`wWX4$+{F>FNU zY%nvDCerB{T=SlfMGu{p9f%oIQzj%;CE92>VJt6fx}I{+aJwk!@mTSc70s{_jaHCo zP=~$_xoBNm`LC@an0{0kF6!g+?q!kp-?XN0amU9bBK>y%MzM6@VZ;f+&HTWZo)7m6 zzngYE9ssd7ItZ!-R*9jZ<^id2K2~OiA``Z-qT1U!7DN-OXvL)ai9uAsZTldv&6b`fq?&W1err%-kazTaQ!f}(uvjN- zf|PFh<2F{55!-EM61t}98Gf+ro`iL~znU2rv8@4_V(@#3K>B@fpdX=;VSYjYTa-Ee zJAE=cR^NKoxtt4~OqZz8K6n>BD10<#jdm-fs)i0vD%PQYY*iw6>td?!K?89KNdGu$ zaAz3B6!teF{JQXtfc3iL$kjXGjVp~ZS`~*dxPo>jAydVY{2=@mSLol^vz*TGR}FhC z_5v`uGu)GYmTUyQ55YkBJ8VPNzjcf*5? zvUvTl2yphJ4gcRv=btRDaU1mU(e10Fkb_v=ym9Ob-KrR4-~G!UH*v8F_rLqJ@AvEO zekyk2!+enrAqLrvbB~_V?5hhyz{{5el!t~80Gx&pVuSr%ewuzsZB3j>kWi-?&*5Ko zFpj)?Z;qaEvt|>ZaxSnQ(^%32AT;mL_L6erT2ga|GgX8Uf^qBJAA=4PGHHzxxm9Wd z5)SeBiu|>30QtTtF;aOk`$W#-_)<1}7>-7yxgT!{UWM|M2(~c)=ym@(s+U`i(e9Io z=9R4J5;aQH57Q4GiyJH0ZcS>gX+MSTWJTuf8yZ#vgMk3-*o#jA>{>PoPQX7eISxk% zNifq%9v1t^E0GH{+(wwSZ%86YHfaX{4jSuc3Y+iw*7EX;7)-Zj@5mO7hAaP0#iR(+ zh=8NqGWN?}_jY^;y?6R|E%XnM<>fz-)!X?}ac?Cg7(Bvj*09&7eJ=ELZ%0U>=kK$Q zu_ug&E%$)0xhH#eTr23L>`jF96Dj9_#N;Pw~IXCnj^xeIotnZ)?q zKJ-bI98?I}Ogi_YpLe^|1~$v+h?|??-%H~QWjcc!ZMd|4Nz0}&9Ga_;(vMpgw?&J` z&1bw=`pOoHgN&V1jd2+-BszxR0>=z45~G9?I9xB!{@6vguA>AXg!tX5;Vf_lQBcpa z^yd;$;fWBqX;v`36Whk`SmXd|ZRzC>K5&nS_7PQfR0UVGd{`@VxP6-$HL4Nn#&~%t zd$=bbq;&mLEd7kmP0vs&-knCs;q`XYDPtJ|3@Eve&Z zl3nazt=Cl+O^yH%&=+wg#;I;lJ79uCD9T|PbNc9u)c{DLS2SbN)-IYM$q6@5&LLz# z7L9JfTtzSC%U`g4IFeSK;`&+j?0!)%$hPB~Ux2w}Ueeam0KK>F<3RO`Q=LZhx@f+- z08%L@hK%DM3}1tpIesDc7eVax%Y|YK^Y))35`*MQ9t)JYIxZO|l=L8gm{AdlMrTLT z#-}>W1mYLj(q_a~Vr9;aG;NIa-b6-9xTEMEGBZ`K7*rl*Uebix?isDL`Ge}Hm*2J> zNo$`F)$q?ywcdNX^+aB*{p-FzArAgL}RPMsX0r?`LNwxX5Q=QfY%HD5#Gz*g)J9%VNz0(NqV!htSf6v zVG}9Yj-0VE;_-qSDqda1*~W20hA;dpr;WdvDdGJ@wpb^ezSa5a)@1w9buM<%uF5Ct z%Qz&=wd8u}gW~P}P-?3N7GIOn>RC{%5!7x{rI&Kha6Z0c#HxS zp?MDRt5tNJa?6V|X{`vA06T^_`Kpxk>~Q98ffmR+Y@ESWIJn(Xp*W;j2-zoe znO!^Fas@K)a`M+_cW={u@Ys!4+?I$!XE^FHZJV4B056sY-T#X>_3XiA)@CF3T0&Hh zhXwp<2*2CXA*_wOznBdMfR|bd#2ZBB#7Rn`@7s^|&Xsq*S4Z|9t_s$US;;z+*cM`I`NBW>B)>%}u2{a~6n7BziuPr_2yL2MLss zqRfwn*zq8Krp3aSKrGPwNX z8_>oK-<)>;n)b{=++{+W`GvCths_eaGf?Nda0<`{80-g8H4+Qr+0MSQp4F+QJHkG(9jMXomtJrRVaC7bxCx+EF|!URQ0?zL8`8#=&@= z;y#8<`4-+;WUCW>Q{2wyM|3U~`n855Jg(W?yDtq_g84mn51|HFLU-QuHT==7v&LH3 z&SFzzb7yj8anj?&IWEsMMaGZ&2R}_$4VUt^1deWkEJk={K8>9dLer(`P3&-5A3-qt^ zv>KA)D3M^!^&UneOkGlmSTcY1-vDpNLzY@rb zkwchc)sIO1*l=)Rc=3DBRrtof!X8DifxlJ1%c^Y~SxCF^^SA|;&LbHGV{uE8UaKMu zAIz6!Sw0OX5{Fc!@fD@j=2W}m7P)uR>;^!TVoga%fb+bA^{zos%G zov`92?{{UnzpNk7{}XfX2;ES$CckYrud{omkXL+I zQOf>}uhRRa*~P{EU<2Lxc}VoWAtMnm6^9=C{R9D&>|hjC3Wy5It_B7GiP&(Ba9MB} z@pu?b@gJ4=#Qe1ILk7e_GySTw`39qx%Hi9GZ~r@%E)i>XI4@mAHzH{zUv>vx1~*;v z1X~8{`q6zWDT75OV58S1&4~aYwFa(tW!tSIQ#wG^HYXDs>?&G7%46JjaO~`X=EV>@ zzG|Y8RyYhcpBd0a zo&x>)#VE^!p5O1*}zGIdvKJ&N@^e@FgBr;h4T??hnvvNZ0QBQ ztWzXi_gk?K$qh}?8r6SdFrc5#@wirTvuLtuP(~ z4~{MVK$-*AL^C~PQs(7R?FS;1&F=5KKh-P&m>Bl*OI^3T!l+B8y^Ej+ZJ2k6=`2(FH!??y z>k$%zw7LD2Ry>T#9PgCjBSjt$XR*)@UafX*hKKfGj&VlGD`Q&Ivab$5QEFJzhJJUy zk_s*D&x7e4!CBaG`u@eG|DY-Cl4;cWbR0SS$mX^+F~lZD;!}gGBmp2_6&u4S9Qrt1 zFr4nenf!wYRTpLkU$6@FJwiu0fP!3$Ee5AtoJLZ1Pxm>?N=z~&E{-DTc>E<9c_vo( zUu4q}g(nEUBnZF*N>~ug2W~i0hYE7pqL`a!^|!XDo!w6ku=D0ec|qJWs!QK_K|kPw!z>FT4T6fP!} zFLuX}G7}i$2OK9@s)JPwYn;U?rsdW}5D3@O+^LF9P`Sof20awDu4JmoybQ4s=~@<% z=n}z~U?Y&@{7mppdhK6EWNHgZ`1nQmxN2bJechv<$;+#)?eH*PRssC1b(Vi@ zv43)czTb{y@JT6BIIjYe@46^@s~a|Q-;~qQ>uL6sl&kO0F~kx9i6}YMN}Pi=xNpCj5gi|)cg-9f2R4Qo{}aQE&o&D9a`@t< zlM7+9v)y6WFHZ^u&8r7iynl`n91=mmgh*TPO2_xtmh z6mW2V=E7nA&DZYErpe=|c)y?5o#{-G!k2m;le#&nhmA&>Nm&QC^>2- z+e1ldEv9K<_81}s?!$m(VwZZQfaG6@orkrwn%Mpv1tEs;LazLQPoj z_*-5*%L+Ta8Gib`_o{jPokKg>cCHbTeJo86x(8nh6Ix($oj9!xR<4H99}CrvX@l8&2~5i>wpjekr+Ga9X*K3q65Jd~EUJRv&2hE*DV^Ti@{5&n~ToOKlgz z_d^f*$#H>H6Kt?ujbj8Q2scR;p=&S2KxeYqGJl%LDfnHaamgb8la*lVyej?icPE!F zkK`TNLk7bxz+MU-zzu-|ivxnYuBdTgk=+0Q5QQ*AwHM**gl5T{j8WD;TQD+EOH_k z=FbFLsW!xw?!r=z+A&;?1ZEjlyqxT@s8(u|UhLMuRDvN-pna2taLtQFNlN(=bvOx{ zuZkEr!VFh6Rf1L#kozRbvL)r&ljDMl4xgA;soOx4R;bVilUd8m&c4CTc7j=<3qtK? z8Qd7R?|od7ign6&S)NipHhX$pP;xmkxlyTzZ4TD!@6TS|2dq64-bgaDcZzt^@W=pu zJrCFS@@>FA2QG0|J;;2`5Ox!`{Iz}H&C1iDpbN|GD9ojkr>AG#^QRY6O|xsA5x;g% z&p+k`ZT#aKOKmm&Zyg}AkAIbF{L!iQxU?hy5TiO)j1Y!Om@M<1-I~MeP>tYn#6+*i zue=W}N0-@gb4(q9VyBnf@ZEkExqI3_=iX3(6r8<4jNX&|aj2X4z!v-)Z%f!~v<;t>8 z`JA#z-?2+(^lLo?Z?p@)O?pWaYT2_n8%FX=0Yy_Y_^g-&^$d!bJAPkAkD{y@uhP)9b^zAm6Ot{-5gWi$CZWd`^30aQ#7{ zkGaq|=^v8-ws$S;to0sX9cuZP)$}56Ykf=#s|Nz?2lvNJ7!Q$;5rcrZY1z^Nn?PUL zz|3Y}uj9+z^tiLPlXG^CAv?JCN;*Y7I2+iQBG_QaK#?G& z9#ivL>z;B0X-rpTwscfIOMWWML1MicuS!)&pQyXT^#Aen-r;QcU)Zn^J62FzgV;rc z+G?f5_@bz)su|R#T6^!Py;FO&)!KXSEvQjyQ>$u^*5=LcdYvPV1?sLw4 zdx*O)#K>n61~bPna!wFX40|x#p7kTY8V*zA1Z5Gfk;bWv1fnEc8bjvx8T=(~bWdFu zV=Ne5^>qpLBQ`rL1u_JR`H^y)O8U1gjlU5h2g&pIhc zLcZ$5v~GC7W-V~77mM8g&d+mY+M6yFA|sY0!qig^9$L??v^591k7aHU<2-@iG56_T znoGQ1YHn}8=p<%-y&qrEcJ-&~$6eVmdZ$*ts{O9=pZCZ42xu^mM9csii^Hy?b9s&Q z>%x7R0-S1l=;UmU9xul|@xs-w*<N+LyrRKyQc0gxyX zaIW4cNJWCFgUL{ogpiOJNQ@$<7vZZAsvtK$OR^}=UyO;b!$QTkltHzfz2U+#$#h7| zUc5Mc{zs*ivm(DATba7O;3RbG>$9_5?X9(TnD^$^cy0w|BZ!e*1ONb%x)nDodtI-w zWtT5k&JPcp@6P=z>T=H8Y&%9{Y_VQ3vFe@Xq3R}sVMpL%dUS$GG2L^6)XBHsqmz-` zsbBqvpA~O`N_fi*>L3EC5*+W{Hh;~>a`eJ}vRk?M?%@twul*HV9LlcS@9%g2EZ2YA zkcx@r@p+OvFQ8nD?6m^C|A;0jg1+L~#<-b?{UG%$v`(_(G6wT20@Y)Qz~E_SWNcKP zO&$@3n(?ccySRWl0e&zOljU$J=F&%A_WsJ{F1|zh)FttHVBmfh=eqLbc66aZE$`&@ z*`%h({)0b(jN7Lx&Sj=U`~RAam*SawLTj$m%0yEr35<6J)97@F(BVu3-JkO2dgX|d z(K8|Qs{L*@S+@OdC|T#9F|?VZ5pL{FZkx=nXO)02;^Lx`r)(ptPqh>t1+;5ShrJp5 z?w%&(@_N4Q%B$wv_|K&T7m%>1%8BXn_ zETP|E>o^lnoySV8{RKe(RL8il2JW>vc|hh_SdT6DM3p>({kv^G@+OjKw+e6V1OXBa}%7p*0p@q0k4xK_oN=oXJ&>l_?L* zRV|AUwSLoCBJn*(L4o8;?f%zUhkze{TT0Dt{IbUbtN&Yv{dg?YH4C;R(ZgngkuP3K=YF6dO-ny7k|(3oG5LNLzfRgMC~%nYbKG zCcN_BxNX77jeVU!=l=_h^d1+E#*B4#?x=l|9dJ3eFL}-`-~N>_2_F=rufbe&FuJy3=vTGZjk90};&%hKQ*H#lgp6cw9JP8neQaYMb^1 zv5K#5vMjsQxebZa~Se%j%K(gNsKMaK+OGFqH5Fy1w-=KkIJaydF=FUp7dEDXva0V?S zg_S)<-@~jdHJvKp?B1)_#eHo>cd0ZtyYklCe#TyrCv(3SH&yhPtLqr|XjvO=Id6J@ z!n`2!wzfviPLVi?gOq7744V*U3+7NPBIO9P2#G?8KKdE=>R_d1BvtSQ<`YIUQjIl! z01Lnuv#erO6}1mg){7+-`uO0`TZ!|2h!${&DJxnEDHpA8w8yzfT^ zDTW}E!blPzA$uSS!Re6SJ~>G6Q!cLjS9qive1xsMTnQ}j$Tz44#OTl)YA)1?6FylH zjZ=gS2cqzMlbF*CqD(Of%E>_sZ6EdGeBKEZM$sj9!#jDkyQ8YDt&pX52gb!lyNc?zm#+NnJF_4(B{y|W zpx-&yxaMk03;zz6S7$w1}b%8UX($Ns!$YD;iI*&__3O> z8p4c%PB5e3qp3}VCq+MoO#~kkGzGJqicf7RW+(H*lMR!-Kb8b>Dye>e7EBt0h0r8h zZ?JzFYwY|k*GSJB#3U&_ybi_E`NFXqEY6$BvK|+Yg>S-9T9FYRi%VbtI-N`^wc94k zPRNK-vs2nM7aW9fWx~n6C*fJZ!34q3qA)sqgk_(ivc@9$HZSj9=|4!ag~j6C76Ehj zHT~-jl^vhfL%(N~k!dV$Yfj%>*SgPtR@_aBunFfx2TH!_c<0gnB`O`!LbWH&sfZ2L|QBdqx_=o1)Y;1o{{fY2znq`$T2a&|o>m zi#ql(DT(X%r1Fjss&sXVMiFW@(=-$l-#2-!f}Cu<2bx>^AA9+FD2pF5rd^kgj~%sB zmn8}v>Ak@uD3XK>;I4xc7^xvDq#3$2SPIx+1k9C-J}49fEC`Z?93c*LgPqZw$QT!O zc83IY9te`myw(uF<*h|Qjf}zvCnex9(G6U2*PDbq*Dg4@8cJh`OA^E<*5h<5eK+{D z7Q!WF#Y12n{Y8n-ZjNymS)W&@F|F#Lgwh@Eq&;1vRt6`klfR*v4pTi5VnP7R2q%)} znI9*eQU~7s&KmbSrE$q|qAp;OF^1MkJ-r+FkbO`i8F<~mi;Ggs^K*&a8a1kToeGx* zE3}sIL-vBnXLefd+|UGl4K*{OPfZ2f-N9GY@jrr~M9O`!muPM8=JNlQkP z7_%V6)HPNKv9}hBt*sGDNRU2wZQABmDAeNg_Wwrf`)InUz}|kF;K1LGS>tnO&AG9+ zbAhJ^Y|zx!wb^URRpAg}$5a(`xa2{Cb?~A)VkbS7v}X6IE)f#^$D@-;*Rs zYTjZP1$n)G;XB_)qjQehi9)~Ab!vY46JzAP1}Ybe>pM5a1<(9-KJ>#Lc@@+%wVJ0@58t z2*)F)pTtj9?#CBOL*bVxL2H>TL+COR5H|Su_}xJWqKst21croQ*#pAA^7V|JA*XH| zO4&bunNv#DjUsq50ICkDO@?G~uRc8@RmOxHhuVFWQsvxwfUDuD%{us1ZdX|;wW6ztfe&z@r!e}*D9ExY1hH}&d|1beay8#Qm%A4hcE`)}?M zx07^?hj(ODJKUe`ja`Mlev!k}{1)+_vq{siWtDl3O~3IFP(+o2AoSF(Thc*-hP7h$ z%wLT^4x^&{LG@d#+ebzRYi3*H?2?I${$YK!>qpbU_^4?+%a6R2|EQ#X%0gye8AJ8D zqNm<_G>jfsC7&i!{J#YG`=V*PT*%>euKb-sozbJmn?L2fypmj&eT#B0zUv&htED%d z?C2y`1fUsNYFPQ7MQ_~m3b@+4l3yfeU06aZ1RHg#)f2b=Cvj3MvoTTlI5KkfXDLo> zq5JJ$@sT!`RZVzofKk3Pq@Ps1^Z~()w3DOJHlhpxU3!adnxY7oaCVK9(5{tP{}A$? zh>=Fsr{K|-(IR>+^?JxsR~Q(^TacgM39LmpqbOjBKs^90I)Mq11c!8n!5Dpx@P%|q ztGNyG$dDU^_{dHm5D|nv!#7Qk)&npJP!{Lq_cY^+Po`&WMiX0EW88qrbx zjoFx2J*B!1D&VI=w6k)O_7tU_0z=cx7OIkuI(to6Fy@9NkrFSAH9tvbsAI|WCgJEe z2;k#bm$5P96Sg!2xM`c_EvV_;DhMIG=`@X}Q^+fVOi-)(rKOfu= zUN5t>4Mj5nO`bF(W<9f9W@?SmbfiVADF6P#zf3EQBG^ts=l;noHpvIh2%x^EL|t`x zDt|r>I-+=}#&v$a7B-^LqeZ-*C%8q>S04@qsk6fZO`EtI=z@)3d3|n)JBFE0H_1Un6D_hIRr` zd7n6vXxs`m-V0PD`0@A7rpn>XXc)^Nkz@AWy%Q5Hj1m5ZvbxRPLpu(vuYJ;f-0A1V zRT)N9cShuR&#ww&Wh44Rnh2q&Zt?g*LV=X>@1Ef``}|u&SY*Ok@j&pSI8HClSDa!S z`1oLyGVc8aH+9NP0?@pAilWo_(a9Jj5qlnjAE`*$rz?X)c`!XO4FY=CP;t26PrQSu zjaQ3_p1NctDG;W{e9jHR);!>FXXx+CKgYek6+PYNIRd`l00dhl@EMF&l$Je#n1zs? z{-~X-XoGHg!B}hw&y1{LjLi7-d;$~yv+aJl$M$7f6P+=6$B0^0Cndh78j1t8ftvI* zllq815q~}Y!^Zw*n703~_?ojX@Nj-}y8{Qrdwp~6+8I8~$=jD3m&XoTx3WhP)Ir5MjU;pR_o)^$JIna$wK$Tp~-0jyN_onDQofSdpfW7 zvfNnbUQsM6EKrlu#Fi>~Y`&SjJa$kxQ^{?)JsH_H@p>hITTg1s-)(70uRLJb+EVbZ zXFC1Y^Y>^zlWHeT^T|c|w)a0lGt1Ldk^BcxRj3y9Gti9NJA74;)YCdE*^%2mIt3tD zx(E520(bq3@pQYrl9iT~wsJ9Y_3`N*n{a1kH7Oi==`zOH92_7D>1CuCdim%<%2-Ar z2e+N>0?;raG!q%z^yM+N>a46GLcmq2IXFpzSRjtR@G0cEp0YsW zNuclvP4oL8O_CC#g{$^Oia}_|Get5-#zALAA}qZ<_cZ_$Oxnc31kcwh0N`UdNJR+M zNh$-nZ00vvGBFhjWW%OCJyCS)tQ(78-1B}xo;eink|p-rWHC8d_K9(SYU2MeCGr4h z*_Mqlww`#8FzC1AF^0`(#P$*zB!;|E>hY?isDIR0^)Ag#d&%*qqiw&mtH`@s0sh;i zqs@B>)`P%b3b&&xSxrZ2ztjE&6z8b?seX*ZizC@=1&=>@qvHB$)qgGj7pWE*_+@z| z>(fQ&*3I4}R8I6!G8Q!!L7kz*L-L50mjrFWSZ~v8TU^?Y_F_J}#x8VC8Zc zq=zU&1DH}vn->q+ZqMo>M^>6g|Nl@f*>>FR-d|d0|4Em-4y=^U70LbYV|>GJFS^6a zcH0w`7lw{cuHaw4yQ(H#0|Ql0n35z@vp$KEhwu_5aF87kpMBdcB71Gt=yvOkbBrHr zTzb26GNt9tB{{U|kj`IxbTmN#PsZ_NHZkwJeF^AG-LYYrm3l|~Fnp#A>388I3G_vs zt*ft<`np)TjntcAe!r0fH6ixJmU*gfpC3QN4u#DKRL5cQ90tiW1_3a&5Nt$BP$Iu> z-a7~q4AxRbak~GOb!5zAQSAJl3}|N@q$ADX?)Fj!pzzU6x@1ipfC*O-@qbdzu?j8j zSev%BrASLLiLIsrEdk4CWbPCNME={TD7UPVvPmj(`%YF=gVFcD@(cT4yt5LdgEKz$ zi%W7Q&EsPx@a-u$gXy+~FhAU2LF1ntKV$41OFxrE1x%>S%4@BAzWIDVrEv4`iAOrQ z3q8e$HGlQC31)5r4=PF{5W(lq|NBA|G+0qFi+yi@X|eN#Y-WJJd%(YksQIsn21(*a z)exY2s8fuiofer%|97gv{ZE+Nik`7b_{E{vMexkmGD#M8c|Y9h$`e`tagRl6j_SwQ zXwRh+`Q(QkrXmU72SV9Mk=1t1+4m-GKG|(;Y@ggv zo`+E}&@FU!8=FSM>cs@XAGs={M~qiFP1-1j!K}&mXU+*-CW0jp3KXG8_#_k0vqBnZ zvDsv49w(NSvom~cFk74kJdAjY1^MBDq@mP8c)YBSLW+7c@_&#DYJbcpfyc)5vZj0o zYjG05DM)oKSr)0B0@NE|2Z7%ZY~VsV5iMvmstx@a~iVMsn?a1 zYuSDDC8lgrx=|Kxm8B}<4$gR0JQI;lsqR?+3Y%Td_?UT0ZriFc#_gkl!&9(+HilX1 zyD=9WjhxIXmwG99dYM)=ruE*YRIHBYL%>kw;*ws3j_v#*_Wo?DA!wuf)vt=%RVsFz z7xlamA%$RgVW`R?`MQVYM_YRbsJhL@;x87f`WMS?fB#%MRJ3_eLZMLuVT+;+8WuTk z9^lIaaZ-;kwc5v?S>*TS1`SnIK-ZpJdtQH;TUV-GK7Qi$`%rp^&EWY2C za=phR1J`N-?}k?7H@g?K|5q{#G9_JR%q~;Snra#vZV)Tm$5~6K8#i2YKUqAe+;NkQ zT`JSfy}z%tqSQJ&ijIk)73B6OMgU6PK*B0=e$=bM;pMy2vu_Ti9z;3KexxcfQ-gQQ zPY%LQYiP$Lw%=KINjd$Tc|6jWRKDvbY0NCp4eYn#fhW(~R8ksLwOjpM0?@;vWG0P! zWCmLBp-k!tUNT#RVn|6=u2QZu5VrF1BClOiEKL6RdH6U;3{!JR=)n}B}FL66Wh!KdX{>3g#jy}m!V zZ}+P%8C{Yo{hJVz!ChxKW3}m8QTP--CiYxiMD#zgGNN0{VC+vv)&Bhlm@h&ZpQO3; z%Xk)=I6b!>)G||(I%o8Hv{<*yB~4`5k5m~Nlcg3|*d{(o_bl8EbeU+siM*!=GAzTX z>OU*E+SQ#?TJKl$eAw)~>*5d!{p+Dp7MrGCs~RmKdOwHzcm)I;yr*1$m4rZcV_iKt zpW0C{KoY@voJojZ3)R+s_{YhyCiR9dT^h336;Sto9!H}Sp|qA+gK;X}=aN$L_lH6H z|Mz*a|6F=~Tlw_zZ?%;Tm-(s*IY!Jh=0tF=!XWyj-j6CIT!}190AVfdk52t&?K%sl9YYgIk>9$8)pY|2UZbv@>~0|G-dcWeiIi zEIQk7#Nwg5=i*>h>)vg7mi)zza{l`9H$Wq5WAwYxU?P|j$YL?<+PPLqr zoMIye#UsiK7o??5EsiJn{|wx%sk!93uL;#n2`x)D{EL&B6&CwBw`9%eBe9V9*8}y@ zmeU6R*#zT%x#e}vE#)l6W&I6|3341Du|(3e2rNty3WXeJT*MWQqz49G|DLEw(<{f; zeT|_V*xqKH?!T+v_LH45=Ar?d=(US{+Bfq(?qj$=f7Rd|i$g&CI0W=RR$OvK*3_u= zTlU7vi?Q(^e{9d=$Ej8t-JN+e`Db}qmc1&sUUtnE9DnX)fu{2MRBFESJ?xL%MX`xe z8BE|G3S-Rj805~SmBH{E_?p;>lQT*t36Qk5JYmcY0O%csVb)BMgh~*R3~3v-GKuK0 zm*FiNadu0oqES>geA=IRC;nVNNdNQ`M zqrjoD1F(D56WPz&6zMdHwP{%W1SVkDViL~X8XXjau+b{3S{|E5etLjV6zz$2sPYtF zczbv{%Fy9^ntPL5;ks64^X|^F-fMUXdrjC987VhkF0CMKmBnK$ZVZKNWeof{S&eT$ zDi(1(|M%}f*(X~KYQ+#zPYWAF$kSC}C=H{q3LGTXzJ8i}ej%=)0*_^N#IZh3jyQlt*U;f;UeV;osfjH7lu8HGFI7@Ag5m3xC5t|BlRwMv5tO zxjUsGLm*`;a0AdWFB){>D3Tik0b&}NI4po)f`BBcii~JJQ{8CQ zNLy~9Az15K5yBxsD1q59<_(2NI8+_L1F|?Uazi8mP?iVCJgpS<{wNYCF*hfS6;;$r zs}ThmTt1L*Wh6kqS3!wudVLw{wBpp%3@Wu2>#!^?7y-)`+x5h6=v8oC>_;^}DfpxSpJQ zLr2A)qw6r@_%ED)%#4sZe$+_<+5TS;Obiu*bhQwW%LgHQ7I#`{9eNx3Qufzn`xJo8 z8fHdp`=`t2_ptPGv(4=n3RG_;*0C%rt)ma`jvwyd4dvd&kh{o+lQXnyIxpY2 zoLlewp$gEJ-EPrzD+-zaq@+6dGY<8Pfp?PF$PuO0p=NDO@?;>%(3^+tI z$yD`FyKrI)O07N#`cavP)58aV5nxi(Dvz?NYEKyfw@y)~ zt0Ia~c#!Tvh+Y(uI)g=sr5x`uZW(Axl`{nmr-@)t*=m%<0CG0ykP#Rq19%d)H018&4reOcRegMZJ45Zmf~n1Rwu?Zuv2J9-M+8?>DsL=x?IINJTVr-*)|| z5s(f$_O^uT9wB$XM{zYN1|kQoV;p8Ln{w}%0yZL- z#|IRS?~h6X?;;mY*OTLCo0pCid;Bsn0 z({Y7#v(Kf~-*>lEasU2__^%Qul;+;f<*uuU+|p&9qDL?JGxxQg;>_p{*DG#*Je0oO z+cs_g`@2j0w%_Fjx4Gf_zx+HiRdG9C!Nxaszh(LJa<-%-=Ewvz0dTaO~r$%eCm+h^+_BSutsU9sGU0H${hQEbVihbf`oyGS5{?a zsv1mF`^9T#Cz3lUK^6sJpdke$1>unwaGQc)#uO=N3=-tYTQh0QC8lUTW`)o2px`k{ zr*#G?jCwl^-H*W2ddW&!_MBdRn5g|J+`CA_KJ^Z zx!BqRN&1_r*#zNM&I!+gA^81G@E=9idbEpd-b%99i~G65#HVXE*m_)k>^r;_={Uc7 zcvG`-TLa#=MLD@tnS3=9{&I?_jczh3BMP_~P{>I2yBw)lxy&X0w%y>o^ot?zm|<2j zN9JOk!CCJ7+_&b(l5Ydr+s4y2(%DG&6-+yI-)3X%RyJi5EGSPkHg2XMyRz)N9$Qft zA9%AacRV~&;kSj`kw4bC9k{<+@%4D=DC#m>sxv+(D~I|h@d#P(4)H8E4gAx!(tLcH zTe5z9#?sLh-hjakj$MRq|KM(=EL>U?q$}3bk4LGnFDYmAS#xroBKPYzd3tx6!BXiJ zPh#Jbf3n-yoAB@dD=Ml3`oNY({NkwnJC9Pg_$WfcgQ}v+y>_5c8i&^Ti(zA_Q&W*z z1D++jkw%i0KUr|zF6EC9?P)T%a{nz5ns z>IgkE5g(gP+?OJ+(u8GLSaGtDXjl*)3atm}0oFZ-Q;3IhcT;zppp<#y(NH(ZgkVyV zY9dZtv510-b%lU>@VxPZi$&Fn&&YrKz>_8gCPJ`yVbCNfZOCC|FEtd!%n0E%=p;YT zHV$@0g-1#s4SQvmNI%g!qfax1kgv0XM&cx%*Y75hyn$~uy~XVUIr45;IuBiX6xe*{ zC?FJK*3)xttL%_HD+Fjh zIPC51zjgh~Zy0&)827N+`-~(t*oi_r{vj-PvCx^F10Ar<{~&S}v*|d6L5W8)D}lq# zwJ_qibq8i8V~#13#|4ZO!RRlsT8Z!t!iPkDzmLY$lc1tZUY1)K40`H_o@hYG47KX$ z2098D^B{EwoXmly%a8^SLhOrALl!HSMVX=@4g{n%%Cnvs>_Has!CFulxW|5AZ(Xg zjZ;W98)T6)mfu}ko_gW*{#JmYUGQ#o{={3d?PrV7gKvZ%ZeQz-N$FtgCXBgWC6v#a zT>Tl%loVV!Zol=PIA=?=XF?F^WH2Rh(X%S?Ae4D%QX%<=mt7BUx;D&C}t?2O5eRY3p7?JzU z>ZYxxX7pNncHxJQW9b0OKK#34$4_orcDcrZxt?pf-pySc{QArPKvs5IqUwq7V6F7} z?~-pO?QWsj+47~&wGUdy=0dMimi+C3wgcyw@dHs8cIA+?$TPL^&dTviAD-uowu7!) zP0wgB1rm(g=5bJs{Er#0`e4+x4{C#&o+k}}hb<$WMIlTF;m`y?$Qv|AtCZb$d+s1$ zNCa>~fUm@fr}_k8a}tIXbvMR`!;=Rin$}HSne-GLP&`|N-d&oJFO}qM3O2*AijbtS zuuRzDadl^Nh(^9wJSG^8L<0D~-w&|2_U?VmejjkRbZ)%sy?icv(DuzE+r(pzXpvDx zWu7A&Xw1iic=iD?l=p}yF^LpWTks_n+s4aeLucJ7rAPEHOr3KRVnM}>Bv4WW#db+} zxL#JizCQ9l-0_}iJ`DW2$V5HDTkK%2x$>R~H&W*~QYH)I0G)<-ceN_p>5?&qkzIS_ zcEr56_%_9DHjnP`_Tc=&7{1Wc9q@84S9_7&FlRC6C)GDfMgG~5k6X-j?-eZ zr8406y`T*-^~gcm`te}voOxf#D8CI|Qds)-+qmd%g!1})A+OJm@g128lA) z+hJn6sfa1JfrEeiRSMOmyuzjd57F+Yd%LAAwi|8(EspJoL{g8Uzlv>}^m^b>$qlL|X zKZY@eI|>V1&(-u9UO7$`y}`HXEJA<};(lnn=cO$*%);MdjKGMd4#T^)pPWmt-)la+ z+K~(hJa@dm2`8RCpS7-9I<;**xjSE9{=%JMZ1I{<+H};i<05fk{~>PFq#z;SdC)3( zv%Fi8oBH0T>V;}tB>$Gv)?XSJ45K4!Bvq<3>O;`+SYYqmPaD2+A(*8BI8 z=>?Ukq7AF_vQ40G&)Qa+%y^c9Uv_C~rmfEJNH*{Je2`!BC(|~p-#tA_HOo-^zl|Vo z57kGHxH9IkjCmkEOFRMkHAdC0L3R|d9K!V`Q={=&o8=aYj6-o1B34SrM}Hdq7EfJZ z5ZKe%NtOqNE9LJPDQiIv5ECy6QnbONf$*c!kBda;;UtT zX|@h%-*y-XFR5^};`ZG>E}!ByNVa0oj2tzPdM713)Cozxm7(sRGl+8a``9!plpEN* zCe)$U;z?Miw^ie8eK))VyB%$kf8BVR(|qx^eZ}EM%Wl8gsors{ypwF#uDBu_3#CJB z(HdUg)MEbKu*E-A__yEYOKSt^3Hqa27WX5Bst4{SLg$XnvLxEtwETGCy!9=!VlSsI z@L6lSh}fs41EQA}A9=Lr+!&nE41uc_4;5Hfmhoi|Zhn4QY&xy@Gpdt%e-o<^$l z{vrN;+W!iP$?9>N*te)^aFK0@Ty1=p#vR2tZ3r`uNsU2h;MPOP7|stwog^N8=8|j} zmtv=Uw5zc+R{M=OMy9Hz$$E)x$U4$8cqiuVUxB|}g45ogaw$PXeYSj@Z@> zRMi-{lEn$cU<9FzT}IlCOfN&l2`UCE9j61K14#JhGJ^f5SvtIT_Kc>5(< z=f?QRQx2~=6|L0+Wb`VoSFO@W>2x>mb0Zdv!{{4ZSpKqlc%AXTOVLx$+soWQ9=NvH0-eY}tFV<(tpU=6F-voM;-GTXS%o3Wo0mRmr(ofi`W`fjW2%(gX9Mnt6}& zEkhs*keswg4Z$d&(!W%b7p0HGzPLXnGU}oz@H7_c{Y@tcQv<^bQt?E)SFm$c1a6rmJI4MDQs=U{9iXUEs6SU_eF}h%dt0k-=%DN^-NOr z+C{P+bR6kl{2<6#-eanwj+B4oi(1_l(G@o99!C6&<5cHPAtK@;V991hKSpvh0r;UX zv{om=rn6a@ClUP_ClU!9Q-pI8aX*a-9%otR4&Febe+RAqpw83cq)1kZMkqvxuV9++LaFez1c}CoK z9mG5MQvp1yA|tLuM7ia24sok})^7|(3%^c@n${W1WU42I&fg!jeh%1q==VF-_$<-* z&(lmBi3Rdjai0$BGi_D4Dj1_ybMkqlT5elb>l9_`+as_=C>Z{WBA3~e>^berr&Ssl zvV&AZ!9M#7ljOs0?3u#djLYq%#r=lE`C)3hs+oUaSnT3rmZ^muI{H{J!_;RYto;W^jqmnZ#q}3+5^H} zp7@WIP`#5&*ag^`FOU!S5olz)Qg9MVN4>}CD?*fj>$MPC)PSovQ8$d30|H@F<@F^N zgQ-AC8PPCRDj$#|NG>$07mFlSM?|Ge*b|Z^Dyix&iXzB3iO_HQs3W8y$4(k>;xHsg z%_~@IO?Z*~{a5N>CO!gdb7%87%vv1>A)K#o2o!^envi`jY{Z`z(ys`fqe7S+&)^NP zw$fFGU;y}}{E5=aUT%u!IPQfM&H*6c;(MZR%oXDzSggEMJrZtmzEREF&yExMi)7_{ z`Y8y&q!_|&q|_cks@t_ zb8_x-<#KIvYsJTtniZ}A0i(E6te-1#7Uz4U*9)IhjJ+?e)SA53crRy?U(JX@41c`~ zTegrH5HSrKWonEad2}P zBDs8csjc2|8p3He%}WJi6d4&~=zQW7l)EQ>k}%+Kh*Iz^O1LLEUj2=K&$ z+=*ihzPl&zFH)dZBTDf|rS$T|E#FO8L)LEO8) zNi2$Q9_nM-kB4*u^!y?n%9#wm5=2<^xq8}*Em9QzDpqtWmGJwjHEK6j9lh%7b+Vy8 zZFjYD{ZZDsa`E8zXRax^^*-0g8M0AYv9`p_QHPFG*$(~x8&~3*`;*xKr>}KpE1wiH zjAfHY1d21?NniQ+OO@H~el_;9@eSy|>?0J=7K|O8Bl@QM;Tcwnm1=R%xhv*{sSo`Y9b3Bq@c}^mL`%;V$3#rfug2?aB)V7 z4(^>Tzlr0`-i?mL$?pWsDIs4~>7Z76pG7n{|Q;M3K~H6#}N+ zm>)udV1kTsYe;I62}Z_&b@X7c9v>Ta2xKW;@HfmmUyHW*!6ym*g_t!Y8ZNqQK`5z9 z#3#zl9;~JaR8=OUHtb{tJ!@Spvn;dBjQCn;|8u%>l*zH8(t~oJ^%MD}zYpbsJtxUk$W%8}rN!NHeuHnSlUbebD{%;`{P(Sgn>LAhAnY6$aT%+tJAa3uMK&XKhtd>U@DLyl3o_8 z6L6CJtHtZ=_k>ra^!4p+^PSK^Rk<2223-pXCtB02CdRS6k58tx6ePzL<{JOc<0YQj zij~y1svUS3aQU~Ry86-&F;bNMqaHI}Z+rSc*7JvW*ojrq^nEl;KE z==cvv4Cr zODGaZofxycv#XI67c+%6k)h#KDZ+;#VXT0}pkTb>K@gM!VWZ3+)!Q1xB#Os9sR(a1 z=+q^$NnpxTZW-vQs6fs}hqo(Nq-#;8V=Y=^OWy>1uS1x)SI|?gil{ zk06x{F!WJ)Vg{I#W~il=aVw(fnM1z8z+xu|V?i2WQT(f{OT0^5Wl0ee+}#1~>}1s- z1lkm_z~7wmwABrK=6-nk``O!VdG~x`k=Z{k$a*^`y(LHBoBhxj)R{ICb<&p?cwCcv zyN5pfb#el>7GHEDz`99B%i7R;5|9;(FA8p@{)!vvuUSVQ4w!w+`CE7I{9ZQp$99?@}nzQw%<>%2PNS7-cI?+(mfPsq(ZJYQt@@G3D`ZixqJ`>(x|soHa{ z((~S_lWaU+eg02#(%MTXW*Pk#F{9dao=`16@L~NlrY1?JL$TwMGs)sqo{z!;arlPh zJi;=MBnn}LryPYv6skN#MgTM{C=m(JKWuCd9ueq+`BU;3V??8@t+V1_GBlw^3^P^W zE?F?YIyVJZmdc7wjmB_#LgpHTSlP19bz62)(MKyF%JcYc7=Q}lJ~E4*x*T1ZaSsB#+jmGtcA=O+!WW>ZTlP zuKzcP1NX`IS1Ypj7XuXvzuE88iUUN8b4{%lMYi65YZ`$dBHysMP(9+jGNt~^%|u}J$cc|~hPbj?PP4PU4U<)H1K zA1d>G+;RHbLgifR$7Ykcw~znumS$Txec;z6iv`pr;{OezGk)=wLKhz$u6xKw45r2h zH4rfsDUK-T{L7H#$){rqjR{U8?AJI8)-#H#@~{MwaB|RSSR^(Az=`OgXVF^i|B;kJ zaKj^RTu;hDU`GgKWJafusE|hYYIZHDm!Y7HC{VxAkQO{79|{!@smjsidGD^B+0e*T zM;A?Qx2gf!ejObQT_kHFDRiY`- zDd>F&JXm(soRaPfg~rp?9uU^`+pfXq&-Ul%|I4itIEOUD&&P>+`^~kg*}NLxN^g5* z9;i~mJEy9DoD5hUx@2N5gtb2>(;K*|Fv161TMbGLuw(~Bef&hu=9G08KJ`mP{pYyx z%`uDBdZxmwnPVXp=Fj2lcSyTbW1hA0U&8v9KgV$rSsWMUn`7~E+c+@WU8vU4emNJ} z;o7$0(URj>KD6lAE|=RdwY3+LxrDCpKjn|lU38Tr?28#05i|9^2r8lXxF*o?@SUMq z>}a>~t+)34d3^IPEi-c)@or3{!h5H9_NR0GBdb;G?qF-_ggW?Zg}m1$YsFPfGKCW_ zSFXsfrbP1pk`yboMM!WF@+E>Z1)A8aK)QL$O2J-gJb&1H)cBE|U_l5z|3q`a6vZF% zEkbo9=K#bKcZGBK(+Co3koP3&eV*1J01i%shvjq7kwQg@IALNby03~qh61D^VJUnu zAQ}Ko*LMh)SGsbFo+zj_5x_~x?aziFgQ-$lzAHjBB^8FgnDb zp(+XgGiU}3$DN1#CIEBB8dwq?L`V+6=<-A>L5gRhx*)v-4ojdY5r)mZXE&^cql-}~ z4ywwlrrVlF$Hduh9tMk%ar$yf)33Afh#td)~cROSKt6+mzhBtkFg`fXZ5 zU-H$wjS4?)oW4eWng&0;V0a21*JvVvhHeNswGDkP&lfQxfPfd_H(j!2p4PDpmKlvN zqdNWX?jD`Hu;8+*)9D|T!kg07?$2nMQB<@Mu@%l=+QvvQaPOqzz@w$>{4Ky~y$==f zyi-)HRm$rZ-6E?X;Ub@NqjX{bv(CUO@x?{v{d|e|eQE037~!}EU(Q|w+ZcRj12K9{ z$q(_P7?Hp~`ndwQC>s8+J2`F|zuaCvr=_ZJIY?PoI=KlzH$1Lrr~boDdj4)_cD!esHw4a|9iJh7pconq6kZKj+4u05-wk~yWanyV2onBUPkXTQfMSB9j2C(VfyPO7Pi+iDYQ2@68)Xg2`9IG@ z%eevSIb%H0iW*RkWsWtd0AVIiEjEvy&d4c>3}28illN$n>vFpd8~AP+jw59+ravz~ zVj6$)y*|CdxJu$bmU+3U^i)w9J|-?L60y6Dl+Ob$?Uyzu@-d>VMwl=V?N&q!0iom? zAt&y~oklYd6UfbcR`Pp@>iV|FQI);cU2X z)LDp~*jsExZEBSuMO8_aQls{&P3@Hst5#|&TBAjOt-AJs4!oqr;B9v;(b=R-9tm~Halh?t#Y&w_ID zMdLfE(;i}i;RfQJAW3W9_0Ia#hupl;`Um=+_q6Y1GQZY629Q2&bz;ifL2|3wS`zvc ze~Ioqok%CCr>2FeQGGoWpaaP##aY%-)|R}262wIsZ=^XGfzaWzRlGCrFKOe@*Ecs>zXDZX? z33+M198}V0jp~<-_shgGw$Bo1c40!sc&>F~Jr7L^Ls?lkXG{O97u!Zb@haXEKSanc z=P3R2b!iiqh(uEAs;Do_!mQ&NS^Ppj$ouVGSzcW2@AthbaqpfU%KkkuGd6h7!NKQU z^^b;z<(q4g*)WgyL1%;VGc0-0^Re8z?E-U_jTjAM3{~7`fmF!`I#qy$Cd37w|f)F0h zd9F9=w$v9+ToaLO!txAsK)9BrTl}B+9RiU}VzZ0K(pW=Sa@ivKk@9Hbklioaq+c*b z{j3BbG%~yn&Wup!x3wgGL2*w!ewnf+yr&2DNmb|#HZAH4H)(2So^dNpe0bVxX8${j zk@^DKcVcW;5xjzg(pmKkkLK^zl2@~&(qyag1$oa> zh-(9-z=Y2)=(wk|_?)F(>99@NbFQnAg`3@`u76)vyP8CH+}{^U7b)1Rr&`yNR0SLz zzjx0#UoO;4%K3lSvq|Ol+(9hyw!1&%cs<6{>Yt6vyruF( zZn_QEJ1wERB9CaS%yP^+7r9sofg8g7y3K9zlZ3m!`wM83uP^C@8=bJyvhEfZCO_ zZM#onc*dB8=_!yP>-M9lDp_BS*dmQZ~`BCmt+FZiow3N`Lj zM@TO(pGU>AAgU9|vKXafE1Jn{A1Q(AJ=A~V$sI95B2WCS#q^_&=`0zIAW>E~p{Zpq zvC(cAFwVAon%?zF^eE>z)eLu$z~ULZQv) z+0Avu)nN=-^%6gJ-Sy(vw45l<)bb^Q;91>QjDAC>oL9dsu79`Xaz0N}^lOT~U7X;| zZIspHtUSK0P`G7|n21oAV;gP0S&b4HN*dP`lFa;kxOfph7rL}wF|5uu(fX(J+;{nA zjjz^wa{qbIaK@kVnhrsDPKnbiiw<|lH4oLrr3kBT*E1IKWzo&bpLaj5 z`J0~A4AtHK>PvkVYj$W210S7^9vRI4>&<97B|&ZsCVe2xx)zpJIB!o*sc)A~$R!T@ ze(y|B+}=xFYnA-cbba;5BIsL9_BGbdyaQS$=D;yqzkXVA5Iix(so3Xa2QAw-_pbY- zGcdW!nE1#`?$zH{*2j@fqmyo8Sw)=zpQRtAH+|zx9T#-=Fw;A}hCfVoD$g&;^ znWqyxU0__{#IGlihv&gSAiVsreZ0CTg6rNanNXUfE9~Ka?Vfsj@71U>_aolh$Z;cx zxsezoaW?`erBy+P69?ILGyGBwiIl)A7!&l>aJBo)0fd)<7=-{ez`T@bbUL)~c*Ikf z^)a?ob9^*?vmu$7<`~F?;_pR$MEJ1@6snWmi=gYpNY98@fxg|V1t*9r17t}ucBc(557@^|_Aiyr zLhSbcvW_kG0lX*sIRF-v`?aRc2r#yy}vE;m5M276Y zqpI&~l65|`L}OqntNok5f7j~`x!%^#`p@W$HeS*mjuh377D(*NA|y1P+-J`E@KfQ! zCQHn9e=Z1=*^J@t0C3^Z5JVmqB@T&*C{UGN6%Wp>5gI=!cpK_H?{ecSdOiL-<(t|15ud^k$T`CGX99^1IFcGYZ}zF!?*t%C9yq^+IkU2ez6g zKAzcE53G6WP;+t?MQ23UHYj+6DER9i&TqV5IW6_(FlP(+M#nfI>@0TbP*WFDU7Vm= z*wVV@w03dP%_I=Wclq7B`jTDxO`+7icamw=WK4zM*%&)d;KH#^*01x#r~3tsIs-qr z`ii@jjP^EU{pq9Ek$S-Z%@-s#tQZJVAO1 z2Cu1K8MLDfL^vCe0?c3^gH;2j_pliFjIq>~gzJDiWevfd+5y93_&hVqr#6uzSSdOs znHZRAgiZ_7igpQZ3kiClL;JLUv4hgAlUSyZn64W`qb9B=eLALvf_cYu%H>>e!Y>&0 zZf2TqF|6*{xkvZ2osM`6=3iz@RoCCodUzUH(9LF?7qhTkyyk3i(YU~RMN}YTSN!lH zj*W&!OG8Km2E)MMm~f}jt@&KV^}81`)(M(;ZP+1u8; zSU>4|WNu~=@I}}`s^RQBXy=smMYG(&;#SA+5tsG*Gm54{aRN!Aatp+Meo~ClJd8#Y zY~|&o137a)9y??0m?DmQ%UrW&I`mtADNaO3j&5S?)+ZMwWn`?${-fTypfMR9bXy6Z z99;GKFwT9--30(?FM?QU#L=YE{gBa)N|i3zvcwFqvJ#NBNUDPZzNluWME2Q(g-a(K zB=4`mbeKxS!Gv$+kTVjG$rFbIoqA&_+HA99q#t0&ZETk$E&n5v7i^&^cq6;o7N{id ze&#rDNjg@tWk5+sYd4A!Qsp$@cfrk7gSgbbP7R`F#K%=n3!?|B{ zzofLI0U1g@xYF`z+Ww!CWFOp<;FlB2y3jK%cKu5%ci<>^W2ouoAGmP5xxM@SUh!w@ z$;*wXS7McOoVum=n;L$t{ifv{xb8()K5H;UR+^Mczg5hV`1N!pxV~o`Q9N4BN;qgFx7E{Z;S^JM zBTkhokYNzlV=G25Y}KD)4#7LEyAJzG={&IK0+Mw+6?LT5SMz0JhfMFpR!1_R!0jrJ zT|;g3!@3h{2DZb#1-L10XoBamW}ZA0{^(_+S%1wt$7C9uw0`8zOc5Jc>1=ai1!MnGvZ);TWjqlAZEIgC^r1VVFm51pn z_>U@ssDDL^y`Q}msqk81cUbQ9<7K_xxSdj|ZLYpB&B!QzKQ*+gnelU|+|K-kIY-UL z%-8I*Y?+O@pG$9EgzVB=ys(gb5HC_zo3l3g{4#cny;J*yeqeae z{qUd%JD)OlPE6fRV`FZ78LRX{D$yusLD43$^N)uyLt-@T8t_rEwe|D@_j;Cg`zA_- zZE|N{e1C|UCsw}O2WvLc@Ut<3A`P#}q$w>+h{ZM1AsG;Nb)1o+@MkMoAz~BFr6$5k;TY!^AG+No0rh@3r+63V5j@Z5Ac@8o(G&^|o7? zXxa<-+n!6z)#3_YFrUQx~xm7v8HM|s!3{et6|I+xivMBF4c~2V`OdG z49^GFBvrgmHgEqKx;2Ej9bTE$H#==ruXk47kC4pD?YCtYUQ=BXn>nN}iL9|4dY6(u zW%ocQ9VHEv@<$*sz=vAEmsB$rYoK3edu_~Y<;X1gVoZYdaY|IJjEZS5(UP$$;Uryt{#%z zB@umZ=GDmS;7|k8BZ^ldjQ2X1UWeB5Y7JJiLcL8GM9UI$#)-t(A)_~JyF=np4FwkW zt)_Fo*Ig>v4MX16h?7}I$*yb((*B13B@4!}p<_R{GERSgiseXTIO19zCEEXE^wVu|QNUIMZ))~vE*SIdVN{#*m zrvj4|xrvlWLQy3^7Yo<=k4?_+PJpex(=J)QrW%~K`>RaxK4?C{+rHB3uuvZ#8GrJd zor}OCIdz;CZww4g_)4yM7>o=y-*LWZdaN$iSgGM0R`F$9*nccg!At4Ih&}u*w4ZKK z8G88isNuY2L*@27_W#z@CZ6lw@+)nU8^}M}8+w)7*o>ih!_kG$d++OhDBs_ZvAH*( z6m}-+cd9sl)D}{-%e>I=CwTXShp{VB^GzStjJksDFtCl{nO>#r*9DhPW?9zjV~pcI z!_=Bv{zO&Z=60vtY7qWja-Ovci)@psGYi%VDgxi}DWgAarhWVNZpgbA-=*4f^Zx)% z<9N-8>X6M43qWLJf#hT!LYDT(P0y;qY-N=8PX=1klAOUHoG2?#Bb^i{`H)dR0%R-5 z!#p#&Xl1L;z#vCU7SnO3;LspEl4LQQIKC}wm|uxT7TY@shAp)bi5ALYG2-qOhkxSR zvDFLuRE}ygKw!TC{~q}&2I}&Lh^YHP9TEs+5_~g+kSAJ$31_|L)X#FrO9xn~JbxFI*-6gl7+cq|qlKB=@x?oY&bV0Cw_~UK0DNm6SIwHN-j7rH3<)v$Jx{d|43v9 z7l@y^H5BU1)*jye>kzp4Jmp|<>%q3NpI&sPSy_Q{dHX$kW<#-P+<(`4X^8V(?H)d4 z@dTGa9{fXX8~z_{%=wk*e&GJ)rs44m1HQh^4mtBD*DMp^Z!_2LddJL@7QzT`=IQsz9o)1oHZ z$@CQZ8{v&fV6UZ7^rTNCh0Yi-7!*8{GZLcBP<@bq?b5c>6taGcrez@XY;0O+Os}yT zy!%16TFw4k(~ls(u$S;r&#+KprRB~U?(*%>Y+04`pWa6ecGEjCu;=84xjGebEawTC z%a6KN4qw;&ksIi^{Sx0`2N#o&%t1{_b3#jY23dui5GT{dw1w%Ma5-M~MS&Nx-BwA{!BNmJi$G5J2VMO>Ra-xXFJ4LvflGy;{B8?P55y0*R(KdQNG`lE?L5Kf zkGKA2`v*W)AT%@IXBrNgsKwo@l>+~nM5dovOr~QkfY7nTvExZ)_sFZLj1{32wKPV{()<_< zivgcdMC4sTBnJ1nduNPEE+E47zt`V-gkJLMwxH#^2`KbGKP?;590MvZUZ+BSRG|o> zg;WOr_-P)4-0Mt~&M)aVZlf7VvlrzvWue~R>sIf0u88f{gc6{4bk*$v|N3PLzpJe2 zv{Fi8w}@nlvbN#3kqt4it_hXHSUrk)?yA}U9!Sf4KvL3S9WfOwS_qih2)-zJeR<}U zN0?o0Xmf1US>&2OLap*S6n+;$GP11hd=W%Y8rM_`4#!Pf+#a3@rUWdrmyjb03P zKT)w&x{$o?VPwt-qu8t5WuCd_h(s`2E46M5<1;V;DHRW_##p}I2giCg6i`tkI6)-y zV+1o@s&{XU5Qj_^?>ur_h#5N}8cZ&Ec%1~S@Eh7+cp^J9BB&uY(p-)l7T+OnNufln ziy$!|5h#O1Naa(=T8cB#itYe^bI}@h6+6GAh;JccdK%M$k+Px?BA-A4$*e$(2EH%N z4~#u2T?n*6S(`ioe_E92m=T`Ppy-`R%mYJJpKpF1`I&f-cxPq{6N}@h<54_!rk|4-ejpc8LX3ir>5WH5d}0C>y7El3wIYrL?#NGX@3Di0@d)EG6FcAs(|G8VPqp)P+j z5f*<0M8_B+FAYsSGAB$Dh|Q7V6kZ zWLVUh_lrptV%dVQVO+D88jy>Rs;u{!KdU)?G$=MPjk81)Fg#?{{i0(`x=?gq>%c6sr7-P|Y6eM8b6>;AFg9 zAe#?um^bL*qOC{roc9B5n<(Gk{v5Kn$+fs0S&;bA&Ehvc+M4#DpVov$t^e-xm*_FQ zy7T7qv0~*DfR_a^=*Xehw`D zvMyT5-a!%bMU_fI+eKEfXdo&Nor|E=HOgr^BsrdFZmGRd@CQ&hK(Solb9JT z3ez<)s+3TKIO_pKQft_Lw*2srzFV_}RBN6W;jaP8Sq+55cZPShNy#$Nf*k%56WOX# zb>l!ecy_Z%5c!Oy2bn{7D~}anKx4%&$7lxrubnWb(OA+pv&4OG(NMBmv5V9ec}Hdz zHFEeP{P7gR3Is#s#7ceI8T(U9_6ZoyQtS89{7}Jzg{-2bje-A37EA2+md-2#s_qU^ zaXn@Mxwu{|9~*6Q#ekG3l<3H<%RX$-t=U1UCi+MzB){i9d)LLB=UGH9t2^sVsaUYb z%fqG^*_z#S*?o_!uU`y&)4z68txtx&_*j15{~+&(;eVMDPvq7afUevC(hezpUOK{t zsIi*Fn~&4GMXNK%-(HlFh1nV0ypcXR@wYZtgwsmMyhT!`yZB^sn=e8XAA>?N;@H`!-P&-?LbY-yBmA+K$Y) zFCZMnU7TRiT>7qC*GiXWnV2XOqh9wmc?@y=WyKtPl)+?C@;{s;nK87Q;q6(8P6AO< zqPa+USY&&s@to9K=SxLOC!^K^eeOK~#>x0QmD%RV(u%jj79*Zdg13 zG%$2S57H7G&^86+As)jFFu1gcQ^??}kx!U3mLvdH^akPl9{JFmSUzz)dlfN{l?WOv zjDVz}kr)?rgiZ-iLKUj5RLCSpBCAJA=lG?TRhIn>$Uw}?1=d~t(1{3gsAMJt9+EpO zeMN@=I_ezW9#0GIW(5-{jWo5WBGx$uzNAe<-%eOae%Pg34o z2~gg0Iyls;O#WV4_I@Aev_`m(_d-|h4Ze2{7Fnzdc-J_$ms#*NvGosm2z$I0S^3AS zMQ*Vqo)dTWbwb<4>sIO=sN0%hLCCe;ig`AD^MuNBF z*iFcY(7Ei&x`rh=(so7b8H;i(y0mz=kg;K?Anw{93S5Vti2gg+@K*v6Th6BM$omph1n z0HnPrK=|S(A&Od^1S-%(%X2gllennI+n2=jWv#}Fg9Mb{@KEpZeD3?-zW07gYL>rf z4w^q+Xxvv0s}7A!V|D)=WU|-QaGllrL*=p5%pAv~v2XfWBHIPhw(CZLiDHdGVf%00 zTgk;5^4guts~i)U9zyBVxah*k=pxyf!2EQRK4_Ih>ac(9>oc#EI5_9#XDVy&0e(kns|b*Ryf~>E(?TOhpqmL z=#$}`kqdjdc`@l!>UXzmZLqNKYk*fkO#8{lni2nnII(XtbIX$?6Mdk!SvA-(U+rMr8P`)Dn8NUsXg%SK)B?ZGX9Xc;E&E5lq(G8yf zU{X3uoF{vW6cv~qX1R)0T9K@bq7{Ln4BO{UlF3k(4)wC`*o( z4s$0i4~d~=%xp``%d<6$C7QG(*xk#b3%tlZAV|EidWtxLUUCQP>`-ENn^->%l*V-4}kE_yuEuanI#`(<03!In}@?QT=v+uLLB z^5C+gyKmQ@bH%|5xInP=+uul1*=__rL}qSUjIdPgaPHeku)sQRzPcz6GfWU0>>%&z zYp={9&K@8pLcK;8m>eEx)*S8nh3$F2zm!DJ{u93Exl-Pkp>lJ$dEI zX@ZmOX(--j*7N7q{LI?Vl(~BrAYAiY$^Zqn}lRn z^Snhnr*^FUrtkZtPPG2X5n1ReP~@Z`r9-8`cNhE^ZS(U^9=z0(l;OoaNSUgv`CW z-dp+;c2mdcznh$)@^seU^tw+kEb!jx;mAz(bz=Y(VfrT_BbhSKC`7`;Wp1|}=iqQ8 z{J+@;$wIhT$mXMq`Lkxtd?bh$4=0rOcS1-)8Gsp9eVLfJ>DE-XzM-l9aYO8KTb?Pa z(j*$~B+oImcUtmzSTP@Q#&75x*+4tr8jG6*c0DZ&YJ|EJ9dk@vWj2c`Y(XOoDf$i7Jq(07EK>KoXPw>Lvzf zA%SgG+^kikrrqEq=L51TtTB4f|9*Y|803=I5=oj9OF>@*_XioNiZQY4AxQgTip|i( zs)pg@LLN-LlWH`}%n_99ciucV;2*?E>rg(Ha&30-+X$XpP&Spl{I24heYG&*bng8k zw66BBI;i&f*-+K)LVE3ttm66rGr^OK%2rI0Hp6oq>~&?;u}&ktzioo5dlH`!`tXrb|D`u1|go3LIr2XXlsFkK_OJ$2?q;_WIrY z_3(7UMdgjH+f^Ew+f~)s-@Y;0S~-rP7w4L{f13YoEi*QI5`sGR8|xQB7jmzsPTdLF zdw<(GZ$js9-|bt!t2q|>@gTd#Wg6nt`J-aaeJVFsRY#6uwBzK{_u~)mp9SxXy+u~k zhjgBodJmo|%4Xk{Ua~`H;U33nRFYO9Avh*_HnDO!^HP8BjDRz4*P!gpadWz1-yqqI z4Hx%0n`sqI=b*p;a3`;jGj}WR9^G(MQg%>z61%8M2_g%6Z7nJ&rva4EmjAuJL^QQv zVj6-v9wctiHT49N_8?$!OU?+9PnLrQGihnW+%pI#0m>P56KPdo0E}onDRh=_e+{e| zYZ0_&cyK|!V+=5))dtIwMKCf(y%edXE`c!C#t4a*Adz-U z(rW0Rti5<5*;G{w5ks}AWu9cdpBb1ZMq?&-duPwv#eG3JKH##r&;EAXwdtfebT7oB zdA0LaY_DT4Qg-0>ZAWRqcdL2VR%`QTW>$2 zLy`;nhPm+HqyJ4driy*H`)m$2>^3$plE3;^lB$7NylK6xjz)v3#exY7xP((o%dS0S zUs}ts5JkQx@T{k2?!WaoC(a#M{Z9917Q8OvpE5>2Ep~V!oBjNli-G#$N5#VX@Ap+~ zcEL;~13BYqE5GE;#a6#n5LR#O_huUh_QsjH1lxbSnx%c>GL3qm-#&?qafsh+^cWO> z&qbjtDD{|i)<}t{{=1@qRJ6c^fdzA;qkLxZ-9EDpx44Vf*3nfY%mmWqP4X7GJ@+*8 zAo%olfMd49Bt7>m)Of}nNds8a2MQudrf)V(zPuftNa;5K zRqocW$=#gll#)-J82p*|Xo;dQIuxV2mPA-0tRxbWeg|1VC;36-0ryWmnd~`pLqa_G z&{R-))%-m}A1An)23LJH!sorFnKu#x;0n(JvcncJ7>bb?V^vaam@ovAc0gnc1Nk8U zOLA&3cE?5C?);%)MpzPL5eZ`Rz^lq}$IKIG(_sb>8Y!5s6pjcKnC7@>qg~3=I3|XC zEpr6)i=(<>ZH@3j9_-kgoCD%{E(A9Qp@avMDk*GX{9<&qFfHs*Zrmyo38C?Kt~Ht! zMsvkUQ|J^6u&MaeKOQ}edn9&SYUsF9 z$Nrx=PosD9j9%YT%gCn0UA{KOZ!KdF)dR9e=H-VRZdqyxoJ6YU%22}l^Q`whe(u+i zVcC`EtbEiGF4d2itEHm@O8fiN03U6WCGUpYJ^z}MnOIXfeK-&0dr4kKm3AF!FTZm* zKgpIb8?sJSvp>INThEn&dWO~)r_>naPQ&$}X&AW8Ai6j>1%y}@ps^3s)&e%6i{41n zcY3P#60@V#ppbwZv?xwdQr!t9tHy)^kpf4Axp^odtd<}eGzP-k0*GFOgB5P0#z@O? zB>Gs7hH{whF&WSw`31Ql@ermFqZxjH1Qo!W{-O>jflwtt1PoE2h>O5E4zpD19wXB$ zQd_~};3sE`?BWPjXoI?6iSQQHUaev>GRmyPuR*lq2Tdtf5er3tpsEmmLP}JN;!*;X zgqj-!f^soiDiuMYON}EF+Q*1CG)ILKFQ+SG+OC7pCTETM?V9kYd;0DsmeoC`rqPv+ zt5K|A;hCg9GoHE8@TIfAGirZ~fpPv{djJQg7THZ0At_DuRTd7m1OnL-@E(X8kE6iD zt#npa8>mA4zn{OYzfBLfdq=IXBld^Nif1|(`#NM%<2!nKn4O(PN>y8?S&ydW z%xLZw9mMfrT|uH3(i2#c{>bGMtf)niDr) zJ~WV;pH_WvHF@eIuCJ#z55+{W5;Y&*1yZ-;_V`0jfrAtOeVvMdPm%spLyRd@34$OP3@I>8R!YmoOKlKSWr>VHgnZn*KS;j*o$B3h?e zm#$SD8Mkkl;1(Hl1gzRK`|k)@PT^h`5)^i5)P9uo@Q~_`+mN$Z^tHCD_4lj;s_433 zK@Oz}IX@3a;2af|+VquMUz}~8GnnkJCd=KIU)$M9o9$B9Dhd)Z zuUHALx$H#$*ii1>ID#lM-y^uLZ30LdkC_+Djz!`9KiH;HcmaiRLr4<&RZ4xB>$=2q zHEj$RNQ6g`-z0J#1CVV2K*bS+{lN)}HXup(0dXsBKAAyN4*(2-V6lpD;ba8c1(_Ub z`E3LZ3WMy4;ZS0DRRLK-DPr!WQl{aefB}5zj|w;mk(hFpFi?)b7D%2}o)*Ld=RlP& z>K@ZmtTMNe`W*li@*(yZxa6vC%Zx|}wVP7HwU;~Rp91EmR%$6qv4b&B?# zurY<(ZtmOO3FXs;CJ5x-yf`7RBJNZCX++_#Bbtacnr9lIVlnc0#ZpCGy#9G(c-Y?c ze%NhW%_3oUr(w@cmS3%v2Z+NWUvbkQVgSti>UW@iV&0_HgWIZ&!Saq4y7umK`@T*u z7>b?5}&sGC}9b=CRo67w@?eWQyCHyL|sn-gGo9wQpVJ>7D&Y z5B*G%TIT>ZY%~#kF)c)oe7F^|oz$7hqJR8S8VhT%Pa;Xopy%e6HHRQF&B8vQE;XJ=htTNpKf8 z6SQm5=HF<;ljuuUv^1(YXCm7%QFp%q#g)R5f_ieyf3(y#DRw9(LA2sPqHZLJIL~9C zjvs*#stA7;L+?>W3eywBBsKu)2@8sC!o%2(DhggGcpuZExCjGMpsj7IXj5|DbK8ty14#qcawJ}UR+Nugpfkr8uIt%ucv-ZUo@re z=ze2!%FzmFF)c2o^eg^dG~VXy{GK&x$?jpU`Lp21;ydM~PCtY7&a*Fsuu@O| zo94^+%?riHe)oEmho4|2S|W-uSkWBN9@hN*i>v8gmegCWhJ9Nlk+N&l&u<}f z^&v06hs+ouTNCchp0yu~Z+0n7@8-KOby31KN=cx_RT^=Kn4_GXxY>=Cnm_;k8ILL4 zvAYBEz@b5_uufVIq}o;{(OhY1L+Fbu#juSni!09C9ct%tDRJ|679FMadMP3!;uI3= z9L~Fc891j)jhdO@s-Gk_u}`~-eM&zWW*Rw^ z4LjR?XMgT&w?}@Yl=96pIJiTf)jOifhw4$}pIL?b%g3vkPW`Layg4E<`n3(Ut1ta} zf_;7l`waf5&l@B`rtXpkcxQ77g!fP9*yff*4P|_Z_Aa5$w*Zpsp@>P-vtFfcHa?ws z_mM-w^`04fSqsI!D#F^_(){4XS3tSZYD#hJfW)h=jMr=7QKSkT`LwQjA1Jj2V7e#_ zA6jh$N=@K0MUcc4(ny0maH|Xn8x#Qsfw%#1={!~BFqYUBNoWLN7^Kp#Jo0`dh-ix( zE(}9pDFyR5MGt&PUcx$MfSpwY?iY#N#>v={HqbVz?xUQQ;JJ>%dJq_piz5y&(#kq^ ze~G>UbEpO0?}k8VP}+iww#yRg#>`r=aY|GcT6P!u0%14gw>!n6i^X+^ov)!GTLf;l zF6XHJgIw=N6tkwSYOZjj{D30Iu({at4$iQEv$ZVu5&Q3NcBxXJa5b`u#n|pV05ynU zu%g9`7JGh3-k=OQl$a{!o@Pwhv<(Zj>Qj=UG!-d*Ri3FzRh-qh@=EEV)#B=>#WkqH zV?~kMdnKq>?|Mw{vEP2v<+*U!i3-89IH>WWwLSlRpn0@aCRy*!M&}L8Z12>iIMzyh z{ii(Jv(vtJGd1q{$+Zdw=_a3?9YkUaS9o-*S)+DfR7?KRpRd8b8yRLw{%LdVmtTw+ zkAH;Qb?)9RGWF|OfxD4@M{T#}h$S*ksxbv&Bat*}t4g#9O9`h!7m)liIP0oKB5WCob}geC88 z9;6=zl7t}aV6t%H|eQN7T>+1 zB?O+&oqiTrKKAjyGiX8BN6~9N<-c!|z68hl*5^`IadKV?dhil#Qqk|P^}|BM#A3Ae{_GE*8E2!wwHvPHz&fwnE9ArFPosDH z^yT~0K3~6E*^S!mxx2SdWp+2s$2B#1lB!c$VT}AVLvj5x`%y4L+Uxuvr#-^xQ^*5Z z4F6yIM;z7hetVKGi7<`iCDEwV=NVbD`z70;XTz@u<}Pe)q=ZW5@KWlkVsRA5_heg5 ztvlXU*Eq_z#hEYoXL1Z|*8{=nOW`n-w<7hH^iE1N#fv{=NR(pUSL;!RbcB|mUTp*n z1xSdYhb29DMTld63nYaiF&Ky(4oSs?h#~eu!brOb2hNLxVL(tQSSE&S3kDR*q{JCn zH}VD&L3jxjb^-*u<=pdk?}jw0et}UzpdL_~ZU6y~so5h1(uo52#8=s&P`D&rEW!i( zQD-nRC$73{rf+3siI3*2K5Zf1`t~K;Hdo+{!o7JqvBu--zgKhr9;w#52INuD-r-Ac z*RJd|$=zx-+cvbk9r8~WJC`L)^N~X?H9S#VU>bfd(l)}A0#E#dLDO(7-Ek$H=>Ozq zVmH&}SLL@RrML9PU32FFvvrr!4>j&ph<`gVHr47N--rv9=N9E9x;OtSi*@0*^2G=v zZLs6f#|(>r%Nw(>`%c#_R__YSt~uE=u6`f>`*;7_0C{Pb6r+*hnV4|7^6co_qw1O* zD`u+tUbnCZLb1jB$^*_uL852E+a{YnMFi5_^U!DfT77GY%T*p}(0j_z&xW z)a9?%8D<;dw*NHo30nLl+u?%)rWkp z{0FC2`D!58jti>jmzvL=7cN!gIQ;hVdalo>adpNcAmCGdwx{&57Dj=e3(0^Zr-S8* z+a_vheWLtGTrQP#qiA)SMz=|jOaEH0(~kn08mUoASuNF01xcM;D&G*CqUarG{YUNG zg748b_&1n^o?i|X?RwYM=!x{Lzt>gYmig@`zCmruI{$p`;`gt=*U{cT!oJ$Puy^B3 zQ1WS5Lo=40N;#J+9i`V-R#&`dEGs)zYPeigmX~i#FN_`D}|YWRHa z^*LXcfaWWi^hPO;+@(1WYDeKMT~Q4jmM`2gSB#0;h%I-PfZA<{ey}7xpXX+1*vo%` z@0@{PxwY)nOX*K^v|y3W!j=R*n--FU0*XW~>qP=Q@A~Ty81+~!WPKGli%u(o?c+@Jgy1iR55b?5oUb-$>-!S zxY;qxv72@1zPH98L2uwyx8SX$L-Ejz-Ja^Gw?Q`WZAD=XUT;2%U4>dR3@E#xseH+$ zyKu@;RQ#^1(t{FLVJ0__n90&i3IQh4!W`mg@Bl2^Se-ov z2!yDaZ7bz3z6Anbp-Jw-fKaVGIP~ED5~Vm2;Ol5#TMnMk<5F09vyt)Hq zKp-^)m*D={TngP4sd#3%svs9FjUa1)$f^^!Yc4q5Y;S*4ssQ zb5N^F{sMTTlXWkG^D1wx;*9>vmsx++leZYJuD=&<)5mvF?!lP2HjSZu8AYi~oY#yHsvGFQ=Lhzi$v8X`|4yH9Zc>k0Q5eUIBU% zDk0}LsvC?XCZ!F#d(3`A)r1-w&B)k))Gsx3|4|Ykeqe<3r+~)5myh{YU?<$TxKv`I zu3XTTEEmrm$LL69qo`epl}%>8gjA(P*9YMPhZ42l$2CgR4)Wr`KA(`9kkz|Sgdei= zK&6yOyEB=4BB0S6g03F%fs>x6W2T#~n(Sm-JaFt*@9ULDQ0x!0CufS)L-R#O5*8eP z8oB9R*m~qwc9Waljjm60uZ&9j75Iu!zT%)22LK_%SO5iqhK10@{I7U&kfML zBOY$gwc}SH{L`Ty&syd-M;M*Th^ru-41QTr6nx*(-|oKJloQAo0D z(|jf2P{`S1ca?1%c(H{Zop@1K`Sb3(#-MD@;62f)#ijFiB#b19H9P_{aB2i4+sop* zLj7(6JpguuG-K=1BRN_uTRH@(PdqE=rPq4TSd7 z%qz}6GmEU0S0(-{Y+VP6HK0i+)_$jD7oL;V317o1xjhVM-+MWpLTb1^NhjZU~(vfZq~8Mp!dp z5yVi?;jt9|hovhIhq{g0W{hPRj2U7=24hQEhS$D~C5D9LMaYst%3k&`W9PNZ$eKMQ zZ)7Q3+1J5DQKV#FBeIk5{oe2UF4yHB#$0}9p7We@pZnZ*k4PktNWnQqM1M!kU^nAG37kUs05EzCpf*28lj9r;YYHzr^ zw4ql>tj$r}H6VhSVyXK-$w+g?!vbf`?~6-!a_8n%m;oMgV4M1GnCpSA^>nE^rJoJ_ z_3eD?*nN>H5rV@&?w<-P`{1QC?}>ahi2-IZmrgd&l_4a8gjL~G9Kr4a(=>0v_)KxR z4Aop~y`Ibagm3d)cc;#rMw+hf4HL9%CvdcKYOBskR6>Mj-kX+ zd|wwQ(Wg^=zebOhaGHm3;WvFEZ5Ui{P*EfdL;TZ`LYB1rLb@M(>R5 zt%Y5c0YtDwx&qlxU(qphpqnvS#Dy2th%7aJ#M$~<<15yKMUqu_Hs4+#8%Iz2m?aSR zU1g5#SSLs{mA;JNXJl;9IM9vy$oJDn@b*gWYJMpMY(WHbq3NMC0EH#0Qh}(U|BIRc zGn&}3N{oUSp+WOc=|TTn1S7-Ij~SA*r076UU|d2EMmXY#Fbr4zFBWhY-F7sLKrEIe zb4JR>-HW!WA$p*-A4TF^Ao5+&$;gZ1Q+nCzD?5HBzuT+!6g-D#wl@n;V?=O36`~jY zZ}pbU>Qxonbf;&Fr0FRC+wN6$2d~DREqes@dWHm#sRpVu&((-d32@F>V=8cyC(J_S zjiH#dvL9j0({u)nh36&pjamos_2a{zH^Hy<>Y|NHF~%l<9gddI<3Ce9$x8VEUZahTn3Vk)-bE%w>AdTORDOOVE&|2^f&6SiCT3G&hb@+(<9c><DG&%sl>-t?@aU;|~Gu-bWPD(g$>|5hxL7tGd+!qsDlab+jt>zQ-y+Igj z+ww-EhyCV}ny>BJq6;G^A;*82Po_PW->K;YFcn%y8s%2k4*sIjVx3Sj!^qv+JPl*G z+NAVt<{wxjCcV-6DP8Tb)KGZ84NzHK`moN9lmK9~xmCsBP(HunDy9~HnC{#Q0y7BZ zLB|KUCF7W*WaDQ|B}oXt!mNYN_dRC{b7?HIo(@J9kVsjzL!xDc{NovU$;Z#xd4VGL zf*tEC2GbX4R8X8OGd+Y^79c1107Dh#V7e3d|yz}i^hd1syg<*&P9I_})E42T9*{*7RSu6HYzxlHydg`iw@nLKzTnJ--z0TBY z;-^Y=oLb;7+@%yQDvUygs>4(9#NB8Zl;3+jWHT+Owe?pQ>ubKkfE8^$=jl5iY;`B` zyijL6%ba%X5^u}j`4hdB%VNO>an z`pxjt(xri*&9!$hKVg>uZeZB*TuraTMs1Efh`)qgP`E29FC#x4HD>GPkM^qW7B-qI>hq?UKB+}Fcw-sz$I zq^*rSMmEi#eYr~dxh;|u1(n~jxyVWfu&%t*D9@!dYWnxtS!EK3{+8g_b4dl; zJczdP5ijowc*<+^+$wH*(t|PfrnN$>`Ik!q6mwU5*YV?yIoVq)^y_ssw5&JX4F2JG zjj8epCcQgE%A2s1?Y-MZC{!$zoAVL@DwPwD&VoHsPZHu@9v`??**e!Mf?^T>VZ4{J z6F+p*f-5pTVch!A>OT}HrfoCcKlUdyljYmOw>LJEGZ8t{d;?NwbzCq6$qAK=UsZoj zfHPdtwQywk3qz0GqZ92A3hE)@;yXzCbl5033p1!w1ZWl+5&yLs6Bj>_z|68myKbTd zfHVy!?omMv9k4Rx$OsHmSUb?F(Pmf)2D}7Hm>Hqy>G&0}o-PGlkEP+@^}7Ff)*YD@ zd<9&2V+6z3kXymWe{X(&=+)w`SkkU^v*3N^6%Ji%=~?ZUQcVlfpT67bJ(hgicF@Ht zqhQYzNO#>FPUYgocfnH=aFA3e83F+fot_*D0J&4~RlC8r@JoMM*Xx$Md%3?YIMWby zCUqTLm|F+b2nNk_=xiRJ_eG$cXypi7wGTg-3^JgGe@&lOo3NKYxWQ5VHUu8_{mIt? zwQ}3HOjdwf#`a$Rt(0<|{hscyIH#`A4{vY19Lq(%ug#lGL5`7wX(wG|)?B$-6k|#Q zUrK7qYO8N>@NjSU!-K39T2{4h(;wuk&2!F^>T^x2>hRd^2koZcA~Igc%Q`OHiG6RI z)6h8JEM}Th3VYv)5G#gBxne_=I#;Ne&4fStrSltsj4| zfT+LT%5K?rPe|24&F7Cct{NKznq7pd>OdTWdv=KC`jR425^Q+-K~yD?#Z-?8Y0cj$xg(87K${(~z3JBni1P&dw2tpwO`4&e95c&Wnz$%Uo6#pQSil$fP1VeS;AP}`9 z+ME$3B!o;eQ{+{=mtB+AiSU9B#`m$gGNcQkIC1f%9gt0d>eigGtCPFOJrRqih507` z4Q{7`ZcV)l9J!wwVVYSmYAu|Kl;pWz^=5i)4z4XOy?Wq&nWx2m6Ng1|&ey`hnC8{H^WM)t_T@E3uWjXjUlN@INjf105GsOa%UF}0cFPrIIT-9>`A5BS(0E-W!Iy|c-8sXFrOt+I_NCPX^kx!j!|v_2O2Yq~k2 zz9u#4%Ih~VHfr@st86)P$@IDJaYp)k`ompBr^{TTv>vZR@^Ouu3coc+)~De9mUkm{ zq13mRimZx2j2=Yrak}3h#pkcf2gV?v+p+2K9@fm)$@p~POm-uk0lc9dGVXgy@Hsg+ zLfheRaW1<>rTBA7qj68p+QNf{*UB?YB&Hzo)jzj^X)~O)hb~hH!6GgUN(PWt_&qwyB5lY7 z!jTAK^FaemKHQNA7w*!29CZ{i8I|QQG`iciH8Zo@vSssN>tV&eui9#Im1=Jo)k#h! z;-xNBe8J2!b9!Qk42S_eK=seP2r%Qga?Zf0FedTvKG{&=7oPq$2>hf$BG)7u|Ax!_ zeVc{VXBD@?R=}<&tWezl-+76rHg}d3d^8K)Alb4~M9L$=u)H*ZAr`EhfEVSx_X1<6 z-#>+iVR7)PxzDolUmt()%dSS5gj;)jzj3y@m>*cML7E+BJ$&zdmj7-jCPJ^ z0tGsx{^aAD#wzpS?{M+9Z>a2*3Zh@ck{Pe4wqyif|tgx-)!2i-Jk8Gx0M;0 z9&G#bV`{HZMq^X@eJqE}tMy+0iMI{v_L|GdYV?JFdNh&FMcbWPs`PQqXbsag9i(<88h# zxE%X#Ox;M0CT4*-ATk3PI~HmjsC7-i1c%H*r{p8tahodKr(&q!c3;k0ehSJk3JSKfR zlTI%*lSJ&L%mHj=`pN2@pB3?cNG^X09gYXB*dOcJgDx-`;#fp#At>-DjzVC;EDCd` zPmdpBgGrfrXE844GcS*F7WSP&Ti?;v7H_n0{T`L{OFZzU8G z^ZEdC*i>mdd3?tz<^6OXpWC}`r!RM8E>N1KLpMG08|~i>&87$sz+#Ji4hh1cD|1T) z^~P_Q*&m48e#k=Aqh#h6A9cI>{LA-IO(%VRZ@jrJsB5V^P+2`V)g_|yYw{cD;jJG7 zvDkbPo8F_Z+t6D3;x+zb72OJttu|6JaybRY!XT4DGjT0}ac6x5)cSc(qOJP%4JKdw zy>S@&s#-d`T4q2oO0SQlN-l}-u7Bn@JO##FgH7|iem}-TFO)Vnp!O*wC2O!rV`1;l zC998Xr&K>>G>@eeLHt=sWj5c(4jNvD9J!3^UqzE3mvI1l0S#b$2|ZVJi>-86^qSH#^qoV{APVr&Jk10uQYwUZvhXC?kBXHg4X zE3Y`Fg8lq?`IeV;4;1B9Tf$eQ%X4)k@7}f?zrcm8ZVQP!oDQWuq5lG`x<9?W(l=^Z z7s!?WehpPV)?o${ZQ>4bXl@DW>SfY$Jzv!r?~Z%pLh>`w2&11Qa@blh-Osn?V6|6M zNhN4FD~EDd%4Q_YZCu)0jHS0{b-A*ql8~8REGx*KE==faLM5#7cDa6cf@k1gl%*%%Z^mCAI`X+EojGu4TQefJI8(T#QQ`i^Sk8xPmlFS*KEhucK! z`qe?W3?41Azr?e=01MzTLZ}J|V8j51@&b|42e?8TBnx~MI*KbB#OQ|>3cgGS8gLLR zxDLSkbo^LtC}afXhz1F}NI(#Vej>Nhw3mP6thU+3s# z`XN4C`%#JW^mXvjt`+Gz@Q7zlrVks!4p+S;cJ23#1PnUv?ULe4ua(rB@TR8kJJ`9| z|Ki*+SKv}NN5s-eQlRFT&WGAyovAPBb!Xe#COeVu<<8HVJ!dG#;s@{YtA{AQy!TwK zA(lx_Y=0d##(JLT07R9Ot#Ck9&aP>{3)QF@5Pmul5vHyYwjjQ;bR_jo_dH=}=ylb<8_74mdD)}^sP*-Ob;;Pxt#m6+n-7HOc(D)Sl@$$x%Yg1vCwh6 z7F*2arH7e_)7WZ1(bPH{(^}Y8+wy5@DXn7Qn{f?T&d@zs@!Y zvcIU=(A>X@%(dEdRwPFA%a%0Vq;U%TZPta|LL(nKSH|)fU@QUhgPnu@=!!rNNHfwi zp6D?lajle`Ox3r@OYdN^P+Y75ag%k$cIV`*Qp2fl+tK4O?yAKFd04LzoQ~L33}lmG zfI?zRp=pCv(0Y#8(nL5J2r6bo#gVve9DucwC9&M2`zQI~WqNzNwN4}r3TyB7KInY;(D{xlR<$*5++#9C-R}2Jl*8GQ!}Kl? zSBo2e>quyWQ96Kc7lj}u4Dv0hzW~Sq-ToDMx-rtW?QQwHQ1oMFuv42R`r)D@f?h}L z%I+i2gTonz%&U#|EDPiPgG%kEo)M>Gg=fYOPh0DISJ+SXrx(O|#Jd%~0A4Li$`^;i zod6r{=aipjDB8UTZuXJ>o-KYW>D~JzK-OJwa*M}?<9FSMaRw1(jc>B-^yX<4yN!Uy zN53LpelHk@C9fPE_zzB&qc$T_LJlrB$>-0M734XJc^xV1a0 z#6SrXAV`8w-X*D@%1<<8NrQNQ0|aUyJOU7C*lQ!ZXBRs9(<^@XX0*M|5bGdPV7@JndzP5AmYd#nead;xHnxT;vq&e=<#b#7%G0Etg8?^6C^ME`ogywl zg8Y|A0TeL49ZnF#qnQAE71E+406h2SRMiCO-0OBRLUgr`&$N-xz0$(F3 zrS+Rr<}2Vgx9{w|{T(A6^1#mF4kag>n+8K!h-awiocAgz9X3vdNG90|N>zvb@>UFf z^RD*5P{U!{dSol_M!#vB@3~Jy)#Nh0m*b;h6a79==6I8)VoE=ENR5D$Ra_rMXZ>zt zp?mA=Vfjkd+_9_Q+^@UVGz=G4RbB--DVhy$$+hiVSU5E`A9iq4Z;r~)sSt7bI{!Jo zZMK{7WyU=z`^I$NC{)t7hY042y_f4E?Wn!UXizQ9VBDn>bHA!7iX#@Xiyd3$uG6ig z!uS-A9z}ZSM%P|CMEBd3B3x#@;vvvVI!4}mV6kW7Cqo=fA$#m`-$ljBO~liEyYA?5 z$olAIJ89Cz&H-7LOvzD}62tT3fxD7lwg5Tu;-T)pEZG7a%5)AtK)rLkuvyjwzM6Nb}Mn57QTi(;bb0 z@nx^=)R&Lux_9??qN*o%rf23JoR9TX@%;EUbJpOkc%W1~b7-@;K&0JXVE|**s+2FKxMa`+m9e@ps~9 z5R9NNH^(qAzHq8Bb)IDi|`*pOFcDzjX7`p6CK zuej&Pp~L~+kxkix{9C3Yl4G%JdwEX(Qj@kH{qwh+pc`2(iQaH@JZ5eYwwstJ@4@&i z5N04vW0MUr6{e>NMJWodu1DUUx0jH%i@DJ0r(UNoE{yA{G>=2ZhD6@h#^qKEsZG*f zOT(mTivju(5@-;24d-Id+q#H9((eqmuv^u?%qzy?Y4H$YJogl9HXh9E!&J>IUh>39 z)}Zeel=%gbZkq=pi8hSn%Slc`VM%oUvh>`JbP#4B)9Q%9)mMO;hYkcn5x29b00|^Q zyqQ7>0OG$|#EwO9<`79Le)RHGOa?QkYh)AnxcZ1J)31MyIYGF~f=sCw|Tk%OWW4_5Aukw2YA?pi5g~VD-3Xc*OC=CPmU^0=R-P z=ENX<^^90q^sOk@vpv&b)O1+THd9rV7HuPJT2OiO)%N{Ts9ko#eKXpK5$gQO*w|RY z|3z`3F?_x82LNSgJ*@LAoQ56b7ymLX{~_ToZX=aiXlH}!nN+}9lG9#(C-s{is5jdy z^YcuHEO0NYG&OB*WH|=f-Oh~hFQBBo5BMhwyq4}O{MxtaEjrZ9WTYbZ>Oxa-NN-pv zES3#9rabRL2l}|}i_YlzihGvAC+swpH(NGpYu*paal)FbKY@NuZoYbDodvS_jGbFb z#4@j*VQ4D8Oaz!jX?q2sKFA8E(K4o^%n(2HywM%N9mTiD&a9v_Nn|wpkg0Lfzf~9d z2ej*JT7~q=aN?zMZFaryfPBQEk$whPynzNK+NS_H3lNF`{1))4a*{yajqxB0;Nx-1 zFNCX$KoS5ml8u660t3i=kThsJ55y=*j^dHkeS|OYD;R*J@qw}6MJh|02-FGq9wi#2 z6FG1*veK-<)`M3lN>gyH|GS|Ye|W{!lk<(_>J57a;4tb;bY~@|dIt+U<`u8x!bUa( z*lb<#{QULxAx+D>>h#EV=J$A)!A8hd;`B=tKC_FCq$iA^hZ<7p81!7PvUb^s^S?28ccH0emBR+)^ydvz}moh zh|OT2Y3$njA4~r7ee2zJkl=7E#nbhQ3Bk{|*)8y%`rD@Y&B>nGzJ=4lSEzR`E}Quc zG(Cq&4@FoRnG#f0IYb#coLQ=Q=h_sS>Mk#YGz4r4+|_OxUHkisMXhkQ+tlSv`W(Yo zY-~vXN9s}7ls!nWxls&+3yl}8z{uau6yY?B1zAYiOE9*I4uwCzHu~CWzJg`56qXTb zBq~4P!f1)*n|!j$CW(GRL!VG+s6|JS(F-0BRLy9nR0_{l&E};sKv9v`k{^X8iwh^f zU}TgZ(?c}#g>*J7ngNDm;Fs+HEDgP(9_qk%vS)>GNfZ$RTrU$ z!qEWt8q7?EfY!)hh!`0c3+!?-W6dB+i-0`__h5WRLb?b)AXv?<>yMsb$V5F90<}_F zk*GqwGl!p&GeP?=&$}fXHT`lbk~t!BlT2a$MJ7nE82>x*_Xv?uHemcEd7bq9E?Xy86 zUL)_I;I_>~hCCMP64jsT(kyVeS9Uz%g?y6xU7J zotPnHD>}yJn$d~X@%r=nz@FUvmlNYJ+rowU`0Q33C#@F9ezDV*KNLP>WRq@_jPX<= z&E%k`>TdT1K?52Kr4K8^pH(D7TGYG#3tER$m=f4cC?z+KX5}zbHwX&xrH2`%x)0#; zBi109Y&9}E%J}!=_h<7OnG2%Q67^=!Ih&u{#-%{Ao&H5LaeEPRq2L@5K5fR!MPQ2# zBA6FVjU1(}7C|7l=|ECFESMv@25l}h2sGaeQH2wQk^%5g7HC4$#o=fQv(F+u)esU7 z!Ggi*?3NuY*-u=}SpTI*6S@+e?PYoVpHeR@`Z7u}$JZmT%QqfXRPD51;yRa``2##? zk$pA594xTkMiXc@>Mwl8l9K%Gi~hM}xK0`h3_8uKA7`~Ez8*70Po^sXjfc@Q22L`k zp4cRyu%h?$uj3B^@9ylRWe;j)&40(-ji`6i+(ab`JtXMxTH?O6`re9|<2e7tA@0>` z)>oAp!^Cl^C*rUqq7G2ZkPmhF6Wp&ksT*hGF=kUe_GV!BMj62@k@B&5NL{5Xns5kT2~}bVM4~T#`|? z({Qrqd0ct)rq_b32R7F(BSr(+Fkx&$Z?e|LOrVodmWdC?{Q}L@BfnfUEWd^W3&=8S z^HcG%On=v~V9yZS7IYwlJGTYhiVA&_ThA>W$xwTgi3$2io*NFHY{zls-MaOw>H)>Vi15P%q*qw!rpZq?^T#&+FI--v zek~w5$`3JDwangV{r+Lq{&s=Edf^oDAdi6dBBV*0fji})=4b|;AlEExP(s^( zg3@uE2h|xH6HC-HKLe_-lCjx$+3PTqacVVx+zGPLUIOSibRy4A#?F@sPwj(GAM+R3 zRUbs)o0AL1T3$_Qs5oB~pup-sjAsmW8)y6GxoK{H4U3=c2Tr5M;t%DA>a;6EU9z4P zpWWBHTG|(X%~^g}th-~LJ}oKzqwR`MSdomjbn=E2C->)DgUaA%haGqD zhI%E3G56$K|6-<42wQMaaI}XbU#ZDf>OdP)Zqd}WefA^CoIb3>*fL5dX@O3xrX#&b z&(DWR1s|{Xl#}hDQ#Wyt&V^{+DHX&3*l45k+{}se^ym&;q#fEaJtc$FgSdDuRA6|f zBL>h5z_J~5Oe*IxC~7B+fH9Tq0AsL1@=91 zP(VsZ)HvL9U$0X!L#w&+C+jK2vuU65@4|sM>-3|!ZrQtjzrRwMLrgQXtye5*}nd4FG-GB8jkdkujQul(dY0h>-M>7@T;IpM{1UgT#35_=*gXj>E zk|)jNU^J(z@C1ib7zA<==uH?GP@2=7KcJuW{qDqD3&rF(a}gK@$b4Al3#+Us)>a0; z@(a#ijE~-ETT&Q*fs8;>04;$)h+L$?d>p}-OLYUO8E-IEk^F}0SvV#L7Y-*3e#7L`+IqBHMDcyC?Y>n5;mkXZ+v|*5GYO6o`wY(CsWTlUr`Ymd zwxCyq%+%jWPudXB(TlrlLeUPOCC{5krsVkWC>i6#{9k0ltUzgen=u=+mWD}Jpe)M^ ze}&y<&YP#Vzii1;W|8T#6w+8;ogC04mdTJKB=y2#(Ihs018o2}1^&cKhpr|YgP@aW z*LZqD+UZk|R1%~AKOAl^JS4j%lUCBV^FjguIOmyxID;V?@wzK{19}yBF$t_x<5UC# zSSz(QWY%hKpBffLI!1(JDU283p^!mPqj$C?8ciq+BYX15)fP^`#PJZ}C1CqL&^Xu=jpSs>m-gS*eD2bPhh3aNz zYs*pFbQtR`2+vaJd!d6peIe(D*oAETOf zw*ar$W1q+Tk_*v=)6QR}b<2Eojj*Z+xRf70fe@ofuVaa}G%HUMv1H@Bte8Y)wPdwq z2?D(=0=qH`Njb7FJEqWpy}w7)7@9s93v`s~D>x9p4<4@6^Dy6y05meH@KP|gN$5IS zo81+S{ttUb$}g1%%E91FmabT8q=r>o4aNUON#0(_qUY!ODay>$#KT5#;`( z(IvsL5TwW(~tC0+#+&3I@z@YHs|9(Q|*+TzABzB zph4LNCSYOPyAF?sm`+Z-j&BXs-IGtrgPGX!pl>gu%#(DNhFxcGRexW~*uDC0eK;;l zek<%KdfM@whLDYOpl`48-zFx*k{xd*&Er@Q`iNFelmr@pMmF^u7?k>3%o2I;_qHN5 zsL`ri>5d3jVZ$_&tK3)$tXd?W8@~#Ia0<}hm!n-1V0~b#vRSRXDQ#UHK#u*}01HR! zf;kBRkq~~Nba_-LHigWZ$Vk&`7v5#A>;N}tFKASHpn=66qpBvJK3~FD4@s9E3^wg9 zBjjq73HjrI|6J?@v?A?CkE*lupe!~i!$~5&c2>8uAM_ct3%V(bW_@9~NM9F8Gh_*x z%VA#4-$;(fi~%Aj9VC#2HiHB%4OB`$k3#*|m-45r)M*1p(hzN+2x#He$q)j_pvVmZ zL@LcoG2TMbl+^F_ z4-{`L{n>!ra(kT*RIkseFdN(W1|s?@v$lWnhMt2&uVfiohU?*KvdeAjED`D&Yh9`_zb{{QBX`lVV@bu9EnI!RcX4PCC{gs>oigMe<*x+Om!hAz)s?gf zgYjq(6CZ`qj6q6WqyvHJ&}OvnV2LkRB{*`D4b#Yd@+>|~f;dJ_AYH6d13avq0Ma5gDIR$^Dt6yy?^i_QyW)xH{2Y=YA(bY4DfB)T#RkrnG5)tLjes zVYA(CHTUcHvMIOY5~r-QGR6Uc;_Ix!O_%36hKGlxgGmS9tFkH397DenArf2(8^xEC zCQE3jj2l|iBHEnqx3kTg5-NutJ5CGAs$5Qi*(E^jg5!!GAIE96O!=(G$o|)jE_2KkQQQXhH5<2o)eAC7LlTYc$$En7DCz; z!Ydq@T~YXNbMQklsP7?z1QExsObs#tygkL`0qb?5HXYa~1`D)TKn7ODOIY@>)RL+-3iY9>gNCajye<1=bi>Ch z(^XS~1O_hcxs>f^l>PP0O?+QmS?j2ar%iSj>^XH+U|7*E@YgW$uxB-9@oriKg9 z8PUop0fvwxGFST+oTR(WUa;@J^|Mp!ua|>yyZE%U&2Rika=D?|qP}zXM5<<%a1m|w z18t&|ak5hWCSY;n(GViJKzm(k9V?Q6^;!!9Xd--oJA(v)&7_32)*_~}Z0{goEkLb{iUK(CI zjSQ946OM#LMnlTzyNcXi4Hyv8x3x-l++7#n@3VbdO~(qM{3L{5@+}{@XD*4K&vKj< z`L58rO$D!oot>SAe*NR7-HyN4m_Ko6?~~KN@ci96UmHsZt?Jj_ni?S0QQP0WQTdiqUN}b2rxIRVJmv^c z*t$oU$awPpqaI*W6q=)}v(uK2M>7&wd_J)(bIYU`o2-yQe6?~9)3r^w3=kd8ggwbdGE_V4VcwOz)8=LVa~Th-rYGd^>#a% z4AnYcI+;3t7<%|UwD5|%mW@qwcHqgC-RS1muUJc%N;ve3jPF#e3IZk_T5~d4eH+iy z(}>Z=y3FQeTw)U1&peE2(JkG56rQMrN~I*#V+w=Qd%;y$@-@H6pxs)~?hI@GPGGkn9D|%I??RO7K8r|!k zaak$36?~sErv?4&yp*i{@TTXlS}SRlOWw*G2YZ~Aw&KpRzxA)i%*V7=B0f!v+pME?cG|3hIM$ev+iyh>Yg01| zVETH7lPe!aZUh{)7)(=Zaa%pBWT{CWD!WGqoh3GvERNRZG1FpfFbIpX^X0ae70rpF zX;)>SsjGW!*gkzMFSMju&!N`btcq|YDg4@j6k^UZE@Kd{JgqL)r&GaQoiZ`VT4wR- zVwr_(e+wD`8Na+73n2(Ikc-$Ev+3ynl28M@r3sh>1tR7a8H~WgM?{&)XnGg0{=aBZ zI64Xf#v-SREeZO|=*El<0Y+=dY`!aQ&%E_upDu>`3gY|rZ~5J+t$1cm=?V2C%Tbx{ zt|wj6n!0)xaPYElR;vB)=G~RF-19ddR;F4Sf}c0!&@kh}@s8`-813c2-B0Jo6Kx0e z@(~GGq{En;E>s6chN00&7qT-Qlv#+J{LOCnWwn-(Cip*Q`R_-BKh9YuVIFzj-gI+S zXq1=`nW#{H81;ndw-q&<;u z=-hV~y`lVTIoErhmuHOTey2&}YThtwHNWfgrxtwij#jJ>xy$|M=6yQrUcpddcM{q@ zKG)DAa`VzF`5-M>#?+m`E;?dly;nGF-7Oo@--7{~3!8dkh!N;083j(m^v%Zn$WZ;e zScWE^EEP1Q!2Gbj@qF__7b0x;mL)?#_a>0=v>=&gD zVwEM`iF_}ih~=qCY>7;R#WQ2@dNYiioec#EfLS|rYPB4RNDuD2TT)nv7;^6WVm3MF zXh`fG-UWCT=ld?M}<<-6`Ghe#nI6P;5WPLw+6zufX zsh%_JL%*Zm2WR*Pxs(1+r_P#?RzolHyov~C8oKu+?XZ;M%8XwvOaF60FkkLRgc4!I zk2Ph&DoIYFOqc{q#P4#=?>gTJ|1lA9%9o)gD6ud+WTbWW^}%6@afFC`g!}Bfjr9WK zCkaDg-Q0G9Jei(mi|U~V(d8orzQMK5TBeod$=`MpbauW<@g(ijEPC~PwmwK~Ho>I& zMwRTXvlto^gNqGjaa(+47DoOya)~@RvZFNZ8lxV^6pN>(W0_A&cwDVC@7*L^&Wm^+ z1QqVpMcX#t#yZ)foIf-s@BU;1(N0(^FAOT&95k*#V#|%?{ofXCuoH5q%;{3K0*1pt zvZezoYfpngUrBf9v=5MyQK}S#5i?kxY8(j|J%_ldRwN^4Jm8h z^%VF-u5W4Pn8g)ux67UxM&AM`gl)hOFge;n#Cf80^ zNEgTDShT1y-%Ko-ECCoZSgu)dKSA40WXS z?03}oa~W-q%QzM?}iErsmO|7s>=Fs^8gK|~ir55*Tl5cqgJS{CQU z?D>r?KlQ_7)zgu%9zjv(yi|h*7vSHS@*%0CnIM8QzuM0>CIj6z9~UL5$&}6fc|X(A z5|Q1W_H8Hd^-5H{kZdaDsom3Z{iM$AmeHw%kH=3MrwLw2;3O;jZO^Nrypb2m)c>#~ zxTbpk?!S5g0q+1S-=5WV{Ta(abs=pyCaO)E_PIiu7M%_(0z%V^N>fd812&d5##)?P zTjw4>?2RYksnHN2fEre&i0QvhFNNkr>6!Mub+W%V#{SR%VJYSLl**Z1^alFv=XP*v z3posanlx|R2j#^v4R?TfSHYjqR*vYxPN_7C*i;@5p(`EZqbiWlhV^r{9VP^P_2R&+ ztT*yDddv5r1}}MZJ=XE>8;Y*ixGcZk;MdkF`jE(&hE$QTm#7!6ZbC5A1O{YbE?}r1 zCXS1oeetr|A`+05zwGEpR?mXYt8BLsVL-|r(2m7YzXS=jrupGP`$sVPWyj*9wpSDY>tze^8E{Ju|Cy~U83pQzS7{+ zPg2zzn{Lm6tSz_M^5|?R;uieuiDnrMxnb&@MVO8Ul=1xDwNRqd-WfrTyI ze)hpWTOD>%UcwI@jW&OZvyfDbd+*)YSQ%{aq3Zq4G0_3MJkG1sQUs@gzY1rk|#lS_#Npc9O*p{MsDh!VeG9@_L-}BUQ zZBlDyODEBMc}fav!SxD{r=7>x@pnBitUk+O^s7W1Tcwa{5(CgDiLl5tvDouz&u?ZY z)LzZ=E_}AJx0}!=o$elIccpPl*6WhoUC=k($r7wnnc4mB=Lw@1?Ohi$mV!#FrBuMd zcX{Dd)@2h{a2E|83n%yRmO@*ERnmmyEnI0*yd&Ia>7zHrwyxvI3_id*UK$-}8K?6| zSasGhrU(6Gv6&Yhb(o%bAN0^%xDnZ05YP2V0MoOUxgik3#JHJaob;_f>1898|Ha>A z66Bk{w42{;)Jlx!ovpy+LaD1~U(4HfeERdxO>-pWPr2q_FIx_MsxgnR0xrsULK6B3 z`Di99CJ?w#c^@;gj8stKY0=DR!!#NuScnLL=8zuPzQRTT1O2PV%xv?48NvJ?RqouO zpn{}|dI(%Dg9MQs*0t>t?-uR3VWz&OUGrm^OPYCFr;{gzwdR`Z^P#VPzhl2uwWKZQ zl=-ivk7*x%D%nkGB1D=!eiJMk(5IB_^S`o(8Aopq!durgs{*FH_;2Cos5U zM|3*DE($!!Zb5-q*31XFXTyd%FeXY?mF2b#yOi3g`On+tYyIMTx#CG&_w;{)tFy9A zo2*)l(^VJz`)qq3IKwN456Ib+vsYGUri=PsW(UdUJaof6TGpQY& zD{jBHjU5!ZNV#)WP9BTfS9kuf!|}lSUIT+k8#~r+Z_LmEPHw%7k zT-{yDJ6}bHp5@*db6d6yuWM2f1MS==_pP<~*mxaxa!i*7FWjnb9XuUkwRx$x_2vgn zv{{Si;F@~2_`zotT8t$Ona#H%x*Fz)@c$|j$r;slB`-_fT9{r&2Lny=bor(z90}u; zyr1Lqu;ZTRa!q^B*c1kosmv|BfQozWMHI*xcO_}b zSg-toTVPWhBE+Hg?iYod?A7K0934_Qp#N5qxe4$_xDwUXf+HK2bJM1H^N`9m(s5(& zV>2+AtF;?59We`NQT*-K>$jhcp9{zBBMLWfJ*T%6Z?XLG>0Z^%=LxD?67K}2FN!d( zuc5vjCvLDBf)fVJB#%DTjG8f`NX<@u7MCsES~iL;79FL1aIqF-H5me94itqG1|fJy zpjaXu3K$?$7%#dxMqT#l#$XDr$D`#tU@wtP&dj1ds+|E~rF&}e1pex^#)-X`{SsD2 zxn`0QBa3UC&6*5gAt2|ihy;h`E*#K8Y@{b#P7Wp4XXd5KuW$Z&5cb$pOYZZI)q|=N z_r{UY05s<}MoF`5n#bO52VerzK=q=?@`FDJw`$v{W{?ewQ^*=8|zzhY|2h#Dk^NTwo3U#cm%$XDwgcT8c5x zk1sNj-<<;iCO13cmE{kN^;7AX7Rg|!C_IWkA0xT>Y4l#%O(MZ@oZ419ZC86$oObQI zAQ>gcxXh3%{$r{C*Y|#H`3OrLFM@p(OFF%^wrjfL!Wby`00U)o`FBb)EG|4m=imXs zFz28rgzzrBPqw@KBye+7kV%UvP6upRWISt< zBEKikLb7^hK^B3*FMycu>x!kV7%zs1jWeKL{B6Ovhj#v8I&men-SmnnMb*&YimVs4 zNo+SM$b=CStVt=i{k@IfT2D>;HX)M~5jP>IxcR63tKjm)^{a&s>OX&VZMkq4;tUZ1 z@D!5yV8r;>v()y3`f7ho*vt{5t_<%yz{Q=;Y7sZY2BpH&WYe$O?r@&u&m8C1P|jr| zZygM8|6nPTs2A!GO%(oTlr(b9HheKzXLw2Nyq|Od;jS-*qgL6_j@06%AXr zYdwvSYsk2JcMs58f(aqO#<96RuAS5?-{iOMp0!bjjxC z+@WUFvMF8P31XG3L?#W2?r2~QJiE#TJR|5}eMN7-{O;Nw;E3NvI(C1f@v<%)MWgSH zg$-_|)wTD0z4RF>!(ENM%pk+youPW;Q^iM{-s=Fm6+mbXjvITegj(Sv2noHowq+)|hK0zO_dCQNB$x>^(zM!Mh@j%l}#{{b*+mpG0DRMM|OMCFTsXaR zC#@DM6eyu128Jh0QFzS3Dj$`09(Td*`RotzIKfh$-^YndlK`bY(5wJI+yR|uUWiQ+ zA%Ig5J1wqx!#!W!%1*1yzePN1-y5NuA1>Vf=^YWOIF@Q!|G4tgy#G^)ve5~XB((6C z@mKMjO<^m`%X`rr1C4F_SO0z>_K>8V2tmX{B3BT29I)e52ODM;tdHXmT;^Vx;p-{+ zs8*i);O;kx`bd}|P>DW^7#$6Nd-j3jug%A3prX$Qe)B62AUMRzdV-oAE2eIU^xu{rKQ`inY z7Ks%?l>HA$*B;OG|NUKtxo=nzGBfuSlib-@j8-lo)Q1QuiIO{EZX=hOOP0IbBlk)UP6mrikF?YHD-rvXXuRR|A+nm?ybKC_P6vA-t(xuCtbI0UL9b$ zn+8|OUz4sGhQfdmPE1^f2|IocJJ}95@((=_Eu*Eqq3um$&nTVXtvkM6W-DvP%b%-M z8+#tIwEybMwO2j29Z# z{!Q;*SMki$PHFg0(0=8WrnC}sy5S;spp)m@f5$V_7PPmIo6nE+C7$0A8)QI|5JAXZ zT#HwR4cC*TlAGH@{nHtK0qP&m??>+WeO~nXyu@$~rdQ|n{C(!V?y>VrE{*Q9UzytK zcD{R`?)_Md6$^xWL*B2I_)Y+R3mg-Mt zhq$A(qA(>e-Rr@=;e4KturJ-+I!wJ<f9j#bGFMH2=|4#@F3kO&N13=}+-Ba}L}m*Sk+;H$qUh;S zddv+pj7nj`^2Ts(vNpw#0%wZirU>e54~O{Cx#6;|4+whSoGz=OcLCh0*O=`JcuWp* zF7if9FpP1a#oZXpWEbe1uxrr<)OD{kFY1-yI-DrqkkRV+wg6& zveTU3HhbE6OBDQ)hIxpIFCr&^c8|09Y_BxqAL(TN+8JVlM;s|iLL#`qJ#2vfhBgU4 zCsi)HgZg*$Ft z8k!$A9bB@KQj85~@i|TSnp8C=EM;Ld0QJs08Arx1x^${BY z0PWvH4Bn0$a$V!L64Q6;0fa_>dZfKtW$`-h-ALP4HN(KKew$n{(8DsWiiInpR4Ia$ z9Z$2oFr8(VTV@i~^VPEqc)b|*ig-CR^OHbyj789OYzxf*&P^7oZzT)Jnn20g>VY6{ zu}8vJY~E#>$P<*D;f=*W+81G63uB%z5)dJ23~ zUpBw!Bc#b=(|oiwM%K`AeZ^T}}YvfRDPo zi>JeZp5wv>TG-{=2lNPwe!us}AJr_t+Sw7({`! z;b^(>3vOBX4Kl_doEN|jLeO-c-)I<-vs%u&V~nWpG`GkhcwW|u$Uh7ZA%dM~tFkFz zPD$oR1O?({=iJ*knXV7w?xbF{os?C!au{Ggiv0a^mt~s~Ng^?WWCLf_l>=!(y@7H) zyu0puKgR-GR!)R8KmYWHLe7&^^G-)!9UFbnIKDntLV`xg@rtbYKUxbGIL!NWKD_qQ zzdzjZV2UdB=1&hv7Ug{vO2DCl@jZbi2ztsU*XhY`WklwWKx6E_}VU=sQzSQFJrRC$R4NGgmV3cZ0{6 zyWtuhs`g2Fdnq2sOKcsRdsONd6~wE&btr7CMy>Pc?)}g4QYT3DGRy05G`l7eU-F_r}Fd7a*3#P3ZNpOV1VeM($MGkPsyJ@ zwKM~TkmH6*k276EB2ebX`dB7YoK2t|uTDp1bzfD+mRTzB;69|V`OPgo>3w{V&w50iqm!Qd)9mWYy zx~Ae7D7`EEkJv~-0Ge;e0x}L{#lhji?PV3ZH$>cI#YGd~qPq@%UU)#^K=dO6%KpFimR*c_``+dQVEomojIzI5+ zU3p)z`DnD1Lj!MphAMvVfRw3lUQ!0z8;eHpzd#COj6E?zy3 z^8K$*ST_XsQ)_2S9_vo~__V)RRjc#t4fZ|oKKVN^N-8%M1_vP!6+uKIx?}t>HTfrf#@dZU3dG* zI~^4yu8IE)uWGyz)S%}jzn>ruttwI>wS%q(Nh!6f`JQ?Le?)IIYt^417w2j1&!yJS zoJLORCtm5{kmriNoBQ?S-h%t5^)AH+5J=`*+ZDR^Xx8gYXvWmf5hYlr`}%ghT%wTzUspYS zIDM(h7EXydmv|U_ghgo-NJ~3Ls06lYm>r`ebKXOJlGm&KN$LHIoU6l^Wu#v<1Wg1c zXeH5Bee=n`#%;;+G!&OFqv|&u$6|SpXo_Mu2)ia97qdcRizLzF0%>gv_+|sQHJA$s zBXnkvyLez|2{hmIK=%=&bV+nWJ&r$>;JW;F3IsHem$%5M3{_efTY-{HU z8wlN*;HrKz-uzg5%FcMAs#q~af$+V40W>`Fgd_LDRSs^KU(K^;i|Knl?dy|+uGJ}@ zekn|QtqHp{?rJqUaMHui zK2g5i>PFC|Tny;WLx-vxMr`_pyEU`wkOX6v1+R`el~#Y7cUPgbMy=e0j^gYanNP1Y z4q1wD;ow|1<)E(JfJ-oof^cKJ+xy$9epW<7JAZxU`ZvzcMcQ#iLmn4*r(i{TnyJH_ zoDtVw)nP2uZ;h*ITcjEOk+XN}bA6fO^NL$iq){9&09Pj;*hvX9J^{lHzFuD+w@$Bo zb`8V&Sw*;-q(b=E!uXvLhlAQZ5Glf1I}WEuaWGpZ9)x&iU)#MAFOdM1l*Y0PV_-u- za^nCT27w66W28}0L{ltFp_YR#7#vuObn99KG9?=)?(qq)O>`VITamdTy7i?e2v-b zKhvu`sT-}R02t-;nITMbfDC9%Mg5WL;Cf1U`y83`XEzNu6o$d}@SsA$Z~|L*V|;ob z9GutpP--Vv(tsp*>`Xk5E4tlZQP$jm;C?2N83@MZ^Qg*nb!T`ly?07K^2}D-!ybMC z7L(te)^_&gvm@TUWh?FO?xyhBiQT&8;E7Fq!u)!3_Fe5|>q-lu7t56USKjQb*mif0 z#`LdAe=HR;R&eyUNf&S9MjEw96{wIoh9p{)BuoLLio!C>5^l2_MS40_a9>=))b6Gb{Xj+(2VJNSkea8!?4x2tiHjvxe=agXz<+dPaq6~ z$4KLD8W{%JL?^`!WCF#e9LOtC0H)l@8BXG(vbXBV;M57IK(Hf8%8<V3)h%_FYHI9no8$v;*;%>ZuRmHL55X?cp%7L^c5YbXvh`fg+7OV*8@^3#( zy-&*PI-PzNT9m405@52WGz;f=caj_^nLscLezrQaQkZgK-&k<1??=AP)_kDl z0t&(!4#)4wuLU*lc!r2~9C}ln7CA*uNR8!_R&g6VB?W=ZL|hU4-9BIC;s*8njLL6t zdjD>^Pj? z7n6AC+KHlWt^`8nPLj15Ll(+`x6GvH6(Y@8sx!pWtawbkYMQbvXvqLpQvs;!p;(+L z$8m(W3gj|&^Xg5kAt4A_WQK8!=DH3N1^piH>-%|C(3!e%Wzcs)51!4S>Ui)_fU zM;8H!T7ZUyjwLSS+CxVBP>GK3#4(8cvud4g1wVv6IJLXbgOfKo+|cVN0F5-6#V)i*uO9mbJhR>(ZL_d3Z`W zTXRE^N972@p7)#aY9lu-cD9k51-f-D&)a9S^V(~!eys5~ zIZxeEIva+U07GL)%|lMFx4)*TCgz`&z^SGSF0uq`w(9)Qdog=mLyzT8BYmS@?U0>veHzI*sFIobgVC&^Vq>&wt>4q| zH~WY_Tyok}lCJ>f4=$NtW(?1F%NEQO5LuAPdEUZe%OEXcDQ(FzDm52Gae(S%+O zWdDSfr;~ey!#%S&_DQ(Ro9Bh#9HT#IDl1tFn=O^2YQ~1|{r#VwlDS+|M<2R8=*c+X zZaT{N+4Fi<0$r4ym^#6G^$K9-t!u&tiG(2tOEJXETvCJdB`_rV6+F7?5$YPCfR?ta z3r*mLXy;^_=}E(39XP0#@cn!lRs?~vb(t2yjfe>RXsBgAK}gc)V??Rc8TO&6L1YP? zF(`K?7@?)pollQL*rP)ZK8a5Q$$~_%VUQyl-U;EivFHJr^k%~YO(cL0gNqObmNhZI zu&Kjkhs-2i0FexHIM4(ZK*POKG~0ej@G(M#h?eXzBn7SnMbJg^gckiCg^eGO(l!Mt z4Y&T4gSfyzj-Vf>Ne2s7&d6XcrMzoaYE;{Ui~AioAWof zxTm+MnvNvHXxt{UL4+^F6WfA6uUH6p;qTwLK<{yW=I>v3Me(TlXzYly`6RqT*>ZX5 zU-H}ZfSA*LmYWa;1#Q4m;(7L(>BfOCWoh5|;-|!-KJMQo)YqOq`-{uxSd;VkS3(^1 zf>^`wz6RbacbfV|?B4AMEDSZiDLvbuo^71dKA-_=B@w8Vgyx@AMMcH6w}DdQud1yo zo6~=n&AeFn9i`3=dz3DA)ie5qJ9ad5P1gnWUxu5%HxM%=5go+=jaqoe|2V(E1g-S< z;K2jy_!^u4biUr8UtaEWoP9O@{-#A9#XB9M(3O>)x4W{NE8MwqE1$+vF(EKl5bgB? zN-alyJ0>Is%C@0xX%$SQA~0eb?8Aho4CGPQ4mF|VqzIHr+yAglz=f>1+2}Dek~b99L#8^i&c*yBJ{9FQ6w!m@ zTpF$gzgqU6OBiMG6sH{`Bm_k_m4l9JjnhB?OjRKp4 zZeBo%eHSOwMkMhGV>E<}HD2$pPUgRznN7_II11K0-?>+G>{2~-t|r z-nc3qw;0`jD;+6x7Cn=Eda_>nrN1GnU84W>+wfD`lEnTU>C2j*XHSmaz4<_Ye7f;N zsA1=8fVcPF%CyV*zRUUW%yHw4liJ>0NwvL>9{gc^z;5hw;_q%;HR&T=`P6?DOVP&o zxe)N(TT7RtU`rp|J_^lfGdptUchfIll)tOu&{tWz;aZ{msJU6pr(*KU1FL7utWWvI z*}|6EdL6zjtjFMWfgWT3SUs1`oUh=9;Xqw7r@o|z=S?7w+}L!+bi?CA2=WU`uqa#wrK2$Y%^riILW#QHN9F|Q}rphWp6Lt zIYTnHOu4?b*P-)onJT`{Q(q4BlUidgDEGmUui%41E}H z&aV7Pek!8Q&qrT0i;hEzybv%A#ZOcihQXx5lH%U!c!>JsFB7r{!ozqv^=YLjgl?^r ziyd^;k?;bEPBu>65s6%g)NfS$yaY3fq=JSbA6E#G%po7_#L5Q}s)6Mj{xQWNTZV}C z(!Cu4M#Eu#I(UpQHx`y{G@zuSK~Mhh#2}9>N#x7DL?F%CF>XX8>Dv(`EOozWIRLjJ zJTs6bSs3c&WPZJjKzhiMn@Kj59?{pKVMcNZDs*-QC9`KEgy96P96n+YFM@@F46@_u z4~dfQQzA=Ym9hy$1O-h}vi#BG(`nPx#d(jYGOfZ;fYfb)Mv(tPuLkll5rkN=Tti{w zbp5cVQ)m@Eo6~eY@v# zt!kb>I?+ ziHNiC59z+4u;j;T45p%bRiZ=uUw#xDJU2Foq0CHkHyS}=U|$1=L*hXUANAfn?OPK?rChYhbH0?0|icR`>4poXs`a~KO3)-7N@if>v;!H`GtFoR9*MV`M+I! z;^N}FxjXx#3NGg>GEHaIrEbTcR8uwFZXJZLEq|Q$UI5k65I7Cbzj}61fAPhUaokX2 zLlPY2Z7n9rEJ2!OW*~?UPsPVqmY$CPI2+3dI8J#?ZE%m>GxwhR>gM-bq|{X{X7@>U zJQlPYYH!4{SK02f$MxgK3|(seYunKLbTCEI`mp=l3qSuBv$;_|aBG8u$@p$q`OEX6 zw$Tx#>&q`|7h6wjZN_)z*Y7`z87l^S#V5Pw2@{uw=@nh3vy|2RHo>E9zb;x*cb6Ve z9WHA$X%&}EJmY<1bdP?rJqR6y^m&x-(BNsDfcjVnD9D|T8f4dNfEr4Mg0wJe@35=R z4qUu9ZOlcoGiN1}rAicwk=1NyOeHtv%4Nr^Jkmt4HlOfCoZ~dqw8GQUt;z(_`n2^v zp#M<`34&okx3u*LEk)!;60*Ry2u_!0$AK$`Um6m}vQg!oCv41Q6%fIj6qWQiK6nI? zD-IcvoMoHItPTWg>WV+k{PvcfD=3r4wCe?yr-$wnBrY?InEla^t(s7Pp@J~_!U`C+ za#~|}+6H%US|&?!R<71MiBSwq$HGF6|o(ZJ@X;kp`qZ-UV4b9RsOKi17JH_vM6Ei}$o4bI~=kGd4U9@02#>=hDsbZ5A~ zP@k4uD;O^_v4EAsM0@ZkS~)F{ahvFUJCEBnc58-S`h*v{bA(g+KL`%1?QH8>iy4Oe-jBY!VZDx#M(C?Pi`bKU{i1j4 z34+B^`t!u>vEX`H?z0#m-IHA!hTRffEeZPDi+lF-p(IgE5>}p5%g8tKS?d-C5d;^p zI3irQS^dxqcT0sCNkDKkUCGpvdp|-e|Gt*-JJg_zK+Mi_)IvPeM^g1W*l3MG7t0=C zcsYm$RjV}4K_(A^(O{^A4iHp^RpE+>g!2G}2)P^Ml3K2;Y~$4FMJehYxuhYt71Mx(x@i)r|e1&4g?VM*2`4x6>W+npbVJ?;w+)<|D z?d!ge-#eUR)gBmOlOhO9kdQitwAz57WW1eoYx0l39QX6ulLqJzq(~$@5?&Ws^mE)) zJ(cF-$;oHYKN(wJ3n>-T#mdUc?e{AtsjL3NK98TI$Mpms$1>@hrnEn;D@M%FTn+q9 zzx~DKb!EWGg3$Sl+q*o=@QWpzf7kgH&nDgsP8~)&`mbEZG9t8cUNlZ>*425PEX&MW z>7PC1z0-8?rPQy}n~Po#7*yg^hIJ18)3RUY`f2Lr+TW=DHFW^?_wdae>3iY-bNcqgIHCjD-(N6lv8(vUt^f=VE-9M${?k6=SQ zlr9E4!g!DIPg4oSv(=sUGLMYmPD|B`c0zDG^{C z6LVzD|7=g08pPx1=!hg{eKOtGKvjH-{JB- z;2!jw?CL;Cgw|7Ge__*1-YOcsHr1Qt&zDx6qlqNSHy#WIgwIkyahGF@ge`x*$72!(aEV zSElV_LGVBVZw_3NC}kRAiGRx*mGP)%dvYk;I?h{pKV;9Lz0OkZHq(B+ptHrw)D!=_ zz4WxJra!RTUrmmKIIaAS0asG!rsn3y=3~$EHEzm!v(Mb$vC^x<4<;*SLFQSDaL&tV z&4=x^P3JSyz9(Nb?Js(ud9Gfo@GqG1*HcZR1 z(a-qq2>*z8t4^s?YR;k2O;ONYgAvKh1cW7U(D~h!vOm3e?T@j`3f)}s>yGAk`p7BY z?%EgZ`PIU5`HN6v?=_(rxG_3-Veei0^-4CUKG~rqJ{f7?%e(Uu)8TYTOBR1+I9MeH zh(DG1!2TaY3Wt}=fP_&fMh7jKM+85GP(uzE!ZjdE(5}+r?wk1%5nf3`qP`B<5)CVX2}+Hp z!Z8d`2uL6Eo#v)|Z_})^jS^}Ebh8CyOhbFOgC#@+y{m)GFTVFvX?_ksY}j#zBGqWT z1aOmPYYS^{*=McSvblp`Jv8L&^o8FZzntj%zJCyNL;PG;@dJ}~ag(9tx$CZd{2jdm zESwui;W5I7n#%bw{#b9B7X;xL_#l!vF)}j+9K`iHLA!Z&`G?YU-@vM^zrei|pnLaS ziplIcE3<$1*mA9EY;|hfKpu*5_?ox<_-8Kz>4dGZZn`J>qE$_NIqJ6Ws=6NSMeQz; zbYI~=;sJ{@0d=vbjXe@TJ+B7*FD6Rwhve2zfAUXhe?32R zsPH4a%cEH&iC*!B$Qo@-jLwobtr3dhg9xTu(wuLl@PReeke_Cu^)O~c#6igQcG4J_ z`XF^?dAd>P#oDKPjyoMss~r?f@;2B$JgT|K$t;7D+F-Ku#7co@F-1d5^spWyoIHzO zi3qYo4Z5MkdYb`6(t={D9*UvK&?_Z2c?Fj`*>u4d5rPm%FdLTjNrWAl7jeg7ei~Ak zP!wnwNyh-ai83<63*ZQFxe%-Z)SykU7x?!rFdB*YiyhG+vl*F#f|091U`IK8Vs_ip zN(Wxuys4PAwOc@xOitUMe<7m|U+OMFd0-+h8M(??#-Y$EGhi2TDUMzKy{^dnU_ybg zRAwXutI~wWUP+D#Q7W)q@(MH}&XYJmSGeU&f2JDU6PZ$7O)Fs7x-(DYF%(wE85Elt z!(0R{^9SW~6-N?oC}R1ahF%HXrSHHWZH!$tXbKpaxYt*AR3snJ#M+!UmOgk@m#=9R zIz}fTtQRSTdD%FCza`>*j|GlqU?ig}vS`Qi*>>7LYN~Fz{r!Eo9N?OI8gSO*HmJTc z&+2*J&e^;>H##&V=W@0gE-=$P=rQra+<{;h@G}_9toig!z`3>GX~y~?U;WlVH{MSp z^6&Ti5IY@oBxI_ysJM!4q3%+s_=r8+WT7?=O^_;Uk~L zC~kM%KTvL-D4)Fj?XgvzJO2J9@sz%yr+~eR$<3#jDzx4}RIc=J>fhy-zQ|8Z_rmSG zUqx5WyEcln1=&pu{%Ss8w0G|-(`diFk}lCETU=%ne86}ElK#qtIOxc?1w!eWCfVl| zXmRY)m}VttJ!W^#($!81#G`w7@Z8c2(hLgpo^&hIK}f)UQ=OR$)+!o^?d*}7#pGlH zW)Kt-{AmGCG0${-99CuZj@LH?32zcg{Ou17Q8c8NFMhUZ(K`)gkYof=;NS?6z(joo zdjY1gHCV})R87B@jrr6u{tu(jwfA?!_TFX1R5_x#gfn5V)=K)wB-#)3#90baQd#wK zOaa^>Gt80}c14Av=b_8>T~HLm29c$`X0FErSfd=!KCUgZv2%U=LVS`f9RjBynL(5w z9&IOD!m~)<*+!!uP7nC`Xm4hkIVM%FoQlBs^_!0s&vwq2KFpjoUOPPvAJjNrY$s)G zIS6Sd83=y%d;N94FOdZJN>W-qD2`JAT;CRJ`Ynz}+ZadQWgZ$=7G#ufLkT0}{53J0wZ2%@od~`U1uJ{Cg2Ps0tTLu8uLTRmIgQ>Bg7W<$c5q&OH%4)s6NoiSua`o3X}yd`xpB-{9b zAx~4;X6RjJ4sBaTT%U`A*aD?z%uZ(~@5l4%k^DjB=9!(pjXx}3-7+lPM8%alN-poT zs+SecwhJd3N1^VGgbcuMZbJX@7NEt5myD(CvY&=iC0$UGPC?i;AgC zhU43UqZbvYQ$lA$oMswbsNvUZiUFtMclO1tA9x%K;>HTMd&Se|cOO4KY+HU!FbG)s z^y4-4Ab{%9e00Ql<5lS_`F3xA?ku#Dz6P0CR6W0Mb3n=zF1*$@M_ zmmZO@h3##Pe!sqk)UwN*IpPtSHx?FHG#}X1hpu2^f)cbGWRD{I2uUoo6innp%ZOK9 zHBjilFyX@c`uIQx%uO5!QiV@=wj1aw&iq{!N=W2;50?rg=ke+}WIiPZQUKq&J9!Hn zSUP3|X* zDlU@_hfsT{r6Qqar)9m>4qF_NtmfLeoN^nO*2XOpiX=UU(@oXf9@hAYGiM;>-nBL4R!JO2$l&NywiKff5T&UrRR zJzd#fSG|SxK2E$h{b?SE%C$C`_^BI`1J*{7c+dMJIJ-0Ior8c_Qp;MDFrznZbV^w@Kwq4>e^-T#$J13(Z+te6h? zMee@q*xi0(romhL`Ak#rlkMFFML8%o+IRV4vX*2Rg_O(nseDhz5F(G_Hr{e1-g&6w zLeD9li<)4}hO4&d3B3GP%ev8g;9nq5!B}{J?^Su+w78R;7n&{&4SEx3+Th~)V8~lc zXZDe{eQZ%hNwmFDmDy&5Arlub(zu+Glnj>Iq)OIF4iLE!Po)+hu^wigw7M+ti) z2l+14mW4Nu#8m)BXhoutd=aX3s=Z8DE)#e{)I-&Dk-4&8*kUcEcBb#K$)+6ww{fcU zM&A|5Eq&ND>CUM2ZL+T?wW5UIj7?JB-BYVX-c}!C=V8q9-cwC*j}RHxIb>XJDJv7D zMsAPLFuaQh;pJE@xI`gk<19#XUMfq$co7^(8wG_Sxy$J^AfSVmA21hbV-|Z<*K^^9 z_{O+6fIdLWi4-)_F8<$Kvm)5qdI8IR1n)-QTnx&$?C(DLZdFg73?E2fBA*Y}td`>s zKg&*Yk8HJ7hhe0^@{lxt=m6*jt^fRjNCZ@MI!*vjuvfV@ou(ePugk_yNl} z&biXiZI@l0sAaqi5CAr5guszcTK!$stmL>8P>-G>F<#_AB$Bo;S<A*vsC8p3{YQ-g^e zeIXW*&+|=ki_BMbA_I9OU^?C1VU{;XXaJ5NtqhR?#Y2l_Xo!0hdLyga&{MUcw9XSn zS5Al&rsQ~{=%pjyAP{z1jOKF17mj?;mWW&+Uy*Emaw_!S)W6o5v%%?q)#t|l@&&0} z;sN&(ACuIivzGmDSz?2U0__f2e?9W8%RCRfPBvLuZtcpT16adgPV>Lh?`Bb!cbv`9s#o@Ac(|cXy#qI z#0+?T+(QjNq+*YHD*0@pz?`{1V_|97`pZBt5&1(Sp{-h1OEKly{BY>RO~UE({QJ~G z$(JvEB9rBgW}p7o3r1nSpm{oYon;ZcSxON^cpkjZ zRC;ps{>TpZpN4RJmq*9@N>%Q>C$Zo<@o7#ZXF(hvmTDNq#(O{iYS?PW3jH?jzkMDB zy4H3mk2E*m*BZgr-~#!gun2OT7qD%DsF@T)z*@q;jA*$`Kv0ZP2N$yEdNEqc?gA>F z*fNaaVMs;NR_l+?Adg|M54E!!>2HX9Mzc_&QO>`9PR-uY6v-El13xPoxEnf`+I)QV z?ZKchLkYuu4}8DY5S>O#Fa($2zP?)`l1oMw7@Cw9AzOKwj%|7e zf2M~*v~P)i*Av1@83$b_g1D=AULp(mal+MFDkznCLH*n@irHf=#R^ZzP5n@|c~`iv zZhoXb$$=OcnK@dPyn-S+8{#oxPpUGm*A51q$ekOk@!o50|B^D3A|E>TS`HC#$q+gY z`s6%19h1=AR=eDzdUJuKP+RpSbX{=OV!G+u`s6auB&%z5S)J@%%(uUnt~!2g@NZIX zg5~h!!)N>&VQ~0wA>qY(;DR6b;#v3TnUG&VdsYtLXrHW>`mZ~=&;~mRMZR2*%dCA$$>Yy3_vnY`7QB1j z{hruXWetJqmprg~Acukm;SCkk$O~i2&(h@RLJs%{mRVyIHt|9bL%1ROT^i;tzQmXIn^x{S{Fg9cv-KzN;Oa}-wRw1V zI_fsKfFP6{CfiWs3qGJVpBxum9~U-GMEqup9!L~r$c>M-4Rgf6OcIRQw?qup8OLGNRVOV zyua(#M*V|S>seu7EcGmRqxf`OM{0?1Fjh+nJ}!2Ud*a*z!R^P0uk# z&F1_2vFpH|FM0^@sr$~YrABb!82j@)($Vp)LN^Iex=6y|S6is$*R2zrzy2%jah#38kz3~G z32%It?FydS8 z;jV`CJdxRen%0=7C=1<@l(l%La8GFC!q?}Y0}kK5UVY1=8Afb);B~{|{+P4t z=b3}kql~x=E_aGXK$1zo3qxG0OX|Q(e!LtOe){Aq=;1w6xC{DM1Vc zXi@sHH>v_xr!o2JtNiPYLf@~>WK9LU^>B~4khNi82;0PPg!5oo`2rOiO8;kez6p{J z#XYXNQx$e_hsrM}AQTLymDd3^x>PyaJ4MS~lDh<0i^m~XgK=Qc|M>hp__laFh6gA9 zE5a3mF@+kTxA4HlEk@FM2X_y@GKg4wGRsyFh;)SYJpJQ4!gEZ`}tTE!my3u=Q zDV|dJ;Mg$1DK@0CvQlPjZ?xuPXE$1{)8Ey{BeJNhMEdPp-K()oM>l#vz;Pb?qP#y_ z$KVX!&_kL7kXWlcL>kFl6i8snY%pNAp_>XGXaQY8i|Ra5Zw@Yk7e(z|KAk?;+`D>u z_TPyYCQW+hj0Z+bwFBmR2no*{{6Y<~rtj4)A~{SLiPW8c)PCFtsLR$uI8iNqbjCzE z-qW6yXbr~qsuThx>#3a>B!r2NxtG58n2SsJ?n?XkSagYV2bj!+rDZTOVXgnQ0KL&bn8*vMr#jerqMhksa4nGk4ih4$CIrjvp%AG(31X$ymbvS@jXf$;nHM ztan7i#g%}Z;#-RopqJLM5G&%#?mEsv*oT>H0@%ZQ5hzZ^@jsG^wj45%i2Xm3&O4gz z|NG;_3Z-^St%%rd5iN?65QLgBs#<$fHA`(VL+#mAQ(MiJ)~ec@Dpj+#s#PQQ9>4eJ z`#UG+$T>O3Kd;w)z3%JY`+Pi6*`I89CaSXcLi?FzwH$!cj1h~2fFb#8#h{Yx(4wCS zRFj>7fHWLV0d%K$bB*z>c2H##o^L*4HSX357lez30M{pd?|BCQ^EH0nVw}G8Tl4ID z=y@5g{>>%t_GrI0Dp-K8iFd;FnW<@GTle(1+3ByQqQH}`xC`sEizg=d?SA)`2x1F!F2*_9HYAER4ubhC6@RVoM+4P?}hn zJ_9*_OZIJMkx;vDIVy^K&5b*CfhYJGp5T+68IAyO*7EQ-@MS1Xu=^ z9-2cHiL>~(m*d@wOLJtmcsw)Q^l8kWEYDc)y+DE+?&r$c{PO>{!u^TUs{X%&zvMov zCIT`{u|NvkQoJd8-`D2(&?pb*i>QYuez6XLro8Vkf*SX4Qx;5o$Ufg+TU%?ZrY~^Y zOmbTp;JQEwGmpYmUm2HwyD*!knyQD_%o702e52?3ez;R)NktqG^-mvRQpurV#YX90 z-S#2M(cm&8G!h!3os7Ft5po@>oeN?8OhWoRTpF+&L&!i?mo=)6j0dTPf7?5N_qJl; zIw&927dXK+E=ICaoB{mZgcKVY;bvchvZ2LWw6xW86h|uQ1MCyt4qeeA0R_q*gh{C) z^)!kM6kR|05z-DJj5FfacqhSmgd`GNHe-j}eHe1a*vlnzBa1*FMx5|OY`+}t;pg)3 zTk+XY0A9`>Mk}6)BuAqG>sL@aVTiC0A3e_iH}yZ&C@Gz-}2oLf?^57^ihzP{sl z|FSar(xmsCN6h5x^7c{&#ntlaPW6wAVA8EUiK9QS#r~z}ObOn^2WgkTOP58`G)hCc zVCu&;wgJtlpLdELCBd+vI6hdfGU%4e)66}_`(=%`U(Z|JuB2-hEx8-5UlxwA-{CO) zRo|T@$2m6F*Z+u7{o{pkU$t7>+FEh{Yr{%T6Bpy^cgqjdRBI$;Y6g}pl`+QY?5xOg zH{x#3lI-^F5G>omfc)cS$6R_fhO!&|BDs-viIqTeibDKX>c8kA^$yIziSTkws9jh0S&dv_u{z91^-6B0e3>7?LQo6*dd#@4`ux z;vrVq2vvqa2M8!{G`2(;>)&?2EZU<`RNEBPHp#Rreh zKw?cr0q74OKGZswUuijSD$2i@3Vt~>DcW*6VXbpMQ(Ar{n0aFYz?v-0WXPA9Q-mRS@3Z z#U%HS3{er^&DoR7Umk0vJ3^_I!u>NuyXiCqQ5@{*5)022nGtni!e4Hc%ZaET(^y0H z;?Y==A=twZ8cY%sW{D~wN})?I~>e_lX(yV&8CQk zeNontqSqh@c~Xd?LYx2+4p*A$5H$vA`Su!n+N|_6RXfcnjZ$(hL={%G1rB{e8tw&Q z#sIziK7eoA5vS*r1)g;NS=TtOF+uX8xPid~ScQmI2t9%=RGub#348-%n07 z?(A9HzGYxNuajs{zOKrXF|)TM?0Xh*)69+AGJ&)kA&GPYEd(;bkQR%o1WF!L-_;Yc zgX87ZP07G1fy4MTjXM$LFHwCrA;A84cyVL?*;(a(8QO>Uej;$6?&67cC+~fWeE;p1 zN!h4UR{f=}-uJ#wDvCe92>Z68CPl9laStf4`F(c#oF(%@G#Bt1Of}JC#qm`(y#@w5 zbxHM<`w=0SoPG<*RIXI!L<(^;42&$wqX=^JsavEE zoMHqK-b1d(MB$VBslz)#X1dUPCdah#wd>6e8Sy1kg(t+FwakHXzfK#DyKnu6?r{NgSCUu<-Ua+k$EtD6#K zN$6_06EcJ|Y~#AM1)eRmFcg6oaf+n*>uvGO6yc0B%GY!NI$=fNz^6Ojh?Q^X4MGufBBzgL5M{QCH&xBaGI-Qfps@Abbs!zn3QhNEBL zcObxY{ys7n(!exh{$RH8+b+&|j30KNnJFS%6VUYXrZ5KKp>1lT<&(3TOZ2C!Pp9Jd z-GKUqh=@4r%xFpQVeyA1H%jn$(Cwv3zJ<}p=5gwOpQQzt{iI~fOb{;}I_p^obA0>z zj$~)H$&yTH`!a15OQ!+ed0{M$Ii_ulY8Wo5CCPaG-tIB?*2-!?=-W_iI>;)U(oR<- zRYN1fBXk1PkD#Cx&n8Kum&u#UGi_4;n33AMeJER1%ljCUiV7iZL{j2-h18W~7Q&ji zTe(UjM`Ijq-`YV5{jg`1Cg>{Gc0f|YCF}B>- zZkC^S=EI_TSpvwxFnU5+Qb7Sg}{^Vlcv)XYy0?@gJKAG3~u`;i=7-G(2Fl*|iOs zmJD@$yZ_kyA-6QQ`%EM{#-D@d$0pS;anh%Jx^hvI9j2SMboln(N zDlkG3RG{grU-w*e>ll{T?X69FNFnm7dj#!w4jCvnHoFhRg}V#_vH%2<+!W z^>Bz|AX#$vi;V}r2RTiY%XKyQhj$$QdM59b=DdAH{@;|6aKsQto*XnAYIBRvGPbqme$DI}9s&fy1bIEA9@76;CVA7v zBao|R{C79U^zD_{&E$RUq%KF(-x-lMA=l=G=!Ib@%G^*bR%VANzS;PnQfLOoX&@e% z&^>JP1YD#|GN?0FRt$FwIo~vNSBwFn1Of3$h)x(FS-|1pXc&d=FDjP20p{P*xm-P( zAbU=+UIz!mG9l>v5BBn;yrjGoC=jKFBP_*~!{Mn)HpzH!D8zx#0#T(($}wp&AG!Kg z+!L($jQH&1Mf3+B^Ty6GQ79M6lWw4cD)}XpKHu4`dEao* zc1=tsL1s;aK}0^0*Cr7L1FOdzme$UCKu*B=eLmedY5%DP7Y@97WvF9sES>1#9^=mR zcdac*j8a@K4OH^DXYL!nJ~SuM;k*7tq%?1SILUh<>tBXTMxC}-N=Xb!>K zFLM(B=BG`C%VY76t2Y&)-K@4jw4$uERcuByj-UbvMwhW8_8ysGGUzLboCTFZHb_#J zWHIo!L-#n_x_`jD!q-5au63Nbl2S}!vV#|Zu|WU=NN^h$efLv7zUcQTnC)9?pGbO> zsl>m5$=N2wFLRHydAO8xv6_~&Y!cgO)sVap%K~T(*;JA@s6J|g!6)feitt0<8iK(mUwlo_l;w5D;Txd|GHeL%E(7U^H5;lXVdvx9N)BmAOl!5Xw8LD&N%0qftd38+)*+S*ntbcaJ& zULaK-O_b%crSiD;idG2ab$6U^<*{hgVxQ$hpkcOf(ZYH3`#Tmw%E!pNmRPzX9!G}RA)g|A5) z0|`N?q5Gb#tz+HmpsY3~=u%prhWp$Yy)rg-0w)Ipxb;kv!LXmUnBq%ldW-E0d>kJ$YYQ210 zZUhY-k_w=OlcCIzUG%er!ng#I-*_Rb>i`mk8b%4VNedDkJ;9wtH19qhFx^eVHJFU& zz2dQmevC=Mw(>NaX_)M5+%IkOH9uvHfH|X7?!f3zbK_@JPGH)Dwq&CW9!0^|T82+%47c zc#`}w|J5W+`pC@r$Ahlb#_IIOuUym-Obo)0VUdMUsMk;7jn$6!r_Y(p`)CcSl(XvF#v z52HVop@Hbw4G&sX9vd3bI9fq2RdNwZJPer(@T_vu+lr9&5KasN3Q|ejvXLzof^SVk zaQXhJetea==X~zZRD0Rncq$Uryt8)vt=m82DC4!<6BFw>??adN8P+X@hAYTXzy(m8 zI-Td6AF}Q<_$-Xo#Duco=sQ{lhhJ_>h)*{UcfT4w^A6s~yyKF~l9$$2@7=Q-#drFl z`JmkOf`x7Rv|6NA!NBX?$oPc_(_Ru?!n>8P6snz(JOiaNi zgRvDX)HzYhJT*G?JRwN+IAl%bUqFJ1{XZL&fkyc}wlKO$5HH%6jmSyn-=6%5-X>=^ z=3fK>7B(UkAWE>?dujke>5ZfM8>F;E`HD2{TmDTTsjjn@(i7mbLf}3?!#H){>Kc{Z z%k>$1nO6;Q>ii5MZDj_WB(?-^18ym9p};sHOy?DX5}qTQqCh6;hSmSNlQuBWYLTRO zyOR71N1@+x*$Nf}R!;@Ylg|8<6=xtsa;#9k93v@rI88DsDL(PK2;@y+^hrgON#Cm4J2xJK_rW-e~lw! z<5if1bqk6tn1s~HSlb&&tiqKAK1Zg}06WD7hVPxV78w$FadEYKzPG1>Iv={HHK_HS zkr%Mz;juyRxSJXTc{K%HMJC{+20Q2+c>*zw?>xMXf^I0+5m8i%#ki&0w&mM^# z1QM1PlQ5zMqg5`E+@h!BsTWEo_xkD1fxhwHtHssQi zjTrKYfzYoVOOTL)=Tx(6F!4pZ=_WozsF%}a-a_vyOpL_^POh8?fRejRwQJ9LxEMij#J@^+H zWY$WxK+r_Q^}`}ldYP${U^?u&y%_auqa2Kkt9_T$#JJ>E7zz^R)I;-`2LF@GhQ+x^ z3V2^v^RjASGkxYd)To$?azAI+Ti6QdvsoCe238zsGi0M&l*%orH6Y<-K*@s%^@lOl zw{4jZAXOh)7Jgy(mvB`Xsylf4UGtlN-5LJ1Tt>62i$dxc4qfBY&c^^*`$4MMymvDA zDF9{MFb;bP$G~*Jsc3O%o?dO>TuK6VF!*vQ%j)#|>|(_3I+;Nqd?K{or`{%tX8TzWsp$|Ez>rHPj43m*#Ukj zQxdl5YND^%>pEG!b)-#GcHpll#;>(A^41U#oog6|WKLYXa$cIG9E-_z!OxwbjpboTdZ+d!gR{|J9^h%S-)Whh5T3r+1ZrW1v`H(b!YW6jD@x$Kdg0>M z25>{lEg)g@avKRfd*A*Zav#$jljy*GZjDbr<=*T4&MY>)KK<}U;jI>$e<9fT{J{7- z3Y~N}%gorRZF0TP3tO=+f}RMOHMwX6voV=yz92ht7bSoPkqVUEcPh6L_)yxt?~<1_ zC`6aWo)%$60q>zL(?qnAVW{nZFTlpcz(~k_Tpj;5CndUD$o`i zmwNbkD)csc<8V|;(k<(<_d8_IgUvC!jFkrxX(p+{U|s|hL{mzZe_-%oMu6t@#^I*! zrQ3esMj*d>8K5g*c~EkAQnfcn4xHEj1!Nk~4e~V{zt8wMW~ls04aN$8tJ7kN3e6(= zCiK7M+w*bIntmre8+iOide$%HC0FViX!M!^(F!Oicj+gI>s;HLb|~OwR(d645yG2Y z6KU9P0GNmx=@BX%Tl-?mc<Ixx})MP<{O zFJ1+GUDlPdi}sJ|>y+2mPJ?->+2vz4t0C!=m7xJ89;Yx@C-5Ji7T zm%fJXt-es3`RKlICkkfn-uf4@2jQ;v;_ZE*|3C$Q@=~~q41SRyO_c45SPM@`(%;Pm zM>Uy+u{$UkB?6n09YWgH_~pIMz6-y4Vm6BdodzG2mvfPtnsiN0B)5usG%^8pbhmwW z-0OJwb>7A6+t95jawzTyx=(W5ZN5MJ)b3F?Xh%aJ$W!NKT8IXs9>00_F_1{J?5 zFc8Xpx(Gg`9Rnk}Z8`ehLepa2;sP<=@p9o>_qQv3x~rA58Z-Cx+GPBa!1pmKr#{A*WiECn|{RiBPE z$~QfbSZ^VY~Z}f8?1gn-n%StFrneG&JNX}M4T(yFTED_g=I0&%I zmpoOa1U5K|t(>iqt(=|jRx&!AHEf|SIX32*%=N8AXd@}}Pb{ey0s$gpUQ`+JoJ zW-!LeTC$PuyR;u+UF}gwVFOWMYCO@}x=iqo8x~ks_B0y(yn&nhoBeBq+MNZ5rf~4D zFw89{x+lDR2=)2>%~(E+1!b{DU&UA=d#v`GZLiy{X9$>+WWl}gg)?@=hmk+qC#TM? zx(6@Mcmjeh!^XGfTux-c=oc5NmxJSGgp$VXqx6DCaj|Zc)dp}$yb2FEejtCPm8^K# z_qgdZQ03_p{bW4i(x-`pN&Y!8$R3^La#zeD9zT2;Ix z^^EZ}%y!|?+@+Kvg>3C5&Ei;8{MB|;m`ejQpPCQPyfRZ@Y(vu%_D0W@I=45szK<9m z#i6{S#{T`|+?}G<=jWn1!7#4H%PKt?I&T z>s0l1C>`(ykusc=*UN~8>lhQV4Rk34D##otMa0R}=1G9E16y-4km-a3WN|i!Tv9&Z zPClca(~jM zwajdogzQ$lZH0k#2_Zj)J){L8kV*;*N7LJ=c0sHWAtNM65Z(sX46RV2DU%ZCqhPV# zAWUe#qEAdRBEgCj7^Joh3I@nocM6r~nuwS7(5$G@udPVFy`Fj!!kd~srkkmIA5IC; z1<>LEM>sNtsVEyG&I-Rq>g6*{fJD?v)A?b9-fS)N!;xyO~sNvF2pE?82La)1uS0~vO z%)jqg^9;b+x)r<>KFL1YcoRD&0&up%yXTDZl%AX^fORRl?|Bq=O0 zWCtssaw+haW{Cn$43oAYwX*>+F=o@kIa#tHh6s5^1}xfuCW*CyBy2`*D`JaajV~&& zVA4QNd83Az_+zc_^~K9hJ|@9reCx+2na2MdZG-=qVOZ#%xqvVC6r#U zmd=fdPaV$WoYs+;?J7F;gKybi%wkWo5X-(r7+&noXZ zHW5*j)!K=w=}N*8iGcA3d-Q+93_XZ)OMPXLj#>7z^jk>UW-8*$N@j5G48uoFhSlt!bF>p^?dzKPH_Xk=%*=N5cDsZ77YJE+4AUT(L5WEx z-&TM}@;@y1;nimFInU)N+m87%TZTlWN#>ifUz6!)=z{$t=$~c^ZqM~kMboE0@jMD6 zKlXfHzw|b@o;or+iVftMRju>paRf<1QeHk4W)%oqwDJ;#xEpmlI<$+Fijta?F|jGk&Vmx; zK_;S0vVw1wq?SoM$9$Zp?~&&#w>cqKhls&+SRrT-91`1q9c}|v&mX9Kna=c1+P>3m zg)H9l-%m1utT(U4qcuWfQ!U|zOz*y7*)0$V+1x}}PY-&V3y!2@(y0wV(?&D~n}3SN{IlcPLuS!xpBo_^M;!LEe<7`T_#q-nz?!xqAHC83fS~iHV@Lc8)vK7C z&}Hh$wkAPLGQLM7(De5a(veU}1XqYWH$dkNNJ69C0IKUX803R-C%H8V6HJI>r0a(I z9SR=o0TRWx2?npugFxtcFwUNwHXGC`*(-T{=gkP^v%GUyFED<{W&;A^E&u}5vSn^A z^$V`VokjKeB{jUHNYKue(;-jv`t*wP_d|S{Bs&X#amRlxl#~)k3l$z~5Yl4PvHI5y z@F8v|C8cK45R?=b%|}RB$U<1*%*9NBe6`n;EFuzhWswM&07Oja!s75Z5WB3~MzaSlfK?l+#W5@jDSQu2Oz_J`j zW8w)H%an6c9%U2q!Ino~T`|E*&oG%wDyd{JW67V{@x%^LPu! zkd*N2STa8*m%3XKtTjs~a*nyJ}u)z>|*?Hm@c+@Z(Tlqlr}QJ8E7IOyLi_l!buzI!C#oQ>Ly21jjHeuV1K9 z9aEy`+!>>%QIH;4tyCN*t1}Uh-OudA`h|UzFO|ZP@(x;39LP~ZsNVp&rY;E?InKO~ z?wrfxoUKzVQ4#eO#N~mY#c>5tC}I`!m*hA%NK+JBbX?uph&yuKZLfW{+#G;Z8SH?X z&FS8I*Ckl@&*$qQ-i6KI!271^5}5`YHB%}Li-*h_SsNB22?0Wv430nuII;pBHYR+G z2Iyoq!i4lME*ci*L?JSwT$7L0Q%f%^If;RB7SU8&Re*6)6!GD7v3vfyl%-2sSt8@S z+#4(^k}sTgm9USkrZiILv5|EQVaH#`8{aQ8gj-He|NQgT}#)gTTzHTS@WVT6hnQz5*pBxQ;k4B&=)FIwSGbe0* zYXuoK{W~jse=aJIXn97U8ZK2dk)yEy%;mUEN96zWj{(mW&n?Cefr`!A0s^zWNsdP1 zk~GkVXrJ40jr1^gdCoc+P0pK(vz;B5#i>vez!7s2<@Z68bHtjqYfWnC`;(*YcK4{K!4W`7b0xqtpG zMXW{LqIH;w@GuI03oBI31k3YbvNQy?E(Hd8{H_Y@crS5Cxo-ZTjirP@X(u|QZ*mcjnHUy z;ysEtR}_d;ep=z%TB!6Y*J~SVpu3Gi7G$rA8>MPPArLZkusC%p1L;cIv&U95+k5w) z+vobA(e#~7KkC0K1g)L^=qsO__|F;6Q}JrX_AXG02C?PQN7BPv`98W|y;kTt3qbkq z({xdFFoQbu?P51*6$&-7n!l)BHn@IE2;}3d0II{Pl1VXhfieT;K}%_mX=;Od>ke}E z{w#KXJxaO%he4uAG2k4^{A28VYbj}E>$XrBCOb-B!}fDqr{%&|xs2&=4%3ZqjMJ4^ z0eny*JBs{Chy(*tV!H{;q|sRhqJ}+qw-vTe!pg*l)wND+Nb~BvM{}C zVK-D(r%1c#PSFn=ydI%d00^#ZKtsXq0Mx%o|KQ1K83twkQl4;clkDD^=jsvdffYYD z^L72yFw@B=t$lqZsq8GC$f4F@TmFWEA&~;GiX}HdlhL4*U{!$qGMLT22N_9rohct^ z8zdehV7>a#M-jeC1%RfH__sdkjQwjKrc%lpM;{x~)D}cnNlG2c85R_%il4X_mTj&G ztzkA2Ex42Ft7`X#e~r+O2!-hhqg4U(iR;3tWPaT+gSb$@S58FeUC;qT@Htxzow;+L5J>QqnA4`^(M~pDTLmg z3(&{gff;nbps@)eg* zZ!FZ}8}At+iQzhU2Sv$~n8aj*pwZxe)VfSj%*#9^=U&e{_*k6siO5LwF9D1a?18Lr zy?{SeX7;`O&~6Jw!p;n`w=Sq(<{>4&6jxEywSNmtybZ)-ZzcA8h^=uZgz|xrf<;Um0u?3aj&fb=*z{qXBQA=qX5?I z`{GhYe79Um$$|oy2R6@Ld9>p>ck)^+_~g5E0C^)IG-hTQCyU0#=W;IzGC@4DcQ@OR#37?z)51c7(-sJgsLEX5~ftiHbI0$e<-_= zA48N2e5fo?AXTASA`YLZ8F0gC`st{`NYN1=n8YQa+1hl}PR>0YBez`MJ9y^xODxrIKW$FAIl zJLC&s-!23pqh)}!w2F*Skng8WOS=iSlTx9WvdPI+vctWKC{>DX6vf?xyqg&Od-9f( z!#MPQnE?hWNzGG{1?V$w6sZYGI#QOzf>q6<0}HbLOC8{?RoRY8DzOqc7ycS9GzL^^ zd`Dc$Lk6x4hqZ*jb1<#sojsae_3)3}Lp%@jf%9`rGFJPA+>j5vrQ%HBV!+qet+#y6 zxH8~mptEytcp=2{n5~WqfA`3Jx1adrVWXh%iI5+)%^Lsj)z!Xtcgam632de23!bu-43XDXp!Xo(<@uUjEg&L@g3(yKH-#i;m1rw+4gv z#Rl6qx(g=EwX03J?Ak*XEqhH;1HJrs*dj^odY_5%=ddRZoOp$SHH1gtdQ+Ok5iAfa zdpPsiz$$?eBH1s3|2eakH_XSLbPR^JV0&OnZ)nx4Oc3H)abqIGBSk5e1wYfHjqHVT ztH_+L6)8Pw0d98V5NZdYVHZLQAHI_9o>cH8_ z(h>r&xkyRJvbm6~aoSt_Ab!T}@r^7>A4TEZ9kKPnSkg!8i<&$7a4ll(nS~GZHJo>@g z^p~xCs^<^-y4O$2a3sujT?ViIt*)6j|L;Vkihar>GIzc#z1aC_=UDMd@akawzsL4@ zqfS6B$NJMf%%uxq>ZvCo>PXv#|AP^=Pg|Jp$2Sm3US=!ZOa~@w8SAIT1(?D}jaCz+ z025hQVv9vA@G8rvE=v?{Baprlc;DKxW-SO`<3=fg40F>X#mkIra%)s8HHuZ8nVE!C z<@525EWK<=aKFh^H}Vd8LXVAp<93 zX^KLaej%x=v4-1Rhmt5Y@?sbtF9n;;wgI%_uW%X&xUqC;grY0kPf%s!iPxDR;@DU> z!EV(J(TVWFZ$;d~wxr}D6ZiPoXMIlVldZE&DorXKDcP;%Bx@uq%k$|C=ztNpv{eoX z$}4(*w{cI$^c#^U_2VXkU*>A-{T0~hYj#MCNZ6m&2&%eUF-4?9pX^erZBu!)3}){> z@|gd!gVfG_gN_Gz$8!B4<-qfHF~>%d3ha%Vni9Kb&z`I|>6^0Jg+jEi&zQb69g3LIfu`cpGrH<-PNYLM^S3ws-7qF>JRVDuSp#8MP}hS;mctBjSoz~iC7sUWX8 zSM742>R<03jeZ_0DLYnuzFB*9INsz<k3!K`0 ztXekIG7>P9atOfH(K`P!(Hl#H@1FXJY1TQWV>dAb-)WpNCo- zf)d_CK346zFDk6F^K$a6m5((3`c!*<4 zox+!Mw~i?%E@d^7t=;`W=261Gg|hh9xSw4EkbKzzgXo}N+O?At6e7d*@+FHuO^TlF zE@(fT{Zc3^FfRI}B<7h%+Pg1)&ZldKYM#|u=Sf*7uC5I`SH*ULJ9dhJFPgu`;q1zV zS3LSzJ-O_^2Kj$Hh&w*XGFK=G!j;t+8W^OO=tc;o#>k#NJ4yR={_KS9DbNtYpB#KY zFz$9w(s*sjF*8;=6!gcVm#4I3neGS2)q-3WPj}6QcE_p5%{k5~qp>oHw^NxZwOP}C z>)xIZ)7K?78`qEt6$i^zE*@s(i$gVZfnJ8O5~y(8&)iZPe6f*9b(t?~9K+ZwegpUVUCdQg6j?H`fF5|A+T|s;`94>Pz*Sw~!x+Aez zELWy@IHJ8J63g{NG&%y6os*a6Tc9f>roupX?X|+HT&c{~8+(zY5}b$#bs4He6?K!b zgkB(_{>2-&De#V zR@EtN$NZu__?TYW)nWFSes)kX(C=BoB7MmZu_OKS*&1R3|MG0z!z2$*C9@+^j zuUAclr)t;lD z%yq-!U0sK>@>Nrto(G0ZXT5b_M~|4x^&Sz3qcf75emA;P>5q0V->>I1=*DRp#W>_D zEn%wrzw31$oKKVnR>ZBNfJ1B)p!+MHc-BfwD*_7EDPXVKau1tCJ-GS3cK7q;fzNWl z7T-onmi${Y=^Ut6I8IlEI(9UxfB%-yI?u|pxyEOos^d!?auQz5)!ZsSEIE|y7F4M8 zSKjMVLk4bj2cKVJ4yN*sf=9cKU%U=7YIr$0{r<&}G20ud@~g72ziZw+)xP z{H&&2Q9rwvz0NwkWgVvqtD(yK99?l^u4~dV3*lT`WOQ^`Y>t<$@=eeW(Q6Trk&*H7 z@rZ2jCxf5fA|3Ky21a1I1uV&$jD%++xzc_gD6DUONSTl-k7$_fT^an_PUy?^2`-yd zokQi|xbKy9o@gsveXO4?23HcQDu-^}!ReMuO40c$csBMZv3ka^Sv9kkS6+X~nM)_( zeQ`)_{l%krQJkod=rbszSn~bJ%-quz|7r{>kHT=oz-*NuVklg`>ih6u@xr~SV<9g1 z)0zGHr>(QYn=jtWJC+l7W%}1gWeeZ!nN$b)>~GQo7sAXM-^SjQut|rO5z0t7H8&?B zQS^_uFWpAo#U6{f;@$CQV!8|4S5l{}Xl_E!Oh5#!H*>ItG-gt3TL6IlzLd#QfLnPyK z5%fdy+pPlHAtjNL)NCczaytByU!z8uk}Jkor5ygaPi38>e5xh|_w(uS(Tw+`%hQ{B zy-OKYkd|!WtupN@RH^vmmyqGUyQF?1%As zt!H`E_oe5y(cmcuRpyZo3Ec--|4sC8n|c1=;9)mE8LJ%_;=a-vJ$+pKzMA`s&tc$1 zw;e}&|Nbl$eMyq2%hRL3_fUm`we26~npk}u?l7R#h4YHP7Qr;Qsg#^E$CO@cr^RS{ znGCDy2kQ4|8rl>p8Op{xatYt`Omk7Bu38mQnv-J8Inr8JHi(gm9geG+`4@Wu=}wOr&Dz1fM%JiRn39=0_rqFn3t=tV|8ebW?9?fJ=%nPs~bSDYKn5!%rXU z?TB^UtqHBMm1o zDG**_b7vvK0c`W5#J*I@nLBHD-*K-iYt26Ru=%_r$a7D6YBg)?OtoNr$;y=e{;>R* zWZuQ8HE}SYK_1l*njyO>Oy8y5E&Y(}?VK**yBE^o`wG zBLO!v{}&rwK$11?v%5g=Y<5{&bn1;;UuATC_=QNucyig z<6!FeIbCRSqG}bYR8=TawK-N7m`jKpqr&V9o*v%VM7

      DGo&R_jJ|Bh+3M=sJMO z-x43OR2T8@>iQPkU=^Y@RKLwK+F-z}GN+TZOzK}e!B+?CW!V$Ddho_tRj=#n6dC=&#t%YQ-!NU1UTq(q zk;`ytj8c3to}!*Qj%aAS_~*_!*Ys}i<+Fhj4Wi{#jg!-0O!L9-+SyZ2Lsm0uAU^mH z)^vgw$nfUT+W=(yU}$=FnyqolZ?CuZ#lvYuzaXENHnKcXnZtHG?YLiZwPzJC#cq2< zZ!1MVqv1Eu&NW~yAIf~EevhX_guMtpj(Jkj)>l>XPVDB&_t67cuDxvfCk01tochi` zaCdc8?1t*y&(ecr9m}2bqG?>@)}(dzE13m>Yu#E|`F~j#9T^!H7NM5S8(4dv=!ckCc2`bf<-N3-IjPG9g_sYPx zNMtgbxR3pL`m=_4sBFD;sf?RvM0P@|iy_U)%;2ue#-su&(l`b9zd3j9eV>iK9cw29 z93z&}Om^#xGt_)JZH{$S8IZJA$GX0}Zwy&V@846D4_r@Kic=g8G`(6Z zKN@sh95K1)tljPY#IQ?d+M7@jR zsMK4RaA1DvNBr{dizhWtiH$zQsyXBCys`Ql3x{^YA}Xv%)-*Hp7{-t#!igXvqN~6% zv>FGN%#ckNvP(<*zEmfxs8Ijx)0n2S_E2Vttiw?567sXs(gIi!W&qCVyz1Z}#KLqf#RT1UEn-*0ub9D1h<`%ox!};- zdb7WK_G1@k|LT6;3gnX#=`mp4*D@$ly6rx__v*vlguhC@#jek#tB`Z-(oWAk?!F8( z$WXig$?2Yix5%5QdNV_3tLCAuY)pBsK)2;Xg`Oz-D)|ogq`^4=tKp=_zoPPf(8-iq z%gzN6lhe-l;1jYn#|#Kj1LKEPeq?||S`Rp^c9~F2{Fp*h_r+*gW`LQ3Y!ZhxG%SZxEp*G!3z+j+ zimj@(BKWnqb|q6mzyUt@M*j8fEYpwk!ND2>j~FVBN!gFFR4BZ`9rL{Svws~4Yr~!c zFFmCNwl%G-L%K^)|!6#;$7^+{Mr$y8BI;qlQZ>zF!ECV)i7&t z5n;e2|3^(Din(5>e~0x)-X-=sc@S+B%bm)HijD-sV;}#LIJANY83sr5_NhA=`z24> z=HzAt3hOwjF;?lA_s~Q|3L@^NPFr(Ysp@iVt{T{_@u~z-ymj5XaqROak^y}^i(wzV zbTlEN%m}K81DfzXqb4=n3MMUfb^B4;CWIl#9sD)n3 zHqEQZsHgd$XJpVb`TrD>>-@Oo6MwXJIkP`fnlv6|1Kmu11XDa5ge*vJ|DRf@?P4KAG_`%(Q z*oJry-Q6A29TW3A4sm1faq4H&jZX#nU#TK~O(V4m%JA%mHfcC?J(Loo+3sYz^1TR@ z(eHjae2L@v;Z3uv1tOXR1-a+q1_B#xFQ=~QGmjwCvVwzhrimAGdhcgLA^ zn`qFr0CMJu$jNE#V5;3iY!pH5mQfkk1nY`}2GG(>4}1#pk9lj9imp-iDV42Y!`II% z%b51e|4{|<;N?T+W?X|0<)AR7`8`EIdCenOI6p5mKg&aC7PaF zozDxXHtcb%)2nj!QFK^y~?ZXyOEWz%b5d0SWI6tC}sqEw0z1jz0aI z|A!^R64E=$ysK?FT%=RZx2>RWXuy`2vd~HP=em4#*BNiE~%{bd1~mNKMY{y^0gEG9h z`Tk6eFdJpmk2a7?+<>)#)0S2H)f>$~L`$Ik+VXV^vhMk>AiQzXutwLOBCeIj4KDqM zqsH;RIX<#wjX@`;f}17!GXX9)k%wV*UPs#eV9-;8#Qj6W)dbeKEH@SE#^EvzD&tZ2wKQ&}M-erxQ#h)UUJ0)s{eO>)F-6T%aCIWUM`D#>3 zl`oT>1CXmObQ$2HnLQl};K;oV(_v&Cd;|C|FgPS9qkl>CZR#JO^i^ zL56(h`f+e~I-&rgK|1>Gx-^?!!JJF3D)S+>a{I^dqoSr*!aKqJQiFiy2xWWaKfkab zN5nQZ=rlMW#@8sysk#NGR2|%PoQX_q98D_p>(4sIwoItFvozSK0fkt+OX0t;G2i-> zA;%!2-LLdy*n`SA!vBM>uI~o z+t2UL-f>xcu_c)$Zlo`h*|NGyLhqr{1V?QOgc0L|r&z!0HjRehR)x<`$ z=J1y~&SF3QTe{)^WI*>YNbB^)eZy$&PW=_TP9)2=lyts^_FMUqR7Hq}eTBIzTZM%> zB0-3m>_={Q%GeGN(DzK))-;%|jKYU_mN%`eb@FrHNwYs3%&KOh-`#dH^LF5sz zb14FzdP5lQjXA{0~K`OV@Z0r7eq3F-+*3C@lq;4Q&(xhdfG>)t9 z2=xoXMzyprV~H^;Clw1-IZ8X{Md>o77^KfU>Az4ei!=!4tpd zZysm5T^g%5$qEjSmjcVp&wrb9GHv2E9mr=4Niux2ZmwpA{KRD_2IyJ%v=#F4QE(l% zhp!d~Cer3d*KQSqc3r^FDZeGy<5|*SnN#f)Dc3J6Yw~V4h`Izj!ZQWLD-PhLsvHvc z4bN8>*y0x%5A6mv;##1QuCc@ZQKf-^Uau_}H(4Qyo1;7bOy;zJEW=8YXiH zD$7PMP?x5M;9^PgvSbj&VFIXL1E{A$Bihx)NyEO)(*E1Netx^=TOUEr+^ij1 z;#y@@4v?{$`?mS1h$CH{oeRVjnNaCojCc&ho;LRq7IYB*w0Fz+KY0%iWBk{f8Xo|6 z)xn**mYa`UJ~$fh_9+`Up_Kwfnn3@~&lR{tgrTq61&)3}l!7*Whnf~@Ux)Ef5gz^g zmfq;ga#*)$nS}^-%4CZXM1HwSA+9nCr*Mdkz7aNtaL?wbCzRI49s8j$XjnbpyX~7e zPzVIvLa!x;C91ZF5?;(HPnz%Hz(OjJtY(-3-P_1dU#Qg3d23MLMpA_|Px0f*e*|cd zi@feW;gr!}R}<}|GF8-;UFM&EUR&T_`a(ajC8)(aC;U+hwN)!Et-Km3D!zHHw2gmn zu(YtfNghwRH&ECinm#}~%qp*JX_^m!?6?ss2;eMfGijIO<5no-OAiVMs*-o%a;?!0 ze+cTiPe+(6HUri0(U@(Fa`nF8yHxpY@P1{y+ta%00C)f(nYwOU|1S@Qz3^-qG3EcRfd&#t2y&1d@Gr0-V z&%2xLg6wroBWHAV`_Se`>bCk;OPAlPUOTOY2k=sgb~V7J2~q)>r>NB{_~v`~D0zrg znwPUBWugsOuqVo^a0AqBn&q)ZSEggqygp*H*}flG^brcRI>Ho`kj+mGQD9AwuH;T* z9CLi7M?fQcyqz-mz2jtf6uWO}7_1$J28p&YW-dqxm*J%;2NkHv@$%xMkYfe^`Am&_ z84H_Jpou78Al(EZnDPf%2Qoami#lq{dRcQic|7gZitUnqoPVZlRI#^6QjFF>^xTSh zJXQmnZRVRleAHa+Dy^RQIWgC!=>wo3#ueN5!ldhApBOW3V$nI~@R}kD!YVYP2w8bL zNf9@xgNXT#v(hbxpv}g-r=BqbRbS`cHsdNG%aTIZrp&I~firSS9#wxPKb^8m1s~>m zNa60epRr=l^T{)EVi(+%*1EI(K%7_%pw|Kh_7(x1sIcO zpElgb!rLEflV5RJmq@Rs?V1~g12yo`bIWX~^3fa6)+s;zJp9(TRIhTYZ4jYPVeKKR z*7yr(!>|{;k;I@*0Z#U9?psu00(3gOuunh-j(Rfj_eRKPsY1s_YVLBngze(G$U53G z4a65?!SUHt1;cU-Lek}N0Cn(1Y7m6+M=+p9Hl>&u2?k|d2xLq|;^X={pPyxP`0G+2 zVg70!B@8IRpMB^jrzSh_8gVj*&lSp>w5Z$gEPR=lata@huUs#>V3yBD&JS<8o-4b2 z?_ozrCPHGZkB7EhSly!4J6w>_!Ho2%o2!-`|Fwb9TW%VVCP9jB$lG!qXUC`9wF_b2 zyGz^ag+NP4NA-w4jM}#)eyo!Z+9#56r73atKIpjLzQkwKe7!VqooJ`D<~B8RKq8l~ zivns?vr8+Zv=gc-M&fkM4i*CP%C@*E1@HVQ8=7!fmW@%hlrgq?IRH*(Skx!646~XO zL6aMsBRk7|B8XinTvhnmA`<51*;9XP3RWo*c>30hbNJf$9CuE<>z&xJikrVn=xeP zb-MWNxQN8V^>W^ClG69BqX_M4dLGtoN;;Frlcz-qze_H+R>J~Ur+um5bB$lzyBK-T z54$7w2ck94t~!tF*|OvV_}#D{0hCSg`~~+T$oZDeQvlQ6;hXL_b5Vc9F!A>6yg#mi zi>(G&j&DBB*ls0f!3lL$ulFH2GvX`R{A_;M1}W9Njj$#z(3`)rzfJ4J&QHXfS38&<1ZTlzD+bKQ-U(lt9rR~(vR(L z)oL=83E}uw%Z=4|jCGo^c#3tFBWae&^_Ed3%#k!gbU(6hpH}?F?>oE3M^i!5DIZ7D zg1_weQ9!2%4?e@EJhw=E6mm_w2fYVtB~6KmW$B{^=|n;t3MLx$uVwN8EUYyk@j7$x zqm`*nh-iEMM9pC5`^;4${2GKD-CKZM9YpE|jppS*1)F&tFMEw2xm25!1GP+F*>IG@8zUIhwY~e75`Zl!>kclewa}LJL zCe|~Oa4Q~xFH-J->=cUZGrm}}KJi#}9MbAH(iI!9vd3HXG z)=WAlHE0cyuQ!(*8^f00RLmZQK|w9LYHB!iJ&v!!l)^t`i@ES6mOy6s6Q%`i za@A!R9_EMPPjK}XGog~zqNCd0cac*4iVMHgWOVE;iT9P+4KhTgn$3g~uap`!^;OGj zxJ^o-;pA3qdvXCj+RX-flS6T}^ND+Ms68zN&S^Og7ReqAA8=D>Xs}++B$RMDRma0j zY>6m+Obt7U8$f^wxwfN0=KW#qaY0Dg!t3lRe8`yDi@6zSn_ItLzyuI+Z;|mKN`}>8DrCacwPF8F_eydhMyLov5 zCR5RJQZ6ymOWg16D9nFw8u6v}0umQfOp_{`4r93wB0ul+aM~?i!7M5Zk~4fgeF^k2 zXNvxSFo$>4l#-bwjf&W39A8Faa$7Q}7cEaansQk3`^B#0(@fR%#W2J!d_Mzc`A)}Hh>z4(D6|dE!t?`7MSMif{S$l(JK|N8P?oaN{y;o zJ9phTfA}G?yIP>M8Lu*P`gG;nK-#ZvV&$=D+f)ugz=`@U=i&n|3+Kf4Zi-UzEN3+H zlOt2)_Y^A~pE@SmO{76iD$DbJm|aVLju%eRoS21IygPysxmN+bYR~7Tz~bEQfC53U z37?Hf#vP-PH9?*rn3}lX+1{Q5kFv|zm)cB0A@OG8SXgB(X@-JJt-b+7=Td8;*|mJy zQ9YuFZV|4HV#*NfYYeZkA098~&}) z|I$+U)9*s-LIES1BDz63x(rmjU?uJ$y5#IS>6Ur*#GkeOuiu9-T_-8kt9xC(ddW5E zHTzya-ge;9j)O~$oy(YkACw=Xgr-1ZX;M}?J73oF(|PzVv#e&&Gv}#dPrwoy{IT`o zhvo60^uhI8yvVq4Cl1a9goTvsVD6p;BWVD2dpiy2jk=wMgr6(fL5+-lpt+algNW)= zz+7vKUJXn_7;C1z|H_WdQf)VFC*q3v@OjP_1hiYvw4*Rvv}+m{%k(^&GCbmL*>eiVkzA|1`SF$}m-8Tx(cN zYfjL?VIT&Q<6*jxOXbLL5F;yB;i6*x+u%27g8xR)g?wUI91L3l>=-Akb5f<;AW@{= z^xp`q-p~}q6asptmzzZ?G|MXGHIcV%oqm$+93z3r{>nS7TUVsIK9Jd-O&C(UD<(a+U`Ez2|(wK?cl^x(TfSpKPH>Z02 zlw`V5z`wUr|mtT$0$b^4QKO z&1``ThuLFMBff|Z`rbyHt>I9a5X3|;U1FLUvq|NfuAgE{lWc5Rpj$65?+N{gPBSEP zVnLsDDCb^}$Kw+o?k@Y*_TTSsB0*G#^qGU?0kWQWi#7&RDxln+gZ6g7d#Bu1*^&H%MlQ-}SRNPP%p>2gY5Jf-` zA1&_Sp@oGKH>c+DACJ{rc`j|Ftdf`;k|o3Cpw524C25K5V{_daYP1nK1^Fg;uP4=PppInY}FM-Ah&&8BqnswYeuL*E`SJP^hDmFH3 zu>s*&cj#GSi`O=i&Jpn?x3Wzn|2W~#-|A1E_3tB(4e<0e{(MV;DDKDAuMV2h8xUmF z^A+R!CPV8Ew>pwXG7cN$XkODt?Trb>9S_2=X5>irVh=6m5HWEK4M{mkvvkROHWo=n z7Sd9EaX+UfkFbb2sixEuL9`#N?o@U@d*Y)6lsH>DSsqVv;l1yb^Sp}-j0uxF*BYJg zd+=x#t^8m-zc_w;sI*KzBCZ*QPZKq$ z!T`naLe-!WHp8(@@bJ_mcr`L?!Wp|3IIt}SBBpTEg6w*T3floD+ z8qPHy)*SG%5RjCVbU|SvIRVywH4~<3)4QzLQhu)IrM7quv9lVAplvU4M6?kqDV%YI`QKe43Rj2YDGWOwu17JEzc*H(Y+PM!Mx9@m zHfNN(^e^2FCY&;1I%prnqLY((E*3MgCni%(aYE32ldz~?B`M*(;u<`xr_X5PRATL? z@k?Etv{hbn-0qlZDXq`t&Kq*j7-lO-aAf$L=e2bFor(K1Oy3zp!^D#gH&~`dBPmCz zypU!HBumSxA4x8Z!s-w~@bH8k62Po`38w5%pb4xo>zD5C4;UW4dwr zbq@m1mIDWUKMv$;Bq=E?Ek%VR$2$uA6DL*>7v^J8%U!wf*7Mc4BKAY~X1v5|_I(^T z&%61}_$u8>WL)K(9Wi4QpJT?=>Q%1B^O(>^SbV^tIb7`EV6M_qM|Y9uzcfQ$KB*je zq$jcde*A14d&eG%RBg%i@bV;s@E7CHa5A-VZIG*eyYqv^6pFD@o4`WvgT-j1~_ROMofVPb$Jitt9fZGe$uE zn5!R~s`GxS!1f}SM~T;B`(O_zUg)8=RjE$EYNQfs(}APx$z4YwJN~Tx!F7_uo`tMb z5_!(ypCq%~(v7*^-Z2Oohsi2Ad9`!3ZmyVKyordgp^=1WcHq(9uMfA1={j9dA+kW* z29lubjnGO}awFoP-P3g=2l;L%uA7iiG(K;W$HzP)d$Iq_3uLt! z4e(=^YG%1A3Q9`Ts|@K5W$7hw&lum0Pa7{`6tyV$MSh|JQWDy?q(DJ~j*1ug3PTuZ zz!67oWHdWGd#noH#sZvZ&te|O@$N@Uo3fRrSD~ALlL0$MF&)&GDW-}QwyYYAaF98* z)PO*3vH=g9l<9mrP9wQ(aVg~T^9R{(_cs7=HGVstuG3x;=$pH1l+$uoPunAiS;7?~ zC4?7P1S6twkN=R6)u+nAKF)`j0O+{%;N~AdOkX+ z=4&rYyz7F&)j@`mMBAe8t3<-B*+cL&A674G*TpH3UJzXpIwplsHQzqq^0qW@D* z@p|tv{J58X_j%*%QIBb&fY+Qpo#X_ITV49~G*Xu)@ff|x`3*jumAl_dz#~b?Qo#u% zBZ(Ed)WaV}7ak7)+Omt;RtcE>{4TQN(NVm1wFIg3TZ~1AMKU@-Qv!)Yc*$ zu6;t&Kx_FLc;UgF#whMO8Rg@iETZNyE9iQwI^M*n<^cO-C#QFAi4#+2^QYp(!rTJ} z5qnh-6^}_o#4!b*u$RftT^|wBpxduvkH{a0xxUaVu)RpezC-SQPHq7vXb-9>oKcXR zgm>LTPHlN&QHbB45SD9Xk=&lqU>+cDnNg0>it|3$efblx5pVjlz1$+4gr)gXJrTWTKlsY!ThEV^_%?zp&*``4SBou2iu>=rb~l~r%qye?yk z-{+H}9FMFfw?{P>>t~^q{(HK)k9QL%tH@4DzuNf9>%UVsm+w^{?sZqLyRiem!k2Dh ze^69(J>Ql}1YNu&5De4R!p1Ij#&TE@h}~G zH?nw-&A!}FM`?^-{Ig$u(GhEdT-pEhA`h9hcdTCMh71Gho2n=5XZUIoW zDS1VsMxCP_Y3Jy{qAHx@1Eal5ciq<+77^%4z%cde+S#m-d|6i~hDG03B?Wp1gCmyptjUQS{K z;(pf!LP~K#?{kw)0%Y07O2CGlMCA6i3u&k8ZLs2ET28Z;M@u1JfUyPsupBeGd*mTc z2=`4hdQnPBj~dARpl7j8)JI#Qy-##Tqz&3_oFs`Vf31J5qPXTjXy)}D-aXO-FH4Wo42Lm(X55V}|D zgFufeS$`i3ZnSvW9I(juUO~C{UJ~Xc`Wrs z!ygP-&!(-iMQd{#VRLD#EDuBU)07;z=rtJ3Qj7LsDK!8C?Dy%_L|4!=u$~HBR zjDl_A)Z6X2Jqms*)yo<1HNd-;M3)t+?U6gD15aGcQ`z2El{otY6Fa_1lvS9Iua8dQ z)>Oz~o}$e%A8Qqe=x}j8->4t$nlo@-hVyf0Y4mUs(r5maT@3gOz{2FAep)Waoi6rm zeDjw61s z3KqF!41gCTQghp4Ebiy%?2j4Ki^w-uv?!Du@(2r)l9K8JX{XmKbzdb!=d#1Y4#UPS zh40?ptDtu<0jEQX@%<(s*xat8#W}!O#b(IY<4)Typ^AR{9{V1Vz*#CQ(@WTsb07T) zplMOzarpH1k=sL~fX2!nm(v50JOkqax*0-Bm7xOw+PBsqEcmnEw05*Xc%r^}&>`w( zRBu@Kxm)4}ZBFc%oOt2VVQR*)d|A=xa%E0g832Dbwo352YG=Xe4LM*U{V3+wn`*Av z!-Zj#A|m$)qLo4*Lyb*NPPSB1opWH!Fhb1jFDjk%kiT4CrqO85$W4i)j-Yv^LFJa% z&Z819)ZTaey$T(l4v$u5+HuTSA*afs(5t?+(u=#8jtI!YSqRyHp;Wy|wZ!Ys5yT3> z!1J0&+wZ4CKMs>y_;TE%&~N{*3Gtm5u6_fB*z>X!K9ii^nfmmYa20T#s_=qAG*Eq- zsoF2yAyHAUc&ykKOuGS@e2OYF;oy3fs1ts=H86Qz8IjxVtOT6vLr++5FE%+DnONg0 z*>@$6mbn9(hDxysa82GCX75#m9P)SRMD9m1vqPZqJkJeJ{OoMR?VLuRHp@6%fFkJO%$VY*ahA@^Sy*rE z<|dEg5lx87ZwTt!Hiw+fUz$;zc+wW{HP}U(4R$_`#2EtxctNMnN+B!`3ccpQW+(S=Gi1h?&I|Q2?)cf;ZPn_&AKYLI zZlYFjD4D8=c^58ON3`zuewFHB3V@Sd5nvGz@URe$F60+1V#K7Ray#vqf&=Jf@|MN- ze0HDyPy~AWT0oaNyxhLlk^s#WW%$Q~>F+6u_h99T@d@#E50l?CZX}k!Sv61OOLC3u zJz70RS|#XcT<7lR!3fpM)g{lC*h3@CP+PJ+IPo?|ld1pn+MPjA~C-DAd9hte5a zF>$~3jYM5%3R#4huPQT$Sph%Mv}QV)TRjAQG0Q=DTqF&UL~{aG4|Sre@Ww+#C#z894IA@Ne$*SZ#mhAyH7L`F;v4rx1|yi#%-u0VSUl@bref z;vp4Ap^an0j`EhnjJK$w2;jtCMdp!bcRZhn-84U6a)@0{sv5c;w}VqLPV|&3y{cgd zTjD4kBE_60*yn;Rq8}R{R3}%^c6RePKHksP%Q`D70DY0YG1_NFVc!5(Pp85dMbhV zhz}DT|2x!OS3VyK?%&2>Z{m1tV$~(ot@eJtUo&}N7CUYpfOebjXQ4cAUIlp(`ncMc zD^`VfTag+bvX1DJhG_Jx%plnUG-(cBO(Rgo5I~et^b7vf9f8HJ!H$={^|dVZYF0Uj z3-bi;vCAQAy+wzp-^BN%eeJ4MC+|KK+gN|C!}AF2ajXpB<3Vk2u3l!10f2<>dxg5u zXq~LVlB-oPGRxZfsPBIjTfpCd6R}|;<6KH&4P5TzRg#_-OzUOlY`^G<$E~1%hvdAw zJ`)4GuGD3?uW@sou->jK#j`HN{EUFOlE)v z-eF;plLtrDw)AYpO%{s)F#WYA-cZyX+AK!R9w%C^Wl{pj`YojEZoFn7s}f~+5Wue)`_RnAJ3az=d>RJ=KO}0 ziYVyk%|ZIg@f2&`Pu#|m!bnG5+Pk%<+q@tv+YwEocRJW(Bl?Nh!~wkB z>+pDqV^*7E>O!}m+#aOS-MBbzwd)+^_WNV+<5u0}4!_ey%hjeV>k*tSLFJvL^86MC zI7|epDWkr35qCHn=3vn>pLqS2g_ID8hpz~N6Iy>2&`zmO;mT>T*<7;-;{}^>1SpbD z!f~Z3891uud!Lbiu??R#d>>ziwg|e+6L@`WH=4D4<9wPR;5+E$M#=>Oh>H;eQvKU_ zmoP|pf{cI{P@haNBpMT~vOw88^t&V8yow|jieB9}3SSPWT7uJRiN{?(|SM;jK@=ow2kpAfs)XzzMx??Ud$ zik~N1R~xnK0CB_1rykeE7eaoB9D6)mpz^jnsR5B zZ|B!JR_eoaT>=WS6S_BHIxq!eSI}kz=8P}*g1>7T?0qLuO;l=&l9TvGBDoq2XTD!lhaZ1V;Tu^n4j#4@y-sb$Z}&0kFI4=2ZQSwo zH|b1eP3PT9v3u-2)W&)9b0Ma7Q#%6O}dhGk_7=Xx^~Zl|Ep` zJYFo#sr{h2vU7h^{^q^h^NZWXyvN14MixrJC9)3t%o1&xvWsyI>kI(iYPk+09u^S4 zjdkWl1}B&1u?v*!?}h0)k%^w4O08ZG(y|+N*pR)}_C0DcpAdh3IGEEl&d9xiuVL-3 zZtR1dx5@GhU>8m;@ai(YJaG=jn(jX2f0plc z{w4@dRrCzx9g3K*`El|W2Eh(iTL(VtxAk_CQ7(kUqu5_Ah=46Ijh}zDT5gW=Hxcy1sM6$T=vzx3}>8;bKj2*g>K( z_Z@{ER#ss{X(zCw;^^{RvzK${>(}f}?@Xr;10VLA4Q8;n+$JE&=}I%g68DQWMaxgC zcXO9WZ`%g>r_QUZJe3x5GQ&{}qFmKA_uKRBhx7EcjN`T)gKERpBqzVax`FeGvCz|<^I5Jf>kmm)P>3Jh}(AE-LT}nJD|S*O>A6t-ADvFXUv;aYCcA{Z;wh!aK{w7 zkR3H^mvpWA`4Dx61Nwt!^&qu%KzF_j-Lrz<%@?r9&4j zt*r%mo+l_CuJk-}yaJ!aC%}GrZU?Gfx&qNO%)=>fhH_o#(ffz#f@9?)sEArKU!V-O z$Rr5hY+=>jrEbv>z0~`}o{cKc@DmhnYM=ean$w)fQjrwPR^6f@8a}7JJG*Fo(geF0 zDX-qiE0L2=v(!tuy(Q~btV})oV|Mm3+>|qycwox=P`bp{e3wy?vdjPXWfjtCO;6NK zpXa2HG+E9^MynT0^&R&N6_gZ28X{FKjLVrWc-E>Bf_Tn?;Deg{TA;=q^umApY)ey9_kB)Nk0%&7JQauHv2nLRorciLj5 z#xE>k;4Es!2kB1_?X)nvU~lpg7nyIWg- zZhp?pXl}k|OIHl7Idkd#cJ}9Tdbo4Asc!=fv-gWAS6pEopYp_l*$aQlq{1>nFdQeT@p~`*x$7Spv zG*qo>5=y&I+fvVgtOH z>7s0Ci~fRvi>FYL1l!yQ16NW?>(z|l`5xR4e8_f~5xRVKy8e3NocU&lQJDl=8am^NQf_n zlKbXQ_a|e|;nw$LC_Hs@$X#Ix2~}(>p0g#9%SS_pu}+>ht6%E$Y@D4{9diBwNMUOA zIzBdEBjaDKZ50Bx(Dxq5-8ZDJfgPY%BGI?H_qQ7(LK8c=hQ;{coP-b=s`ET8R_E%c z0g0*yxcc@Zw9H&eGT9WJy7r@FgZI~r0Rq|$K<&=XqO6s-l=I+Dah0rUmDJ9xWDQ{C zZ6si3N8%Bn&pw~{hD8O*c2p)@(#YQF<*RT2Kz-|Mbgi%PM6IZ?APx` zQxZ-x_>cLBByhSh<^0QDlFoBa{`2CE+$R(=zz$x#L+AX@FaVOMPv3xV2DMkDe}76J zwV$n#udLo$|1j}B=3Sb-z)i>PFFo+d!|!K|{LA=er9EQg10O|`jx9u=Rk`UkUr54# zt70~s;PD^nCvX^2xo0Qb79PdfgjwW*7 zo+MQ4P^OMsFz3zGBynUf{CIcrX#9m(mFwy4AL}1r`;)e_vV3c4sm{~-tUcfwG61*0x;d=7UK#xgFctqF zE@ZLTO8;*w7roqINOCuIt*m`vlR@9{KSENF(7L&6WYwIR01WIWyerh(9z|gj1MSfBYM%9MB^6ASXm1-q6T7yGCnIqLbt640vbVJY#=a`FlNH z%)tOz1@bIgEMmQot)D2;|MZBy2rk&jjYN<6gsS(_>Msd;!l&1GlP_MQP67rc_h~aT z7?qbZnDpmMtif-gbp-#uCQdY(<`r-7Z+tqMHR%AWvowg%a94hAP2bZCcbWO5w;ws^ZyuNUfQRhrkNXDsE|JyWaQtc?v zZq0L$o$&~$ZL+a12LCbZ=Uj{Q4eustB24A3KsX(=23Grdtv0zj zH&$nrpX`zjD1I0a%Nq0K!*pIStDBr!%@quJKkjh$__D6au+#DWz9s@PGviFEN=88u z1u^n`*Pi_>DUr9fM;Vb_$=4)bvrsy*>=Xoooy*__X>&d=}%%1Ln)y$hj4TjS1yi?l` z_R)QZtZVnH$fu8-=ZaJp4P1fMe`bzJ2fmKV!nX&6goLjG&f%+*EQ*Nd}1z~6JYd9X8qB~54P|7-D@tu{Q4zC+um{h z_o7Qy9E`oH$?ogRl%K=>i*{ol3rNgyFczi0;JQW25nhb{;*7TvLK04~(XbWcQufmH z^tVpZDB+f%x4!_nGdQx*j0WT%XjV9`s|R5vnhO6=(jog*6;6HiHxH}lMJ&AF`v*azIl)C{1i#zBqB`5!5YO0-}4?N z^?v<|iSzyBznF=V1U+rSb0+N$n{JJF@hn$sqJB3U2oXOt>`}1=ZY3;EjH%yN)B^Ee zSbt^ojLvfEe`iw?C9LPv-^Kl2pgk(a7RIUhbBLy|;>db&ahY$!-Qg^3L?fU~S^2Y} zCTdp%suG@*{biG@8{uUuZ7C@Fufji4_;#-(q_@a+ba|6%FW479)0zDX~ zxLoE@*C=b%`+nWY6mbl1?@u!h&_*AnVJrBg|{aC+3edc*Bc0%W&$ zxdSEl$&a-wT)AZgp;7>kc9Ypkun39WN7&Nz%KG(@oweh<=d#phB=^<0j;)h}G+lH8 zR}aqEe~v9<=YzTZloVvBdldfB%-jj*0Oe^#ipUJWOg6e(8EsIVqc$=;~%g7(i$ z%oy|rm?f=;C@ix*%A-f)XeU6&gYOPCAUsFBP6LW=ega3J1 zIO)a8{qiHrM?5|ht``K1PT<)fHCTM--CjQQXmM?6xjn%2m|=*leS>Z{DkBCm`h)KM zyoF3Hy}n@5Xb-6D#Iha-X7N4DLS<$}yWOOuo$L^;+F6z=U4c=R_v={evm)KhASnkw=w^v^oSV{KrI2<;n+0%2baC>-lH8<{Ca{5Hfdp)*y z|5w{re?=9wZ3`&fAcAx=64FSDgtXuwIdlv$^pFyYgdp88q@)5vI=~<$>5$Uhr8FW8 zh`@Jvp7&ep{RiIpVa`5lW}SWReeS*Y9oM<9FTf({vVYiKxcU1{ZO+NFvp@L)zGnvl zzP+44M{Pxc?b$z_Oxf)))coEC{pB?yCJ6T1L zh7Gup)-}f$qj{{Bc6R}%t0cD9uvLDxexW#ZR5BaoXI_fZx2-^#lT?=)3UXH&gIb?H z!tB-6nTkWY2b~4UiH5ROt?GXYa>ODf4f7|m{@OhG!$LP|0-GFqBv}z}S5K%cUkByT zE*g~Ovm5B-u^&mUoy2I8XZfNlTrRwTV-b(@3c$w?xI(L>UGH9sGoZv~1m1 zQ-?9=vhY#Uf&g7UK286fs42xz;_sxo48k7D$U-_wEA!4lbvc`@gqk3Xv21Bmv_Kfs z;2mYqsHYP_{rf#{ye(l=l1?bMjX+?BP=Zt8q2b;se;olbUOf7gRvT$8z9sDmlN3|C z^zk|S&ET7;f3p;{-D{UeI=Hw=?Uu=~W!*&7H7iE->Xk5&8BTeB9cUKcEQfo!}}PofNYJRS^x)=8VXA(>#gR!_pT{JDl4A-lxgz6pwZ> ziz&@j6vlPaBWs22>TL*6iI%yAMe(c)mCJqxS}=bepfXM!o!PlW&phl?4%1)q`_fpJ z!b$&P*roOfB?MT$&VCO(9yw^exVkwXz8>Z82n|reA0?(5GAoR3kD9x_asqMYul)a& zwYQH*I0PR*%951Ke?0c7)|M2au&e5*H1LL3Pb%o3Y%RLc8F`cLI5H_(V!`3VY_1ur z&sh?bTC#k7*mix|Xc2rmDJDjfZ=IFTdW?zHwGRr9Pq-jF4qgB1JBTVO;yh;0&B>wH zmBx87yR0Tx3mA`5mRmYid(pSMed`la$?T7wOs9wTvx>P!x^xVs_j*d$y6jWRS#b^>00lx&+vXw7~}QcX!9 z*5@6_-s-oe`C9BaoNmWsA7ca3FeH+@?>C@c2c;w~vtv^SEhOH@u(6jr*jAX>3?xr_ zj;C2SEiC8Oii!Q+>|R*iawFJDoXS4*!wh3CGB&kDya?exN&=$gl9U%tG{ z&K^<4PB`)Kqhj)}+UM$&$)r?D>V!l@%HfpYaI^4;4LVVcDE>Jce*gR$VM0Ykw!6f_ zAjm80&$|*M=3x_q;#n8?LE}E=?57l1_l5HlMydQajT2)a=dYf-XMrW!bb?u^a1;OI zhnP-z8W=}F%n@}ay8PZ&6D<_gznCVcHmvHV@p(kV`JDTBtoOIl17Ul?@W{+d2k;w)$gtId(!w;aCOJB#?;Wl<7q*zagArN2BP5&vE? z-hI|Zk_Ms{7$_tCZgpdjE(WjmbeQo_?h>i?;AZHei{R#4_03iLGBPr0yRGTleV0fa z)uOol<>`+LZ>~YL6>DA3xJI&F4~V*oQfW$$`Y|gQ zO4`m203`LnS3`#i|E}Ca*5BY621VLmZ$-1(|5NEDh#wWXb<}O2-~-hQyVY`Xa%NUn zKb~h^UFjcpd!fJdTvh+NzV*~~_$wZhpV?b6sf9IWmO9w*6@}{aOYT2tr+MKDMG7KO z&QwD-)w6E%wPS-lD&{YQB)+;#+OkL*xg#qS7@ybgIB^!D;;_LE%0hEa7z2AGiK_4@@u`t=7N=A`w6t*n&)JaV7#Lu_!|kznZ&H?@E8)+6}{}$({7- zQ{tn45=DJi-YdQ*tR%DhTA(GoD|2>shB5E!I!#_wwU;*&ett*ZgNTk^UYgr&xd{WU zFa2u9{;qyNOVWO0^yfy)fLn&k!G>o`Q>}%|e%3H}>7*1sq4Y!VOH^Wn(sNGQWK^l9ioQxj2L~<{u>=({sN2vAhdt|I9kfYt zh!(;V1%=}932=;pxM2rQ;VoH1%|0`QJ}(v*%`4$c%mr4!E;%_iK`(r`nWyBFm<1zE zsofO9aOrS~NrP)O=&CRRE{Pwspw#QcSSFALG;C(>v=S%La7%#%`!bBzljuML#Qja7 z23lVGqe60kj0}x;unVqFz zx$2+atzT|=66mpxlnUwa3)vh27{I_8v$L}RhlOl-xnu?f;Fzuc{29f%4eSq*7WaEc zVsO@~hy4+F_|+unVk?8&AH`?sls%xGNomX^pf4?Cr{YC zN_`edMzO(rdwboxF%kZ8$mw^mZ{yt#Gq=S`Ew0*c{=K=X2hRl{?JWlIP5X>V24d9y z4z~w}sW~{@9L-nf|HEQK&JXf&m+`wDUTwL*_eUJlFtD=6uhM(&SJnCik=_Q|H~}MS z@90SHU({hud%}#VP)37s!RqyZZ$51NOWohKu{P4!bw?MS@p_Te(T>meVLDT8i*?y3 zW7uRdN>x0@k?yOkb_XOHtH~yXASxE_oDLoXUi)H?gd;xl7x=j(G5vDZ zMMe_tQT@Ft8DxG%lAIgfM5TtccK)tkKCLkGd5Cr1&8ra>cz*V`M82`Tx08~2tHy~Y zn9=(60~_)-ikIxedKS3y80wfR+nX(x%|*>P&5WK`tnA#yoltMl!c>(EcTbOV0gQ@) zhX`rvl(E}$CfkPgfw(rYn7O#Fi^7A&*^M@LYFz8O-t?xi4sDx(M9kgqdg`Hoov*G= z`6(g`^_KE3B@bfop$fer_q`v&24ST~Y_Gx`6~$D$^mUAB#KA&}B&zy9%*1PUeiTo2 zlt=TZc1;fI<2jQ>D5sdFfMW>J;~QUQyha7&xE{XZsuqN^8% zd13i>-vttjWtdFPnM+U1h{6M0MR4?)k5lpD{5`YK#0gk*?%*rw2yy zg6k+d*K>5dYVGqjxNtrYvZag_n3BkvvkuZ^5Pgf~cQc_@&ovFaYloVmuZzI}H-Aip z+VF3~$-1Po@~X0lmP!u;YbCOW?uu0j(2*RAU~kqEwwsPPX;K> zk+d>%=a_vRaZV~KcE#F`4Rjx&>yih{$jL+IS_vr5zhyt&0C>7to(AnQA1uF7%BNn7{Y`Upi z+@2FhR!)^1+xv$w#UY&#m84qya%xC=yVB48i}S}`N~MgNP*2vfyviL5^3ny@(jE+9J`~d)ykO_3;eyrO1!scTi{5E^ zCZdGqd6XWE*90}3j+Vv^Ie17Ln9l#;ng}1Ka^))QN(gS|0}@v6V+eeXlaFFXkwcB6KvB8 ziXtMEj6imf2^neSp;04nzdU>Sc!9rpo5Fe%Pgte?U}keq1-u$eB^QOOg4(}I)Nv;Y zYx5*3s)q|_jVgVQBLoR6#R=0wr|yPMgiENbyowh7q?n?Lhlp@qAyXBrj6^|y=!pxY zl|KAYj*u#nP@yq=31jC#-^W1U2$gzMrQeDAg3gJ~sF~#Vot%XYM|9Lfokgg`zI4@a zH7gy^=G2@(r3>v4C*WD|UPV}7NA>;c9?A@b3YjBPNVsB$`jKQdfsuOO_QKQqubK!h2H3|?_$dX z&}hoRm-Fj#mM_}#=uYIN!gD;uUY2#fZr$43i)Fn&Vs6p88iY4qF2MgH0e2uqS5C)c zFwH$ACEdXcl%uwHnE!zn&{wi9tA(d)Oqr!=lQQ&> z(~FW|qRdi^mr(h*XsNH&N~&l;cUvu9a$xry2SPO*6>xacu>DckwVW*YG-Tmid{@v& zw{8w9AyD`i82DtV>ZGp`Sr|TU%Bm2}s8+qU#&U%~P=8_-*iBD5IUG-j) zd$A8n(jY_`w5eVg;~sJRsS$c9n!5NY00ouVoAi)elu^UJdwTv$h_gzaJtLdLf*en_0N&}(~z4$G>*Ru+y{=5KM==OKO4 z-mJRzS!|;60A8=H2b}jtgk-34xSAW4dGo+}F>|O_(T)P084CAg5HY zixJEhB@APi5JT8mt0)_G(H1-cg&Vwss&G46FA2QjmXI@em3>d=d5S4s1v$!@*sns= z9qhE^s3=(aGZj&(5><*o1iJE}h*I<+U0rJK2OpvyLa9(##nK5$!3zOI8fB@v&JtK0 z6k93UP$T+XUM-xzgbsrs%%mPQz^s97xBE>-B~=eJc74)Ks3FqRY-I0>5y>q`b!m+J zuN+uN(qqaJ>MHmc6luNE517gRYku+Z-au}P>D%aN(s{JSN^qZ?C+W?q(M?c>U3;X>18VDU zO(4>7l`kEO-zqhZt9OKhZcx(}*%pEO636<|Tic332N}cd=j%_=D2C~gr!8-mqkjzB z5G<(kXg^m8;}IJ0_I7e0u^Ivlje6A}nRR^z$XdS$(}@C(UmSm*N02vHRW$E^;x%Vj z?7cEG6QPV=ny!lhUvlm7erM)b;JqkTR|F5-uQyPxKWCcJ7#=?4wLq} zY?8_T5C6v_Cq2K%31y8e4Tt4l-pmNNWExgV#SL+lON*F+E2!R;Ru2757M0*&`lzT5d2iiomfsyO(=*;}0?U~Km#^o}%V+S!knx`;&(jOzo3*C)ZOJMO@ zKf{SJQTrXvuHdH6!%}6qm;cn}&6^B}6!}k9g%!jd2Ytqb$k2IN_<*^?7nv`Hx{HEb z5l#aFT%8Yf_%^8c5J#m^cO*zEqDt-J3bG0#AgaU#pI15UkAJp?qX&Y+0uZU zO4|SLH|5j3W6oGX&rKi`*JK;U2{f2bKHlczIB%nY$W1RN7af`evaW&Nsn@|fE|c48 zm(=p(U%r4p)Eex%hJAxT&U;i*QE5)qvqD3?awn?HgPfHXMEE*ctIx@$RoUU>uw_ZJ9E zc3$xpPyDK}#D5=Da2PlQ6?@i~CY8BD_WdHprUK9#TLJ`6gT;D(1{T{BgEx(z=i8sw z)s^wz;FO4N%cDG-pgP;@a)7_-{+q)gsrDbL0Aa%7Gn7I9tEe%;@a&@Q_Lsk( z2A*8^5L4z~E?ABi)dkp+SKDJBaB@VbkN_-Al%;x^Sr!esxzZFYM+|th`pdM%D z$}f2Q_UkJX&1W+d^RTam^DBEoE*3EZoqVu-(?djr$zGtju2U?cxF#LEGa*+e2RY}0 zEVftPU>EvgnRE5#k-L6HA<@QXVc`RXbU` z$Bd|F%Z6*a&Gk-xq;Ph(;czhAuQqM$(Tu2;^DnU1gZm39h9Hn%{fdCch|NIjIJBOl zgd_kUHU`O02o}ihb=%?ryB2cR<)hTh>#)AkUnT%bByg=3PlpCl?YT)_bF&%ztpgPb zD>Iwl{_ocH^QZbh+W1+St!Sej{a9V!LCchL+S@#8bMQI%Y4$#sU&m*ZC`!q|ie41* z?BB~~rgjQi1uQy0>=Jv}ep2!{Xf;9UZ}p;!6jy-wAQL596#kl)v<-!@Vi3N)0@ib_ zjQ7bF(K>y6zz4uVo)I~eRoJQp){-#!B|ktZfR#-vjI-)CvyW1cJKcVJ8=8I2Pg`-? zut`c4*64HWBUdxCT;CcwIhWILTpBU?MZ*cf7<@{1yaWJ2{esbL(%QeC*Ln?T$RaPy zx;uxql!6@uCi=+W`^AQSw5D%;6mfk*Uq|>n-+gN_voLsav&^#aZzdqmhw0|J>M|rx z%wZ{cCbyeT-Bk)Xku-BoQ9v@ohC^*dRI8}&Vp4-!+aG!_Es>I%pU%a&@ZY^C9Xj>5 zi4ox&pHvJGsp4)>XW8`t>20}@;N=H>3gfYsJH4*!;%?O=4wZt;j~|%uf?QdHQ+XjY zpT%_3KZG^u5;>9+9({Z*BN*c?{&Zk`oF>h*740lJxrNpgF9>DwD7fFfn4(`&MNmH; zhxua&UxcG<9PjQFgcutuAgq=JY_IM-&+tgpnlwNfn1p?hfNkuc$aKCZnW+kyh%rKv zm@pD_kCO+$<9r#tJaIx8Zn#Ds*j4p+$)M|!ndx~Vm@(mj`c^VJD?k&if$0HdUTi_V znv^v#V&-_B$;k;k+uub7$n^*7vVF+!m+XQ3^5BOV=PY0Jn+I~?RfmBD>r=(>1Atvc zYeUY*@NdX`*|<>uM~>a!-;O82^b1_QpEOJxo85nR@p8SvfwLnA9MnzkBc06m8e=7H6Zz z#Vh0_PlX=D!4QlP@|v-mfLM7&|3Phnb0HY$OM0 zmI&Gp+W>`plBEZMlu{}qKky7M7Lwtw-=)fJS25$sXt<^4+^^rO%w+y*;%f-*&l@uz zk(S+Vu7b0SVOy6G+Btp~#>4uDm#z->GQm9oEBF-iv$LLgHty~t(v9uyhjL*I{9-I8 z%_ndDhjT{0SGSzaO=X@Oo~2+cxqk5{JJDwd&}pSjCC)Yq&MzmRZ?>^O?Ly@Os?IVy zKR@i)=C41jqW_?pPMo|aIkLh<5*fAvEguzbdgp<9ht-O!!1H?i=$OxQ*`?P7zkXNB zCl>{{a?Gr(Bvm5$%ag)6pHk6P7Us_M__d<(83z3Y)|n8Ps%xV&EXNdSu->F@`XhgW zJ=>&yzv?wy$5aFxyYph?nTNl7*aI6Qy~Wx;##lF_pZB^x;dXjtD{uvdJ?Ubl&e*6;@t zFT=sQveewP)B_oQX@_mIX`dx*EBxT*Z4hitj%)if_Nt*?G`DTdBdl*9PVZO z`Q>H5yl=5-3<&{?0g7@C!`lhL$2*hN@i7<6{;>9~vB2ZN0(Fni{NYLWN`qR-fe7-lHT~!9`!Wna(=lD=aq#bNBBtOc_!W)&_VXM z(`>08G%$X(UO_&2nOjN8%%IjBt81p8pIca{+#-A-`eWNC5>y3(_h zeNtmh|HW9K5;ZLz4-foR!VE)xO8ty7?D-IQpC9a1Pa~xja=28S6}$odjoGjmlD^#9 zf@Z%c3$bAs=8TDEhfxXA{N^Yc#WxRQ#$Q04Ko+&2gW8R;WIcE2% zs(I(b_BS&t5HgPf&u=F1&h3cv&{^$i_oWHIttBTXccE|l1m0?X?E^ZukF@^SwtR%y z_)#|gxgERIpj&o$9>*xfMUlw=0~JZx`=tO^n^IhuKN5Iq+4Jdl~* zKiYjS^W;fQeZ7&ZU!(5$ua`2imrABWy86q9r5HP>lGyZ3?vZjLcMb%Gg> zGlqr^rvkA;lVOL~eXQb^*9>V!jN(V50=W&TA%XOfjd!;1aVrtB%W0cYQkXqp+L=Sy z0ufjfkSZWbucw>61H!@?*o?EdrEV9Ev(&mW?`ikt;Uyo4tL=KtY4G9ruL(b2-+x0= zSf(Y94q%)g6W8)IRl%m0YUdmM#)2AkjrI#^i@+P+P6e(?*?Gb?l_LS58a-IJI|6*y-ksYGBWP|r%!jPK zyHm@f_|}+)O|ddsEt)r40>IE0bt9SpENv6p)Ger1h>#Kn0PH0^pZz0^qP!hT5*+d< z34-o>1l_vd7n8cB9ds(C*@T|JaC6FN9xzeDfSwfP`q6A1IG*2daE9Srw|yOn187~? zFv368KN$~b>Ut@@J^2?+GMvyCkZKIi0qSUCt417tZE{_x+w!T{L-VLp6~(T)DkFz711 z#anY2z`5<|6N|ln;yZ;9( ClSXa; literal 0 HcmV?d00001 diff --git a/web/src/components/Empty/PageEmpty.tsx b/web/src/components/Empty/PageEmpty.tsx new file mode 100644 index 00000000..17926fde --- /dev/null +++ b/web/src/components/Empty/PageEmpty.tsx @@ -0,0 +1,16 @@ +import { useTranslation } from 'react-i18next' +import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png' +import Empty from './index' +const PageEmpty = ({ size = [240, 210] }: { size?: number | number[] }) => { + const { t } = useTranslation() + return ( + + ) +} +export default PageEmpty; \ No newline at end of file From 74f0018962bde6a6e5a9ffc9a6ffdb3e1e376bf7 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 19:17:32 +0800 Subject: [PATCH 103/175] feat(web): add PageTabs component --- web/src/components/PageTabs/index.module.css | 13 +++++++++++++ web/src/components/PageTabs/index.tsx | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 web/src/components/PageTabs/index.module.css create mode 100644 web/src/components/PageTabs/index.tsx diff --git a/web/src/components/PageTabs/index.module.css b/web/src/components/PageTabs/index.module.css new file mode 100644 index 00000000..6eab8a48 --- /dev/null +++ b/web/src/components/PageTabs/index.module.css @@ -0,0 +1,13 @@ +.page-tabs:global(.ant-segmented) { + background-color: rgba(91, 97, 103, 0.08); + padding: 4px; +} +.page-tabs:global(.ant-segmented .ant-segmented-item-label) { + line-height: 24px; + min-height: 24px; + padding: 0 12px; +} + +.page-tabs:global(.ant-segmented .ant-segmented-item-selected) { + box-shadow: 0px 2px 4px 0px rgba(33, 35, 50, 0.16); +} \ No newline at end of file diff --git a/web/src/components/PageTabs/index.tsx b/web/src/components/PageTabs/index.tsx new file mode 100644 index 00000000..33f02097 --- /dev/null +++ b/web/src/components/PageTabs/index.tsx @@ -0,0 +1,18 @@ +import { type FC } from 'react'; +import { Segmented, type SegmentedProps } from 'antd'; +import styles from './index.module.css'; + +const PageTabs: FC = ({ + value, + options, + onChange +}) => { + return ; +}; + +export default PageTabs; From c818ba7bc740c9390f004d2f90d8e5d7d2c18934 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Tue, 27 Jan 2026 19:26:50 +0800 Subject: [PATCH 104/175] perf(workflow): make memory configuration backward compatible --- api/app/core/workflow/nodes/memory/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 853ba882..57ee6dc2 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -10,7 +10,7 @@ class MemoryReadNodeConfig(BaseNodeConfig): ... ) - config_id: UUID = Field( + config_id: UUID | int = Field( ... ) From 75b3ea1f05c2229bcb4fd4c177fe367d6dfe87a0 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 20:07:53 +0800 Subject: [PATCH 105/175] feat(web): update model management --- web/src/api/fileStorage.ts | 25 +++ web/src/api/models.ts | 71 +++++-- web/src/components/RbCard/Card.tsx | 6 +- web/src/components/Upload/UploadImages.tsx | 56 +++--- web/src/i18n/en.ts | 64 +++++- web/src/i18n/zh.ts | 65 +++++- web/src/styles/antdThemeConfig.ts | 5 +- web/src/utils/request.ts | 5 +- web/src/views/MemberManagement/index.tsx | 4 +- web/src/views/ModelManagement/Group.tsx | 97 +++++++++ web/src/views/ModelManagement/List.tsx | 83 ++++++++ web/src/views/ModelManagement/Square.tsx | 94 +++++++++ .../components/ConfigModal.tsx | 171 ---------------- .../components/CustomModelModal.tsx | 162 +++++++++++++++ .../components/GroupModelModal.tsx | 155 +++++++++++++++ .../components/KeyConfigModal.tsx | 92 +++++++++ .../ModelImplement/SubModelModal.tsx | 173 ++++++++++++++++ .../components/ModelImplement/index.tsx | 106 ++++++++++ .../components/ModelImplement/types.ts | 16 ++ .../components/ModelListDetail.tsx | 111 +++++++++++ .../components/ModelSquareDetail.tsx | 86 ++++++++ .../components/MultiKeyConfigModal.tsx | 121 ++++++++++++ web/src/views/ModelManagement/index.tsx | 174 +++++++++------- web/src/views/ModelManagement/types.ts | 186 ++++++++++++------ 24 files changed, 1768 insertions(+), 360 deletions(-) create mode 100644 web/src/api/fileStorage.ts create mode 100644 web/src/views/ModelManagement/Group.tsx create mode 100644 web/src/views/ModelManagement/List.tsx create mode 100644 web/src/views/ModelManagement/Square.tsx delete mode 100644 web/src/views/ModelManagement/components/ConfigModal.tsx create mode 100644 web/src/views/ModelManagement/components/CustomModelModal.tsx create mode 100644 web/src/views/ModelManagement/components/GroupModelModal.tsx create mode 100644 web/src/views/ModelManagement/components/KeyConfigModal.tsx create mode 100644 web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx create mode 100644 web/src/views/ModelManagement/components/ModelImplement/index.tsx create mode 100644 web/src/views/ModelManagement/components/ModelImplement/types.ts create mode 100644 web/src/views/ModelManagement/components/ModelListDetail.tsx create mode 100644 web/src/views/ModelManagement/components/ModelSquareDetail.tsx create mode 100644 web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx diff --git a/web/src/api/fileStorage.ts b/web/src/api/fileStorage.ts new file mode 100644 index 00000000..e7b476a3 --- /dev/null +++ b/web/src/api/fileStorage.ts @@ -0,0 +1,25 @@ +import { request, API_PREFIX } from '@/utils/request' + +// Upload file,file storage has expiration period +export const fileUploadUrl = `${API_PREFIX}/storage/files` +export const fileUpload = (formData?: unknown) => { + return request.uploadFile('/storage/files', formData) +} + +// Get file access URL (no token required) +export const getFileUrl = (file_id: string) => `/storage/files/${file_id}/url` +export const getFileLink = (fileId: string, data: { permanent?: boolean } = { permanent: true }) => { + return request.get(getFileUrl(fileId), data) +} + +// Get file internally +export const getInternalFileUrl = (file_id: string) => `/storage/files/${file_id}` +export const getInternalFile = (fileId: string) => { + return request.get(getInternalFileUrl(fileId)) +} + +// Delete file +export const deleteFileUrl = (file_id: string) => `/storage/files/${file_id}` +export const deleteFile = (fileId: string) => { + return request.delete(deleteFileUrl(fileId)) +} diff --git a/web/src/api/models.ts b/web/src/api/models.ts index 20fdf91a..f8619e43 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -1,23 +1,68 @@ import { request } from '@/utils/request' -import type { ModelFormData } from '@/views/ModelManagement/types' +import type { MultiKeyForm, Query, KeyConfigModalForm, CompositeModelForm, CustomModelForm } from '@/views/ModelManagement/types' -// 模型列表 +// Model list export const getModelListUrl = '/models' -export const getModelList = (data: { type: string; pagesize: number; page: number; }) => { +export const getModelList = (data: Query) => { return request.get(getModelListUrl, data) } -// 创建模型 -export const addModel = (data: ModelFormData) => { - return request.post('/models', data) -} -// 更新模型 -export const updateModel = (apiKeyId: string, data: ModelFormData) => { - return request.put(`/models/apikeys/${apiKeyId}`, data) -} -// 模型类型列表 +// Model type list export const modelTypeUrl = '/models/type' -// 模型供应商列表 +// Model provider list export const modelProviderUrl = '/models/provider' export const getModelProviderList = () => { return request.get(modelProviderUrl) +} +// New model list +export const getModelNewListUrl = '/models/new' +export const getModelNewList = (data: Query) => { + return request.get(getModelNewListUrl, data) +} +// Get model information +export const getModelInfo = (model_id: string) => { + return request.get(`/models/${model_id}`) +} +// Create composite model +export const addCompositeModel = (data: CompositeModelForm) => { + return request.post('/models/composite', data) +} +// Update composite model +export const updateCompositeModel = (model_id: string, data: CompositeModelForm) => { + return request.put(`/models/composite/${model_id}`, data) +} +// Delete composite model +export const deleteCompositeModel = (model_id: string) => { + return request.delete(`/models/composite/${model_id}`) +} +// Create API keys for all matching models by provider +export const updateProviderApiKeys = (data: KeyConfigModalForm) => { + return request.post('/models/provider/apikeys', data) +} +// Create model API key +export const addModelApiKey = (model_id: string, data: MultiKeyForm) => { + return request.post(`/models/${model_id}/apikeys`, data) +} +// Delete model API key +export const delteModelApiKey = (api_key_id: string) => { + return request.delete(`/models/apikeys/${api_key_id}`) +} +// Update model status +export const updateModelStatus = (model_id: string, data: { is_active: boolean; }) => { + return request.put(`/models/${model_id}`, data) +} +// Model plaza list +export const getModelPlaza = (data: { search?: string; provider?: string; }) => { + return request.get('/models/model_plaza', data) +} +// Add model to plaza +export const addModelPlaza = (model_base_id: string) => { + return request.post(`/models/model_plaza/${model_base_id}/add`) +} +// Create custom model +export const addCustomModel = (data: CustomModelForm) => { + return request.post('/models/model_plaza', data) +} +// Update custom model +export const updateCustomModel = (model_base_id: string, data: CustomModelForm) => { + return request.put(`/models/model_plaza/${model_base_id}`, data) } \ No newline at end of file diff --git a/web/src/components/RbCard/Card.tsx b/web/src/components/RbCard/Card.tsx index f86b1c60..eadd2916 100644 --- a/web/src/components/RbCard/Card.tsx +++ b/web/src/components/RbCard/Card.tsx @@ -1,5 +1,5 @@ import { type FC, type ReactNode } from 'react' -import { Card } from 'antd'; +import { Card, Tooltip } from 'antd'; import clsx from 'clsx'; interface RbCardProps { @@ -9,7 +9,7 @@ interface RbCardProps { extra?: ReactNode; children?: ReactNode; avatar?: ReactNode; - avatarUrl?: string; + avatarUrl?: string | null; bodyPadding?: string; bodyClassName?: string; headerType?: 'border' | 'borderless' | 'borderBL' | 'borderL'; @@ -63,7 +63,7 @@ const RbCard: FC = ({ } ) }> -
      {title}
      +
      {title}
      {subTitle &&
      {subTitle}
      }
  • : null diff --git a/web/src/components/Upload/UploadImages.tsx b/web/src/components/Upload/UploadImages.tsx index 2006ea09..77291e92 100644 --- a/web/src/components/Upload/UploadImages.tsx +++ b/web/src/components/Upload/UploadImages.tsx @@ -1,13 +1,12 @@ import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { Upload, Modal, Image, App } from 'antd'; +import { Upload, Image, App } from 'antd'; import type { GetProp, UploadFile, UploadProps } from 'antd'; // import { UploadOutlined, } from '@ant-design/icons'; import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface'; import { useTranslation } from 'react-i18next'; import PlusIcon from '@/assets/images/plus.svg' import { cookieUtils } from '@/utils/request' - -const { confirm } = Modal; +import { fileUploadUrl } from '@/api/fileStorage' interface UploadImagesProps extends Omit { /** 上传接口地址 */ @@ -17,7 +16,7 @@ interface UploadImagesProps extends Omit { /** 已上传的文件列表 */ fileList?: UploadFile[]; /** 文件列表变化回调 */ - onChange?: (fileList: UploadFile[]) => void; + onChange?: (fileList?: UploadFile[] | UploadFile) => void; /** 禁用上传 */ disabled?: boolean; /** 文件大小限制(MB) */ @@ -28,6 +27,7 @@ interface UploadImagesProps extends Omit { isAutoUpload?: boolean; /** 最大上传文件数 */ maxCount?: number; + className?: string; } const ALL_FILE_TYPE: { [key: string]: string; @@ -59,7 +59,7 @@ const getBase64 = (file: FileType): Promise => { * 支持单文件/多文件上传、拖拽上传、文件验证、预览等功能 */ const UploadImages = forwardRef(({ - action = '/api/upload', + action = fileUploadUrl, multiple = false, fileList: propFileList = [], onChange, @@ -68,27 +68,36 @@ const UploadImages = forwardRef(({ fileType = ['png', 'jpg', 'gif'], isAutoUpload = true, maxCount = 1, + className = 'rb:size-24! rb:leading-1!', ...props }, ref) => { const { t } = useTranslation(); - const { message } = App.useApp() + const { message, modal } = App.useApp() const [fileList, setFileList] = useState(propFileList); const [accept, setAccept] = useState(); // const [loading, setLoading] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [previewImage, setPreviewImage] = useState(''); + const updateValue = (list: UploadFile[]) => { + if (maxCount === 1) { + onChange?.(list[0]) + } else { + onChange?.(list) + } + } + // 处理文件移除 const handleRemove = (file: UploadFile) => { - confirm({ - title: '确定要删除此文件吗?', - okText: '确定', + modal.confirm({ + title: t('common.confirmRemoveFile'), + okText: `${t('common.confirm')}`, okType: 'danger', - cancelText: '取消', + cancelText: `${t('common.cancel')}`, onOk: () => { const newFileList = fileList.filter((item) => item.uid !== file.uid); setFileList(newFileList); - onChange?.(newFileList); + updateValue(newFileList) }, }); return false; // 阻止默认删除行为,由confirm控制 @@ -100,7 +109,7 @@ const UploadImages = forwardRef(({ if (fileSize && file.size) { const isLtMaxSize = (file.size / 1024 / 1024) < fileSize; if (!isLtMaxSize) { - message.error(`文件大小不能超过 ${fileSize}MB`); + message.error(t('common.fileSizeTip', { size: fileSize })); return Upload.LIST_IGNORE; } } @@ -108,7 +117,7 @@ const UploadImages = forwardRef(({ if (accept && accept.length > 0 && file.type) { const isAccept = accept.includes(file.type); if (!isAccept) { - message.error(`不支持的文件类型: ${file.type}`); + message.error(`${t('common.fileAcceptTip')}${file.type}`); return Upload.LIST_IGNORE; } } @@ -119,7 +128,7 @@ const UploadImages = forwardRef(({ } const newFileList = [...fileList, file]; setFileList(newFileList); - onChange?.(newFileList); + updateValue(newFileList); return Upload.LIST_IGNORE; // 阻止自动上传 } @@ -129,17 +138,13 @@ const UploadImages = forwardRef(({ // 处理上传状态变化 const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { setFileList(newFileList); - if (onChange) { - onChange(newFileList); - } + updateValue(newFileList); }; // 清空已上传文件 const clearFiles = () => { setFileList([]); - if (onChange) { - onChange([]); - } + updateValue([]); } const handlePreview = async (file: UploadFile) => { @@ -167,7 +172,7 @@ const UploadImages = forwardRef(({ fileList, beforeUpload, headers: { - authorization: cookieUtils.get('authToken') || '', + authorization: `Bearer ${cookieUtils.get('authToken') }`, }, onPreview: handlePreview, onRemove: handleRemove, @@ -193,16 +198,9 @@ const UploadImages = forwardRef(({ <> {fileList.length < maxCount && ( -
    - -
    {t('common.clickUploadIcon')}
    -
    + )}
    {previewImage && ( diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1df2eb6d..aee68114 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -419,6 +419,9 @@ export const en = { statusEnabled: 'Available', statusDisabled: 'Unavailable', remove: 'Remove', + + fileSizeTip: 'File size cannot exceed {{size}}MB', + fileAcceptTip: 'Unsupported file type:' }, model: { searchPlaceholder: 'search model…', @@ -510,6 +513,59 @@ export const en = { gpustack: "Gpustack", bedrock: "Bedrock" }, + modelNew: { + group: 'Model Group', + list: 'Model List', + square: 'Model Plaza', + createGroupModel: 'Create Model Group', + groupSearchPlaceholder: 'Search model groups', + listSearchPlaceholder: 'Search available models', + squareSearchPlaceholder: 'Search platform models', + status: 'Model Status', + created_at: 'Created At', + configureBtn: 'Click to Configure', + showModel: 'Show Model', + keyConfig: 'Configure KEY', + + modelConfiguration: 'Model Configuration', + logo: 'Model LOGO', + name: 'Model Name', + type: 'Model Type', + modelImplement: 'Model Implementation', + addImplement: 'Add Implementation', + noAuth: 'Unauthorized (Limited to 1 implementation)', + implementConfig: 'Configure Model Implementation', + provider: 'Model Provider', + api_key_ids: 'Select Model', + viewAll: 'More', + modelCount: 'Total {{count}} models', + modelList: 'Model List', + added: ' Added', + addSuccess: 'Added successfully', + model_name: 'Model Name', + tags: 'Tags', + createCustomModel: 'Add Custom Model', + edit: 'Edit', + selectOneTip: 'Model API KEY not configured, please configure in Model Plaza first', + + api_key: 'API KEY', + api_base: 'API Base URL', + description: 'Description', + add: 'Add', + item: 'item', + apiKeyNum: ' API Keys', + + llm: 'LLM', + chat: 'Chat', + embedding: 'Embedding', + rerank: 'Rerank', + openai: "Openai", + dashscope: "Dashscope", + ollama: "Ollama", + xinference: "Xinference", + gpustack: "Gpustack", + bedrock: "Bedrock" + }, knowledgeBase: { pleaseUploadFileFirst: 'Please upload file first', shareSuccess: 'Share successfully', @@ -866,7 +922,7 @@ export const en = { minimumRetention: 'Minimum retention (λ_time)', minimumRetentionDesc: 'Controls the minimum retention threshold of memory retention', - forgettingRate: 'Forgetting rate (λ_mem)', + forgettingRate: 'Forgetting rate (λ_mem)', forgettingRateDesc: 'Control the speed of memory forgetting, the higher the value, the faster the forgetting', offset: 'Offset (offset)', offsetDesc: 'The offset of the minimum preservation degree', @@ -934,7 +990,7 @@ export const en = { number: 'Number', checkbox: 'Checkbox', apiVariable: 'API Variable', - + displayName: 'Display Name', maxLength: 'Max Length', required: 'Required', @@ -1534,7 +1590,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re noPermissionDesc: ' Please contact the administrator to grant permission', tableEmpty: 'No data available.', loadingEmpty: 'The content is loading…', - loadingEmptyDesc: 'Your content is on its way by rocket! It will soon land on your screen' + loadingEmptyDesc: 'Your content is on its way by rocket! It will soon land on your screen', + pageEmpty: 'Oops! No search results available at the moment', + pageEmptyDesc: "Red Bear tilts its head and waits for you to change a new keyword, let's explore together.", }, apiKey: { name: 'Project Name', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 39908757..78fe948a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -967,6 +967,9 @@ export const zh = { statusEnabled: '可用', statusDisabled: '不可用', remove: '删除', + + fileSizeTip: '文件大小不能超过 {{size}}MB', + fileAcceptTip: '不支持的文件类型:' }, product: { applicationManagement: '应用管理', @@ -1076,6 +1079,59 @@ export const zh = { gpustack: "Gpustack", bedrock: "Bedrock" }, + modelNew: { + group: '模型组合', + list: '模型列表', + square: '模型广场', + createGroupModel: '创建模型组合', + groupSearchPlaceholder: '搜索模型组合', + listSearchPlaceholder: '搜索可用模型', + squareSearchPlaceholder: '搜索平台模型', + status: '模型状态', + created_at: '创建时间', + configureBtn: '点击配置', + showModel: '显示模型', + keyConfig: '配置 KEY', + + modelConfiguration: '模型配置', + logo: '模型LOGO', + name: '模型名称', + type: '模型类型', + modelImplement: '模型实现', + addImplement: '添加实现', + noAuth: '未授权(限1个实现)', + implementConfig: '配置模型实现', + provider: '模型供应商', + api_key_ids: '选择模型', + viewAll: '更多', + modelCount: '共 {{count}} 个模型', + modelList: '模型列表', + added: ' 已添加', + addSuccess: '添加成功', + model_name: '模型名称', + tags: '标签', + createCustomModel: '添加自定义模型', + edit: '编辑', + selectOneTip: '模型未配置API KEY,请先在模型广场配置', + + api_key: 'API KEY', + api_base: 'API Base URL', + description: '描述', + add: '添加', + item: '个', + apiKeyNum: '个 API Key', + + llm: 'LLM', + chat: 'Chat', + embedding: 'Embedding', + rerank: 'Rerank', + openai: "Openai", + dashscope: "Dashscope", + ollama: "Ollama", + xinference: "Xinference", + gpustack: "Gpustack", + bedrock: "Bedrock" + }, timezones: { 'Asia/Shanghai': '中国标准时间 (UTC+8)', 'Asia/Kolkata': '印度标准时间 (UTC+5:30)', @@ -1607,13 +1663,10 @@ export const zh = { noPermissionDesc: '请联系管理员授予权限', tableEmpty: '目前没有数据', loadingEmpty: '内容正在加载中…', - loadingEmptyDesc: '您的内容正在火箭运输中!很快就会降落在您的屏幕上' + loadingEmptyDesc: '您的内容正在火箭运输中!很快就会降落在您的屏幕上', + pageEmpty: '哎呀!暂无搜索结果', + pageEmptyDesc: '红熊歪着头等待您更换新的关键词,让我们一起探索吧。', }, - count: '计数: {{count}}', - increment: '增加', - decrement: '减少', - reset: '重置', - switchLanguage: '切换语言', home: { title: '首页', diff --git a/web/src/styles/antdThemeConfig.ts b/web/src/styles/antdThemeConfig.ts index db1166fb..1d281730 100644 --- a/web/src/styles/antdThemeConfig.ts +++ b/web/src/styles/antdThemeConfig.ts @@ -22,7 +22,7 @@ export const lightTheme: ThemeConfig = { // colorBgContainer: '#FBFDFF', colorError: '#FF5D34', sizeSM: 12, - fontSizeSM: 12, + fontSizeSM: 12, }, components: { Layout: { @@ -105,6 +105,9 @@ export const lightTheme: ThemeConfig = { }, Select: { lineHeightSM: 26 + }, + Upload: { + pictureCardSize: 96, } } }; \ No newline at end of file diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index 479fc1f3..e7112ded 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -23,9 +23,10 @@ interface data { } +export const API_PREFIX = '/api' // 创建axios实例 const service = axios.create({ - baseURL: '/api', // 与vite.config.ts中的代理配置对应 + baseURL: API_PREFIX, // 与vite.config.ts中的代理配置对应 // timeout: 10000, // 请求超时时间 withCredentials: false, headers: { @@ -126,7 +127,7 @@ service.interceptors.response.use( if (axios.isCancel(error) || error.name === 'AbortError' || error.code === 'ERR_CANCELED') { return Promise.reject(error); } - + // 处理网络错误、超时等 let msg = error.response?.data?.error || error.response?.error; const status = error?.response ? error.response.status : error; diff --git a/web/src/views/MemberManagement/index.tsx b/web/src/views/MemberManagement/index.tsx index 8ce2fc62..68c90410 100644 --- a/web/src/views/MemberManagement/index.tsx +++ b/web/src/views/MemberManagement/index.tsx @@ -39,7 +39,7 @@ const MemberManagement: React.FC = () => { onOk: () => { deleteMember(member.id) .then(() => { - message.success(t('member.deleteSuccess')); + message.success(t('common.deleteSuccess')); refreshTable(); }) } @@ -93,7 +93,7 @@ const MemberManagement: React.FC = () => { return ( <> -
    +
    diff --git a/web/src/views/ModelManagement/Group.tsx b/web/src/views/ModelManagement/Group.tsx new file mode 100644 index 00000000..311455b4 --- /dev/null +++ b/web/src/views/ModelManagement/Group.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import clsx from 'clsx' +import { Button } from 'antd' +import { useTranslation } from 'react-i18next'; + +import type { ProviderModelItem, ModelListItem, DescriptionItem, BaseRef } from './types' +import RbCard from '@/components/RbCard/Card' +import { getModelNewList } from '@/api/models' +import PageEmpty from '@/components/Empty/PageEmpty'; +import { formatDateTime } from '@/utils/format'; + +const Group = forwardRef void; }>(({ query, handleEdit }, ref) => { + const { t } = useTranslation(); + const [list, setList] = useState([]) + useEffect(() => { + getList() + }, [query]) + const getList = () => { + getModelNewList({ + ...query, + is_composite: true, + is_active: true, + }) + .then(res => { + const response = res as ProviderModelItem[] + setList(response[0]?.models || []) + }) + } + const formatData = (data: ModelListItem) => { + return [ + { + key: 'type', + label: t(`modelNew.type`), + children: data.type || '-', + }, + { + key: 'provider', + label: t(`modelNew.provider`), + children: data.provider || '-', + }, + { + key: 'is_active', + label: t(`modelNew.status`), + children: data.is_active ? t(`common.statusEnabled`) : t(`common.statusDisabled`), + }, + { + key: 'created_at', + label: t(`modelNew.created_at`), + children: data.created_at ? formatDateTime(data.created_at, 'YYYY-MM-DD HH:mm:ss') : '-', + }, + ] + } + + useImperativeHandle(ref, () => ({ + getList, + })); + + return ( + <> + {list.length === 0 + ? + :( +
    + {list.map(item => ( + + {item.name[0]} +
    + } + > + {formatData(item)?.map((description: DescriptionItem) => ( +
    + {(description.label as string)} + {(description.children as string)} +
    + ))} + + + ))} +
    + ) + } + + ) +}) + +export default Group \ No newline at end of file diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx new file mode 100644 index 00000000..f1127623 --- /dev/null +++ b/web/src/views/ModelManagement/List.tsx @@ -0,0 +1,83 @@ +import { useRef, useState, useEffect, type FC } from 'react'; +import { Button, Space, Row, Col } from 'antd' +import { useTranslation } from 'react-i18next'; + +import type { ProviderModelItem, KeyConfigModalRef, ModelListDetailRef } from './types' +import RbCard from '@/components/RbCard/Card' +import { getModelNewList } from '@/api/models' +import PageEmpty from '@/components/Empty/PageEmpty'; +import Tag from '@/components/Tag'; +import KeyConfigModal from './components/KeyConfigModal' +import ModelListDetail from './components/ModelListDetail' + +const ModelList: FC<{ query: any }> = ({ query }) => { + const { t } = useTranslation(); + const keyConfigModalRef = useRef(null) + const modelListDetailRef = useRef(null) + const [list, setList] = useState([]) + useEffect(() => { + getList() + }, [query]) + const getList = () => { + getModelNewList({ + ...query, + is_composite: false, + is_active: true, + }) + .then(res => { + setList((res || []) as ProviderModelItem[]) + }) + } + + const handleShowModel = (vo: ProviderModelItem) => { + modelListDetailRef.current?.handleOpen(vo) + } + const handleKeyConfig = (vo: ProviderModelItem) => { + keyConfigModalRef.current?.handleOpen(vo) + } + + return ( + <> + {list.length === 0 + ? + :( +
    + {list.map(item => ( + + {item.provider[0]} +
    + } + > + {item.tags.map(tag => {t(`modelNew.${tag}`)})} + + + + + + + + + + ))} +
    + ) + } + + + + + ) +} + +export default ModelList \ No newline at end of file diff --git a/web/src/views/ModelManagement/Square.tsx b/web/src/views/ModelManagement/Square.tsx new file mode 100644 index 00000000..8b69140b --- /dev/null +++ b/web/src/views/ModelManagement/Square.tsx @@ -0,0 +1,94 @@ +import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { Button, Space, App, Divider, Flex } from 'antd' +import { UsergroupAddOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; + +import type { ModelPlaza, ModelPlazaItem, ModelSquareDetailRef, BaseRef } from './types' +import RbCard from '@/components/RbCard/Card' +import { getModelPlaza, addModelPlaza } from '@/api/models' +import PageEmpty from '@/components/Empty/PageEmpty'; +import Tag from '@/components/Tag'; +import ModelSquareDetail from './components/ModelSquareDetail' + +const ModelSquare = forwardRef void; }>(({ query, handleEdit }, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp() + const modelSquareDetailRef = useRef(null) + const [list, setList] = useState([]) + useEffect(() => { + getList() + }, [query]) + const getList = () => { + getModelPlaza(query) + .then(res => { + setList((res as ModelPlaza[]) || []) + }) + } + + const handleMore = (vo: ModelPlaza) => { + modelSquareDetailRef.current?.handleOpen(vo) + } + const handleAdd = (item: ModelPlazaItem) => { + addModelPlaza(item.id) + .then(() => { + message.success(`${item.name}${t('modelNew.addSuccess')}`) + getList() + }) + } + + useImperativeHandle(ref, () => ({ + getList, + })); + return ( + <> + {list.length === 0 + ? + : list.map(vo => ( +
    +
    +
    {vo.provider}
    + +
    + +
    + {vo.models.slice(0, 6).map(item => ( + + {item.name[0]} +
    + } + > + {t(`modelNew.${item.type}`)} +
    {item.description}
    + {item.tags.map((tag, tagIndex) => {tag})} + + + {item.add_count} + + {!item.is_official && } + {item.is_added + ? + : + } + + + + ))} +
    +
    + )) + } + + + + ) +}) + +export default ModelSquare \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ConfigModal.tsx b/web/src/views/ModelManagement/components/ConfigModal.tsx deleted file mode 100644 index e4bdf84c..00000000 --- a/web/src/views/ModelManagement/components/ConfigModal.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, Input, App } from 'antd'; -import { useTranslation } from 'react-i18next'; -import type { ModelFormData, Model, ConfigModalRef, ConfigModalProps } from '../types'; -import RbModal from '@/components/RbModal' -import CustomSelect from '@/components/CustomSelect' -import { updateModel, addModel, modelTypeUrl, modelProviderUrl } from '@/api/models' - -const ConfigModal = forwardRef(({ - refresh -}, ref) => { - const { t } = useTranslation(); - const { message } = App.useApp(); - const [visible, setVisible] = useState(false); - const [model, setModel] = useState({} as Model); - const [isEdit, setIsEdit] = useState(false); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false) - - const values = Form.useWatch([], form); - - // 封装取消方法,添加关闭弹窗逻辑 - const handleClose = () => { - setModel({} as Model); - form.resetFields(); - setLoading(false) - setVisible(false); - }; - - const handleOpen = (model?: Model) => { - if (model) { - setIsEdit(true); - setModel(model); - // 设置表单值 - const apiKeyInfo = model.api_keys[0] - form.setFieldsValue({ - provider: apiKeyInfo.provider, - model_name: apiKeyInfo.model_name, - api_key: apiKeyInfo.api_key, - api_base: apiKeyInfo.api_base - }); - } else { - setIsEdit(false); - form.resetFields(); - } - setVisible(true); - }; - // 封装保存方法,添加提交逻辑 - const handleSave = () => { - form - .validateFields() - .then(() => { - const data = { - name: values.name, - type: values.type, - api_keys: { - provider: values.provider, - model_name: values.model_name, - api_key: values.api_key, - api_base: values.api_base - }, - } - setLoading(true) - const res = isEdit - ? updateModel(model.api_keys[0].id, { - provider: values.provider, - model_name: values.model_name, - api_key: values.api_key, - api_base: values.api_base - } as ModelFormData) - : addModel(data as ModelFormData) - - res.then(() => { - if (refresh) { - refresh(); - } - handleClose() - message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) - }) - .catch(() => { - setLoading(false) - }); - }) - .catch((err) => { - console.log('err', err) - }); - } - - // 暴露给父组件的方法 - useImperativeHandle(ref, () => ({ - handleOpen, - handleClose - })); - - return ( - - - {!isEdit && ( - <> - - - - - items.map((item) => ({ label: t(`model.${item}`), value: item }))} - /> - - - )} - - - - items.map((item) => ({ label: t(`model.${item}`), value: item }))} - /> - - - - - - - - - - - - - - - ); -}); - -export default ConfigModal; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx new file mode 100644 index 00000000..df6693f7 --- /dev/null +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -0,0 +1,162 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App, Select } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { CustomModelForm, ModelPlazaItem, CustomModelModalRef, CustomModelModalProps } from '../types'; +import RbModal from '@/components/RbModal' +import CustomSelect from '@/components/CustomSelect' +import UploadImages from '@/components/Upload/UploadImages' +import { updateCustomModel, addCustomModel, modelTypeUrl, modelProviderUrl } from '@/api/models' +import { getFileLink } from '@/api/fileStorage' + +const CustomModelModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [model, setModel] = useState({} as ModelPlazaItem); + const [isEdit, setIsEdit] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const formValues = Form.useWatch([], form) + + const handleClose = () => { + setModel({} as ModelPlazaItem); + form.resetFields(); + setLoading(false) + setVisible(false); + }; + + const handleOpen = (model?: ModelPlazaItem) => { + if (model) { + setIsEdit(true); + setModel(model); + form.setFieldsValue({ + ...model, + }); + } else { + setIsEdit(false); + form.resetFields(); + } + setVisible(true); + }; + const handleSave = () => { + form + .validateFields() + .then((values) => { + setLoading(true) + values.is_official = false; + const logo = values.logo as any; + if (typeof logo === 'object') { + getFileLink(logo?.response?.data.file_id).then(res => { + const logoRes = res as { url: string } + values.logo = logoRes.url.replace('http://127.0.0.1:8000', 'https://devmemorybear.redbearai.com') + addCustomModel(values).then(() => { + if (refresh) { + refresh(); + } + handleClose() + message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) + }) + .catch(() => { + setLoading(false) + }); + }) + } else { + updateCustomModel(model.id, values).then(() => { + if (refresh) { + refresh(); + } + handleClose() + message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) + }) + .catch(() => { + setLoading(false) + }); + } + }) + .catch((err) => { + console.log('err', err) + }); + } + + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + console.log('formValues', formValues) + + return ( + +
    + {!isEdit && + + } + + + + + + items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} + /> + + + + items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} + /> + + + + + + + + + + + items.map((item) => ({ + label: t(`modelNew.${typeof item === 'object' ? item.value : item}`), + value: typeof item === 'object' ? item.value : item + }))} + disabled={isEdit} + /> + + + + + + + + + +
    +
    + ); +}); + +export default GroupModelModal; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/KeyConfigModal.tsx b/web/src/views/ModelManagement/components/KeyConfigModal.tsx new file mode 100644 index 00000000..d157dde7 --- /dev/null +++ b/web/src/views/ModelManagement/components/KeyConfigModal.tsx @@ -0,0 +1,92 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App } from 'antd'; +import { useTranslation } from 'react-i18next'; +import type { KeyConfigModalForm, ProviderModelItem, KeyConfigModalRef, KeyConfigModalProps } from '../types'; +import RbModal from '@/components/RbModal' +import { updateProviderApiKeys } from '@/api/models' + +const KeyConfigModal = forwardRef(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [model, setModel] = useState({} as ProviderModelItem); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + + const handleClose = () => { + setModel({} as ProviderModelItem); + form.resetFields(); + setLoading(false) + setVisible(false); + }; + + const handleOpen = (vo: ProviderModelItem) => { + setVisible(true); + setModel(vo); + }; + const handleSave = () => { + form + .validateFields() + .then((values) => { + setLoading(true) + + updateProviderApiKeys({ + ...values, + provider: model.provider + }).then(() => { + if (refresh) { + refresh(); + } + handleClose() + message.success(t('common.updateSuccess')) + }) + .catch(() => { + setLoading(false) + }); + }) + .catch((err) => { + console.log('err', err) + }); + } + + useImperativeHandle(ref, () => ({ + handleOpen, + handleClose + })); + + return ( + +
    + + + + + + + +
    +
    + ); +}); + +export default KeyConfigModal; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx b/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx new file mode 100644 index 00000000..cfe3f090 --- /dev/null +++ b/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx @@ -0,0 +1,173 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Cascader, App } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { SubModelModalForm, SubModelModalRef, SubModelModalProps, ModelList } from './types'; +import RbModal from '@/components/RbModal' +import CustomSelect from '@/components/CustomSelect' +import { modelProviderUrl, getModelNewList } from '@/api/models' +import type { ProviderModelItem } from '../../types' + +const { SHOW_CHILD } = Cascader; + +interface Option { + value: string | number; + label: string; + children?: Option[]; + [key: string]: any; +} +const SubModelModal = forwardRef(({ + refresh, + type +}, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp() + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [selecteds, setSelecteds] = useState([]) + const [modelList, setModelList] = useState([]) + + // 封装取消方法,添加关闭弹窗逻辑 + const handleClose = () => { + form.resetFields(); + setLoading(false) + setVisible(false); + setSelecteds([]) + }; + + const handleOpen = (list?: ModelList[], provider?: string) => { + if (list?.length && provider) { + const initialValue: SubModelModalForm = { + provider, + api_key_ids: list.map(vo => { + return [vo.model_config_ids[0], vo.id] + }) + } + + form.setFieldsValue(initialValue); + handleChangeProvider(provider, initialValue.api_key_ids) + } else { + form.resetFields() + } + setVisible(true); + }; + // 封装保存方法,添加提交逻辑 + const handleSave = () => { + form + .validateFields() + .then((values) => { + console.log('SubModelModal values', values, selecteds, selecteds.map(vo => ({ + ...vo[0], + model_name: vo[0].name, + model_config_ids: [vo[0].id], + id: vo[1].value + }))) + refresh?.(selecteds.map(vo => ({ + ...vo[0], + model_name: vo[0].name, + model_config_ids: [vo[0].id], + id: vo[1].value + }))) + handleClose() + }) + } + const handleChange = (value: (string | number)[][], selectedOptions: Option[][]) => { + const filterList = selectedOptions.filter(vo => vo.length === 1).map(item => item[0]) + const lastFilterLit = value.filter(vo => vo.length !== 1) + console.log('onchange', value, lastFilterLit, selectedOptions, filterList) + if (filterList.length) { + message.warning(`【${filterList.map(vo => vo.label)}】${t('modelNew.selectOneTip')}`) + form.setFieldValue('api_key_ids', lastFilterLit) + } + setSelecteds(selectedOptions) + } + + const handleChangeProvider = (provider: string, api_key_ids?: any[]) => { + form.setFieldValue('api_key_ids', undefined) + getModelNewList({ + provider: provider, + is_composite: false, + is_active: true, + type + }) + .then(res => { + const response = res as ProviderModelItem[] + const list = response[0]?.models || [] + setModelList(list.map(vo => { + const children = vo.api_keys.map(item => ({ + label: item.api_key, + value: item.id, + })) + return { + ...vo, + label: vo.name, + value: vo.id, + children: children + } + })) + + if (api_key_ids?.length) { + form.setFieldsValue({ + api_key_ids: api_key_ids + }) + } + }) + } + + // 暴露给父组件的方法 + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + return ( + +
    + + items.map((item) => ({ + label: t(`modelNew.${typeof item === 'object' ? item.value : item}`), + value: typeof item === 'object' ? item.value : item + }))} + onChange={(value) => handleChangeProvider(value)} + /> + + + + +
    +
    + ); +}); + +export default SubModelModal; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ModelImplement/index.tsx b/web/src/views/ModelManagement/components/ModelImplement/index.tsx new file mode 100644 index 00000000..a876587d --- /dev/null +++ b/web/src/views/ModelManagement/components/ModelImplement/index.tsx @@ -0,0 +1,106 @@ +import { type FC, useRef } from "react"; +import { useTranslation } from 'react-i18next'; +import { Flex, Button, Space, App } from 'antd' + +import type { SubModelModalRef, ModelList } from './types' +import SubModelModal from './SubModelModal' +import Empty from '@/components/Empty' +import Tag from '@/components/Tag' + +interface ModelImplementProps { + type?: string; + value?: any; + onChange?: (value: any) => void; +} +const ModelImplement: FC = ({ type, value, onChange }) => { + const { t } = useTranslation(); + const { modal, message } = App.useApp(); + const subModelModalRef = useRef(null) + + const handleAdd = () => { + if (!type || type.trim() === '') { + message.warning(t('common.selectPlaceholder', { title: t('modelNew.type') })) + return + } + subModelModalRef.current?.handleOpen() + } + const handleEdit = (list: ModelList[], provider: string ) => { + subModelModalRef.current?.handleOpen(list, provider) + } + const handleDelete = (provider: string) => { + modal.confirm({ + title: t('common.confirmDeleteDesc', { name: provider }), + content: t('application.apiKeyDeleteContent'), + okText: t('common.delete'), + cancelText: t('common.cancel'), + okType: 'danger', + onOk: () => { + onChange?.(value?.filter((item: any) => item.provider !== provider)) + } + }) + } + const handleRefresh = (list: ModelList[]) => { + const existingModels = value || []; + let updatedModels = [...existingModels]; + + const provider = list[0].provider + + updatedModels = updatedModels.filter(item => item.provider !== provider) + updatedModels = [...updatedModels, ...list] + + onChange?.([...updatedModels]); + } + + const groupedByProvider: Record = (value || []).reduce((acc: Record, item: ModelList) => { + const provider = item.provider || 'unknown'; + if (!acc[provider]) acc[provider] = []; + acc[provider].push(item); + return acc; + }, {} as Record); + + return ( +
    + + {t('modelNew.modelImplement')} + + + + + + + + +
    + {!value || value.length === 0 + ? + : Object.entries(groupedByProvider).map(([provider, items]: [string, ModelList[]]) => { + return ( +
    + +
    {[...new Set(items?.map((vo) => vo.model_name))].join(', ')}
    + +
    handleEdit(items, provider)} + >
    +
    handleDelete(provider)} + >
    +
    +
    + {provider} +
    + ) + })} +
    + +
    + ) +} + +export default ModelImplement \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ModelImplement/types.ts b/web/src/views/ModelManagement/components/ModelImplement/types.ts new file mode 100644 index 00000000..c6d2f6d6 --- /dev/null +++ b/web/src/views/ModelManagement/components/ModelImplement/types.ts @@ -0,0 +1,16 @@ +import type { ModelListItem } from '../../types' + +export interface ModelList extends ModelListItem { + api_key_id: string; +} +export interface SubModelModalForm { + provider: string; + api_key_ids: string[][]; +} +export interface SubModelModalRef { + handleOpen: (list?: ModelList[], provider?: string) => void; +} +export interface SubModelModalProps { + type?: string; + refresh?: (vo: ModelList[]) => void; +} \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ModelListDetail.tsx b/web/src/views/ModelManagement/components/ModelListDetail.tsx new file mode 100644 index 00000000..48abd953 --- /dev/null +++ b/web/src/views/ModelManagement/components/ModelListDetail.tsx @@ -0,0 +1,111 @@ +import { useState, useImperativeHandle, forwardRef, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Switch, Row, Col, Space } from 'antd' + +import type { ProviderModelItem, ModelListItem, ModelListDetailRef, MultiKeyConfigModalRef } from '../types'; +import RbDrawer from '@/components/RbDrawer'; +import RbCard from '@/components/RbCard/Card' +import Tag from '@/components/Tag'; +import PageEmpty from '@/components/Empty/PageEmpty'; +import MultiKeyConfigModal from './MultiKeyConfigModal' +import { getModelNewList, updateModelStatus } from '@/api/models' + +interface ModelListDetailProps { + refresh?: () => void; +} + +const ModelListDetail = forwardRef(({ refresh }, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [data, setData] = useState({} as ProviderModelItem) + const [list, setList] = useState([]) + const multiKeyConfigModalRef = useRef(null) + const [loading, setLoading] = useState(false) + + const handleOpen = (vo: ProviderModelItem) => { + setOpen(true) + getData(vo) + } + + const getData = (vo: ProviderModelItem) => { + if (!vo.provider) return + + getModelNewList({ + provider: vo.provider + }) + .then(res => { + const response = res as ProviderModelItem[] + setData(response[0]) + setList(response[0].models) + }) + } + const handleKeyConfig = (vo: ModelListItem) => { + multiKeyConfigModalRef.current?.handleOpen(vo, data.provider) + } + const handleChange = (vo: ModelListItem) => { + setLoading(true) + updateModelStatus(vo.id, { is_active: !vo.is_active }) + .finally(() => { + getData(data) + setLoading(false) + }) + } + + const handleClose = () => { + setOpen(false) + refresh?.() + } + const handleRefresh = () => { + getData(data) + } + + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + return ( + {data.provider} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} + open={open} + onClose={handleClose} + > + {list.length === 0 + ? + :
    + {list.map(item => ( + + {t(`modelNew.${item.type}`)} + {item.api_keys.length}{t('modelNew.apiKeyNum')} + } + avatarUrl={item.logo} + avatar={ +
    + {item.name[0]} +
    + } + extra={ handleChange(item)} />} + > + +
    {item.description}
    + + + + + +
    + ))} +
    + } + + +
    + ); +}); + +export default ModelListDetail; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx new file mode 100644 index 00000000..6d437729 --- /dev/null +++ b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx @@ -0,0 +1,86 @@ +import { useState, useImperativeHandle, forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Space, App } from 'antd' + +import type { ModelPlaza, ModelPlazaItem, ModelSquareDetailRef } from '../types'; +import RbDrawer from '@/components/RbDrawer'; +import { getModelPlaza, addModelPlaza } from '@/api/models' +import RbCard from '@/components/RbCard/Card' +import Tag from '@/components/Tag'; +import PageEmpty from '@/components/Empty/PageEmpty'; + +interface ModelSquareDetailProps { + refresh: () => void; +} +const ModelSquareDetail = forwardRef(({ refresh }, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp() + const [model, setModel] = useState({} as ModelPlaza) + const [open, setOpen] = useState(false); + + const [list, setList] = useState([]) + + const handleOpen = (vo: ModelPlaza) => { + setModel(vo) + setOpen(true) + getList(vo) + } + const handleClose = () => { + setOpen(false) + refresh() + } + const getList = (vo: ModelPlaza) => { + getModelPlaza({ provider: vo.provider }) + .then(res => { + const response = res as ModelPlaza[] + setList(response.length > 0 ? response[0].models : []) + }) + } + const handleAdd = (item: ModelPlazaItem) => { + addModelPlaza(item.id) + .then(() => { + message.success(`${item.name}${t('modelNew.addSuccess')}`) + getList(model) + }) + } + + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + return ( + {model.provider} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} + open={open} + onClose={handleClose} + > + {list.length === 0 + ? + :
    + {list.map(item => ( + + {item.name[0]} +
    + } + > + {t(`modelNew.${item.type}`)} +
    {item.description}
    + {item.tags.map((tag, tagIndex) => {tag})} + {item.is_added + ? + : + } +
    + ))} + + } + + ); +}); + +export default ModelSquareDetail; \ No newline at end of file diff --git a/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx new file mode 100644 index 00000000..860efc3f --- /dev/null +++ b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx @@ -0,0 +1,121 @@ +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { Form, Input, App, Button } from 'antd'; +import { useTranslation } from 'react-i18next'; +import type { ModelListItem, MultiKeyForm, MultiKeyConfigModalRef, MultiKeyConfigModalProps } from '../types'; +import RbModal from '@/components/RbModal' +import { addModelApiKey, delteModelApiKey, getModelInfo } from '@/api/models' + +const MultiKeyConfigModal = forwardRef(({ refresh }, ref) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [visible, setVisible] = useState(false); + const [model, setModel] = useState({} as ModelListItem); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + + const handleClose = () => { + setModel({} as ModelListItem); + refresh?.() + + form.resetFields(); + setLoading(false) + setVisible(false); + }; + + const handleOpen = (vo: ModelListItem) => { + setVisible(true); + getData(vo) + }; + + const getData = (vo: ModelListItem) => { + if (!vo.id) return + + getModelInfo(vo?.id) + .then(res => { + setModel(res as ModelListItem) + }) + } + const handleSave = () => { + form + .validateFields() + .then((values) => { + addModelApiKey(model.id, { + ...values, + model_config_id: model.id, + model_name: model.name, + provider: model.provider, + }).then(() => { + message.success(t('common.saveSuccess')) + form.resetFields(); + getData(model) + }) + .catch(() => { + setLoading(false) + }); + }) + .catch((err) => { + console.log('err', err) + }); + } + const handleDelete = (api_key_id: string) => { + delteModelApiKey(api_key_id) + .then(() => { + message.success(t('common.deleteSuccess')) + getData(model) + }) + } + + useImperativeHandle(ref, () => ({ + handleOpen, + })); + + return ( + + {model.api_keys && model.api_keys.length > 0 && ( +
    + {model.api_keys.map((key) => ( +
    +
    +
    {key.api_key}
    +
    {key.api_base}
    +
    + +
    + ))} +
    + )} +
    + + + + + + + + + + + +
    +
    + ); +}); + +export default MultiKeyConfigModal; \ No newline at end of file diff --git a/web/src/views/ModelManagement/index.tsx b/web/src/views/ModelManagement/index.tsx index 930a18e6..35f4c887 100644 --- a/web/src/views/ModelManagement/index.tsx +++ b/web/src/views/ModelManagement/index.tsx @@ -1,99 +1,123 @@ import { useState, useRef, type FC } from 'react'; -import { Row, Col, Button } from 'antd' +import { Button, Flex, Space, type SegmentedProps } from 'antd' import { useTranslation } from 'react-i18next'; -import clsx from 'clsx'; -import ConfigModal from './components/ConfigModal' -import type { Model, DescriptionItem, ConfigModalRef } from './types' -import RbCard from '@/components/RbCard/Card' +import GroupModelModal from './components/GroupModelModal' +import type { ModelListItem, GroupModelModalRef, CustomModelModalRef, ModelPlazaItem, BaseRef } from './types' import SearchInput from '@/components/SearchInput' -import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList' -import { getModelListUrl } from '@/api/models' -import { formatDateTime } from '@/utils/format'; +import PageTabs from '@/components/PageTabs' +import GroupModel from './Group' +import ModelList from './List' +import ModelSquare from './Square' +import CustomModelModal from './components/CustomModelModal' +import CustomSelect from '@/components/CustomSelect' +import { modelTypeUrl, modelProviderUrl } from '@/api/models' +const tabKeys = ['group', 'list', 'square'] const ModelManagement: FC = () => { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState('group'); const [query, setQuery] = useState({}) - const configModalRef = useRef(null) - const scrollListRef = useRef(null) + const configModalRef = useRef(null) + const customModelModalRef = useRef(null) + const groupRef = useRef(null) + const squareRef = useRef(null) - const formatData = (data: Model) => { - return [ - { - key: 'type', - label: t(`model.type`), - children: data.type || '-', - }, - { - key: 'provider', - label: t(`model.provider`), - children: data.api_keys[0].provider || '-', - }, - { - key: 'is_active', - label: t(`model.status`), - children: data.is_active ? t(`common.statusEnabled`) : t(`common.statusDisabled`), - }, - { - key: 'created', - label: t(`model.created`), - children: data.created_at ? formatDateTime(data.created_at, 'YYYY-MM-DD HH:mm:ss') : '-', - }, - ] + const formatTabItems = () => { + return tabKeys.map(value => ({ + value, + label: t(`modelNew.${value}`), + })) + } + const handleChangeTab = (value: SegmentedProps['value']) => { + setActiveTab(value as string); + setQuery({}) } - const handleEdit = (model?: Model) => { - configModalRef?.current?.handleOpen(model) + const handleEdit = (vo?: ModelListItem | ModelPlazaItem) => { + switch(activeTab) { + case 'group': + configModalRef?.current?.handleOpen(vo as ModelListItem) + break + case 'square': + customModelModalRef?.current?.handleOpen(vo as ModelPlazaItem) + break + } + } + const handleRefresh = () => { + switch (activeTab) { + case 'group': + groupRef.current?.getList() + break + case 'square': + squareRef.current?.getList() + break + } } const handleSearch = (value?: string) => { setQuery({ search: value }) } + const handleTypeChange = (value: string) => { + setQuery(pre => ({ ...pre, type: value })) + } + const handleProviderChange = (value: string) => { + setQuery(pre => ({ ...pre, provider: value })) + } return ( -
    - - - + + + + + {activeTab === 'list' ? <> + items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} + onChange={handleTypeChange} + className="rb:w-30" + allowClear={true} + placeholder={t('modelNew.type')} + /> + items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} + onChange={handleProviderChange} + className="rb:w-30" + allowClear={true} + placeholder={t('modelNew.provider')} + /> + + : - - - - - + className="rb:w-70!" + />} + {activeTab === 'group' && } + {activeTab === 'square' && } + + - ( - - {formatData(item)?.map((description: DescriptionItem) => ( -
    - {(description.label as string)} - {(description.children as string)} -
    - ))} - -
    - )} - /> - - + {activeTab === 'group' && } + {activeTab === 'list' && } + {activeTab === 'square' && } +
    + scrollListRef?.current?.refresh()} + refresh={handleRefresh} /> - + + ) } diff --git a/web/src/views/ModelManagement/types.ts b/web/src/views/ModelManagement/types.ts index 215e0d9f..b8d830cc 100644 --- a/web/src/views/ModelManagement/types.ts +++ b/web/src/views/ModelManagement/types.ts @@ -1,70 +1,146 @@ -// 模型表单数据类型 -export interface ModelFormData extends ApiKey { - name: string; - type: string; - api_keys: ApiKey; +export interface Query { + type?: string; + provider?: string; + is_active?: boolean; + is_public?: boolean; + is_composite?: boolean; + search?: string; } - export interface DescriptionItem { key: string; label: string; children: string; } +export interface CompositeModelForm { + logo?: any; + name: string; + type: string; + description: string; + api_key_ids: ModelApiKey[] | string[]; +} +export interface GroupModelModalRef { + handleOpen: (model?: ModelListItem) => void; +} +export interface GroupModelModalProps { + refresh?: () => void; +} +export interface SubModelModalForm { + provider: string; + model_ids: string[] +} +export interface SubModelModalRef { + handleOpen: (model?: SubModelModalForm) => void; +} +export interface SubModelModalProps { + refresh?: () => void; +} +export interface ModelListDetailRef { + handleOpen: (vo: ProviderModelItem) => void; +} -// 模型类型定义 -export interface Model { + +export interface ModelApiKey { + model_name: string; + description: string | null; + provider: string; + api_key: string; + api_base: string; + config: any; + is_active: boolean; + priority: string; + id: string; + usage_count: string; + last_used_at: number; + created_at: number; + updated_at: number; + model_config_ids: string[]; +} +export interface ModelListItem { + model_name?: string; + model_config_ids: string[]; + name: string; + type: string; + logo: string; + description: string; + provider: string; + config: any; + is_active: boolean; + is_public: boolean; + id: string; + created_at: number; + updated_at: number; + api_keys: ModelApiKey[] +} +export interface ProviderModelItem { + provider: string; + logo?: string; + tags: string[]; + models: ModelListItem[]; +} +export interface KeyConfigModalForm { + provider: string; + api_key: string; + api_base: string; +} +export interface KeyConfigModalRef { + handleOpen: (vo: ProviderModelItem) => void; +} +export interface KeyConfigModalProps { + refresh?: () => void; +} +export interface MultiKeyForm { + model_config_id?: string; + model_name: string; + provider: string; + api_key: string; + api_base: string; +} + +export interface MultiKeyConfigModalRef { + handleOpen: (vo: ModelListItem, provider?: string) => void; +} +export interface MultiKeyConfigModalProps { + refresh?: () => void; +} + + +export interface ModelPlaza { + provider: string; + models: ModelPlazaItem[]; +} +export interface ModelPlazaItem { id: string; name: string; type: string; - description?: string; - config: Record; - is_active: boolean; - is_public: boolean; - created_at: string | number; - updated_at: string | number; - api_keys: ApiKey[]; - - // provider: string; - // temperature: number, - // topP: number, - // status: string; - // vectorDimension: number; - // batchSize: number; - // truncateStrategy: string; - // created: string; - // updatedAt: string; - // descriptionItems?: Record[]; - // basicParameters?: string; - // normalization?: string; - // maxInputLength?: number; - // encodingFormat?: string; - // enablePooling?: boolean; - // poolingStrategy?: string; - // apiKey?: string; - // apiEndpoint?: string; - // timeout?: number; - // autoRetry?: boolean; - // retryCount?: number; -} -interface ApiKey { - model_name?: string; provider: string; - api_key?: string; - api_base?: string; - config?: Record; - is_active?: boolean; - priority?: string; - id: string; - model_config_id?: string; - usage_count?: string; - last_used_at?: string | null; - created_at?: string; - updated_at?: string; + logo: string; + description: string; + is_deprecated: boolean; + is_official: boolean; + tags: string[]; + add_count: number; + is_added: boolean; } -// 定义组件暴露的方法接口 -export interface ConfigModalRef { - handleOpen: (model?: Model) => void; +export interface ModelSquareDetailRef { + handleOpen: (vo: ModelPlaza) => void; } -export interface ConfigModalProps { +export interface CustomModelForm { + name: string; + type: string; + provider: string; + logo: string; + description: string; + is_official: boolean; + tags: string[]; +} +export interface CustomModelModalRef { + handleOpen: (vo?: ModelPlazaItem) => void; +} +export interface CustomModelModalProps { refresh?: () => void; +} + + +export interface BaseRef { + getList: () => void; } \ No newline at end of file From f821893653f633d344e718f0573c91d63e38c53a Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 20:22:14 +0800 Subject: [PATCH 106/175] =?UTF-8?q?config=5Fid=E5=81=9A=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 4 +-- api/app/services/memory_config_service.py | 30 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 09a14c32..c05ae027 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -265,7 +265,7 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID]|int, db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id @@ -374,7 +374,7 @@ class MemoryAgentService: message: str, history: List[Dict], search_switch: str, - config_id: Optional[UUID], + config_id: Optional[uuid.UUID]|int, db: Session, storage_type: str, user_rag_memory_id: str) -> Dict: diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index e901d65d..f423e31e 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -7,7 +7,8 @@ This service eliminates code duplication between MemoryAgentService and MemorySt import time from datetime import datetime - +from app.models.memory_config_model import MemoryConfig as MemoryConfigModel +from sqlalchemy import select from app.core.logging_config import get_config_logger, get_logger from app.core.validators.memory_config_validators import ( validate_and_resolve_model_id, @@ -29,7 +30,7 @@ logger = get_logger(__name__) config_logger = get_config_logger() import uuid -def _validate_config_id(config_id): +def _validate_config_id(config_id, db: Session = None): """Validate configuration ID format (supports both UUID and integer).""" if isinstance(config_id, uuid.UUID): return config_id @@ -48,6 +49,16 @@ def _validate_config_id(config_id): field_name="config_id", invalid_value=config_id, ) + + # 如果提供了数据库会话,尝试通过 user_id 查询 config_id + if db is not None: + # 查询 user_id 匹配的记录 + stmt = select(MemoryConfigModel).where(MemoryConfigModel.config_id_old == str(config_id)) + result = db.execute(stmt).scalars().first() + if result: + logger.info(f"Found config_id {result.config_id} for user_id {config_id}") + return result.config_id + return config_id if isinstance(config_id, str): @@ -61,13 +72,24 @@ def _validate_config_id(config_id): # Fall back to integer parsing try: - parsed_id = config_id_stripped + parsed_id = int(config_id_stripped) if parsed_id <= 0: raise InvalidConfigError( f"Configuration ID must be positive: {parsed_id}", field_name="config_id", invalid_value=config_id, ) + + # 如果提供了数据库会话,尝试通过 user_id 查询 config_id + if db is not None: + # 查询 user_id 匹配的记录 + stmt = select(MemoryConfigModel).where(MemoryConfigModel.user_id == str(parsed_id)) + result = db.execute(stmt).scalars().first() + + if result: + logger.info(f"Found config_id {result.config_id} for user_id {parsed_id}") + return result.config_id + return parsed_id except ValueError: raise InvalidConfigError( @@ -136,7 +158,7 @@ class MemoryConfigService: logger.info(f"Loading memory configuration from database: config_id={config_id}") try: - validated_config_id = _validate_config_id(config_id) + validated_config_id = _validate_config_id(config_id, self.db) # Step 1: Get config and workspace db_query_start = time.time() From 46abb23ee81a0de280162cca989167fe4cc9dc6b Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 20:24:05 +0800 Subject: [PATCH 107/175] =?UTF-8?q?config=5Fid=E5=81=9A=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_config_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index f423e31e..26b86b71 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -49,7 +49,6 @@ def _validate_config_id(config_id, db: Session = None): field_name="config_id", invalid_value=config_id, ) - # 如果提供了数据库会话,尝试通过 user_id 查询 config_id if db is not None: # 查询 user_id 匹配的记录 From 375660f2321e946ef2e54068a9762aed7a1015d8 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:26:14 +0800 Subject: [PATCH 108/175] Fix/memory bug fix (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * 检查需要更改的格式问题 * 修复宿主列表获取memory_config_idBUG * config_id做映射 * config_id做映射 --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/services/memory_agent_service.py | 4 ++-- api/app/services/memory_config_service.py | 29 +++++++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 09a14c32..c05ae027 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -265,7 +265,7 @@ class MemoryAgentService: logger.info("Log streaming completed, cleaning up resources") # LogStreamer uses context manager for file handling, so cleanup is automatic - async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID], db: Session, storage_type: str, user_rag_memory_id: str) -> str: + async def write_memory(self, end_user_id: str, messages: list[dict], config_id: Optional[uuid.UUID]|int, db: Session, storage_type: str, user_rag_memory_id: str) -> str: """ Process write operation with config_id @@ -374,7 +374,7 @@ class MemoryAgentService: message: str, history: List[Dict], search_switch: str, - config_id: Optional[UUID], + config_id: Optional[uuid.UUID]|int, db: Session, storage_type: str, user_rag_memory_id: str) -> Dict: diff --git a/api/app/services/memory_config_service.py b/api/app/services/memory_config_service.py index e901d65d..26b86b71 100644 --- a/api/app/services/memory_config_service.py +++ b/api/app/services/memory_config_service.py @@ -7,7 +7,8 @@ This service eliminates code duplication between MemoryAgentService and MemorySt import time from datetime import datetime - +from app.models.memory_config_model import MemoryConfig as MemoryConfigModel +from sqlalchemy import select from app.core.logging_config import get_config_logger, get_logger from app.core.validators.memory_config_validators import ( validate_and_resolve_model_id, @@ -29,7 +30,7 @@ logger = get_logger(__name__) config_logger = get_config_logger() import uuid -def _validate_config_id(config_id): +def _validate_config_id(config_id, db: Session = None): """Validate configuration ID format (supports both UUID and integer).""" if isinstance(config_id, uuid.UUID): return config_id @@ -48,6 +49,15 @@ def _validate_config_id(config_id): field_name="config_id", invalid_value=config_id, ) + # 如果提供了数据库会话,尝试通过 user_id 查询 config_id + if db is not None: + # 查询 user_id 匹配的记录 + stmt = select(MemoryConfigModel).where(MemoryConfigModel.config_id_old == str(config_id)) + result = db.execute(stmt).scalars().first() + if result: + logger.info(f"Found config_id {result.config_id} for user_id {config_id}") + return result.config_id + return config_id if isinstance(config_id, str): @@ -61,13 +71,24 @@ def _validate_config_id(config_id): # Fall back to integer parsing try: - parsed_id = config_id_stripped + parsed_id = int(config_id_stripped) if parsed_id <= 0: raise InvalidConfigError( f"Configuration ID must be positive: {parsed_id}", field_name="config_id", invalid_value=config_id, ) + + # 如果提供了数据库会话,尝试通过 user_id 查询 config_id + if db is not None: + # 查询 user_id 匹配的记录 + stmt = select(MemoryConfigModel).where(MemoryConfigModel.user_id == str(parsed_id)) + result = db.execute(stmt).scalars().first() + + if result: + logger.info(f"Found config_id {result.config_id} for user_id {parsed_id}") + return result.config_id + return parsed_id except ValueError: raise InvalidConfigError( @@ -136,7 +157,7 @@ class MemoryConfigService: logger.info(f"Loading memory configuration from database: config_id={config_id}") try: - validated_config_id = _validate_config_id(config_id) + validated_config_id = _validate_config_id(config_id, self.db) # Step 1: Get config and workspace db_query_start = time.time() From 1e481a311afb3ec34cdc5235f60a61f945c4ae24 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 27 Jan 2026 20:33:23 +0800 Subject: [PATCH 109/175] feat(web): getModelListUrl add is_active param --- web/src/api/knowledgeBase.ts | 2 +- web/src/views/ApplicationConfig/Agent.tsx | 2 +- web/src/views/ApplicationConfig/Cluster.tsx | 2 +- .../ApplicationConfig/components/AiPromptModal.tsx | 2 +- .../Knowledge/KnowledgeGlobalConfigModal.tsx | 2 +- web/src/views/EmotionEngine/index.tsx | 2 +- web/src/views/MemoryExtractionEngine/index.tsx | 10 +++++----- web/src/views/ModelManagement/types.ts | 3 +++ web/src/views/SelfReflectionEngine/index.tsx | 2 +- web/src/views/SpaceConfig/index.tsx | 6 +++--- .../views/SpaceManagement/components/SpaceModal.tsx | 12 ++++++------ .../Knowledge/KnowledgeGlobalConfigModal.tsx | 2 +- web/src/views/Workflow/constant.ts | 6 +++--- 13 files changed, 28 insertions(+), 25 deletions(-) diff --git a/web/src/api/knowledgeBase.ts b/web/src/api/knowledgeBase.ts index 5f171a72..38a0d40d 100644 --- a/web/src/api/knowledgeBase.ts +++ b/web/src/api/knowledgeBase.ts @@ -65,7 +65,7 @@ export const getModelTypeList = async () => { }; // 获取模型列表 export const getModelList = async (pageInfo: PageRequest) => { - const response = await request.get(`${apiPrefix}/models`, pageInfo); + const response = await request.get(`${apiPrefix}/models`, { ...pageInfo, is_active: true }); return response as any; }; //获取模型提供者 diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 77e90440..279ac18c 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -237,7 +237,7 @@ const Agent = forwardRef((_props, ref) => { }) } const getModels = () => { - getModelList({ type: 'llm,chat', pagesize: 100, page: 1 }) + getModelList({ type: 'llm,chat', pagesize: 100, page: 1, is_active: true }) .then(res => { const response = res as { items: Model[] } setModelList(response.items) diff --git a/web/src/views/ApplicationConfig/Cluster.tsx b/web/src/views/ApplicationConfig/Cluster.tsx index 3081aa04..aa4a5d98 100644 --- a/web/src/views/ApplicationConfig/Cluster.tsx +++ b/web/src/views/ApplicationConfig/Cluster.tsx @@ -225,7 +225,7 @@ const Cluster = forwardRef((_props, ref) => { (({ > { const values = Form.useWatch([], form) const [loading, setLoading] = useState(false) const [iterationPeriodDisabled, setIterationPeriodDisabled] = useState(false) - const [modelList, setModelList] = useState([]) + const [modelList, setModelList] = useState([]) useEffect(() => { if (values?.reflexion_range === 'database') { @@ -55,9 +55,9 @@ const MemoryExtractionEngine: FC = () => { }, [values]) const getModels = () => { - getModelList({ type: 'llm,chat', pagesize: 100, page: 1 }) + getModelList({ type: 'llm,chat', pagesize: 100, page: 1, is_active: true }) .then(res => { - const response = res as { items: Model[] } + const response = res as { items: ModelListItem[] } setModelList(response.items) }) } diff --git a/web/src/views/ModelManagement/types.ts b/web/src/views/ModelManagement/types.ts index b8d830cc..ad0dca26 100644 --- a/web/src/views/ModelManagement/types.ts +++ b/web/src/views/ModelManagement/types.ts @@ -5,6 +5,9 @@ export interface Query { is_public?: boolean; is_composite?: boolean; search?: string; + + pagesize?: number; + page?: number; } export interface DescriptionItem { key: string; diff --git a/web/src/views/SelfReflectionEngine/index.tsx b/web/src/views/SelfReflectionEngine/index.tsx index 784f066c..30117bed 100644 --- a/web/src/views/SelfReflectionEngine/index.tsx +++ b/web/src/views/SelfReflectionEngine/index.tsx @@ -24,7 +24,7 @@ const configList = [ key: 'reflection_model_id', type: 'customSelect', url: getModelListUrl, - params: { type: 'chat,llm', page: 1, pagesize: 100 }, // chat,llm + params: { type: 'chat,llm', page: 1, pagesize: 100, is_active: true }, // chat,llm }, // 迭代周期 { diff --git a/web/src/views/SpaceConfig/index.tsx b/web/src/views/SpaceConfig/index.tsx index ad99e220..25490e91 100644 --- a/web/src/views/SpaceConfig/index.tsx +++ b/web/src/views/SpaceConfig/index.tsx @@ -66,7 +66,7 @@ const SpaceConfig: FC = () => { > { > { > (({ const [form] = Form.useForm(); const [loading, setLoading] = useState(false) const [editVo, setEditVo] = useState(null) - const [modelList, setModelList] = useState([]) + const [modelList, setModelList] = useState([]) const values = Form.useWatch([], form); @@ -80,9 +80,9 @@ const SpaceModal = forwardRef(({ }, []) const getModels = () => { - getModelList({ type: 'llm,chat', pagesize: 100, page: 1 }) + getModelList({ type: 'llm,chat', pagesize: 100, page: 1, is_active: true }) .then(res => { - const response = res as { items: Model[] } + const response = res as { items: ModelListItem[] } setModelList(response.items) }) } @@ -134,7 +134,7 @@ const SpaceModal = forwardRef(({ > (({ > Date: Tue, 27 Jan 2026 21:09:06 +0800 Subject: [PATCH 110/175] =?UTF-8?q?config=5Fid=E5=81=9A=E6=98=A0=E5=B0=84+?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 38 ++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index c05ae027..a56ffa68 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1283,6 +1283,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) # 3. 收集所有 memory_config_id 并批量查询配置名称 memory_config_ids = [] + old_config_ids = [] # 存储旧的整数ID + for end_user_id, app_id in user_to_app.items(): release = app_to_release.get(app_id) if release: @@ -1290,13 +1292,39 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) memory_obj = config.get('memory', {}) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None if memory_config_id: - memory_config_ids.append(memory_config_id) - + # 判断是否为UUID格式 + try: + # 尝试将其解析为UUID + uuid.UUID(str(memory_config_id)) + memory_config_ids.append(memory_config_id) + except (ValueError, AttributeError): + # 如果不是UUID,则是旧的整数ID + old_config_ids.append(str(memory_config_id)) + # 批量查询 memory_config_name config_id_to_name = {} + + # 记录分类结果 + if memory_config_ids or old_config_ids: + logger.info(f"Collected {len(memory_config_ids)} UUID config_ids and {len(old_config_ids)} old integer config_ids") + if old_config_ids: + logger.debug(f"Old config IDs: {old_config_ids}") + + # 查询新的UUID格式的config_id if memory_config_ids: memory_configs = db.query(MemoryConfig).filter(MemoryConfig.config_id.in_(memory_config_ids)).all() - config_id_to_name = {str(mc.config_id): mc.config_name for mc in memory_configs} + config_id_to_name.update({str(mc.config_id): mc.config_name for mc in memory_configs}) + + # 查询旧的整数ID(通过config_id_old字段) + if old_config_ids: + old_memory_configs = db.query(MemoryConfig).filter(MemoryConfig.config_id_old.in_(old_config_ids)).all() + # 使用config_id_old作为key,这样后面查找时能匹配上 + config_id_to_name.update({str(mc.config_id_old): mc.config_name for mc in old_memory_configs}) + # 同时也添加config_id作为key,方便后续使用 + for mc in old_memory_configs: + if mc.config_id_old: + config_id_to_name[str(mc.config_id)] = mc.config_name + logger.info(f"Found {len(old_memory_configs)} configs for old IDs") # 4. 构建最终结果 for end_user_id, app_id in user_to_app.items(): @@ -1312,8 +1340,8 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) memory_obj = config.get('memory', {}) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None - # 获取配置名称 - memory_config_name = config_id_to_name.get(memory_config_id) if memory_config_id else None + # 获取配置名称(使用字符串形式的ID进行查找,兼容新旧格式) + memory_config_name = config_id_to_name.get(str(memory_config_id)) if memory_config_id else None result[end_user_id] = { "memory_config_id": memory_config_id, From d373f924f63a39652ed9ae05cc2c160d0f4702bb Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 21:10:32 +0800 Subject: [PATCH 111/175] =?UTF-8?q?config=5Fid=E5=81=9A=E6=98=A0=E5=B0=84+?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_dashboard_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 6181c319..88684a39 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -92,7 +92,6 @@ async def get_workspace_end_users( workspace_id=workspace_id, current_user=current_user ) - if not end_users: api_logger.info("工作空间下没有宿主") # 缓存空结果,避免重复查询 From 2a23082203703c8dd143257fab845ea38a3043e2 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Tue, 27 Jan 2026 21:15:38 +0800 Subject: [PATCH 112/175] =?UTF-8?q?config=5Fid=E5=81=9A=E6=98=A0=E5=B0=84+?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_agent_service.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index a56ffa68..2378da51 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -1293,12 +1293,10 @@ def get_end_users_connected_configs_batch(end_user_ids: List[str], db: Session) memory_config_id = memory_obj.get('memory_content') if isinstance(memory_obj, dict) else None if memory_config_id: # 判断是否为UUID格式 - try: - # 尝试将其解析为UUID + if len(str(memory_config_id))>=5: uuid.UUID(str(memory_config_id)) memory_config_ids.append(memory_config_id) - except (ValueError, AttributeError): - # 如果不是UUID,则是旧的整数ID + else: old_config_ids.append(str(memory_config_id)) # 批量查询 memory_config_name From ce01e588c9f6b2327c6612880ab6b9b37f7591f8 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 09:55:20 +0800 Subject: [PATCH 113/175] feat(web): remove file url replace --- web/src/views/ModelManagement/components/CustomModelModal.tsx | 4 +--- web/src/views/ModelManagement/components/GroupModelModal.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index df6693f7..ebc5a5ce 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -51,7 +51,7 @@ const CustomModelModal = forwardRef( if (typeof logo === 'object') { getFileLink(logo?.response?.data.file_id).then(res => { const logoRes = res as { url: string } - values.logo = logoRes.url.replace('http://127.0.0.1:8000', 'https://devmemorybear.redbearai.com') + values.logo = logoRes.url addCustomModel(values).then(() => { if (refresh) { refresh(); @@ -143,14 +143,12 @@ const CustomModelModal = forwardRef( diff --git a/web/src/views/ModelManagement/components/GroupModelModal.tsx b/web/src/views/ModelManagement/components/GroupModelModal.tsx index 9d8132d9..53a80c61 100644 --- a/web/src/views/ModelManagement/components/GroupModelModal.tsx +++ b/web/src/views/ModelManagement/components/GroupModelModal.tsx @@ -36,7 +36,7 @@ const GroupModelModal = forwardRef(({ form.setFieldsValue({ ...model, api_key_ids: model.api_keys, - logo: model.logo ? [{url: model.logo, uid: model.logo, status: 'done', name: 'logo'}] : undefined + logo: model.logo ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined }) } else { setIsEdit(false); @@ -48,7 +48,7 @@ const GroupModelModal = forwardRef(({ form .validateFields() .then((values) => { - const { api_key_ids, logo, ...rest } = values + const { api_key_ids = [], logo, ...rest } = values const formData: CompositeModelForm = { ...rest, @@ -62,6 +62,7 @@ const GroupModelModal = forwardRef(({ handleUpdate(formData) }) } else { + formData.logo = typeof logo === 'string' ? logo : logo.url handleUpdate(formData) } }) diff --git a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx index 6d437729..d7a5f807 100644 --- a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx @@ -1,6 +1,7 @@ import { useState, useImperativeHandle, forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Space, App } from 'antd' +import { Button, Space, App, Flex } from 'antd' +import { UsergroupAddOutlined } from '@ant-design/icons'; import type { ModelPlaza, ModelPlazaItem, ModelSquareDetailRef } from '../types'; import RbDrawer from '@/components/RbDrawer'; @@ -11,8 +12,9 @@ import PageEmpty from '@/components/Empty/PageEmpty'; interface ModelSquareDetailProps { refresh: () => void; + handleEdit: (vo: ModelPlazaItem) => void; } -const ModelSquareDetail = forwardRef(({ refresh }, ref) => { +const ModelSquareDetail = forwardRef(({ refresh, handleEdit }, ref) => { const { t } = useTranslation(); const { message } = App.useApp() const [model, setModel] = useState({} as ModelPlaza) @@ -71,10 +73,17 @@ const ModelSquareDetail = forwardRef{t(`modelNew.${item.type}`)}
    {item.description}
    {item.tags.map((tag, tagIndex) => {tag})} - {item.is_added - ? - : - } + + + {item.add_count} + + {!item.is_official && } + {item.is_added + ? + : + } + + ))} diff --git a/web/src/views/ModelManagement/types.ts b/web/src/views/ModelManagement/types.ts index ad0dca26..1967f393 100644 --- a/web/src/views/ModelManagement/types.ts +++ b/web/src/views/ModelManagement/types.ts @@ -27,16 +27,6 @@ export interface GroupModelModalRef { export interface GroupModelModalProps { refresh?: () => void; } -export interface SubModelModalForm { - provider: string; - model_ids: string[] -} -export interface SubModelModalRef { - handleOpen: (model?: SubModelModalForm) => void; -} -export interface SubModelModalProps { - refresh?: () => void; -} export interface ModelListDetailRef { handleOpen: (vo: ProviderModelItem) => void; } @@ -131,7 +121,7 @@ export interface CustomModelForm { name: string; type: string; provider: string; - logo: string; + logo?: any; description: string; is_official: boolean; tags: string[]; From d34ad7343947c5867b5d3c191f796cdc208477df Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 10:56:41 +0800 Subject: [PATCH 117/175] =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B1=82memory=5Fcon?= =?UTF-8?q?tent->memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/app_schema.py | 2 +- api/app/services/draft_run_service.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 09410091..b350f17c 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -51,7 +51,7 @@ class ToolOldConfig(BaseModel): class MemoryConfig(BaseModel): """记忆配置""" enabled: bool = Field(default=True, description="是否启用对话历史记忆") - memory_content: Optional[str] = Field(default=None, description="选择记忆的内容类型") + memory_config: Optional[str] = Field(default=None, description="选择记忆的内容类型") max_history: int = Field(default=10, ge=0, le=100, description="最大保留的历史对话轮数") diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 9766eec0..0d1f51a4 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -56,7 +56,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str 长期记忆工具 """ # search_switch = memory_config.get("search_switch", "2") - config_id= memory_config.get("memory_content",None) + config_id= memory_config.get("memory_content") or memory_config.get("memory_config",None) logger.info(f"创建长期记忆工具,配置: end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") @tool(args_schema=LongTermMemoryInput) def long_term_memory(question: str) -> str: @@ -106,9 +106,9 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str "app.core.memory.agent.read_message", args=[end_user_id, question, [], "1", config_id, storage_type, user_rag_memory_id] ) - # result = task_service.get_task_memory_read_result(task.id) - # status = result.get("status") - # logger.info(f"读取任务状态:{status}") + result = task_service.get_task_memory_read_result(task.id) + status = result.get("status") + logger.info(f"读取任务状态:{status}") finally: db.close() @@ -418,7 +418,7 @@ class DraftRunService: ) memory_config_= agent_config.memory - config_id = memory_config_.get("memory_content") + config_id = memory_config_.get("memory_content") or memory_config_.get("memory_config",None) # 7. 调用 Agent result = await agent.chat( @@ -644,7 +644,7 @@ class DraftRunService: }) memory_config_ = agent_config.memory - config_id = memory_config_.get("memory_content") + config_id = memory_config_.get("memory_content") or memory_config_.get("memory_config",None) # 9. 流式调用 Agent full_content = "" From 462c3b069654edddabeccf057633ba980fca0ea8 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 10:57:45 +0800 Subject: [PATCH 118/175] fix(web): correct spelling --- web/src/api/models.ts | 2 +- .../views/ModelManagement/components/MultiKeyConfigModal.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/api/models.ts b/web/src/api/models.ts index f8619e43..e5d0f339 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -43,7 +43,7 @@ export const addModelApiKey = (model_id: string, data: MultiKeyForm) => { return request.post(`/models/${model_id}/apikeys`, data) } // Delete model API key -export const delteModelApiKey = (api_key_id: string) => { +export const deleteModelApiKey = (api_key_id: string) => { return request.delete(`/models/apikeys/${api_key_id}`) } // Update model status diff --git a/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx index 860efc3f..c76e195d 100644 --- a/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx +++ b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx @@ -3,7 +3,7 @@ import { Form, Input, App, Button } from 'antd'; import { useTranslation } from 'react-i18next'; import type { ModelListItem, MultiKeyForm, MultiKeyConfigModalRef, MultiKeyConfigModalProps } from '../types'; import RbModal from '@/components/RbModal' -import { addModelApiKey, delteModelApiKey, getModelInfo } from '@/api/models' +import { addModelApiKey, deleteModelApiKey, getModelInfo } from '@/api/models' const MultiKeyConfigModal = forwardRef(({ refresh }, ref) => { const { t } = useTranslation(); @@ -58,7 +58,7 @@ const MultiKeyConfigModal = forwardRef { - delteModelApiKey(api_key_id) + deleteModelApiKey(api_key_id) .then(() => { message.success(t('common.deleteSuccess')) getData(model) From d2a67a53b592d5c6430605f6aa3aa12a5aff8b65 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 10:58:46 +0800 Subject: [PATCH 119/175] =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B1=82memory=5Fcon?= =?UTF-8?q?tent->memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/draft_run_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 0d1f51a4..88a2f26a 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -86,6 +86,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str 检索到的历史记忆内容 """ logger.info(f" 长期记忆工具被调用!question={question}, user={end_user_id}") + try: from app.db import get_db db = next(get_db()) From b58d97fad373a4a693c325c9ad023695924a4032 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 10:59:38 +0800 Subject: [PATCH 120/175] =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B1=82memory=5Fcon?= =?UTF-8?q?tent->memory=5Fconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/draft_run_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 88a2f26a..0d1f51a4 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -86,7 +86,6 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str 检索到的历史记忆内容 """ logger.info(f" 长期记忆工具被调用!question={question}, user={end_user_id}") - try: from app.db import get_db db = next(get_db()) From 7ba443afa558126e947d02e191a963c09ca4b3e0 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:01:58 +0800 Subject: [PATCH 121/175] Fix/memory bug fix (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * 检查需要更改的格式问题 * 修复宿主列表获取memory_config_idBUG * config_id做映射 * config_id做映射 * config_id做映射+1 * config_id做映射+1 * config_id做映射+1 * 应用层memory_content->memory_config * 应用层memory_content->memory_config * 应用层memory_content->memory_config --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/schemas/app_schema.py | 2 +- api/app/services/draft_run_service.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 09410091..b350f17c 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -51,7 +51,7 @@ class ToolOldConfig(BaseModel): class MemoryConfig(BaseModel): """记忆配置""" enabled: bool = Field(default=True, description="是否启用对话历史记忆") - memory_content: Optional[str] = Field(default=None, description="选择记忆的内容类型") + memory_config: Optional[str] = Field(default=None, description="选择记忆的内容类型") max_history: int = Field(default=10, ge=0, le=100, description="最大保留的历史对话轮数") diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 9766eec0..0d1f51a4 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -56,7 +56,7 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str 长期记忆工具 """ # search_switch = memory_config.get("search_switch", "2") - config_id= memory_config.get("memory_content",None) + config_id= memory_config.get("memory_content") or memory_config.get("memory_config",None) logger.info(f"创建长期记忆工具,配置: end_user_id={end_user_id}, config_id={config_id}, storage_type={storage_type}") @tool(args_schema=LongTermMemoryInput) def long_term_memory(question: str) -> str: @@ -106,9 +106,9 @@ def create_long_term_memory_tool(memory_config: Dict[str, Any], end_user_id: str "app.core.memory.agent.read_message", args=[end_user_id, question, [], "1", config_id, storage_type, user_rag_memory_id] ) - # result = task_service.get_task_memory_read_result(task.id) - # status = result.get("status") - # logger.info(f"读取任务状态:{status}") + result = task_service.get_task_memory_read_result(task.id) + status = result.get("status") + logger.info(f"读取任务状态:{status}") finally: db.close() @@ -418,7 +418,7 @@ class DraftRunService: ) memory_config_= agent_config.memory - config_id = memory_config_.get("memory_content") + config_id = memory_config_.get("memory_content") or memory_config_.get("memory_config",None) # 7. 调用 Agent result = await agent.chat( @@ -644,7 +644,7 @@ class DraftRunService: }) memory_config_ = agent_config.memory - config_id = memory_config_.get("memory_content") + config_id = memory_config_.get("memory_content") or memory_config_.get("memory_config",None) # 9. 流式调用 Agent full_content = "" From e5e914903cf0c6c5783b0f23d5d5dbb1c3f400ab Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 28 Jan 2026 11:04:46 +0800 Subject: [PATCH 122/175] feat(model and app statistic): 1. Optimize the model list; 2. Increase the model combination; 3. Add a model square; 4. Add application management statistics --- api/app/models/models_model.py | 2 +- api/app/schemas/model_schema.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/app/models/models_model.py b/api/app/models/models_model.py index a2bfa284..a8918c7c 100644 --- a/api/app/models/models_model.py +++ b/api/app/models/models_model.py @@ -149,7 +149,7 @@ class ModelBase(Base): description = Column(Text, comment="模型描述") is_deprecated = Column(Boolean, default=False, nullable=False, comment="是否弃用") is_official = Column(Boolean, default=True, comment="是否供应商官方模型(区分自定义)") - tags = Column(ARRAY(String), default=[], nullable=False, comment="模型标签(如['聊天', '创作'])") + tags = Column(ARRAY(String), default=list, nullable=False, comment="模型标签(如['聊天', '创作'])") add_count = Column(Integer, default=0, nullable=False, comment="模型被用户添加的次数") # 关联关系 diff --git a/api/app/schemas/model_schema.py b/api/app/schemas/model_schema.py index b83107ef..ce1b36bb 100644 --- a/api/app/schemas/model_schema.py +++ b/api/app/schemas/model_schema.py @@ -4,6 +4,10 @@ import datetime import uuid from app.models.models_model import ModelProvider, ModelType +from app.core.logging_config import get_business_logger + +schema_logger = get_business_logger() + @@ -164,7 +168,7 @@ class ModelApiKey(ModelApiKeyBase): and getattr(mc, 'name', None) == self.model_name)) ] except Exception as e: - print(f"提取 model_config_ids 失败:{e}") + schema_logger.warning(f"提取 model_config_ids 失败:{e}") self.model_config_ids = [] model_config = ConfigDict( From 18204bc1f7dddfaa2491722b44bc622d07d32d04 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 11:11:28 +0800 Subject: [PATCH 123/175] fix(web): model loading update --- .../components/CustomModelModal.tsx | 63 ++++++++++--------- .../components/GroupModelModal.tsx | 2 + .../ModelImplement/SubModelModal.tsx | 11 +--- .../components/MultiKeyConfigModal.tsx | 1 + 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index edeb732f..d22fbcdd 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -42,40 +42,43 @@ const CustomModelModal = forwardRef( } setVisible(true); }; + const handleUpdate = (data: CustomModelForm) => { + setLoading(true) + const res = isEdit ? updateCustomModel(model.id, data) : addCustomModel(data) + + res.then(() => { + refresh && refresh() + handleClose() + message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) + }) + .catch(() => { + setLoading(false) + }); + } const handleSave = () => { form .validateFields() .then((values) => { setLoading(true) - values.is_official = false; - const logo = values.logo as any; + const { logo, ...rest } = values; + let formData: CustomModelForm = { + ...rest + } + formData.is_official = false; + if (typeof logo === 'object' && logo?.response?.data.file_id) { - getFileLink(logo?.response?.data.file_id).then(res => { - const logoRes = res as { url: string } - values.logo = logoRes.url - addCustomModel(values).then(() => { - if (refresh) { - refresh(); - } - handleClose() - message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) + getFileLink(logo?.response?.data.file_id) + .then(res => { + const logoRes = res as { url: string } + formData.logo = logoRes.url + handleUpdate(formData) }) - .catch(() => { - setLoading(false) - }); - }) - } else { - values.logo = typeof logo === 'string' ? logo : logo.url - updateCustomModel(model.id, values).then(() => { - if (refresh) { - refresh(); - } - handleClose() - message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) - }) .catch(() => { - setLoading(false) - }); + handleUpdate(formData) + }) + } else { + formData.logo = typeof logo === 'string' ? logo : logo.url + handleUpdate(formData) } }) .catch((err) => { @@ -102,18 +105,18 @@ const CustomModelModal = forwardRef( form={form} layout="vertical" > - {!isEdit && - } +
    diff --git a/web/src/views/ModelManagement/components/GroupModelModal.tsx b/web/src/views/ModelManagement/components/GroupModelModal.tsx index 53a80c61..6ae54d4c 100644 --- a/web/src/views/ModelManagement/components/GroupModelModal.tsx +++ b/web/src/views/ModelManagement/components/GroupModelModal.tsx @@ -60,6 +60,8 @@ const GroupModelModal = forwardRef(({ const logoRes = res as { url: string } formData.logo = logoRes.url handleUpdate(formData) + }).catch(() => { + handleUpdate(formData) }) } else { formData.logo = typeof logo === 'string' ? logo : logo.url diff --git a/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx b/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx index cfe3f090..069f785d 100644 --- a/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx +++ b/web/src/views/ModelManagement/components/ModelImplement/SubModelModal.tsx @@ -24,14 +24,12 @@ const SubModelModal = forwardRef(({ const { message } = App.useApp() const [visible, setVisible] = useState(false); const [form] = Form.useForm(); - const [loading, setLoading] = useState(false) const [selecteds, setSelecteds] = useState([]) const [modelList, setModelList] = useState([]) // 封装取消方法,添加关闭弹窗逻辑 const handleClose = () => { form.resetFields(); - setLoading(false) setVisible(false); setSelecteds([]) }; @@ -56,13 +54,7 @@ const SubModelModal = forwardRef(({ const handleSave = () => { form .validateFields() - .then((values) => { - console.log('SubModelModal values', values, selecteds, selecteds.map(vo => ({ - ...vo[0], - model_name: vo[0].name, - model_config_ids: [vo[0].id], - id: vo[1].value - }))) + .then(() => { refresh?.(selecteds.map(vo => ({ ...vo[0], model_name: vo[0].name, @@ -127,7 +119,6 @@ const SubModelModal = forwardRef(({ onCancel={handleClose} okText={t('common.save')} onOk={handleSave} - confirmLoading={loading} >
    { + setLoading(true) addModelApiKey(model.id, { ...values, model_config_id: model.id, From 511e16f1d332bafd50d3d90da13ee0520cd1cf7e Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:18:11 +0800 Subject: [PATCH 124/175] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=BAconfig=5Fid=5Fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 784288de..ab910536 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -186,7 +186,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) config_id_old = None if config.user_id: try: - config_id_old = int(config.user_id) + config_id_old = int(config.config_id_old) except (ValueError, TypeError): config_id_old = None From 4786b0c5d4480e9978c47a5daa9814f9fed2ddaa Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:19:24 +0800 Subject: [PATCH 125/175] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=BAconfig=5Fid=5Fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index ab910536..87352f35 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -178,7 +178,6 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Read All --- def get_all(self, workspace_id = None) -> List[Dict[str, Any]]: # 获取所有配置参数 configs = MemoryConfigRepository.get_all(self.db, workspace_id) - # 将 ORM 对象转换为字典列表 data_list = [] for config in configs: From 9a4b1f093789e8fb26eae3e5cbe1dea2b3c91fce Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 28 Jan 2026 11:42:45 +0800 Subject: [PATCH 126/175] feat(model and app statistic): 1. Optimize the model list; 2. Increase the model combination; 3. Add a model square; 4. Add application management statistics --- api/app/controllers/model_controller.py | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/api/app/controllers/model_controller.py b/api/app/controllers/model_controller.py index 481c520e..509f7cad 100644 --- a/api/app/controllers/model_controller.py +++ b/api/app/controllers/model_controller.py @@ -36,7 +36,7 @@ def get_model_providers(): @router.get("", response_model=ApiResponse) def get_model_list( - type: Optional[str | list[str]] = Query(None, description="模型类型筛选(支持多个,如 ?type=LLM 或 ?type=LLM,EMBEDDING)"), + type: Optional[list[str]] = Query(None, description="模型类型筛选(支持多个,如 ?type=LLM 或 ?type=LLM,EMBEDDING)"), provider: Optional[model_schema.ModelProvider] = Query(None, description="提供商筛选(基于API Key)"), is_active: Optional[bool] = Query(None, description="激活状态筛选"), is_public: Optional[bool] = Query(None, description="公开状态筛选"), @@ -60,11 +60,14 @@ def get_model_list( try: # 解析 type 参数(支持逗号分隔) type_list = [] - if isinstance(type, str): - type_values = [t.strip() for t in type.split(',')] - type_list = [model_schema.ModelType(t.lower()) for t in type_values if t] - elif isinstance(type, list): - type_list = type + if type is not None: + flat_type = [] + for item in type: + split_items = [t.strip() for t in item.split(',') if t.strip()] + flat_type.extend(split_items) + + unique_flat_type = list(dict.fromkeys(flat_type)) + type_list = [ModelType(t.lower()) for t in unique_flat_type] api_logger.error(f"获取模型type_list: {type_list}") query = model_schema.ModelConfigQuery( @@ -89,7 +92,7 @@ def get_model_list( @router.get("/new", response_model=ApiResponse) def get_model_list( - type: Optional[str | list[str]] = Query(None, description="模型类型筛选(支持多个,如 ?type=LLM 或 ?type=LLM,EMBEDDING)"), + type: Optional[list[str]] = Query(None, description="模型类型筛选(支持多个,如 ?type=LLM 或 ?type=LLM,EMBEDDING)"), provider: Optional[model_schema.ModelProvider] = Query(None, description="提供商筛选(基于ModelConfig)"), is_active: Optional[bool] = Query(None, description="激活状态筛选"), is_public: Optional[bool] = Query(None, description="公开状态筛选"), @@ -111,11 +114,14 @@ def get_model_list( try: # 解析 type 参数(支持逗号分隔) type_list = [] - if isinstance(type, str): - type_values = [t.strip() for t in type.split(',')] - type_list = [model_schema.ModelType(t.lower()) for t in type_values if t] - elif isinstance(type, list): - type_list = type + if type is not None: + flat_type = [] + for item in type: + split_items = [t.strip() for t in item.split(',') if t.strip()] + flat_type.extend(split_items) + + unique_flat_type = list(dict.fromkeys(flat_type)) + type_list = [ModelType(t.lower()) for t in unique_flat_type] api_logger.info(f"获取模型type_list: {type_list}") query = model_schema.ModelConfigQueryNew( From fbc7bedb6c2d8c7797ed34c8a9fda1effc3224e1 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:45:51 +0800 Subject: [PATCH 127/175] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=BAconfig=5Fid=5Fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 87352f35..db1c6415 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -178,6 +178,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) # --- Read All --- def get_all(self, workspace_id = None) -> List[Dict[str, Any]]: # 获取所有配置参数 configs = MemoryConfigRepository.get_all(self.db, workspace_id) + # 将 ORM 对象转换为字典列表 data_list = [] for config in configs: @@ -185,12 +186,17 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) config_id_old = None if config.user_id: try: - config_id_old = int(config.config_id_old) + config_id_old = int(config.user_id) except (ValueError, TypeError): config_id_old = None - + + + if config_id_old: + memory_config=config_id_old + else: + memory_config=config.config_id config_dict = { - "config_id": config.config_id, + "config_id": memory_config, "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, From 981d78c8ba951950e527d02605459c97bab73c13 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:47:52 +0800 Subject: [PATCH 128/175] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=BAconfig=5Fid=5Fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index db1c6415..573e2ca7 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -186,7 +186,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) config_id_old = None if config.user_id: try: - config_id_old = int(config.user_id) + config_id_old = int(config.config_id_old) except (ValueError, TypeError): config_id_old = None From 1be6de30d78dfb75ecc08c87bea49b07a7e08407 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:54:07 +0800 Subject: [PATCH 129/175] =?UTF-8?q?memory=5Fcontent=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/schemas/app_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index b350f17c..09410091 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -51,7 +51,7 @@ class ToolOldConfig(BaseModel): class MemoryConfig(BaseModel): """记忆配置""" enabled: bool = Field(default=True, description="是否启用对话历史记忆") - memory_config: Optional[str] = Field(default=None, description="选择记忆的内容类型") + memory_content: Optional[str] = Field(default=None, description="选择记忆的内容类型") max_history: int = Field(default=10, ge=0, le=100, description="最大保留的历史对话轮数") From 45833542a77a435b007a31c2de89def0f8c895ba Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 11:57:17 +0800 Subject: [PATCH 130/175] =?UTF-8?q?memory=5Fcontent=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/memory_storage_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 573e2ca7..eec1007b 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -184,7 +184,7 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) for config in configs: # 安全地转换 user_id 为 int config_id_old = None - if config.user_id: + if config.config_id_old: try: config_id_old = int(config.config_id_old) except (ValueError, TypeError): From 2e7f6afe3f9a8d9cef03df08a2dfae4bf83cb96f Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:58:10 +0800 Subject: [PATCH 131/175] Fix/memory bug fix (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 图谱数据量限制数量去掉 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 用户详情优化 * 读取的接口,去掉全局锁 * 输出数组 * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化1.0(优化隐私输出、时间检索) * 反思优化测试接口 * 反思优化测试接口 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 读取接口内层嵌套BUG修复 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察) * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 新增中翻英功能(记忆时间线)(用户摘要)(兴趣分布接口)(查询核心档案)(记忆洞察)-接口添加翻译字段 * 把group_id替换end_user_id * 把group_id替换end_user_id_ * 把group_id替换end_user_id_ * config_config替换成memory_config * config_config替换成memory_config * [fix]Fix the memory interface to use end_user_id. * config_config替换成memory_config * config_config替换成memory_config * config_config替换成memory_config * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID * config_id字段改成UUID,与develop校对恢复 * 检查项目,修复group_id的遗留问题 * 检查项目,修复group_id的遗留问题 * 解决冲突 * 解决冲突 * end_user_id清理干净 * end_user_id清理干净 * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 修复遗留合并BUG * 感知meta_data字段BUG修复 * user_id->现实为config_id_old * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * user_id->显示为config_id_old传输 * 检查需要更改的格式问题 * 修复宿主列表获取memory_config_idBUG * config_id做映射 * config_id做映射 * config_id做映射+1 * config_id做映射+1 * config_id做映射+1 * 应用层memory_content->memory_config * 应用层memory_content->memory_config * 应用层memory_content->memory_config * 统一字段为config_id_old * 统一字段为config_id_old * 统一字段为config_id_old * 统一字段为config_id_old * memory_content暂时不修改 * memory_content暂时不修改 --------- Co-authored-by: lanceyq <1982376970@qq.com> --- api/app/schemas/app_schema.py | 2 +- api/app/services/memory_storage_service.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index b350f17c..09410091 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -51,7 +51,7 @@ class ToolOldConfig(BaseModel): class MemoryConfig(BaseModel): """记忆配置""" enabled: bool = Field(default=True, description="是否启用对话历史记忆") - memory_config: Optional[str] = Field(default=None, description="选择记忆的内容类型") + memory_content: Optional[str] = Field(default=None, description="选择记忆的内容类型") max_history: int = Field(default=10, ge=0, le=100, description="最大保留的历史对话轮数") diff --git a/api/app/services/memory_storage_service.py b/api/app/services/memory_storage_service.py index 784288de..eec1007b 100644 --- a/api/app/services/memory_storage_service.py +++ b/api/app/services/memory_storage_service.py @@ -184,14 +184,19 @@ class DataConfigService: # 数据配置服务类(PostgreSQL) for config in configs: # 安全地转换 user_id 为 int config_id_old = None - if config.user_id: + if config.config_id_old: try: - config_id_old = int(config.user_id) + config_id_old = int(config.config_id_old) except (ValueError, TypeError): config_id_old = None - + + + if config_id_old: + memory_config=config_id_old + else: + memory_config=config.config_id config_dict = { - "config_id": config.config_id, + "config_id": memory_config, "config_name": config.config_name, "config_desc": config.config_desc, "workspace_id": str(config.workspace_id) if config.workspace_id else None, From 9e4a5276758c261e1efa4305386d3296fca986cc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 11:59:37 +0800 Subject: [PATCH 132/175] feat(web): add app statistics --- web/src/i18n/en.ts | 6 + web/src/i18n/zh.ts | 8 +- .../views/ApplicationConfig/Statistics.tsx | 86 ++++++++++++ .../components/ConfigHeader.tsx | 2 +- .../ApplicationConfig/components/LineCard.tsx | 127 ++++++++++++++++++ web/src/views/ApplicationConfig/index.tsx | 2 + web/src/views/ApplicationConfig/types.ts | 15 +++ 7 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 web/src/views/ApplicationConfig/Statistics.tsx create mode 100644 web/src/views/ApplicationConfig/components/LineCard.tsx diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1df2eb6d..595337ba 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1175,6 +1175,12 @@ export const en = { priority: 'Structured Integration', addTool: 'Add Tool', tool: 'Tool', + + statistics: 'Data Statistics', + daily_conversations: 'Daily Conversations', + daily_new_users: 'Daily New Users', + daily_api_calls: 'Daily API Calls', + daily_tokens: 'Token Consumption', }, userMemory: { userMemory: 'User Memory', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 39908757..daf5977c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -658,7 +658,13 @@ export const zh = { priority: '结构化整合', addTool: '添加工具', tool: '工具', - variableConfig: '配置变量' + variableConfig: '配置变量', + + statistics: '数据统计', + daily_conversations: '消息会话数', + daily_new_users: '新增用户数', + daily_api_calls: '调用次数', + daily_tokens: 'Token消耗', }, role: { roleManagement: '角色管理', diff --git a/web/src/views/ApplicationConfig/Statistics.tsx b/web/src/views/ApplicationConfig/Statistics.tsx new file mode 100644 index 00000000..8a76ab06 --- /dev/null +++ b/web/src/views/ApplicationConfig/Statistics.tsx @@ -0,0 +1,86 @@ +import { type FC, useState, useEffect } from 'react'; +import { Row, Col, Flex, DatePicker } from 'antd'; +import type { Dayjs } from 'dayjs' +import dayjs from 'dayjs'; + +const { RangePicker } = DatePicker; + +import type { Application } from '@/views/ApplicationManagement/types' +import { getAppStatistics } from '@/api/application'; +import LineCard from './components/LineCard' +import type { StatisticsData, StatisticsItem } from './types' + +const TotalObj: Record = { + daily_conversations: 'total_conversations', + daily_new_users: 'total_new_users', + daily_api_calls: 'total_api_calls', + daily_tokens: 'total_tokens', +} +const Statistics: FC<{ application: Application | null }> = ({ application }) => { + const [data, setData] = useState({ + daily_conversations: [], + total_conversations: 0, + daily_new_users: [], + total_new_users: 0, + daily_api_calls: [], + total_api_calls: 0, + daily_tokens: [], + total_tokens: 0 + }) + const [query, setQuery] = useState({ + start_date: dayjs().subtract(6, 'd'), + end_date: dayjs().subtract(0, 'd'), + }) + + useEffect(() => { + getData() + }, [application, query]) + const getData = () => { + if (!application?.id) { + return + } + const params = { + start_date: query.start_date.startOf('d').valueOf(), + end_date: query.end_date.endOf('d').valueOf(), + } + + getAppStatistics(application.id, params) + .then(res => { + setData(res as StatisticsData) + }) + } + const handleChange = (date: [Dayjs | null, Dayjs | null] | null) => { + if (!date || !date[0] || !date[1]) return + setQuery({ + start_date: date[0], + end_date: date[1], + }) + } + return ( +
    + + + + + + + {Object.entries(data).map(([key, value]) => { + if (key.includes('total')) { + return null + } + const totalKey = TotalObj[key]; + return ( + + + + ) + })} + +
    + ); +} +export default Statistics; \ No newline at end of file diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index 94ef0ef7..db1e0fa5 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -17,7 +17,7 @@ import CopyModal from './CopyModal' const { Header } = Layout; -const tabKeys = ['arrangement', 'api', 'release'] +const tabKeys = ['arrangement', 'api', 'release', 'statistics'] const menuIcons: Record = { edit: editIcon, copy: copyIcon, diff --git a/web/src/views/ApplicationConfig/components/LineCard.tsx b/web/src/views/ApplicationConfig/components/LineCard.tsx new file mode 100644 index 00000000..0cfc3f0e --- /dev/null +++ b/web/src/views/ApplicationConfig/components/LineCard.tsx @@ -0,0 +1,127 @@ +import { type FC, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import ReactEcharts from 'echarts-for-react'; +import * as echarts from 'echarts'; +import Empty from '@/components/Empty' + +import Card from './Card' +import type { StatisticsItem } from '../types' + +interface LineCardProps { + chartData: StatisticsItem[]; + type: string; + total: number; +} + +const SeriesConfig = { + type: 'line', + stack: 'Total', + smooth: true, + lineStyle: { + width: 3 + }, + showSymbol: true, + label: { + show: false, + position: 'top' + }, + emphasis: { + focus: 'series' + }, +} + +const ColorObj: Record = { + daily_conversations: '#FFB048', + daily_new_users: '#4DA8FF', + daily_api_calls: '#155EEF', + daily_tokens: '#AD88FF' +} + +const LineCard: FC = ({ chartData, type, total }) => { + const { t } = useTranslation() + const chartRef = useRef(null); + + useEffect(() => { + + }, [chartData]) + + const getSeries = () => { + return [{ + ...SeriesConfig, + name: t(`application.${type}`), + data: chartData.map(vo => vo.count), + areaStyle: { + opacity: 0.8, + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: ColorObj[type] }, + { offset: 1, color: '#FFFFFF' } + ]) + }, + }] + } + + return ( + {t(`application.${type}`)} {total}} + > + {chartData && chartData.length > 0 ? ( + item.date), + boundaryGap: false, + }, + yAxis: { + type: 'value', + axisLabel: { + color: '#A8A9AA', + fontFamily: 'PingFangSC, PingFang SC', + align: 'right', + lineHeight: 17, + }, + axisLine: { + lineStyle: { + color: '#EBEBEB', + } + }, + }, + series: getSeries() + }} + style={{ height: '265px', width: '100%', minWidth: '100%', boxSizing: 'border-box' }} + opts={{ renderer: 'canvas' }} + notMerge={true} + lazyUpdate={true} + /> + ) : } + + ) +} + +export default LineCard diff --git a/web/src/views/ApplicationConfig/index.tsx b/web/src/views/ApplicationConfig/index.tsx index 7d5d5950..4dd9231a 100644 --- a/web/src/views/ApplicationConfig/index.tsx +++ b/web/src/views/ApplicationConfig/index.tsx @@ -9,6 +9,7 @@ import ReleasePage from './ReleasePage' import Cluster from './Cluster' import { getApplication } from '@/api/application' import Workflow from '@/views/Workflow'; +import Statistics from './Statistics' const ApplicationConfig: React.FC = () => { const { id } = useParams(); @@ -68,6 +69,7 @@ const ApplicationConfig: React.FC = () => { {activeTab === 'arrangement' && application?.type === 'workflow' && } {activeTab === 'api' && } {activeTab === 'release' && } + {activeTab === 'statistics' && } ); }; diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts index 6f641ebb..9df6e04a 100644 --- a/web/src/views/ApplicationConfig/types.ts +++ b/web/src/views/ApplicationConfig/types.ts @@ -150,4 +150,19 @@ export interface AiPromptForm { } export interface ChatVariableConfigModalRef { handleOpen: (values: Variable[]) => void; +} + +export interface StatisticsItem { + count: number; + date: string; +} +export interface StatisticsData { + daily_conversations: StatisticsItem[]; + daily_new_users: StatisticsItem[]; + daily_api_calls: StatisticsItem[]; + daily_tokens: StatisticsItem[]; + total_conversations: number; + total_new_users: number; + total_api_calls: number; + total_tokens: number; } \ No newline at end of file From dbc4ba84c2a6086a3855b9597b57696493b462d6 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 28 Jan 2026 13:02:50 +0800 Subject: [PATCH 133/175] fix(workflow): fix streaming output issues with multi-output End nodes End nodes with multiple output segments could cause cursor errors or leave some segments inactive, resulting in incorrect final outputs. Unified _emit_active_chunks and _update_scope_activate to ensure all segments are activated in order and streamed correctly. --- api/app/core/workflow/executor.py | 257 ++++++++++++++++--------- api/app/core/workflow/graph_builder.py | 183 ++++++++++-------- 2 files changed, 272 insertions(+), 168 deletions(-) diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index f0411ae3..b7abf659 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -16,7 +16,6 @@ from app.core.workflow.graph_builder import GraphBuilder, StreamOutputConfig from app.core.workflow.nodes import WorkflowState from app.core.workflow.nodes.base_config import VariableType from app.core.workflow.nodes.enums import NodeType -from app.core.workflow.template_renderer import render_template logger = logging.getLogger(__name__) @@ -157,12 +156,137 @@ class WorkflowExecutor: "error": result.get("error"), } - def _update_end_activate(self, node_id): + def _update_scope_activate(self, scope, status=None): + """ + Update the activation state of all End nodes based on a completed scope (node or variable). + + Iterates over all End nodes in `self.end_outputs` and calls + `update_activate` on each, which may: + - Activate variable segments that depend on the completed node/scope. + - Activate the entire End node output if all control conditions are met. + + If any End node becomes active and `self.activate_end` is not yet set, + this node will be marked as the currently active End node. + + Args: + scope (str): The node ID or scope that has completed execution. + status (str | None): Optional status of the node (used for branch/control nodes). + """ for node in self.end_outputs.keys(): - self.end_outputs[node].update_activate(node_id) + self.end_outputs[node].update_activate(scope, status) if self.end_outputs[node].activate and self.activate_end is None: self.activate_end = node + def _update_stream_output_status(self, activate, data): + """ + Update the stream output state of End nodes based on workflow state updates. + + This method checks which nodes/scopes are activated and propagates + activation to End nodes accordingly. + + Args: + activate (dict): Mapping of node_id -> bool indicating which nodes/scopes are activated. + data (dict): Mapping of node_id -> node runtime data, including outputs. + + Behavior: + For each node in `data`: + 1. If the node is activated (`activate[node_id]` is True), + retrieve its output status from `runtime_vars`. + 2. Call `_update_scope_activate` to propagate the activation + to all relevant End nodes and update `self.activate_end`. + """ + for node_id in data.keys(): + if activate.get(node_id): + node_output_status = ( + data[node_id] + .get('runtime_vars', {}) + .get(node_id) + .get("output") + ) + self._update_scope_activate(node_id, status=node_output_status) + + async def _emit_active_chunks( + self, + node_outputs: dict, + variables: dict, + force=False + ): + """ + Process and yield all currently active output segments for the currently active End node. + + This method handles stream-mode output for an End node by iterating through its output segments + (`OutputContent`). Only segments marked as active (`activate=True`) are processed, unless + `force=True`, which allows all segments to be processed regardless of their activation state. + + Behavior: + 1. Iterates from the current `cursor` position to the end of the outputs list. + 2. For each segment: + - If the segment is literal text (`is_variable=False`), append it directly. + - If the segment is a variable (`is_variable=True`), evaluate it using + `evaluate_expression` with the given `node_outputs` and `variables`, + then transform the result with `_trans_output_string`. + 3. Yield a stream event of type "message" containing the processed chunk. + 4. Move the `cursor` forward after processing each segment. + 5. When all segments have been processed, remove this End node from `end_outputs` + and reset `activate_end` to None. + + Args: + node_outputs (dict): Current runtime node outputs, used for variable evaluation. + variables (dict): Current runtime variables, used for variable evaluation. + force (bool, default=False): If True, process segments even if `activate=False`. + + Yields: + dict: A stream event of type "message" containing the processed chunk. + + Notes: + - Segments that fail evaluation (ValueError) are skipped with a warning logged. + - This method only processes the currently active End node (`self.activate_end`). + - Use `force=True` for final emission regardless of activation state. + """ + + end_info = self.end_outputs[self.activate_end] + + while end_info.cursor < len(end_info.outputs): + final_chunk = '' + current_segment = end_info.outputs[end_info.cursor] + + if not current_segment.activate and not force: + # Stop processing until this segment becomes active + break + + # Literal segment + if not current_segment.is_variable: + final_chunk += current_segment.literal + else: + # Variable segment: evaluate and transform + try: + chunk = evaluate_expression( + current_segment.literal, + variables=variables, + node_outputs=node_outputs + ) + chunk = self._trans_output_string(chunk) + final_chunk += chunk + except ValueError: + # Log failed evaluation but continue streaming + logger.warning(f"[STREAM] Failed to evaluate segment: {current_segment.literal}") + + if final_chunk: + yield { + "event": "message", + "data": { + "chunk": final_chunk + } + } + + # Advance cursor after processing + end_info.cursor += 1 + + # Remove End node from active tracking if all segments have been processed + if end_info.cursor >= len(end_info.outputs): + self.end_outputs.pop(self.activate_end) + self.activate_end = None + @staticmethod def _trans_output_string(content): if isinstance(content, str): @@ -218,14 +342,8 @@ class WorkflowExecutor: result = await graph.ainvoke(initial_state, config=self.checkpoint_config) full_content = '' - for end_info in self.end_outputs.values(): - output_template = "".join([output.literal for output in end_info.outputs]) - full_content += render_template( - output_template, - result.get("variables", {}), - result.get("runtime_vars", {}), - strict=False - ) + for end_id in self.end_outputs.keys(): + full_content += result.get('runtime_vars', {}).get(end_id, {}).get('output', '') result["messages"].extend( [ { @@ -306,7 +424,7 @@ class WorkflowExecutor: try: chunk_count = 0 full_content = '' - + self._update_scope_activate("sys") async for event in graph.astream( initial_state, stream_mode=["updates", "debug", "custom"], # Use updates + debug + custom mode @@ -333,9 +451,12 @@ class WorkflowExecutor: if not end_info or end_info.cursor >= len(end_info.outputs): continue current_output = end_info.outputs[end_info.cursor] - if current_output.is_variable and current_output.depends_on_node(node_id): + if current_output.is_variable and current_output.depends_on_scope(node_id): if data.get("done"): end_info.cursor += 1 + if end_info.cursor >= len(end_info.outputs): + self.end_outputs.pop(self.activate_end) + self.activate_end = None else: full_content += data.get("chunk") yield { @@ -415,91 +536,53 @@ class WorkflowExecutor: elif mode == "updates": # Handle state updates - store final state - for node_id in data.keys(): - self._update_end_activate(node_id) - wait = False - state = graph.get_state(config=self.checkpoint_config) - node_outputs = state.values.get("runtime_vars", {}) - for _ in data.keys(): - node_outputs = node_outputs | data.get(_).get("runtime_vars", {}) + state = graph.get_state(config=self.checkpoint_config).values + node_outputs = state.get("runtime_vars", {}) + variables = state.get("variables", {}) + activate = state.get("activate", {}) + for _, node_data in data.items(): + node_outputs |= node_data.get("runtime_vars", {}) + variables |= node_data.get("variables", {}) + self._update_stream_output_status(activate, data) + wait = False while self.activate_end and not wait: - message = '' - logger.info(self.activate_end) - end_info = self.end_outputs[self.activate_end] - content = end_info.outputs[end_info.cursor] - while content.activate: - if not content.is_variable: - full_content += content.literal - message += content.literal - else: - try: - chunk = evaluate_expression( - content.literal, - variables={}, - node_outputs=node_outputs - ) - chunk = self._trans_output_string(chunk) - message += chunk - full_content += chunk - except ValueError: - pass - end_info.cursor += 1 - if end_info.cursor == len(end_info.outputs): - break - content = end_info.outputs[end_info.cursor] - if end_info.cursor != len(end_info.outputs): + async for msg_event in self._emit_active_chunks( + node_outputs=node_outputs, + variables=variables + ): + full_content += msg_event["data"]['chunk'] + yield msg_event + + if self.activate_end: wait = True else: - self.end_outputs.pop(self.activate_end) - self.activate_end = None - for node_id in data.keys(): - self._update_end_activate(node_id) - if message: - yield { - "event": "message", - "data": { - "chunk": message - } - } + self._update_stream_output_status(activate, data) logger.debug(f"[UPDATES] 收到 state 更新 from {list(data.keys())} " f"- execution_id: {self.execution_id}") result = graph.get_state(self.checkpoint_config).values - while self.activate_end: - message = '' - end_info = self.end_outputs[self.activate_end] - content = end_info.outputs[end_info.cursor] - if not content.is_variable: - message += content.literal - else: - node_outputs = result.get("runtime_vars", {}) - variables = result.get("variables", {}) - try: - chunk = evaluate_expression( - content.literal, + node_outputs = result.get("runtime_vars", {}) + variables = result.get("variables", {}) + self.end_outputs = { + node_id: node_info + for node_id, node_info in self.end_outputs.items() + if node_info.activate + } + + if self.end_outputs or self.activate_end: + while self.activate_end: + async for msg_event in self._emit_active_chunks( + node_outputs=node_outputs, variables=variables, - node_outputs=node_outputs - ) - chunk = self._trans_output_string(chunk) - message += chunk - full_content += chunk - except ValueError: - pass - end_info.cursor += 1 - if end_info.cursor == len(end_info.outputs): - self.end_outputs.pop(self.activate_end) - self.activate_end = None - if self.end_outputs: + force=True + ): + full_content += msg_event["data"]['chunk'] + yield msg_event + + if not self.activate_end and self.end_outputs: self.activate_end = list(self.end_outputs.keys())[0] - if message: - yield { - "event": "message", - "data": { - "chunk": message - } - } # 计算耗时 end_time = datetime.datetime.now() diff --git a/api/app/core/workflow/graph_builder.py b/api/app/core/workflow/graph_builder.py index 9fa89fd2..b1d43e08 100644 --- a/api/app/core/workflow/graph_builder.py +++ b/api/app/core/workflow/graph_builder.py @@ -53,114 +53,110 @@ class OutputContent(BaseModel): ) ) - def depends_on_node(self, node_id: str) -> bool: + def depends_on_scope(self, scope: str) -> bool: """ - Check if this output segment depends on a specific node's variable. - - This method examines the `literal` of the output segment to see if it - contains a variable placeholder referencing the given node in the form: - - {{ node_id.field_name }} - - It uses a regular expression to match the exact node ID, avoiding - false positives from substring matches (e.g., 'node1' should not match 'node10'). + Check if this segment depends on a given scope. Args: - node_id (str): The ID of the node to check for in this segment's variable placeholders. + scope (str): Node ID or special variable prefix (e.g., "sys"). Returns: - bool: - - True if the segment contains a variable referencing the given node. - - False otherwise. - - Example: - literal = "{{node1.name}}" - - depends_on_node("node1") -> True - depends_on_node("node2") -> False - - Usage: - This method is primarily used in stream mode to determine whether - a particular variable output segment should be activated when a - specific upstream node completes execution. + bool: True if this segment references the given scope. """ - variable_pattern = rf"\{{\{{\s*{re.escape(node_id)}\.[a-zA-Z0-9_]+\s*\}}\}}" - pattern = re.compile(variable_pattern) - match = pattern.search(self.literal) - if match: - return True - return False + pattern = rf"\{{\{{\s*{re.escape(scope)}\.[a-zA-Z0-9_]+\s*\}}\}}" + return bool(re.search(pattern, self.literal)) class StreamOutputConfig(BaseModel): """ Streaming output configuration for an End node. - This structure controls: - - whether the End node output is globally active - - which upstream branch nodes are responsible for activation - - how each output segment behaves in streaming mode + This configuration describes how the End node output behaves in streaming mode, + including: + - whether output emission is globally activated + - which upstream branch/control nodes gate the activation + - how each parsed output segment is streamed and activated """ activate: bool = Field( ..., description=( - "Global activation state of the End node output.\n" - "If False, no output should be emitted until all control nodes are resolved." + "Global activation flag for the End node output.\n" + "When False, output segments should not be emitted even if available.\n" + "This flag typically becomes True once required control branch conditions " + "are satisfied." ) ) - control_nodes: list[str] = Field( + control_nodes: dict[str, str] = Field( ..., description=( - "List of upstream branch node IDs that control this End node.\n" - "Each node must signal completion before output becomes active." + "Control branch conditions for this End node output.\n" + "Mapping of `branch_node_id -> expected_branch_label`.\n" + "The End node output becomes globally active when a controlling branch node " + "reports a matching completion status." ) ) outputs: list[OutputContent] = Field( ..., - description="Ordered list of output segments parsed from the output template." + description=( + "Ordered list of output segments parsed from the output template.\n" + "Each segment represents either a literal text block or a variable placeholder " + "that may be activated independently." + ) ) cursor: int = Field( ..., description=( "Streaming cursor index.\n" - "Indicates how many output segments have already been emitted." + "Indicates the next output segment index to be emitted.\n" + "Segments with index < cursor are considered already streamed." ) ) - def update_activate(self, node_id): + def update_activate(self, scope: str, status=None): """ - Update activation state based on an upstream node completion. + Update streaming activation state based on an upstream node or special variable. - This method is typically called when a branch/control node finishes execution. + Args: + scope (str): + Identifier of the completed upstream entity. + - If a control branch node, it should match a key in `control_nodes`. + - If a variable placeholder (e.g., "sys.xxx"), it may appear in output segments. + status (optional): + Completion status of the control branch node. + Required when `scope` refers to a control node. Behavior: - 1. If the node is a control node: - - Remove it from `control_nodes` - - If all control nodes are resolved, activate the entire output + 1. Control branch nodes: + - If `scope` matches a key in `control_nodes` and `status` matches the expected + branch label, the End node output becomes globally active (`activate = True`). - 2. Activate variable output segments that depend on this node: - - If an output segment is a variable - - And its literal references the completed node_id - - Mark that segment as active + 2. Variable output segments: + - For each segment that is a variable (`is_variable=True`): + - If the segment literal references `scope`, mark the segment as active. + - This applies both to regular node variables (e.g., "node_id.field") + and special system variables (e.g., "sys.xxx"). + + Notes: + - This method does not emit output or advance the streaming cursor. + - It only updates activation flags based on upstream events or special variables. """ # Case 1: resolve control branch dependency - if node_id in self.control_nodes: - self.control_nodes.remove(node_id) - - # All branch constraints resolved → enable output - if not self.control_nodes: + if scope in self.control_nodes.keys(): + if status is None: + raise RuntimeError("[Stream Output] Control node activation status not provided") + if status == self.control_nodes[scope]: self.activate = True # Case 2: activate variable segments related to this node for i in range(len(self.outputs)): if ( self.outputs[i].is_variable - and self.outputs[i].depends_on_node(node_id) + and self.outputs[i].depends_on_scope(scope) ): self.outputs[i].activate = True @@ -184,11 +180,11 @@ class GraphBuilder: self._find_upstream_branch_node = lru_cache( maxsize=len(self.nodes) * 2 )(self._find_upstream_branch_node) - self._analyze_end_node_output() self.graph = StateGraph(WorkflowState) self.add_nodes() self.add_edges() + self._analyze_end_node_output() # EDGES MUST BE ADDED AFTER NODES ARE ADDED. @property @@ -216,30 +212,53 @@ class GraphBuilder: except KeyError: raise RuntimeError(f"Node not found: Id={node_id}") - def _find_upstream_branch_node(self, target_node: str) -> tuple[bool, tuple[str]]: - """Find upstream branch nodes for a given target node in the workflow graph. + def _find_upstream_branch_node(self, target_node: str) -> tuple[bool, tuple[tuple[str, str]]]: + """ + Recursively find all upstream branch (control) nodes that influence the execution + of the given target node. - This method identifies all upstream control (branch) nodes that can affect - the execution of `target_node`. If `target_node` is reachable from a start - node (i.e., a node with no upstream nodes), the method returns an empty tuple. + This method walks upstream along the workflow graph starting from `target_node`. + It distinguishes between: + - branch nodes (node types listed in `BRANCH_NODES`) + - non-branch nodes (ordinary processing nodes) - The function distinguishes between branch nodes (defined in `BRANCH_NODES`) - and non-branch nodes, recursively traversing upstream through non-branch - nodes. If any non-branch upstream path does not lead to a branch node, - the result will indicate that no valid upstream branch node exists. + Traversal rules: + 1. For each immediate upstream node: + - If it is a branch node, it is recorded as an affecting control node. + - If it is a non-branch node, the traversal continues recursively upstream. + 2. If ANY upstream path reaches a START / CYCLE_START node without encountering + a branch node, the traversal is considered invalid: + - `has_branch` will be False + - no branch nodes are returned. + 3. Only when ALL upstream non-branch paths eventually lead to at least one + branch node will `has_branch` be True. + + Special case: + - If `target_node` has no upstream nodes AND its type is START or CYCLE_START, + it is considered directly reachable from the workflow entry, and therefore + has no controlling branch nodes. Args: - target_node (str): The identifier of the target node. + target_node (str): + The identifier of the node whose upstream control branches + are to be resolved. Returns: - tuple[bool, tuple[str]]: - - has_branch (bool): True if all upstream non-branch paths lead to at least - one branch node; False if any path reaches a start node without a branch. - - branch_nodes (tuple[str]): A deduplicated tuple of upstream branch node IDs - affecting `target_node`. Returns an empty tuple if `has_branch` is False. + tuple[bool, tuple[tuple[str, str]]]: + - has_branch (bool): + True if every upstream path from `target_node` encounters + at least one branch node. + False if any path reaches a start node without a branch. + - branch_nodes (tuple[tuple[str, str]]): + A deduplicated tuple of `(branch_node_id, branch_label)` pairs + representing all branch nodes that can influence `target_node`. + Returns an empty tuple if `has_branch` is False. """ source_nodes = [ - edge.get("source") + { + "id": edge.get("source"), + "branch": edge.get("label") + } for edge in self.edges if edge.get("target") == target_node ] @@ -249,11 +268,13 @@ class GraphBuilder: branch_nodes = [] non_branch_nodes = [] - for node_id in source_nodes: - if self.get_node_type(node_id) in BRANCH_NODES: - branch_nodes.append(node_id) + for node_info in source_nodes: + if self.get_node_type(node_info["id"]) in BRANCH_NODES: + branch_nodes.append( + (node_info["id"], node_info["branch"]) + ) else: - non_branch_nodes.append(node_id) + non_branch_nodes.append(node_info["id"]) has_branch = True for node_id in non_branch_nodes: @@ -334,7 +355,7 @@ class GraphBuilder: activate=not has_branch, # Branch nodes that control activation of this End node - control_nodes=list(control_nodes), + control_nodes=dict(control_nodes), # Convert output segments into OutputContent objects outputs=list( @@ -362,7 +383,7 @@ class GraphBuilder: else: self.end_node_map[end_node_id] = StreamOutputConfig( activate=True, - control_nodes=[], + control_nodes={}, outputs=list( [ OutputContent( From 8984ba7aeff5a69e4ab7152e5869a1c03285bc60 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 14:49:30 +0800 Subject: [PATCH 134/175] feat(web): add apps statistics api --- web/src/api/application.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/api/application.ts b/web/src/api/application.ts index 69d27d44..1f20282e 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -108,4 +108,8 @@ export const getShareToken = (share_token: string, user_id: string) => { // 复制应用 export const copyApplication = (app_id: string, new_name: string) => { return request.post(`/apps/${app_id}/copy?new_name=${new_name}`) -} \ No newline at end of file +} +// 数据统计 +export const getAppStatistics = (app_id: string, data: { start_date: number; end_date: number; }) => { + return request.get(`/apps/${app_id}/statistics`, data) +} From 35a10e86b5b49e92e9058c13f92c21b1018372dc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 15:05:12 +0800 Subject: [PATCH 135/175] fix(web): agent's knowledge_bases bugfix --- web/src/views/ApplicationConfig/Agent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 77e90440..82a3f177 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -212,7 +212,7 @@ const Agent = forwardRef((_props, ref) => { ...data.knowledge_retrieval, ...knowledgeRest, knowledge_bases: knowledge_bases.map(item => ({ - kb_id: item.id, + kb_id: item.kb_id || item.id, ...(item.config || {}) })) } as KnowledgeConfig : null, From 102712a16e4efc76e11bcf8f9f316d737fe45eeb Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 15:20:31 +0800 Subject: [PATCH 136/175] Revert "feat(web): update read_all_config select valueKey" This reverts commit 46f0f3cee90f7cf852bf5bcf89866b57448f1ffa. --- web/src/components/CustomSelect/index.tsx | 19 ++++++------------- web/src/views/ApplicationConfig/Agent.tsx | 6 ++---- web/src/views/Workflow/constant.ts | 4 ++-- web/src/views/Workflow/types.ts | 2 +- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/web/src/components/CustomSelect/index.tsx b/web/src/components/CustomSelect/index.tsx index 6153a76d..1887d635 100644 --- a/web/src/components/CustomSelect/index.tsx +++ b/web/src/components/CustomSelect/index.tsx @@ -15,7 +15,7 @@ interface ApiResponse { interface CustomSelectProps extends Omit { url: string; params?: Record; - valueKey?: string | string[]; + valueKey?: string; labelKey?: string; placeholder?: string; hasAll?: boolean; @@ -66,18 +66,11 @@ const CustomSelect: FC = ({ {...props} > {hasAll && {allTitle || t('common.all')}} - {displayOptions.map((option) => { - const getValue = () => { - if (typeof valueKey === 'string') return option[valueKey]; - return valueKey.find(key => option[key] != null) ? option[valueKey.find(key => option[key] != null)!] : undefined; - }; - const value = getValue(); - return ( - - {String(option[labelKey])} - - ); - })} + {displayOptions.map((option) => ( + + {String(option[labelKey])} + + ))} ); }; diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 97a622d1..77e90440 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -79,7 +79,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], placeholder={t('common.pleaseSelect')} url={url} hasAll={false} - valueKey={['config_id_old', 'config_id']} + valueKey='config_id' labelKey="config_name" /> @@ -126,14 +126,12 @@ const Agent = forwardRef((_props, ref) => { getApplicationConfig(id as string).then(res => { const response = res as Config let allTools = Array.isArray(response.tools) ? response.tools : [] - const memoryContent = response.memory?.memory_content - const convertedMemoryContent = memoryContent && !isNaN(Number(memoryContent)) ? Number(memoryContent) : memoryContent form.setFieldsValue({ ...response, tools: allTools, memory: { ...response.memory, - memory_content: convertedMemoryContent + memory_content: response.memory?.memory_content ? Number(response.memory?.memory_content) : undefined } }) setData({ diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index aab8be7d..e250e184 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -200,7 +200,7 @@ export const nodeLibrary: NodeLibrary[] = [ config_id: { type: 'customSelect', url: memoryConfigListUrl, - valueKey: ['config_id_old', 'config_id'], + valueKey: 'config_id', labelKey: 'config_name' }, search_switch: { @@ -223,7 +223,7 @@ export const nodeLibrary: NodeLibrary[] = [ config_id: { type: 'customSelect', url: memoryConfigListUrl, - valueKey: ['config_id_old', 'config_id'], + valueKey: 'config_id', labelKey: 'config_name' } } diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 31d1f512..909c30e4 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -14,7 +14,7 @@ export interface NodeConfig { url?: string; params?: { [key: string]: unknown; } - valueKey?: string | string[]; + valueKey?: string; labelKey?: string; defaultValue?: any; From 44bf1eeae2d88383cca334a464e33c4d3aac3bad Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 28 Jan 2026 15:24:55 +0800 Subject: [PATCH 137/175] [add] migrations script --- .../versions/915bed077f8d_202601281340.py | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 api/migrations/versions/915bed077f8d_202601281340.py diff --git a/api/migrations/versions/915bed077f8d_202601281340.py b/api/migrations/versions/915bed077f8d_202601281340.py new file mode 100644 index 00000000..022f0d25 --- /dev/null +++ b/api/migrations/versions/915bed077f8d_202601281340.py @@ -0,0 +1,224 @@ +"""202601281340 + +Revision ID: 915bed077f8d +Revises: 75f0ec80e50b +Create Date: 2026-01-28 13:38:49.471560 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '915bed077f8d' +down_revision: Union[str, None] = '75f0ec80e50b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +BACKUP_TABLE_NAME = 'model_api_keys_backup_20260123' + +def get_temp_models(): + """创建临时模型,用于迁移过程中查询数据""" + metadata = sa.MetaData() + + # 临时ModelApiKey表(仅包含需要的字段) + ModelApiKey = sa.Table( + 'model_api_keys', metadata, + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('model_config_id', sa.UUID(), nullable=True), + ) + + # 临时关联表(和升级脚本创建的表结构一致) + ModelConfigApiKeyAssociation = sa.Table( + 'model_config_api_key_association', metadata, + sa.Column('model_config_id', sa.UUID(), nullable=False), + sa.Column('api_key_id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + ) + + ModelApiKeyBackup = sa.Table( + BACKUP_TABLE_NAME, metadata, + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('model_name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('provider', sa.String(), nullable=False), + sa.Column('api_key', sa.String(), nullable=False), + sa.Column('api_base', sa.String(), nullable=True), + sa.Column('config', sa.JSON(), nullable=True), + sa.Column('usage_count', sa.String(), default="0"), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('priority', sa.String(), default="1"), + sa.Column('model_config_id', sa.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), default=True), + ) + + return ModelApiKey, ModelConfigApiKeyAssociation, ModelApiKeyBackup + + +def backup_model_api_keys(): + """备份model_api_keys表的结构和数据""" + connection = op.get_bind() + + # 检查备份表是否已存在 + result = connection.execute(sa.text(f""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = '{BACKUP_TABLE_NAME}' + ); + """)).scalar() + + if result: + # 备份表已存在,先删除再重建(确保结构一致) + op.execute(f"DROP TABLE IF EXISTS {BACKUP_TABLE_NAME};") + + # 直接复制表结构和数据(PostgreSQL专用,一步完成) + op.execute(f""" + CREATE TABLE {BACKUP_TABLE_NAME} AS + SELECT * FROM model_api_keys; + """) + + # 统计行数 + backup_count = connection.execute(sa.text(f"SELECT COUNT(*) FROM {BACKUP_TABLE_NAME}")).scalar() + original_count = connection.execute(sa.text("SELECT COUNT(*) FROM model_api_keys")).scalar() + + print( + f"已备份model_api_keys表到 {BACKUP_TABLE_NAME} \n" + f" 原表数据行数:{original_count} | 备份表数据行数:{backup_count}" + ) + +# def restore_model_api_keys_from_backup(): +# """从备份表恢复model_api_keys数据(可选,用于回滚失败时手动恢复)""" +# # 1. 清空原表(谨慎使用!) +# # op.execute("TRUNCATE TABLE model_api_keys;") +# +# # 2. 从备份表恢复数据 +# op.execute(f""" +# INSERT INTO model_api_keys +# SELECT * FROM {BACKUP_TABLE_NAME} +# ON CONFLICT (id) DO UPDATE SET +# model_name = EXCLUDED.model_name, +# description = EXCLUDED.description, +# provider = EXCLUDED.provider, +# api_key = EXCLUDED.api_key, +# api_base = EXCLUDED.api_base, +# config = EXCLUDED.config, +# usage_count = EXCLUDED.usage_count, +# last_used_at = EXCLUDED.last_used_at, +# priority = EXCLUDED.priority, +# model_config_id = EXCLUDED.model_config_id, +# created_at = EXCLUDED.created_at, +# updated_at = EXCLUDED.updated_at, +# is_active = EXCLUDED.is_active; +# """) +# print(f"✅ 已从 {BACKUP_TABLE_NAME} 恢复model_api_keys表数据") + +def upgrade() -> None: + backup_model_api_keys() + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('model_bases', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('logo', sa.String(length=255), nullable=True, comment='模型logo图片URL'), + sa.Column('name', sa.String(), nullable=False, comment='模型唯一标识(如gpt-3.5-turbo)'), + sa.Column('type', sa.String(), nullable=False, comment='模型类型'), + sa.Column('provider', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True, comment='模型描述'), + sa.Column('is_deprecated', sa.Boolean(), nullable=False, comment='是否弃用'), + sa.Column('is_official', sa.Boolean(), nullable=True, comment='是否供应商官方模型(区分自定义)'), + sa.Column('tags', sa.ARRAY(sa.String()), nullable=False, comment="模型标签(如['聊天', '创作'])"), + sa.Column('add_count', sa.Integer(), nullable=False, comment='模型被用户添加的次数'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', 'provider', name='uk_model_name_provider') + ) + op.create_index(op.f('ix_model_bases_id'), 'model_bases', ['id'], unique=False) + op.create_index(op.f('ix_model_bases_provider'), 'model_bases', ['provider'], unique=False) + op.create_index(op.f('ix_model_bases_type'), 'model_bases', ['type'], unique=False) + op.create_table('model_config_api_key_association', + sa.Column('model_config_id', sa.UUID(), nullable=False), + sa.Column('api_key_id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['api_key_id'], ['model_api_keys.id'], ), + sa.ForeignKeyConstraint(['model_config_id'], ['model_configs.id'], ), + sa.PrimaryKeyConstraint('model_config_id', 'api_key_id') + ) + op.add_column('model_api_keys', sa.Column('description', sa.String(), nullable=True, comment='备注')) + op.add_column('model_configs', sa.Column('model_id', sa.UUID(), nullable=True, comment='基础模型ID')) + op.add_column('model_configs', sa.Column('logo', sa.String(length=255), nullable=True, comment='模型logo图片URL')) + op.add_column('model_configs', sa.Column('provider', sa.String(), server_default='composite', nullable=False, comment='供应商')) + op.add_column('model_configs', sa.Column('is_composite', sa.Boolean(), server_default='true', nullable=False, comment='是否为组合模型')) + op.add_column('model_configs', sa.Column('load_balance_strategy', sa.String(), nullable=True, comment='负载均衡策略')) + op.create_index(op.f('ix_model_configs_model_id'), 'model_configs', ['model_id'], unique=False) + op.create_foreign_key("model_configs_model_id_fkey", 'model_configs', 'model_bases', ['model_id'], ['id']) + connection = op.get_bind() + ModelApiKey, ModelConfigApiKeyAssociation, _ = get_temp_models() + + # 查询所有有model_config_id的API Key + api_keys = connection.execute( + sa.select(ModelApiKey.c.id, ModelApiKey.c.model_config_id) + .where(ModelApiKey.c.model_config_id.isnot(None)) + ).fetchall() + + # 批量插入到多对多表 + if api_keys: + association_data = [ + { + 'model_config_id': row.model_config_id, + 'api_key_id': row.id + } + for row in api_keys + ] + connection.execute(ModelConfigApiKeyAssociation.insert(), association_data) + op.drop_constraint(op.f('model_api_keys_model_config_id_fkey'), 'model_api_keys', type_='foreignkey') + op.drop_column('model_api_keys', 'model_config_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("model_configs_model_id_fkey", 'model_configs', type_='foreignkey') + op.drop_index(op.f('ix_model_configs_model_id'), table_name='model_configs') + op.drop_column('model_configs', 'load_balance_strategy') + op.drop_column('model_configs', 'is_composite') + op.drop_column('model_configs', 'provider') + op.drop_column('model_configs', 'logo') + op.drop_column('model_configs', 'model_id') + op.add_column('model_api_keys', sa.Column('model_config_id', sa.UUID(), autoincrement=False, nullable=True, comment='模型配置ID')) + connection = op.get_bind() + ModelApiKey, ModelConfigApiKeyAssociation, _ = get_temp_models() + + # 查询多对多表中的关联数据(取每个API Key的第一个关联的model_config_id) + association_data = connection.execute( + sa.select( + ModelConfigApiKeyAssociation.c.api_key_id, + ModelConfigApiKeyAssociation.c.model_config_id + ).distinct(ModelConfigApiKeyAssociation.c.api_key_id) + ).fetchall() + + # 批量更新model_api_keys表 + if association_data: + for api_key_id, model_config_id in association_data: + connection.execute( + sa.update(ModelApiKey) + .where(ModelApiKey.c.id == api_key_id) + .values(model_config_id=model_config_id) + ) + + op.execute( + "UPDATE model_api_keys SET model_config_id = '00000000-0000-0000-0000-000000000000' WHERE model_config_id IS NULL") + op.alter_column('model_api_keys', 'model_config_id', nullable=False) + op.create_foreign_key(op.f('model_api_keys_model_config_id_fkey'), 'model_api_keys', 'model_configs', ['model_config_id'], ['id']) + op.drop_column('model_api_keys', 'description') + op.drop_table('model_config_api_key_association') + # ### 可选:回滚时恢复备份(如需)### + # restore_model_api_keys_from_backup() + + print( + f"回滚完成!备份表 {BACKUP_TABLE_NAME} 仍保留,如需手动恢复可执行 restore_model_api_keys_from_backup() 函数") + op.drop_index(op.f('ix_model_bases_type'), table_name='model_bases') + op.drop_index(op.f('ix_model_bases_provider'), table_name='model_bases') + op.drop_index(op.f('ix_model_bases_id'), table_name='model_bases') + op.drop_table('model_bases') + # ### end Alembic commands ### From 1748a390ecaa81efc84254fb43cb28b7c2cc66d8 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 28 Jan 2026 15:30:36 +0800 Subject: [PATCH 138/175] perf(workflow): make memory write node backward-compatible and defer config validation --- api/app/core/workflow/nodes/memory/config.py | 2 +- api/app/core/workflow/nodes/memory/node.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/app/core/workflow/nodes/memory/config.py b/api/app/core/workflow/nodes/memory/config.py index 57ee6dc2..31881e24 100644 --- a/api/app/core/workflow/nodes/memory/config.py +++ b/api/app/core/workflow/nodes/memory/config.py @@ -25,6 +25,6 @@ class MemoryWriteNodeConfig(BaseNodeConfig): ... ) - config_id: UUID = Field( + config_id: UUID | int = Field( ... ) diff --git a/api/app/core/workflow/nodes/memory/node.py b/api/app/core/workflow/nodes/memory/node.py index f71c70ee..13860bec 100644 --- a/api/app/core/workflow/nodes/memory/node.py +++ b/api/app/core/workflow/nodes/memory/node.py @@ -36,9 +36,10 @@ class MemoryReadNode(BaseNode): class MemoryWriteNode(BaseNode): def __init__(self, node_config: dict[str, Any], workflow_config: dict[str, Any]): super().__init__(node_config, workflow_config) - self.typed_config = MemoryWriteNodeConfig(**self.config) + self.typed_config: MemoryWriteNodeConfig | None = None async def execute(self, state: WorkflowState) -> Any: + self.typed_config = MemoryWriteNodeConfig(**self.config) end_user_id = self.get_variable("sys.user_id", state) if not end_user_id: From 00c4a524b7689e1c5590fb607ab186d7b24c1558 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 16:04:38 +0800 Subject: [PATCH 139/175] =?UTF-8?q?=E6=97=A7=E6=95=B0=E6=8D=AE=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/emotion_config_controller.py | 5 +-- .../controllers/memory_forget_controller.py | 13 +++++-- .../memory_reflection_controller.py | 12 ++++--- .../repositories/memory_config_repository.py | 6 ++-- api/app/schemas/memory_storage_schema.py | 8 ++--- api/app/services/emotion_config_service.py | 28 +++++++++++++++ api/app/services/memory_agent_service.py | 4 ++- api/app/utils/config_utils.py | 35 +++++++++++++++++++ 8 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 api/app/utils/config_utils.py diff --git a/api/app/controllers/emotion_config_controller.py b/api/app/controllers/emotion_config_controller.py index b0015bc2..752f4c49 100644 --- a/api/app/controllers/emotion_config_controller.py +++ b/api/app/controllers/emotion_config_controller.py @@ -21,6 +21,7 @@ from app.schemas.response_schema import ApiResponse from app.services.emotion_config_service import EmotionConfigService from app.core.logging_config import get_api_logger from app.db import get_db +from app.utils.config_utils import resolve_config_id # 获取API专用日志器 api_logger = get_api_logger() @@ -46,7 +47,7 @@ class EmotionConfigUpdate(BaseModel): @router.get("/read_config", response_model=ApiResponse) def get_emotion_config( - config_id: UUID = Query(..., description="配置ID"), + config_id: UUID|int = Query(..., description="配置ID"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -79,7 +80,7 @@ def get_emotion_config( f"用户 {current_user.username} 请求获取情绪配置", extra={"config_id": config_id} ) - + config_id=resolve_config_id(config_id, db) # 初始化服务 config_service = EmotionConfigService(db) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index a6b6028f..b1f0ccc1 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -34,7 +34,7 @@ from app.schemas.memory_storage_schema import ( ) from app.schemas.response_schema import ApiResponse from app.services.memory_forget_service import MemoryForgetService - +from app.utils.config_utils import resolve_config_id # 获取API专用日志器 api_logger = get_api_logger() @@ -84,6 +84,9 @@ async def trigger_forgetting_cycle( connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") + config_id = resolve_config_id(int(config_id), db) + + if config_id is None: api_logger.warning(f"终端用户 {end_user_id} 未关联记忆配置") @@ -129,7 +132,7 @@ async def trigger_forgetting_cycle( @router.get("/read_config", response_model=ApiResponse) async def read_forgetting_config( - config_id: UUID, + config_id: UUID|int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -158,6 +161,7 @@ async def read_forgetting_config( ) try: + config_id=resolve_config_id(config_id, db) # 调用服务层读取配置 config = forget_service.read_forgetting_config(db=db, config_id=config_id) @@ -195,6 +199,8 @@ async def update_forgetting_config( ApiResponse: 包含更新结果的响应 """ workspace_id = current_user.current_workspace_id + payload.config_id=resolve_config_id(int(payload.config_id), db) + # 检查用户是否已选择工作空间 if workspace_id is None: @@ -269,6 +275,7 @@ async def get_forgetting_stats( connected_config = get_end_user_connected_config(end_user_id, db) config_id = connected_config.get("memory_config_id") + config_id = resolve_config_id(config_id, db) if config_id is None: api_logger.warning(f"终端用户 {end_user_id} 未关联记忆配置") @@ -325,7 +332,7 @@ async def get_forgetting_curve( ApiResponse: 包含遗忘曲线数据的响应 """ workspace_id = current_user.current_workspace_id - + request.config_id = resolve_config_id(int(request.config_id), db) # 检查用户是否已选择工作空间 if workspace_id is None: api_logger.warning(f"用户 {current_user.username} 尝试获取遗忘曲线但未选择工作空间") diff --git a/api/app/controllers/memory_reflection_controller.py b/api/app/controllers/memory_reflection_controller.py index ccf9485f..dbf3bf16 100644 --- a/api/app/controllers/memory_reflection_controller.py +++ b/api/app/controllers/memory_reflection_controller.py @@ -25,6 +25,8 @@ from fastapi import APIRouter, Depends, HTTPException, status,Header from sqlalchemy import text from sqlalchemy.orm import Session +from app.utils.config_utils import resolve_config_id + load_dotenv() api_logger = get_api_logger() @@ -157,17 +159,19 @@ async def start_workspace_reflection( @router.get("/reflection/configs") async def start_reflection_configs( - config_id: uuid.UUID, + config_id: uuid.UUID|int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: """通过config_id查询memory_config表中的反思配置信息""" try: + config_id=resolve_config_id(config_id,db) api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) + memory_config_id = resolve_config_id(result.config_id, db) # 构建返回数据 reflection_config = { - "config_id": result.config_id, + "config_id": memory_config_id, "reflection_enabled": result.enable_self_reflexion, "reflection_period_in_hours": result.iteration_period, "reflexion_range": result.reflexion_range, @@ -192,7 +196,7 @@ async def start_reflection_configs( @router.get("/reflection/run") async def reflection_run( - config_id: UUID, + config_id: UUID|int, language_type: str = Header(default="zh", alias="X-Language-Type"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -200,7 +204,7 @@ async def reflection_run( """Activate the reflection function for all matching applications in the workspace""" api_logger.info(f"用户 {current_user.username} 查询反思配置,config_id: {config_id}") - + config_id = resolve_config_id(config_id, db) # 使用MemoryConfigRepository查询反思配置 result = MemoryConfigRepository.query_reflection_config_by_id(db, config_id) if not result: diff --git a/api/app/repositories/memory_config_repository.py b/api/app/repositories/memory_config_repository.py index 12e564e2..fbc04f2e 100644 --- a/api/app/repositories/memory_config_repository.py +++ b/api/app/repositories/memory_config_repository.py @@ -24,6 +24,8 @@ from app.schemas.memory_storage_schema import ( from sqlalchemy import desc, select from sqlalchemy.orm import Session +from app.utils.config_utils import resolve_config_id + # 获取数据库专用日志器 db_logger = get_db_logger() # 获取配置专用日志器 @@ -410,7 +412,7 @@ class MemoryConfigRepository: raise @staticmethod - def get_extracted_config(db: Session, config_id: UUID) -> Optional[Dict]: + def get_extracted_config(db: Session, config_id: UUID |int) -> Optional[Dict]: """获取萃取配置,通过主键查询某条配置 Args: @@ -420,8 +422,8 @@ class MemoryConfigRepository: Returns: Optional[Dict]: 萃取配置字典,不存在则返回None """ + config_id=resolve_config_id(config_id,db) db_logger.debug(f"查询萃取配置: config_id={config_id}") - try: db_config = db.query(MemoryConfig).filter(MemoryConfig.config_id == config_id).first() if not db_config: diff --git a/api/app/schemas/memory_storage_schema.py b/api/app/schemas/memory_storage_schema.py index d9c04f8f..b855d57d 100644 --- a/api/app/schemas/memory_storage_schema.py +++ b/api/app/schemas/memory_storage_schema.py @@ -147,7 +147,7 @@ class ReflexionResultSchema(BaseModel): # Composite key identifying a config row class ConfigKey(BaseModel): # 配置参数键模型 model_config = ConfigDict(populate_by_name=True, extra="forbid") - config_id: uuid.UUID = Field("config_id", description="配置唯一标识(UUID)") + config_id: Union[uuid.UUID, int] = Field(..., description="配置唯一标识(UUID或int)") user_id: str = Field("user_id", description="用户标识(字符串)") apply_id: str = Field("apply_id", description="应用或场景标识(字符串)") @@ -423,8 +423,8 @@ class ForgettingConfigResponse(BaseModel): class ForgettingConfigUpdateRequest(BaseModel): """遗忘引擎配置更新请求模型""" model_config = ConfigDict(populate_by_name=True, extra="forbid") - - config_id: uuid.UUID = Field(..., description="配置ID") + + config_id: Union[uuid.UUID, int,str] = Field(..., description="配置唯一标识(UUID或int)") decay_constant: Optional[float] = Field(None, ge=0.0, le=1.0, description="衰减常数 d") lambda_time: Optional[float] = Field(None, ge=0.0, le=1.0, description="时间衰减参数") lambda_mem: Optional[float] = Field(None, ge=0.0, le=1.0, description="记忆衰减参数") @@ -499,7 +499,7 @@ class ForgettingCurveRequest(BaseModel): importance_score: float = Field(0.5, ge=0.0, le=1.0, description="重要性分数(0-1)") days: int = Field(60, ge=1, le=365, description="模拟天数(默认60天)") - config_id: Optional[uuid.UUID] = Field(None, description="配置ID(可选,如果为None则使用默认配置)") + config_id: Union[uuid.UUID, int, str] = Field(..., description="配置唯一标识(UUID或int)") class ForgettingCurveResponse(BaseModel): diff --git a/api/app/services/emotion_config_service.py b/api/app/services/emotion_config_service.py index 9880d4e1..bab6d7a8 100644 --- a/api/app/services/emotion_config_service.py +++ b/api/app/services/emotion_config_service.py @@ -212,3 +212,31 @@ class EmotionConfigService: self.db.rollback() logger.error(f"更新情绪配置失败: {str(e)}", exc_info=True) raise + + +def resolve_config_id(config_id: UUID | int, db: Session) -> UUID: + """ + 解析 config_id,如果是整数则通过 config_id_old 查找对应的 UUID + + Args: + config_id: 配置ID(UUID 或整数) + db: 数据库会话 + + Returns: + UUID: 解析后的配置ID + + Raises: + ValueError: 当找不到对应的配置时 + """ + if isinstance(config_id, int): + from app.models.memory_config_model import MemoryConfig + memory_config = db.query(MemoryConfig).filter( + MemoryConfig.config_id_old == config_id + ).first() + + if not memory_config: + raise ValueError(f"未找到 config_id_old={config_id} 对应的配置") + + return memory_config.config_id + + return config_id diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index 2378da51..823d5d43 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -334,7 +334,9 @@ class MemoryAgentService: langchain_messages.append(HumanMessage(content=msg['content'])) elif msg['role'] == 'assistant': langchain_messages.append(AIMessage(content=msg['content'])) - + print(100*'-') + print(langchain_messages) + print(100*'-') # 初始状态 - 包含所有必要字段 initial_state = { "messages": langchain_messages, diff --git a/api/app/utils/config_utils.py b/api/app/utils/config_utils.py new file mode 100644 index 00000000..f75b0aab --- /dev/null +++ b/api/app/utils/config_utils.py @@ -0,0 +1,35 @@ +""" +Configuration utility functions + +Shared utilities for configuration handling to avoid circular imports. +""" +from uuid import UUID +from sqlalchemy.orm import Session + + +def resolve_config_id(config_id: UUID | int, db: Session) -> UUID: + """ + 解析 config_id,如果是整数则通过 config_id_old 查找对应的 UUID + + Args: + config_id: 配置ID(UUID 或整数) + db: 数据库会话 + + Returns: + UUID: 解析后的配置ID + + Raises: + ValueError: 当找不到对应的配置时 + """ + if isinstance(config_id, int): + from app.models.memory_config_model import MemoryConfig + memory_config = db.query(MemoryConfig).filter( + MemoryConfig.config_id_old == config_id + ).first() + + if not memory_config: + raise ValueError(f"未找到 config_id_old={config_id} 对应的配置") + + return memory_config.config_id + + return config_id From 7207d7c8474eea5b9cf73016141883a694684816 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 16:05:35 +0800 Subject: [PATCH 140/175] =?UTF-8?q?=E6=97=A7=E6=95=B0=E6=8D=AE=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_forget_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index b1f0ccc1..27ca54e2 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -261,7 +261,6 @@ async def get_forgetting_stats( ApiResponse: 包含统计信息的响应 """ workspace_id = current_user.current_workspace_id - # 检查用户是否已选择工作空间 if workspace_id is None: api_logger.warning(f"用户 {current_user.username} 尝试获取遗忘引擎统计但未选择工作空间") From 95f15b77a3f293124b69676c73014c2da478a079 Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 16:05:54 +0800 Subject: [PATCH 141/175] =?UTF-8?q?=E6=97=A7=E6=95=B0=E6=8D=AE=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_forget_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/app/controllers/memory_forget_controller.py b/api/app/controllers/memory_forget_controller.py index 27ca54e2..ea55ea26 100644 --- a/api/app/controllers/memory_forget_controller.py +++ b/api/app/controllers/memory_forget_controller.py @@ -265,7 +265,6 @@ async def get_forgetting_stats( if workspace_id is None: api_logger.warning(f"用户 {current_user.username} 尝试获取遗忘引擎统计但未选择工作空间") return fail(BizCode.INVALID_PARAMETER, "请先切换到一个工作空间", "current_workspace_id is None") - # 如果提供了 end_user_id,通过它获取 config_id config_id = None if end_user_id: From 0f1eed0b1e9a87425497a0fa8ba0b772f55b980b Mon Sep 17 00:00:00 2001 From: lixinyue <2569494688@qq.com> Date: Wed, 28 Jan 2026 16:07:53 +0800 Subject: [PATCH 142/175] =?UTF-8?q?=E6=97=A7=E6=95=B0=E6=8D=AE=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/services/emotion_config_service.py | 28 ---------------------- 1 file changed, 28 deletions(-) diff --git a/api/app/services/emotion_config_service.py b/api/app/services/emotion_config_service.py index bab6d7a8..9880d4e1 100644 --- a/api/app/services/emotion_config_service.py +++ b/api/app/services/emotion_config_service.py @@ -212,31 +212,3 @@ class EmotionConfigService: self.db.rollback() logger.error(f"更新情绪配置失败: {str(e)}", exc_info=True) raise - - -def resolve_config_id(config_id: UUID | int, db: Session) -> UUID: - """ - 解析 config_id,如果是整数则通过 config_id_old 查找对应的 UUID - - Args: - config_id: 配置ID(UUID 或整数) - db: 数据库会话 - - Returns: - UUID: 解析后的配置ID - - Raises: - ValueError: 当找不到对应的配置时 - """ - if isinstance(config_id, int): - from app.models.memory_config_model import MemoryConfig - memory_config = db.query(MemoryConfig).filter( - MemoryConfig.config_id_old == config_id - ).first() - - if not memory_config: - raise ValueError(f"未找到 config_id_old={config_id} 对应的配置") - - return memory_config.config_id - - return config_id From e89c23da4d20d5139352e30c1b00fd0b2e986415 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 18:41:56 +0800 Subject: [PATCH 143/175] fix(web): model bugfix --- web/src/assets/images/model/bedrock.svg | 15 ++++ web/src/assets/images/model/dashscope.png | Bin 0 -> 2835 bytes web/src/assets/images/model/gpustack.png | Bin 0 -> 57988 bytes web/src/assets/images/model/ollama.svg | 15 ++++ web/src/assets/images/model/openai.svg | 4 + web/src/assets/images/model/xinference.svg | 24 ++++++ web/src/views/ModelManagement/List.tsx | 26 +++--- web/src/views/ModelManagement/Square.tsx | 40 ++++++---- .../components/ModelListDetail.tsx | 23 +++--- .../components/ModelSquareDetail.tsx | 74 ++++++++++-------- web/src/views/ModelManagement/utils.ts | 26 ++++++ 11 files changed, 177 insertions(+), 70 deletions(-) create mode 100644 web/src/assets/images/model/bedrock.svg create mode 100644 web/src/assets/images/model/dashscope.png create mode 100644 web/src/assets/images/model/gpustack.png create mode 100644 web/src/assets/images/model/ollama.svg create mode 100644 web/src/assets/images/model/openai.svg create mode 100644 web/src/assets/images/model/xinference.svg create mode 100644 web/src/views/ModelManagement/utils.ts diff --git a/web/src/assets/images/model/bedrock.svg b/web/src/assets/images/model/bedrock.svg new file mode 100644 index 00000000..6a0235af --- /dev/null +++ b/web/src/assets/images/model/bedrock.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/model/dashscope.png b/web/src/assets/images/model/dashscope.png new file mode 100644 index 0000000000000000000000000000000000000000..c1aff40ee092ae1758ad72d592081cc6b99c5b54 GIT binary patch literal 2835 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/images/model/gpustack.png b/web/src/assets/images/model/gpustack.png new file mode 100644 index 0000000000000000000000000000000000000000..b154821db91ab02007d7c48700dea34d57871ec6 GIT binary patch literal 57988 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

    edBnRuu(@m|j_~Qk1q_=Uy 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 WV5hA3PnA6Y0000 + + + + + + + + + + + + + + diff --git a/web/src/assets/images/model/openai.svg b/web/src/assets/images/model/openai.svg new file mode 100644 index 00000000..70686f9b --- /dev/null +++ b/web/src/assets/images/model/openai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/images/model/xinference.svg b/web/src/assets/images/model/xinference.svg new file mode 100644 index 00000000..f5c5f75e --- /dev/null +++ b/web/src/assets/images/model/xinference.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx index f1127623..ac85125a 100644 --- a/web/src/views/ModelManagement/List.tsx +++ b/web/src/views/ModelManagement/List.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useEffect, type FC } from 'react'; -import { Button, Space, Row, Col } from 'antd' +import { Button, Flex, Row, Col } from 'antd' import { useTranslation } from 'react-i18next'; import type { ProviderModelItem, KeyConfigModalRef, ModelListDetailRef } from './types' @@ -9,6 +9,7 @@ import PageEmpty from '@/components/Empty/PageEmpty'; import Tag from '@/components/Tag'; import KeyConfigModal from './components/KeyConfigModal' import ModelListDetail from './components/ModelListDetail' +import { getLogoUrl } from './utils' const ModelList: FC<{ query: any }> = ({ query }) => { const { t } = useTranslation(); @@ -46,22 +47,25 @@ const ModelList: FC<{ query: any }> = ({ query }) => { {item.provider[0]} } + bodyClassName="rb:relative rb:pb-[64px]! rb:h-[calc(100%-64px)]!" > - {item.tags.map(tag => {t(`modelNew.${tag}`)})} - - - - - - - - + {item.tags.map(tag => {t(`modelNew.${tag}`)})} +

    + + + + + + + + +
    ))} diff --git a/web/src/views/ModelManagement/Square.tsx b/web/src/views/ModelManagement/Square.tsx index 7ecd838c..af72700f 100644 --- a/web/src/views/ModelManagement/Square.tsx +++ b/web/src/views/ModelManagement/Square.tsx @@ -1,5 +1,5 @@ import { useRef, useState, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { Button, Space, App, Divider, Flex } from 'antd' +import { Button, Space, App, Divider, Flex, Tooltip } from 'antd' import { UsergroupAddOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -9,6 +9,7 @@ import { getModelPlaza, addModelPlaza } from '@/api/models' import PageEmpty from '@/components/Empty/PageEmpty'; import Tag from '@/components/Tag'; import ModelSquareDetail from './components/ModelSquareDetail' +import { getLogoUrl } from './utils' const ModelSquare = forwardRef void; }>(({ query, handleEdit }, ref) => { const { t } = useTranslation(); @@ -46,7 +47,7 @@ const ModelSquare = forwardRef (
    -
    {vo.provider}
    +
    {t(`modelNew.${vo.provider}`)}
    @@ -55,27 +56,32 @@ const ModelSquare = forwardRef {t(`modelNew.${item.type}`)}} + avatarUrl={getLogoUrl(item.logo)} avatar={
    {item.name[0]}
    } + bodyClassName="rb:relative rb:pb-[80px]! rb:h-[calc(100%-64px)]!" > - {t(`modelNew.${item.type}`)} -
    {item.description}
    - {item.tags.map((tag, tagIndex) => {tag})} - - - {item.add_count} - - {!item.is_official && } - {item.is_added - ? - : - } - - + +
    {item.description}
    +
    + {item.tags.map((tag, tagIndex) => {tag})} +
    + + + {item.add_count} + + {!item.is_official && } + {item.is_added + ? + : + } + + +
    ))}
    diff --git a/web/src/views/ModelManagement/components/ModelListDetail.tsx b/web/src/views/ModelManagement/components/ModelListDetail.tsx index 48abd953..9d4303f8 100644 --- a/web/src/views/ModelManagement/components/ModelListDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelListDetail.tsx @@ -1,6 +1,6 @@ import { useState, useImperativeHandle, forwardRef, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Switch, Row, Col, Space } from 'antd' +import { Button, Switch, Row, Col, Space, Tooltip } from 'antd' import type { ProviderModelItem, ModelListItem, ModelListDetailRef, MultiKeyConfigModalRef } from '../types'; import RbDrawer from '@/components/RbDrawer'; @@ -9,6 +9,7 @@ import Tag from '@/components/Tag'; import PageEmpty from '@/components/Empty/PageEmpty'; import MultiKeyConfigModal from './MultiKeyConfigModal' import { getModelNewList, updateModelStatus } from '@/api/models' +import { getLogoUrl } from '../utils' interface ModelListDetailProps { refresh?: () => void; @@ -80,21 +81,25 @@ const ModelListDetail = forwardRef(({ {t(`modelNew.${item.type}`)} {item.api_keys.length}{t('modelNew.apiKeyNum')} } - avatarUrl={item.logo} + avatarUrl={getLogoUrl(item.logo)} avatar={
    {item.name[0]}
    } extra={ handleChange(item)} />} + bodyClassName="rb:relative rb:pb-[64px]! rb:h-[calc(100%-64px)]!" > - -
    {item.description}
    - - - - - + +
    {item.description}
    +
    +
    + + + + + +
    ))} diff --git a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx index d7a5f807..844339fb 100644 --- a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx @@ -1,6 +1,6 @@ import { useState, useImperativeHandle, forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Space, App, Flex } from 'antd' +import { Button, Space, App, Flex, Tooltip, Divider } from 'antd' import { UsergroupAddOutlined } from '@ant-design/icons'; import type { ModelPlaza, ModelPlazaItem, ModelSquareDetailRef } from '../types'; @@ -9,6 +9,7 @@ import { getModelPlaza, addModelPlaza } from '@/api/models' import RbCard from '@/components/RbCard/Card' import Tag from '@/components/Tag'; import PageEmpty from '@/components/Empty/PageEmpty'; +import { getLogoUrl } from '../utils' interface ModelSquareDetailProps { refresh: () => void; @@ -52,42 +53,49 @@ const ModelSquareDetail = forwardRef{model.provider} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} + title={<>{t(`modelNew.${model.provider}`)} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} open={open} onClose={handleClose} > - {list.length === 0 - ? - :
    - {list.map(item => ( - - {item.name[0]} +
    + {list.length === 0 + ? + :
    + {list.map(item => ( + {t(`modelNew.${item.type}`)}} + avatarUrl={getLogoUrl(item.logo)} + avatar={ +
    + {item.name[0]} +
    + } + bodyClassName="rb:relative rb:pb-[80px]! rb:h-[calc(100%-64px)]!" + > + +
    {item.description}
    +
    + {item.tags.map((tag, tagIndex) => {tag})} +
    + + + {item.add_count} + + {!item.is_official && } + {item.is_added + ? + : + } + +
    - } - > - {t(`modelNew.${item.type}`)} -
    {item.description}
    - {item.tags.map((tag, tagIndex) => {tag})} - - - {item.add_count} - - {!item.is_official && } - {item.is_added - ? - : - } - - -
    - ))} -
    - } + + ))} +
    + } +
    ); }); diff --git a/web/src/views/ModelManagement/utils.ts b/web/src/views/ModelManagement/utils.ts new file mode 100644 index 00000000..c753a8b6 --- /dev/null +++ b/web/src/views/ModelManagement/utils.ts @@ -0,0 +1,26 @@ +import bedrockIcon from '@/assets/images/model/bedrock.svg' +import dashscopeIcon from '@/assets/images/model/dashscope.png' +import gpustackIcon from '@/assets/images/model/gpustack.png' +import ollamaIcon from '@/assets/images/model/ollama.svg' +import openaiIcon from '@/assets/images/model/openai.svg' +import xinferenceIcon from '@/assets/images/model/xinference.svg' + +export const ICONS = { + bedrock: bedrockIcon, + dashscope: dashscopeIcon, + gpustack: gpustackIcon, + ollama: ollamaIcon, + openai: openaiIcon, + xinference: xinferenceIcon +} + +export const getLogoUrl = (logo?: string) => { + if (!logo) { + return undefined + } + if (logo.startsWith('http')) { + return logo + } + + return ICONS[logo as keyof typeof ICONS] || undefined +} \ No newline at end of file From 0eb335d1122c7116951cb8d613a16391d15aeec8 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 28 Jan 2026 19:58:33 +0800 Subject: [PATCH 144/175] fix(web): model bugfix --- web/src/components/RbCard/Card.tsx | 4 ++-- web/src/views/ModelManagement/Group.tsx | 7 +------ web/src/views/ModelManagement/List.tsx | 4 ++-- web/src/views/ModelManagement/Square.tsx | 2 +- .../ModelManagement/components/ModelImplement/index.tsx | 2 +- .../views/ModelManagement/components/ModelListDetail.tsx | 4 ++-- .../views/ModelManagement/components/ModelSquareDetail.tsx | 2 +- .../ModelManagement/components/MultiKeyConfigModal.tsx | 2 +- 8 files changed, 11 insertions(+), 16 deletions(-) diff --git a/web/src/components/RbCard/Card.tsx b/web/src/components/RbCard/Card.tsx index eadd2916..7ed81160 100644 --- a/web/src/components/RbCard/Card.tsx +++ b/web/src/components/RbCard/Card.tsx @@ -50,7 +50,7 @@ const RbCard: FC = ({ +
    {avatarUrl ? : avatar ? avatar : null @@ -59,7 +59,7 @@ const RbCard: FC = ({ clsx( { 'rb:max-w-full': !avatarUrl && !avatar, - 'rb:max-w-[calc(100%-60px)]': avatarUrl || avatar, + 'rb:max-w-[calc(100%-80px)]': avatarUrl || avatar, } ) }> diff --git a/web/src/views/ModelManagement/Group.tsx b/web/src/views/ModelManagement/Group.tsx index 311455b4..398bd60b 100644 --- a/web/src/views/ModelManagement/Group.tsx +++ b/web/src/views/ModelManagement/Group.tsx @@ -31,12 +31,7 @@ const Group = forwardRef = ({ query }) => { {list.map(item => ( - {item.provider[0]} + {item.provider[0].toUpperCase()}
    } bodyClassName="rb:relative rb:pb-[64px]! rb:h-[calc(100%-64px)]!" diff --git a/web/src/views/ModelManagement/Square.tsx b/web/src/views/ModelManagement/Square.tsx index af72700f..ad5d2441 100644 --- a/web/src/views/ModelManagement/Square.tsx +++ b/web/src/views/ModelManagement/Square.tsx @@ -56,7 +56,7 @@ const ModelSquare = forwardRef {t(`modelNew.${item.type}`)}} + subTitle={{t(`modelNew.${item.type}`)}} avatarUrl={getLogoUrl(item.logo)} avatar={
    diff --git a/web/src/views/ModelManagement/components/ModelImplement/index.tsx b/web/src/views/ModelManagement/components/ModelImplement/index.tsx index a876587d..e2d7aa79 100644 --- a/web/src/views/ModelManagement/components/ModelImplement/index.tsx +++ b/web/src/views/ModelManagement/components/ModelImplement/index.tsx @@ -89,7 +89,7 @@ const ModelImplement: FC = ({ type, value, onChange }) => { >
    - {provider} + {t(`modelNew.${provider}`)} ) })} diff --git a/web/src/views/ModelManagement/components/ModelListDetail.tsx b/web/src/views/ModelManagement/components/ModelListDetail.tsx index 9d4303f8..61951dc9 100644 --- a/web/src/views/ModelManagement/components/ModelListDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelListDetail.tsx @@ -66,7 +66,7 @@ const ModelListDetail = forwardRef(({ return ( {data.provider} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} + title={<>{t(`modelNew.${data.provider}`)} {t('modelNew.modelList')} ({list.length}{t('modelNew.item')})} open={open} onClose={handleClose} > @@ -77,7 +77,7 @@ const ModelListDetail = forwardRef(({ + subTitle={ {t(`modelNew.${item.type}`)} {item.api_keys.length}{t('modelNew.apiKeyNum')} } diff --git a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx index 844339fb..55d4ff63 100644 --- a/web/src/views/ModelManagement/components/ModelSquareDetail.tsx +++ b/web/src/views/ModelManagement/components/ModelSquareDetail.tsx @@ -65,7 +65,7 @@ const ModelSquareDetail = forwardRef{t(`modelNew.${item.type}`)}} + subTitle={{t(`modelNew.${item.type}`)}} avatarUrl={getLogoUrl(item.logo)} avatar={
    diff --git a/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx index 334badc8..2638f10c 100644 --- a/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx +++ b/web/src/views/ModelManagement/components/MultiKeyConfigModal.tsx @@ -50,7 +50,7 @@ const MultiKeyConfigModal = forwardRef { + .finally(() => { setLoading(false) }); }) From 4d80e119f74cad53eecf6c3794e6e2f4174d2974 Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:13:55 +0800 Subject: [PATCH 145/175] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=81=97=E6=BC=8F=20?= =?UTF-8?q?(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controllers/memory_storage_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/memory_storage_controller.py b/api/app/controllers/memory_storage_controller.py index fb0ebc14..f24d2f70 100644 --- a/api/app/controllers/memory_storage_controller.py +++ b/api/app/controllers/memory_storage_controller.py @@ -233,7 +233,7 @@ def update_config_extracted( @router.get("/read_config_extracted", response_model=ApiResponse) # 通过查询参数读取某条配置(固定路径) 没有意义的话就删除 def read_config_extracted( - config_id: UUID, + config_id: UUID | int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict: From 808961243dc4ef72823020aa31005e533c43986c Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 11:47:39 +0800 Subject: [PATCH 146/175] [fix] chat api for workflow --- api/app/controllers/service/app_api_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index 677e1623..326649c6 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -238,7 +238,7 @@ async def chat( user_id=new_end_user.id, # 转换为字符串 variables=payload.variables, config=config, - web_search=payload.web_search, + web_search=web_search, memory=payload.memory, storage_type=storage_type, user_rag_memory_id=user_rag_memory_id, From cd4c93a5cb2a6269989624ad343bff01b02a6650 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Jan 2026 11:52:59 +0800 Subject: [PATCH 147/175] [fix] web search set for v1 api --- api/app/controllers/service/app_api_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index 326649c6..94d7b60b 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -271,7 +271,7 @@ async def chat( user_id=new_end_user.id, # 转换为字符串 variables=payload.variables, config=config, - web_search=payload.web_search, + web_search=web_search, memory=payload.memory, storage_type=storage_type, user_rag_memory_id=user_rag_memory_id, From 1e16b06a24bcf25ab183aa5cd184a11013c8e66e Mon Sep 17 00:00:00 2001 From: zhaoying Date: Thu, 29 Jan 2026 12:10:19 +0800 Subject: [PATCH 148/175] fix(web): model bugfix --- web/src/i18n/en.ts | 5 +- web/src/i18n/zh.ts | 5 +- .../components/CustomModelModal.tsx | 2 + .../components/GroupModelModal.tsx | 16 ++- .../components/KeyConfigModal.tsx | 4 +- .../ModelImplement/SubModelModal.tsx | 106 ++++++++++-------- .../components/ModelImplement/index.tsx | 19 +--- .../components/ModelImplement/types.ts | 3 +- .../components/ModelListDetail.tsx | 36 +++++- web/src/views/ModelManagement/index.tsx | 85 +++++++------- web/src/views/Workflow/constant.ts | 6 +- 11 files changed, 173 insertions(+), 114 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index aee68114..e986562c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -546,7 +546,10 @@ export const en = { tags: 'Tags', createCustomModel: 'Add Custom Model', edit: 'Edit', - selectOneTip: 'Model API KEY not configured, please configure in Model Plaza first', + selectOneTip: 'Model API KEY not configured, please configure it in the model list first', + load_balance_strategy: 'Concurrency Strategy', + round_robin: 'Sequential Execution - Call each model in order', + none: 'None', api_key: 'API KEY', api_base: 'API Base URL', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 78fe948a..87f96cf0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1112,7 +1112,10 @@ export const zh = { tags: '标签', createCustomModel: '添加自定义模型', edit: '编辑', - selectOneTip: '模型未配置API KEY,请先在模型广场配置', + selectOneTip: '模型未配置API KEY,请先在模型列表配置', + load_balance_strategy: '并发策略', + round_robin: '顺序执行 - 按顺序依次调用每个模型', + none: '无', api_key: 'API KEY', api_base: 'API Base URL', diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index d22fbcdd..47928d87 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -129,6 +129,7 @@ const CustomModelModal = forwardRef( items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} /> @@ -141,6 +142,7 @@ const CustomModelModal = forwardRef( items.map((item) => ({ label: t(`modelNew.${item}`), value: String(item) }))} /> diff --git a/web/src/views/ModelManagement/components/GroupModelModal.tsx b/web/src/views/ModelManagement/components/GroupModelModal.tsx index 6ae54d4c..c66288e5 100644 --- a/web/src/views/ModelManagement/components/GroupModelModal.tsx +++ b/web/src/views/ModelManagement/components/GroupModelModal.tsx @@ -1,5 +1,5 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; -import { Form, Input, App } from 'antd'; +import { Form, Input, App, Select } from 'antd'; import { useTranslation } from 'react-i18next'; import type { ModelListItem, CompositeModelForm, GroupModelModalRef, GroupModelModalProps, ModelApiKey } from '../types'; @@ -106,6 +106,7 @@ const GroupModelModal = forwardRef(({ (({ + +