From bd24de45775572f2ceade1a8db2e20250d0add7f 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, 10 Feb 2026 16:17:05 +0800 Subject: [PATCH 01/55] Fix/bug en zh (#389) * [fix]The log retains genuine alerts and errors, while filtering out unnecessary noise. * [fix]Scenario English and Chinese, emotion specifications * [fix]Change the "no data" scenario from 0.0 to None * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]Separate expected errors from unexpected errors * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation --- api/app/services/emotion_analytics_service.py | 6 +++--- api/app/services/memory_dashboard_service.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index d78dc20c..89e3cab9 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -124,17 +124,17 @@ class EmotionAnalyticsService: # 将查询结果转换为字典,方便查找 tags_dict = {tag["emotion_type"]: tag for tag in tags} - # 补全缺失的情绪维度,并翻译 emotion_type + # 补全缺失的情绪维度,直接使用英文枚举key(前端自行翻译) complete_tags = [] for emotion in all_emotion_types: if emotion in tags_dict: tag = tags_dict[emotion].copy() - tag["emotion_type"] = self._translate_emotion_type(emotion, language) + tag["emotion_type"] = emotion complete_tags.append(tag) else: # 如果该情绪类型不存在,添加默认值 complete_tags.append({ - "emotion_type": self._translate_emotion_type(emotion, language), + "emotion_type": emotion, "count": 0, "percentage": 0.0, "avg_intensity": 0.0 diff --git a/api/app/services/memory_dashboard_service.py b/api/app/services/memory_dashboard_service.py index 6fa8b228..8d6071cc 100644 --- a/api/app/services/memory_dashboard_service.py +++ b/api/app/services/memory_dashboard_service.py @@ -55,7 +55,7 @@ def get_workspace_end_users( ) -> List[EndUser]: """获取工作空间的所有宿主(优化版本:减少数据库查询次数) - 返回结果按 updated_at 从新到旧排序(NULL 值排在最后) + 返回结果按 created_at 从新到旧排序(NULL 值排在最后) """ business_logger.info(f"获取工作空间宿主列表: workspace_id={workspace_id}, 操作者: {current_user.username}") @@ -71,13 +71,13 @@ def get_workspace_end_users( app_ids = [app.id for app in apps_orm] # 批量查询所有 end_users(一次查询而非循环查询) - # 按 updated_at 降序排序,NULL 值排在最后;id 作为次级排序键保证确定性 + # 按 created_at 降序排序,NULL 值排在最后;id 作为次级排序键保证确定性 from app.models.end_user_model import EndUser as EndUserModel from sqlalchemy import desc, nullslast end_users_orm = db.query(EndUserModel).filter( EndUserModel.app_id.in_(app_ids) ).order_by( - nullslast(desc(EndUserModel.updated_at)), + nullslast(desc(EndUserModel.created_at)), desc(EndUserModel.id) ).all() From f966176694e9e89148c4bbe1ba50d7d39cd7972c Mon Sep 17 00:00:00 2001 From: yujiangping Date: Tue, 10 Feb 2026 16:32:35 +0800 Subject: [PATCH 02/55] feat(web): improve knowledge base form validation and parser config handling - Refactor form validation logic to support tab-specific field validation in edit mode - Add conditional validation for knowledge graph fields when editing existing knowledge base - Preserve all existing parser_config fields when merging graphrag configuration - Skip third-party authentication check when editing on knowledge graph tab - Update form value retrieval to include disabled fields using getFieldsValue(true) - Improve comments to clarify parser_config field preservation and validation behavior - This change enables users to edit knowledge graph settings without re-validating all basic configuration fields --- .../KnowledgeBase/components/CreateModal.tsx | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/views/KnowledgeBase/components/CreateModal.tsx b/web/src/views/KnowledgeBase/components/CreateModal.tsx index 71fe1b94..76640058 100644 --- a/web/src/views/KnowledgeBase/components/CreateModal.tsx +++ b/web/src/views/KnowledgeBase/components/CreateModal.tsx @@ -221,9 +221,9 @@ const CreateModal = forwardRef(({ status: record.status, }; - // Process parser_config data, set default values if not present + // Process parser_config data, preserve all existing fields and merge graphrag config baseValues.parser_config = { - ...record.parser_config, + ...record.parser_config, // Preserve all existing parser_config fields (yuque_user_id, yuque_token, feishu_app_id, etc.) graphrag: { use_graphrag: false, scene_name: '', @@ -362,12 +362,32 @@ const CreateModal = forwardRef(({ // Actual save logic const performSave = async () => { try { - await form.validateFields(); - setLoading(true); - const formValues = form.getFieldsValue(); + // Get fields to validate based on current tab and edit mode + let fieldsToValidate: any[] | undefined = undefined; - // Check Third-party authentication before saving - if (formValues.type === 'Third-party' || currentType === 'Third-party') { + // If in edit mode and on knowledge graph tab, only validate knowledge graph fields + if (datasets?.id && activeTab === 'knowledgeGraph') { + fieldsToValidate = [ + ['parser_config', 'graphrag', 'use_graphrag'], + ['parser_config', 'graphrag', 'scene_name'], + ['parser_config', 'graphrag', 'entity_types'], + ['parser_config', 'graphrag', 'method'], + ['parser_config', 'graphrag', 'resolution'], + ['parser_config', 'graphrag', 'community'], + ]; + } + + // Validate only specified fields or all fields + await form.validateFields(fieldsToValidate); + setLoading(true); + // Get all field values including disabled fields + const formValues = form.getFieldsValue(true); + + // Only check Third-party authentication when creating new or explicitly on basic config tab + // Skip authentication check when editing and on knowledge graph tab + const shouldCheckAuth = !datasets?.id || activeTab === 'basic'; + + if (shouldCheckAuth && (formValues.type === 'Third-party' || currentType === 'Third-party')) { const platform = formValues.parser_config?._third_party_platform || thirdPartyPlatform; try { From 7044f705e764dd1d84bf5438b843eecb007ba2e0 Mon Sep 17 00:00:00 2001 From: yujiangping Date: Tue, 10 Feb 2026 16:51:41 +0800 Subject: [PATCH 03/55] fix(web): improve infinite scroll handling in knowledge base list - Add auto-load detection when initial data doesn't fill viewport to prevent empty scrollbar - Implement scroll height check to automatically load more data if content is insufficient - Fix hasMore condition to prevent premature loader hiding - Update loader visibility to only show when data exists and is actively loading - Refine end message display to show only when all data is loaded and no more items available - Resolves issue where knowledge base list shows no scrollbar on initial load with limited items --- web/src/views/KnowledgeBase/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/web/src/views/KnowledgeBase/index.tsx b/web/src/views/KnowledgeBase/index.tsx index cef520ab..1ad6997b 100644 --- a/web/src/views/KnowledgeBase/index.tsx +++ b/web/src/views/KnowledgeBase/index.tsx @@ -334,6 +334,18 @@ const KnowledgeBaseManagement: FC = () => { setHasMore(hasNext); buildModelMenus(list, isLoadMore); + + // 首次加载后,检查是否需要自动加载更多(解决无滚动条问题) + if (!isLoadMore && hasNext) { + setTimeout(() => { + const scrollDiv = document.getElementById('scrollableDiv'); + if (scrollDiv && scrollDiv.scrollHeight <= scrollDiv.clientHeight) { + console.log('No scrollbar detected, auto-loading more data'); + setPage(2); + fetchData(2, true); + } + }, 100); + } } catch (error) { console.error('Failed to fetch knowledge base list:', error); if (!isLoadMore) { @@ -532,10 +544,10 @@ const KnowledgeBaseManagement: FC = () => { {t('common.loading')}} + hasMore={hasMore} + loader={loading && data.length > 0 ?
{t('common.loading')}
: null} endMessage={ - data.length > 0 ? ( + data.length > 0 && !hasMore ? (
{t('common.noMoreData')}
From bacffc94d91312fbd681c1576f49855471e4ef73 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 10 Feb 2026 17:42:40 +0800 Subject: [PATCH 04/55] fix(web): FileUpload bugfix --- .../ApplicationConfig/components/Chat.tsx | 48 +++++++++---------- .../Conversation/components/FileUpload.tsx | 14 +----- web/src/views/Conversation/index.tsx | 6 +-- .../views/Workflow/components/Chat/Chat.tsx | 6 +-- 4 files changed, 26 insertions(+), 48 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index effb34c3..794489c6 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 12:18:23 + * @Last Modified time: 2026-02-10 17:40:15 */ /** * Chat debugging component for application testing @@ -366,10 +366,8 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc const handleMessageChange = (message: string) => { setMessage(message) } - const [update, setUpdate] = useState(false) const fileChange = (file?: any) => { setFileList([...fileList, file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -456,29 +454,27 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc onChange={handleMessageChange} > - - - ) - }, - ], - onClick: handleShowUpload - }} - > -
-
+ + + ) + }, + ], + onClick: handleShowUpload + }} + > +
+
{/* diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx index b7d65f80..1e8d33aa 100644 --- a/web/src/views/Conversation/components/FileUpload.tsx +++ b/web/src/views/Conversation/components/FileUpload.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:09:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 16:41:31 + * @Last Modified time: 2026-02-10 17:40:08 */ /** * File Upload Component @@ -55,8 +55,6 @@ interface UploadFilesProps extends Omit { maxCount?: number; /** Custom file removal callback */ onRemove?: (file: UploadFile) => boolean | void | Promise; - /** Trigger to reset file list */ - update?: boolean; } // Mapping of file extensions to MIME types const ALL_FILE_TYPE: { @@ -109,7 +107,6 @@ const UploadFiles = forwardRef(({ isAutoUpload = true, maxCount = 1, onRemove: customOnRemove, - update, requestConfig, ...props }, ref) => { @@ -118,11 +115,6 @@ const UploadFiles = forwardRef(({ const [fileList, setFileList] = useState(propFileList); const [accept, setAccept] = useState(); - // Reset file list when update prop changes - useEffect(() => { - setFileList([]) - }, [update]) - /** * Validates file type and size before upload * @returns Upload.LIST_IGNORE to prevent upload, or true to proceed @@ -185,9 +177,7 @@ const UploadFiles = forwardRef(({ /** * Handles upload state changes */ - const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => { - console.log('event', event) - setFileList(newFileList); + const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { if (onChange) { onChange(maxCount === 1 ? newFileList[0] : newFileList); } diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index fcc32bf8..825ea834 100644 --- a/web/src/views/Conversation/index.tsx +++ b/web/src/views/Conversation/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 20:20:01 + * @Last Modified time: 2026-02-10 17:41:05 */ /** * Conversation Page @@ -254,10 +254,8 @@ const Conversation: FC = () => { }) } - const [update, setUpdate] = useState(false) const fileChange = (file?: any) => { form.setFieldValue('files', [...(queryValues.files || []), file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -353,8 +351,6 @@ const Conversation: FC = () => { action={shareFileUploadUrlWithoutApiPrefix} fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']} onChange={fileChange} - fileList={[]} - update={update} requestConfig={{ headers: { 'Content-Type': 'multipart/form-data', diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 95f43a9c..00a0c7fb 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 12:17:41 + * @Last Modified time: 2026-02-10 17:41:24 */ /** * Workflow Chat Component @@ -343,13 +343,11 @@ const Chat = forwardRef(({ appId const handleMessageChange = (message: string) => { setMessage(message) } - const [update, setUpdate] = useState(false) /** * Handles file upload from local device */ const fileChange = (file?: any) => { setFileList([...fileList, file]) - setUpdate(prev => !prev) } // const handleRecordingComplete = async (file: any) => { // console.log('file', file) @@ -517,8 +515,6 @@ const Chat = forwardRef(({ appId ) }, From 9ae26129459be9912d1a70142e34d66790113eb4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 10 Feb 2026 18:00:56 +0800 Subject: [PATCH 05/55] fix(web): change skill search key --- .../components/Skill/SkillListModal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx b/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx index 8220878e..0a56b82f 100644 --- a/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx +++ b/web/src/views/ApplicationConfig/components/Skill/SkillListModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-05 10:45:08 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-05 10:45:08 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-10 17:59:37 */ import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { Space, List, Flex, Tooltip } from 'antd'; @@ -31,7 +31,7 @@ interface SkillModalProps { * * A modal dialog for selecting skills from a searchable list. * Features: - * - Search functionality to filter skills by keywords + * - Search functionality to filter skills by search * - Grid layout displaying skill cards with icons and descriptions * - Multi-select capability with visual feedback * - Excludes already selected skills from the list @@ -49,7 +49,7 @@ const SkillListModal = forwardRef(({ const [visible, setVisible] = useState(false); const [list, setList] = useState([]) const [filterList, setFilterList] = useState([]) - const [query, setQuery] = useState<{keywords?: string}>({}) + const [query, setQuery] = useState<{search?: string}>({}) const [selectedIds, setSelectedIds] = useState([]) const [selectedRows, setSelectedRows] = useState([]) @@ -82,7 +82,7 @@ const SkillListModal = forwardRef(({ if (visible) { getList() } - }, [query.keywords, visible]) + }, [query.search, visible]) /** * Fetches the skill list from API with current search parameters @@ -123,7 +123,7 @@ const SkillListModal = forwardRef(({ * @param value - Search keyword */ const handleSearch = (value?: string) => { - setQuery({keywords: value}) + setQuery({search: value}) setSelectedIds([]) setSelectedRows([]) } From 2103410694e70c214d8e77583ae44da572c6f551 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, 10 Feb 2026 18:02:25 +0800 Subject: [PATCH 06/55] Fix/bug en zh (#391) * [fix]The log retains genuine alerts and errors, while filtering out unnecessary noise. * [fix]Scenario English and Chinese, emotion specifications * [fix]Change the "no data" scenario from 0.0 to None * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]The emotional health indicators, emotional advice, and emotional distribution analysis are all linked together. * [fix]Separate expected errors from unexpected errors * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [changes]Translation of emotion labels, and the list of hosts arranged in the order of creation * [fix]The mainframe engineering supports Chinese verification. * [fix]The mainframe engineering supports Chinese verification. --- .../core/memory/models/ontology_scenario_models.py | 13 ++++++++----- .../memory/utils/validation/ontology_validator.py | 12 +++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api/app/core/memory/models/ontology_scenario_models.py b/api/app/core/memory/models/ontology_scenario_models.py index 24a61f5f..b51d8bb2 100644 --- a/api/app/core/memory/models/ontology_scenario_models.py +++ b/api/app/core/memory/models/ontology_scenario_models.py @@ -74,7 +74,7 @@ class OntologyClass(BaseModel): """Validate that the class name follows PascalCase convention. PascalCase rules: - - Must start with an uppercase letter + - Must start with an uppercase letter (for English) or any character (for Chinese/Unicode) - Cannot contain spaces - Should not contain special characters except underscores @@ -90,7 +90,10 @@ class OntologyClass(BaseModel): if not v: raise ValueError("Class name cannot be empty") - if not v[0].isupper(): + # For Chinese/Unicode characters, skip the uppercase check + # Only check uppercase for ASCII letters + first_char = v[0] + if first_char.isascii() and first_char.isalpha() and not first_char.isupper(): raise ValueError( f"Class name '{v}' must start with an uppercase letter (PascalCase)" ) @@ -100,11 +103,11 @@ class OntologyClass(BaseModel): f"Class name '{v}' cannot contain spaces (PascalCase)" ) - # Check for invalid characters (allow alphanumeric and underscore only) - if not all(c.isalnum() or c == '_' for c in v): + # Check for invalid characters (allow alphanumeric, underscore, and Unicode characters) + if not all(c.isalnum() or c == '_' or ord(c) > 127 for c in v): raise ValueError( f"Class name '{v}' contains invalid characters. " - "Only alphanumeric characters and underscores are allowed" + "Only alphanumeric characters, underscores, and Unicode characters are allowed" ) return v diff --git a/api/app/core/memory/utils/validation/ontology_validator.py b/api/app/core/memory/utils/validation/ontology_validator.py index 1e1ee506..cb3bcec8 100644 --- a/api/app/core/memory/utils/validation/ontology_validator.py +++ b/api/app/core/memory/utils/validation/ontology_validator.py @@ -88,8 +88,10 @@ class OntologyValidator: logger.warning(f"Validation failed: {error_msg}") return False, error_msg - # Check if starts with uppercase letter - if not name[0].isupper(): + # Check if starts with uppercase letter (only for ASCII letters) + # For Chinese/Unicode characters, skip this check + first_char = name[0] + if first_char.isascii() and first_char.isalpha() and not first_char.isupper(): error_msg = f"Class name '{name}' must start with an uppercase letter (PascalCase)" logger.warning(f"Validation failed: {error_msg}") return False, error_msg @@ -100,9 +102,9 @@ class OntologyValidator: logger.warning(f"Validation failed: {error_msg}") return False, error_msg - # Check for invalid characters (only alphanumeric and underscore allowed) - if not re.match(r'^[A-Za-z0-9_]+$', name): - error_msg = f"Class name '{name}' contains invalid characters. Only alphanumeric characters and underscores are allowed" + # Check for invalid characters (allow alphanumeric, underscore, and Unicode characters) + if not re.match(r'^[A-Za-z0-9_\u4e00-\u9fff]+$', name): + error_msg = f"Class name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and Chinese characters are allowed" logger.warning(f"Validation failed: {error_msg}") return False, error_msg From 41334f5f1e155c24267c9879f256e7faea6d1a66 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 10 Feb 2026 18:47:11 +0800 Subject: [PATCH 07/55] fix(web): update en --- web/src/i18n/en.ts | 1 + web/src/views/ApplicationConfig/Agent.tsx | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8d99872d..d44b0461 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1286,6 +1286,7 @@ export const en = { priority: 'Structured Integration', addTool: 'Add Tool', tool: 'Tool', + variableConfig: 'Variable Configuration', statistics: 'Data Statistics', daily_conversations: 'Daily Conversations', diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index a0587798..4c3b73ba 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:29:21 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 16:56:27 + * @Last Modified time: 2026-02-10 18:46:40 */ import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'; import clsx from 'clsx' @@ -480,9 +480,11 @@ const Agent = forwardRef((_props, ref) => { {t('application.debuggingAndPreview')} - + {chatVariables.length > 0 && + + } From 32b40fc6bf67ca72c9b64ee39f554b2b2bbe7bf5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 11 Feb 2026 11:34:20 +0800 Subject: [PATCH 08/55] fix(web): file upload bugfix --- web/src/views/Conversation/components/FileUpload.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx index 1e8d33aa..70ee9cf2 100644 --- a/web/src/views/Conversation/components/FileUpload.tsx +++ b/web/src/views/Conversation/components/FileUpload.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:09:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-10 17:40:08 + * @Last Modified time: 2026-02-11 11:32:48 */ /** * File Upload Component @@ -167,7 +167,7 @@ const UploadFiles = forwardRef(({ formData.append('file', file); const response = await request.uploadFile(action, formData, requestConfig); - + onSuccess?.({data: response}); } catch (error) { onError?.(error as Error); @@ -178,8 +178,9 @@ const UploadFiles = forwardRef(({ * Handles upload state changes */ const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { + setFileList(newFileList); if (onChange) { - onChange(maxCount === 1 ? newFileList[0] : newFileList); + onChange(maxCount === 1 ? newFileList[newFileList.length - 1] : newFileList); } }; From 1795364f5f0a3f712ca23d2ef2c28c997e6e22e9 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 11 Feb 2026 12:08:35 +0800 Subject: [PATCH 09/55] fix(web): memory-write node hide message config --- web/src/views/Workflow/components/Properties/index.tsx | 3 ++- web/src/views/Workflow/constant.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index 9e6e418b..e96b1757 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 19:56:42 + * @Last Modified time: 2026-02-11 12:07:06 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -627,6 +627,7 @@ const Properties: FC = ({ } layout={config.type === 'switch' ? 'horizontal' : 'vertical'} className={key === 'parallel_count' ? 'rb:-mt-3! rb:leading-3.5!' : ''} + hidden={Boolean(config.hidden)} > {config.type === 'input' ? diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index d897ba2c..b3e5f33b 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:06:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 20:08:03 + * @Last Modified time: 2026-02-11 12:07:20 */ import LoopNode from './components/Nodes/LoopNode'; import NormalNode from './components/Nodes/NormalNode'; @@ -240,7 +240,8 @@ export const nodeLibrary: NodeLibrary[] = [ config: { message: { type: 'editor', - isArray: false + isArray: false, + hidden: true, }, messages: { type: 'messageEditor', From 67053ab8aeb23440934ef2581af3499ae56be95b Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Thu, 26 Feb 2026 13:35:07 +0800 Subject: [PATCH 10/55] fix(workspace member): After the space inviter is removed, it can still be invited again. --- api/app/repositories/workspace_repository.py | 1 + api/app/services/workspace_service.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/app/repositories/workspace_repository.py b/api/app/repositories/workspace_repository.py index 70ed7521..87b0e20f 100644 --- a/api/app/repositories/workspace_repository.py +++ b/api/app/repositories/workspace_repository.py @@ -115,6 +115,7 @@ class WorkspaceRepository: self.db.query(Workspace) .join(WorkspaceMember, Workspace.id == WorkspaceMember.workspace_id) .filter(WorkspaceMember.user_id == user_id) + .filter(WorkspaceMember.is_active.is_(True)) .filter(Workspace.is_active.is_(True)) .order_by(Workspace.updated_at.desc()) .all() diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 9ee98fa0..6f102695 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -70,10 +70,10 @@ def delete_workspace_member( _check_workspace_admin_permission(db, workspace_id, user) workspace_member = workspace_repository.get_member_by_id(db=db, member_id=member_id) if not workspace_member: - raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_MEMBER_NOT_FOUND) + raise BusinessException(f"工作空间成员 {member_id} 不存在", BizCode.WORKSPACE_NOT_FOUND) if workspace_member.workspace_id != workspace_id: - raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_MEMBER_NOT_FOUND) + raise BusinessException(f"工作空间成员 {member_id} 不存在于工作空间 {workspace_id}", BizCode.WORKSPACE_NOT_FOUND) try: workspace_member.is_active = False From fa4da8f4679c76a75c751278b36d610c00578cbe Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 10:23:19 +0800 Subject: [PATCH 11/55] fix(web): change model list provider logo --- web/src/views/ModelManagement/List.tsx | 8 ++++---- web/src/views/ModelManagement/utils.ts | 25 +++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/web/src/views/ModelManagement/List.tsx b/web/src/views/ModelManagement/List.tsx index ffa89fb4..ce4d61aa 100644 --- a/web/src/views/ModelManagement/List.tsx +++ b/web/src/views/ModelManagement/List.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:50:10 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:50:10 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-27 10:20:51 */ /** * Model List View @@ -21,7 +21,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' +import { getListLogoUrl } from './utils' /** * Model list component @@ -70,7 +70,7 @@ const ModelList = forwardRef {item.provider[0].toUpperCase()} diff --git a/web/src/views/ModelManagement/utils.ts b/web/src/views/ModelManagement/utils.ts index fe36e137..bf44367f 100644 --- a/web/src/views/ModelManagement/utils.ts +++ b/web/src/views/ModelManagement/utils.ts @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:50:22 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:50:22 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-27 10:22:46 */ /** * Utility functions for Model Management @@ -40,5 +40,26 @@ export const getLogoUrl = (logo?: string) => { return logo } + return ICONS[logo as keyof typeof ICONS] || undefined +} + +/** + * Get logo URL from provider name or URL + * @param provider - Provider name + * @param logo - Provider name or logo URL + * @returns Logo URL or undefined + */ +export const getListLogoUrl = (provider?: string, logo?: string) => { + let url = ICONS[provider as keyof typeof ICONS] + + if (url) return url + + 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 d4971893520db2b1a3711f4e20616729194265db Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 10:24:03 +0800 Subject: [PATCH 12/55] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/api/app/version_info.json b/api/app/version_info.json index aea03dcd..a4ff5d55 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,34 @@ { + "v0.2.5": { + "introduction": { + "codeName": "行云", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 精炼根基,优化核心用户体验与系统稳定性", + "coreUpgrades": [ + "1. 用户体验与国际化 🎨
* SSO 语言参数修复:解决 SSO 认证后添加 `?language=zh` 参数仍显示英文的问题,语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", + "2. 工作流可视化增强 💬
* 循环与迭代节点输出展示:实时显示执行进度和中间输出,便于调试复杂迭代过程
* 变量支持回车选择:支持回车键确认变量选择,简化工作流配置流程", + "3. 多租户模型管理 ⚙️
* 租户隔离的模型密钥:模型广场排除自定义模型,模型列表按租户隔离密钥,防止跨租户密钥泄露", + "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", + "
", + "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", + "智慧致远 🐻✨" + ] + }, + "introduction_en": { + "codeName": "Flowing Clouds", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 Refined foundations with enhanced user experience and system stability", + "coreUpgrades": [ + "1. User Experience & Internationalization 🎨
* SSO Language Parameter Fix: Resolved issue where `?language=zh` parameter still showed English UI after SSO authentication
* Email Update Support: Users can now modify email addresses directly in user management system", + "2. Workflow Visualization Enhancements 💬
* Loop & Iteration Node Output Display: Real-time display of execution progress and intermediate outputs for easier debugging
* Variable Selection with Enter Key: Enabled Enter key confirmation for streamlined variable assignment", + "3. Multi-Tenant Model Management ⚙️
* Tenant-Scoped Model Keys: Model marketplace excludes custom models, model list properly isolates keys per tenant to prevent cross-tenant exposure", + "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", + "
", + "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", + "Intelligent Resilience 🐻✨" + ] + } + }, "v0.2.4": { "introduction": { "codeName": "智远", From b9201c918a56a32dad3ea1327e00d7a624425393 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 11:06:00 +0800 Subject: [PATCH 13/55] [fix]Complete the API call logic for the homepage --- .../controllers/memory_agent_controller.py | 5 +- .../memory_dashboard_controller.py | 50 +++++++++++++---- api/app/services/memory_agent_service.py | 53 ++----------------- redbear-mem-benchmark | 2 +- 4 files changed, 47 insertions(+), 63 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 0e632fcc..b88e65ff 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -633,12 +633,11 @@ async def get_knowledge_type_stats_api( current_user: User = Depends(get_current_user) ): """ - 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder | memory。 + 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder。 会对缺失类型补 0,返回字典形式。 可选按状态过滤。 - 知识库类型根据当前用户的 current_workspace_id 过滤 - - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (end_user_id) 过滤 - - 如果用户没有当前工作空间或未提供 end_user_id,对应的统计返回 0 + - 如果用户没有当前工作空间,对应的统计返回 0 """ api_logger.info(f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") try: diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 88684a39..475d184e 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -9,6 +9,7 @@ from app.schemas.response_schema import ApiResponse from app.services import memory_dashboard_service, memory_storage_service, workspace_service from app.services.memory_agent_service import get_end_users_connected_configs_batch +from app.services.app_statistics_service import AppStatisticsService from app.core.logging_config import get_api_logger # 获取API专用日志器 @@ -469,6 +470,8 @@ async def get_chunk_insight( @router.get("/dashboard_data", response_model=ApiResponse) async def dashboard_data( end_user_id: Optional[str] = Query(None, description="可选的用户ID"), + start_date: Optional[int] = Query(None, description="开始时间戳(毫秒)"), + end_date: Optional[int] = Query(None, description="结束时间戳(毫秒)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -503,6 +506,15 @@ async def dashboard_data( workspace_id = current_user.current_workspace_id api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的dashboard整合数据") + # 如果没有提供时间范围,默认使用最近30天 + if start_date is None or end_date is None: + from datetime import datetime, timedelta + end_dt = datetime.now() + start_dt = end_dt - timedelta(days=30) + end_date = int(end_dt.timestamp() * 1000) + start_date = int(start_dt.timestamp() * 1000) + api_logger.info(f"使用默认时间范围: {start_dt} 到 {end_dt}") + # 获取 storage_type,如果为 None 则使用默认值 storage_type = workspace_service.get_workspace_storage_type( db=db, @@ -563,17 +575,22 @@ async def dashboard_data( except Exception as e: api_logger.warning(f"获取知识库类型统计失败: {str(e)}") - # 3. 获取API调用增量(total_api_call,转换为整数) + # 3. 获取API调用统计(total_api_call) try: - api_increment = memory_dashboard_service.get_workspace_api_increment( - db=db, + # 使用 AppStatisticsService 获取真实的API调用统计 + app_stats_service = AppStatisticsService(db) + api_stats = app_stats_service.get_workspace_api_statistics( workspace_id=workspace_id, - current_user=current_user + start_date=start_date, + end_date=end_date ) - neo4j_data["total_api_call"] = api_increment - api_logger.info(f"成功获取API调用增量: {neo4j_data['total_api_call']}") + # 计算总调用次数 + total_api_calls = sum(item.get("total_calls", 0) for item in api_stats) + neo4j_data["total_api_call"] = total_api_calls + api_logger.info(f"成功获取API调用统计: {neo4j_data['total_api_call']}") except Exception as e: - api_logger.warning(f"获取API调用增量失败: {str(e)}") + api_logger.error(f"获取API调用统计失败: {str(e)}") + neo4j_data["total_api_call"] = 0 result["neo4j_data"] = neo4j_data api_logger.info("成功获取neo4j_data") @@ -602,10 +619,23 @@ async def dashboard_data( total_kb = memory_dashboard_service.get_rag_total_kb(db, current_user) rag_data["total_knowledge"] = total_kb - # total_api_call: 固定值 - rag_data["total_api_call"] = 1024 + # total_api_call: 使用 AppStatisticsService 获取真实的API调用统计 + try: + app_stats_service = AppStatisticsService(db) + api_stats = app_stats_service.get_workspace_api_statistics( + workspace_id=workspace_id, + start_date=start_date, + end_date=end_date + ) + # 计算总调用次数 + total_api_calls = sum(item.get("total_calls", 0) for item in api_stats) + rag_data["total_api_call"] = total_api_calls + api_logger.info(f"成功获取RAG模式API调用统计: {rag_data['total_api_call']}") + except Exception as e: + api_logger.warning(f"获取RAG模式API调用统计失败,使用默认值: {str(e)}") + rag_data["total_api_call"] = 0 - api_logger.info(f"成功获取RAG相关数据: memory={total_chunk}, app={len(apps_orm)}, knowledge={total_kb}") + api_logger.info(f"成功获取RAG相关数据: memory={total_chunk}, app={len(apps_orm)}, knowledge={total_kb}, api_calls={rag_data['total_api_call']}") except Exception as e: api_logger.warning(f"获取RAG相关数据失败: {str(e)}") diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index da8a8e06..1f3667a6 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -816,11 +816,10 @@ class MemoryAgentService: """ 统计知识库类型分布,包含: 1. PostgreSQL 中的知识库类型:General, Web, Third-party, Folder(根据 workspace_id 过滤) - 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) - 3. total: 所有类型的总和 + 2. total: 所有类型的总和 参数: - - end_user_id: 用户组ID(可选,未提供时 memory 统计为 0) + - end_user_id: 用户组ID(可选,保留参数以保持接口兼容性) - only_active: 是否仅统计有效记录 - current_workspace_id: 当前工作空间ID(可选,未提供时知识库统计为 0) - db: 数据库会话 @@ -831,7 +830,6 @@ class MemoryAgentService: "Web": count, "Third-party": count, "Folder": count, - "memory": chunk_count, "total": sum_of_all } """ @@ -878,51 +876,8 @@ class MemoryAgentService: logger.error(f"知识库类型统计失败: {e}") raise Exception(f"知识库类型统计失败: {e}") - # 2. 统计 Neo4j 中的 memory 总量(统计当前空间下所有宿主的 Chunk 总数) - try: - if current_workspace_id: - # 获取当前空间下的所有宿主 - from app.repositories import app_repository, end_user_repository - from app.schemas.app_schema import App as AppSchema - from app.schemas.end_user_schema import EndUser as EndUserSchema - - # 查询应用并转换为 Pydantic 模型 - apps_orm = app_repository.get_apps_by_workspace_id(db, current_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(h for h in end_user_orm_list) - - # 统计所有宿主的 Chunk 总数 - total_chunks = 0 - for end_user in end_users: - end_user_id_str = str(end_user.id) - memory_query = """ - 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, - end_user_id=end_user_id_str, - ) - chunk_count = neo4j_result[0]["Count"] if neo4j_result else 0 - total_chunks += chunk_count - logger.debug(f"EndUser {end_user_id_str} Chunk数量: {chunk_count}") - - result["memory"] = total_chunks - logger.info(f"Neo4j memory统计成功: 总Chunk数={total_chunks}, 宿主数={len(end_users)}") - else: - # 没有 workspace_id 时,返回 0 - result["memory"] = 0 - logger.info("未提供 workspace_id,memory 统计为 0") - - except Exception as e: - logger.error(f"Neo4j memory统计失败: {e}", exc_info=True) - # 如果 Neo4j 查询失败,memory 设为 0 - result["memory"] = 0 + # 2. 统计 Neo4j 中的 memory 总量已移除 + # memory 字段不再返回 # 3. 计算知识库类型总和(不包括 memory) result["total"] = ( diff --git a/redbear-mem-benchmark b/redbear-mem-benchmark index 4b0257bb..8494e824 160000 --- a/redbear-mem-benchmark +++ b/redbear-mem-benchmark @@ -1 +1 @@ -Subproject commit 4b0257bb4e7dc384b2aaf849b0bd6eae4b39835d +Subproject commit 8494e82498cb99c70ac67a64a544ff872432363a From 6a6e64f487e1c9fde7e1668ab4016e8fb7997845 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 11:06:17 +0800 Subject: [PATCH 14/55] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/app/version_info.json b/api/app/version_info.json index a4ff5d55..772ff56e 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -5,9 +5,9 @@ "releaseDate": "2026-2-26", "upgradePosition": "🐻 精炼根基,优化核心用户体验与系统稳定性", "coreUpgrades": [ - "1. 用户体验与国际化 🎨
* SSO 语言参数修复:解决 SSO 认证后添加 `?language=zh` 参数仍显示英文的问题,语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", + "1. 用户体验与国际化 🎨
* 语言参数修复:语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", "2. 工作流可视化增强 💬
* 循环与迭代节点输出展示:实时显示执行进度和中间输出,便于调试复杂迭代过程
* 变量支持回车选择:支持回车键确认变量选择,简化工作流配置流程", - "3. 多租户模型管理 ⚙️
* 租户隔离的模型密钥:模型广场排除自定义模型,模型列表按租户隔离密钥,防止跨租户密钥泄露", + "3. 优化模型管理 ⚙️
* 模型广场移除自定义模型,优化模型使用体验", "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", "
", "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", @@ -19,9 +19,9 @@ "releaseDate": "2026-2-26", "upgradePosition": "🐻 Refined foundations with enhanced user experience and system stability", "coreUpgrades": [ - "1. User Experience & Internationalization 🎨
* SSO Language Parameter Fix: Resolved issue where `?language=zh` parameter still showed English UI after SSO authentication
* Email Update Support: Users can now modify email addresses directly in user management system", + "1. User Experience & Internationalization 🎨
* Language parameter fix: language preferences are now correctly retained
* Email Update Support: Users can now modify email addresses directly in user management system", "2. Workflow Visualization Enhancements 💬
* Loop & Iteration Node Output Display: Real-time display of execution progress and intermediate outputs for easier debugging
* Variable Selection with Enter Key: Enabled Enter key confirmation for streamlined variable assignment", - "3. Multi-Tenant Model Management ⚙️
* Tenant-Scoped Model Keys: Model marketplace excludes custom models, model list properly isolates keys per tenant to prevent cross-tenant exposure", + "3. Optimized Model Management ⚙️
* Custom models have been removed from the Model marketplace to optimize the model usage experience", "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", "
", "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", From 2d731c64123254608ad8440095cd5bbbf52be1ad Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 11:16:15 +0800 Subject: [PATCH 15/55] docs(version): Version 0.2.5 Release Notes --- api/app/version_info.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/version_info.json b/api/app/version_info.json index 772ff56e..7d82eabc 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -10,7 +10,7 @@ "3. 优化模型管理 ⚙️
* 模型广场移除自定义模型,优化模型使用体验", "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", "
", - "版本 0.2.5 通过解决国际化边界情况、增强多租户隔离和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", + "版本 0.2.5 通过解决国际化边界情况和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", "智慧致远 🐻✨" ] }, @@ -24,7 +24,7 @@ "3. Optimized Model Management ⚙️
* Custom models have been removed from the Model marketplace to optimize the model usage experience", "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", "
", - "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases, enhancing multi-tenant isolation, and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", + "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", "Intelligent Resilience 🐻✨" ] } From bbaa39c569eb3aa261ed7de6cbfbdc499ce22c3e Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Fri, 27 Feb 2026 12:08:18 +0800 Subject: [PATCH 16/55] fix(user): The user changes the space and modifies the role, the role information is synchronized. --- api/app/controllers/user_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/controllers/user_controller.py b/api/app/controllers/user_controller.py index 3c574c81..2806da1a 100644 --- a/api/app/controllers/user_controller.py +++ b/api/app/controllers/user_controller.py @@ -100,7 +100,7 @@ def get_current_user_info( result_schema.current_workspace_name = current_workspace.name for ws in result.workspaces: - if ws.workspace_id == current_user.current_workspace_id: + if ws.workspace_id == current_user.current_workspace_id and ws.is_active: result_schema.role = ws.role break From a7ffc19ba1c86abf56630967f0d0893b137e52de Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 12:20:51 +0800 Subject: [PATCH 17/55] [fix]Reconstructing memory incremental statistical scheduling task --- api/app/celery_app.py | 17 ++-- api/app/core/config.py | 1 - api/app/tasks.py | 197 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 8ef44975..f422f4a0 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -82,7 +82,7 @@ celery_app.conf.update( 'app.tasks.workspace_reflection_task': {'queue': 'periodic_tasks'}, 'app.tasks.regenerate_memory_cache': {'queue': 'periodic_tasks'}, 'app.tasks.run_forgetting_cycle_task': {'queue': 'periodic_tasks'}, - 'app.controllers.memory_storage_controller.search_all': {'queue': 'periodic_tasks'}, + 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, }, ) @@ -115,16 +115,11 @@ beat_schedule_config = { "config_id": None, # 使用默认配置,可以通过环境变量配置 }, }, + "write-all-workspaces-memory": { + "task": "app.tasks.write_all_workspaces_memory_task", + "schedule": memory_increment_schedule, + "args": (), + }, } -#如果配置了默认工作空间ID,则添加记忆总量统计任务 -if settings.DEFAULT_WORKSPACE_ID: - beat_schedule_config["write-total-memory"] = { - "task": "app.controllers.memory_storage_controller.search_all", - "schedule": memory_increment_schedule, - "kwargs": { - "workspace_id": settings.DEFAULT_WORKSPACE_ID, - }, - } - celery_app.conf.beat_schedule = beat_schedule_config diff --git a/api/app/core/config.py b/api/app/core/config.py index 3a0c97b4..2e6b4136 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -202,7 +202,6 @@ class Settings: REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) MEMORY_INCREMENT_INTERVAL_HOURS: float = float(os.getenv("MEMORY_INCREMENT_INTERVAL_HOURS", "24")) - DEFAULT_WORKSPACE_ID: Optional[str] = os.getenv("DEFAULT_WORKSPACE_ID", None) REFLECTION_INTERVAL_TIME: Optional[str] = int(os.getenv("REFLECTION_INTERVAL_TIME", 30)) # Memory Cache Regeneration Configuration diff --git a/api/app/tasks.py b/api/app/tasks.py index d408a0da..8e3aea85 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1304,6 +1304,203 @@ def write_total_memory_task(workspace_id: str) -> Dict[str, Any]: "workspace_id": workspace_id, "elapsed_time": elapsed_time, } +@celery_app.task( + name="app.tasks.write_all_workspaces_memory_task", + bind=True, + ignore_result=False, + max_retries=3, + acks_late=True, + time_limit=3600, + soft_time_limit=3300, +) +def write_all_workspaces_memory_task(self) -> Dict[str, Any]: + """定时任务:遍历所有工作空间,统计并写入记忆增量 + + 此任务会: + 1. 查询所有活跃的工作空间 + 2. 对每个工作空间统计记忆总量 + 3. 将统计结果写入 memory_increments 表 + + Returns: + 包含任务执行结果的字典 + """ + start_time = time.time() + + async def _run() -> Dict[str, Any]: + from app.core.logging_config import get_api_logger + from app.models.workspace_model import Workspace + from app.models.app_model import App + from app.models.end_user_model import EndUser + from app.repositories.memory_increment_repository import write_memory_increment + from app.services.memory_storage_service import search_all + + api_logger = get_api_logger() + + with get_db_context() as db: + try: + # 获取所有活跃的工作空间 + workspaces = db.query(Workspace).filter( + Workspace.is_active.is_(True) + ).all() + + if not workspaces: + api_logger.warning("没有找到活跃的工作空间") + return { + "status": "SUCCESS", + "message": "没有找到活跃的工作空间", + "workspace_count": 0, + "workspace_results": [] + } + + api_logger.info(f"开始统计 {len(workspaces)} 个工作空间的记忆增量") + all_workspace_results = [] + + # 遍历每个工作空间 + for workspace in workspaces: + workspace_id = workspace.id + api_logger.info(f"开始处理工作空间: {workspace.name} (ID: {workspace_id})") + + try: + # 1. 查询当前workspace下的所有app(仅未删除的) + apps = db.query(App).filter( + App.workspace_id == workspace_id, + App.is_active.is_(True) + ).all() + + if not apps: + # 如果没有app,总量为0 + memory_increment = write_memory_increment( + db=db, + workspace_id=workspace_id, + total_num=0 + ) + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "SUCCESS", + "total_num": 0, + "end_user_count": 0, + "memory_increment_id": str(memory_increment.id), + "created_at": memory_increment.created_at.isoformat(), + }) + api_logger.info(f"工作空间 {workspace.name} 没有应用,记录总量为0") + continue + + # 2. 查询所有app下的end_user_id(去重) + app_ids = [app.id for app in apps] + end_users = db.query(EndUser.id).filter( + EndUser.app_id.in_(app_ids) + ).distinct().all() + + # 3. 遍历所有end_user,查询每个宿主的记忆总量并累加 + total_num = 0 + end_user_details = [] + + for (end_user_id,) in end_users: + try: + # 调用 search_all 接口查询该宿主的总量 + result = await search_all(str(end_user_id)) + user_total = result.get("total", 0) + total_num += user_total + end_user_details.append({ + "end_user_id": str(end_user_id), + "total": user_total + }) + except Exception as e: + # 记录单个用户查询失败,但继续处理其他用户 + api_logger.warning(f"查询用户 {end_user_id} 记忆失败: {str(e)}") + end_user_details.append({ + "end_user_id": str(end_user_id), + "total": 0, + "error": str(e) + }) + + # 4. 写入数据库 + memory_increment = write_memory_increment( + db=db, + workspace_id=workspace_id, + total_num=total_num + ) + + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "SUCCESS", + "total_num": total_num, + "end_user_count": len(end_users), + "memory_increment_id": str(memory_increment.id), + "created_at": memory_increment.created_at.isoformat(), + }) + + api_logger.info( + f"工作空间 {workspace.name} 统计完成: 总量={total_num}, 用户数={len(end_users)}" + ) + + except Exception as e: + db.rollback() # 回滚失败的事务,允许继续处理下一个工作空间 + api_logger.error(f"处理工作空间 {workspace.name} (ID: {workspace_id}) 失败: {str(e)}") + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "FAILURE", + "error": str(e), + "total_num": 0, + "end_user_count": 0, + }) + + total_memory = sum(r.get("total_num", 0) for r in all_workspace_results) + success_count = sum(1 for r in all_workspace_results if r.get("status") == "SUCCESS") + + return { + "status": "SUCCESS", + "message": f"成功处理 {success_count}/{len(workspaces)} 个工作空间,总记忆量: {total_memory}", + "workspace_count": len(workspaces), + "success_count": success_count, + "total_memory": total_memory, + "workspace_results": all_workspace_results + } + + except Exception as e: + api_logger.error(f"记忆增量统计任务执行失败: {str(e)}") + return { + "status": "FAILURE", + "error": str(e), + "workspace_count": 0, + "workspace_results": [] + } + + try: + # 使用 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 + + return result + except Exception as e: + elapsed_time = time.time() - start_time + return { + "status": "FAILURE", + "error": str(e), + "elapsed_time": elapsed_time, + "task_id": self.request.id + } @celery_app.task( From 550bd4da231f67e78f713a2424574efb3a356596 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 14:47:23 +0800 Subject: [PATCH 18/55] [fix]Modify the person who generates the user summary --- .../core/memory/utils/prompt/prompt_utils.py | 14 +++++++--- .../utils/prompt/prompts/user_summary.jinja2 | 26 ++++++++++++++----- api/app/services/user_memory_service.py | 24 ++++++++++++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/api/app/core/memory/utils/prompt/prompt_utils.py b/api/app/core/memory/utils/prompt/prompt_utils.py index 50d31f2a..d88f50cf 100644 --- a/api/app/core/memory/utils/prompt/prompt_utils.py +++ b/api/app/core/memory/utils/prompt/prompt_utils.py @@ -400,7 +400,8 @@ async def render_user_summary_prompt( user_id: str, entities: str, statements: str, - language: str = "zh" + language: str = "zh", + user_display_name: str = None ) -> str: """ Renders the user summary prompt using the user_summary.jinja2 template. @@ -410,16 +411,22 @@ async def render_user_summary_prompt( entities: Core entities with frequency information statements: Representative statement samples language: The language to use for summary generation ("zh" for Chinese, "en" for English) + user_display_name: Display name for the user (e.g., other_name or "该用户"/"the user") Returns: Rendered prompt content as string """ + # 如果没有提供 user_display_name,使用默认值 + if user_display_name is None: + user_display_name = "该用户" if language == "zh" else "the user" + template = prompt_env.get_template("user_summary.jinja2") rendered_prompt = template.render( user_id=user_id, entities=entities, statements=statements, - language=language + language=language, + user_display_name=user_display_name ) # 记录渲染结果到提示日志 @@ -429,7 +436,8 @@ async def render_user_summary_prompt( 'user_id': user_id, 'entities_len': len(entities), 'statements_len': len(statements), - 'language': language + 'language': language, + 'user_display_name': user_display_name }) return rendered_prompt diff --git a/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 b/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 index 35619112..30b48719 100644 --- a/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 @@ -14,8 +14,8 @@ Your task is to generate a comprehensive user profile based on the provided enti {% endif %} ===Inputs=== -{% if user_id %} -- User ID: {{ user_id }} +{% if user_display_name %} +- User Display Name: {{ user_display_name }} {% endif %} {% if entities %} - Core Entities & Frequency: {{ entities }} @@ -33,6 +33,20 @@ Your task is to generate a comprehensive user profile based on the provided enti 3. Avoid excessive adjectives and empty phrases 4. Strictly follow the output format specified below +{% if language == "zh" %} +**【严格人称规定】** +- 在描述用户时,必须使用"{{ user_display_name }}"作为人称 +- 绝对禁止使用用户ID(如 {{ user_id }})来称呼用户 +- 绝对禁止在摘要中出现任何形式的UUID或ID字符串 +- 如果需要指代用户,只能使用"{{ user_display_name }}"或相应的代词(他/她/TA) +{% else %} +**【STRICT PRONOUN RULES】** +- When describing the user, you MUST use "{{ user_display_name }}" as the reference +- It is ABSOLUTELY FORBIDDEN to use the user ID (such as {{ user_id }}) to refer to the user +- It is ABSOLUTELY FORBIDDEN to include any form of UUID or ID string in the summary +- If you need to refer to the user, you can ONLY use "{{ user_display_name }}" or appropriate pronouns (he/she/they) +{% endif %} + **Section-Specific Requirements:** {% if language == "zh" %} @@ -103,13 +117,13 @@ Your task is to generate a comprehensive user profile based on the provided enti {% if language == "zh" %} Example Input: -- User ID: user_12345 +- User Display Name: 张三 - Core Entities & Frequency: 产品经理 (15), AI (12), 深圳 (10), 数据分析 (8), 团队协作 (7) - Representative Statement Samples: 我在深圳从事产品经理工作已经5年了 | 我相信好的产品源于对用户需求的深刻理解 | 我喜欢在团队中起到协调作用 | 数据驱动决策是我的工作原则 Example Output: 【基本介绍】 -我是张三,一名充满热情的高级产品经理。在过去的5年里,我专注于AI和数据驱动的产品设计,致力于创造能够真正改善用户生活的产品。我相信好的产品源于对用户需求的深刻理解和对技术可能性的不断探索。 +张三是一名充满热情的高级产品经理,在深圳工作。在过去的5年里,张三专注于AI和数据驱动的产品设计,致力于创造能够真正改善用户生活的产品。张三相信好的产品源于对用户需求的深刻理解和对技术可能性的不断探索。 【性格特点】 性格开朗,善于沟通,注重细节。喜欢在团队中起到协调作用,帮助大家达成共识。面对挑战时保持乐观,相信每个问题都有解决方案。 @@ -121,13 +135,13 @@ Example Output: "让每一个产品决策都充满温度。" {% else %} Example Input: -- User ID: user_12345 +- User Display Name: John - Core Entities & Frequency: Product Manager (15), AI (12), San Francisco (10), Data Analysis (8), Team Collaboration (7) - Representative Statement Samples: I have been working as a product manager in San Francisco for 5 years | I believe good products come from deep understanding of user needs | I enjoy playing a coordinating role in teams | Data-driven decision making is my work principle Example Output: 【Basic Introduction】 -This is a passionate senior product manager based in San Francisco. Over the past 5 years, they have focused on AI and data-driven product design, dedicated to creating products that truly improve users' lives. They believe good products stem from deep understanding of user needs and continuous exploration of technological possibilities. +John is a passionate senior product manager based in San Francisco. Over the past 5 years, John has focused on AI and data-driven product design, dedicated to creating products that truly improve users' lives. John believes good products stem from deep understanding of user needs and continuous exploration of technological possibilities. 【Personality Traits】 Outgoing personality with excellent communication skills and attention to detail. Enjoys playing a coordinating role in teams, helping everyone reach consensus. Maintains optimism when facing challenges, believing every problem has a solution. diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 80413c12..e34756b9 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1163,11 +1163,32 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st """ from app.core.memory.utils.prompt.prompt_utils import render_user_summary_prompt from app.core.language_utils import validate_language + from app.repositories.end_user_repository import EndUserRepository + from app.db import get_db import re # 验证语言参数 language = validate_language(language) + # 获取用户的 other_name 字段 + user_display_name = "该用户" if language == "zh" else "the user" + if end_user_id: + try: + # 获取数据库会话并查询用户信息 + db = next(get_db()) + try: + repo = EndUserRepository(db) + end_user = repo.get_by_id(uuid.UUID(end_user_id)) + if end_user and end_user.other_name: + user_display_name = end_user.other_name + logger.info(f"使用 other_name 作为用户显示名称: {user_display_name}") + else: + logger.info(f"用户 {end_user_id} 的 other_name 为空,使用默认称呼: {user_display_name}") + finally: + db.close() + except Exception as e: + logger.warning(f"获取用户 other_name 失败,使用默认称呼: {str(e)}") + # 创建 UserSummaryHelper 实例 user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_end_user_id", "group_123")) @@ -1184,7 +1205,8 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st user_id=user_summary_tool.user_id, entities=", ".join(entity_lines) if entity_lines else "(空)" if language == "zh" else "(empty)", statements=" | ".join(statement_samples) if statement_samples else "(空)" if language == "zh" else "(empty)", - language=language + language=language, + user_display_name=user_display_name ) messages = [ From 97d8168824a2d9afb20b44a26fe99ca8cf1230ba Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 14:59:28 +0800 Subject: [PATCH 19/55] [fix]Reduce the default number of items returned for popular tags --- api/app/core/memory/analytics/hot_memory_tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index f99b811e..5ffc6fed 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -139,10 +139,10 @@ async def get_raw_tags_from_db( return [(record["name"], record["frequency"]) for record in results] -async def get_hot_memory_tags(end_user_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 = 10, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 - 查询更多的标签(limit=40)给LLM提供更丰富的上下文进行筛选。 + 查询更多的标签(limit=10)给LLM提供更丰富的上下文进行筛选。 Args: end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id From f7d92be5ea452f38142a47f2272ec2ce69d397d3 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 15:08:06 +0800 Subject: [PATCH 20/55] [changes]Ensure that there are sufficient labels for LLM to process, and control the number of label returns. --- api/app/core/memory/analytics/hot_memory_tags.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index 5ffc6fed..abb0f138 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -142,11 +142,11 @@ async def get_raw_tags_from_db( async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 - 查询更多的标签(limit=10)给LLM提供更丰富的上下文进行筛选。 + 查询更多的标签(40条)给LLM提供更丰富的上下文进行筛选,但最终返回数量由limit参数控制。 Args: end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id - limit: 返回的标签数量限制 + limit: 最终返回的标签数量限制(默认10) by_user: 是否按user_id查询(默认False,按end_user_id查询) Raises: @@ -161,8 +161,9 @@ async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = # 使用项目的Neo4jConnector connector = Neo4jConnector() try: - # 1. 从数据库获取原始排名靠前的标签 - raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, limit, by_user=by_user) + # 1. 从数据库获取原始排名靠前的标签(查询40条给LLM提供更丰富的上下文) + query_limit = 40 + raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, query_limit, by_user=by_user) if not raw_tags_with_freq: return [] @@ -177,7 +178,8 @@ async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = if tag in meaningful_tag_names: final_tags.append((tag, freq)) - return final_tags + # 4. 限制返回的标签数量 + return final_tags[:limit] finally: # 确保关闭连接 await connector.close() From 5253cf3899e265208713d95b3281457b05298b2a Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 16:09:22 +0800 Subject: [PATCH 21/55] [fix]Address the shortcomings of intelligent pruning --- .../data_preprocessing/data_pruning.py | 518 ++++++++++++++---- 1 file changed, 423 insertions(+), 95 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index d19e511b..2d0142c6 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -5,14 +5,17 @@ - 对话级一次性抽取判定相关性 - 仅对"不相关对话"的消息按比例删除 - 重要信息(时间、编号、金额、联系方式、地址等)优先保留 +- 改进版:增强重要性判断、智能填充消息识别、问答对保护、并发优化 """ +import asyncio import os import hashlib import json import re +from collections import OrderedDict from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Dict, Tuple, Set from pydantic import BaseModel, Field from app.core.memory.models.message_models import DialogData, ConversationMessage, ConversationContext @@ -36,6 +39,23 @@ class DialogExtractionResponse(BaseModel): keywords: List[str] = Field(default_factory=list) +class MessageImportanceResponse(BaseModel): + """消息重要性批量判断的结构化返回(用于LLM语义判断)。 + + - importance_scores: 消息索引到重要性分数的映射 (0-10分) + - reasons: 可选的判断理由 + """ + importance_scores: Dict[int, int] = Field(default_factory=dict, description="消息索引到重要性分数(0-10)的映射") + reasons: Optional[Dict[int, str]] = Field(default_factory=dict, description="可选的判断理由") + + +class QAPair(BaseModel): + """问答对模型,用于识别和保护对话中的问答结构。""" + question_idx: int = Field(..., description="问题消息的索引") + answer_idx: int = Field(..., description="答案消息的索引") + confidence: float = Field(default=1.0, description="问答对的置信度(0-1)") + + class SemanticPruner: """语义剪枝:在预处理与分块之间过滤与场景不相关内容。 @@ -43,109 +63,353 @@ class SemanticPruner: 重要信息(时间、编号、金额、联系方式、地址等)优先保留。 """ - def __init__(self, config: Optional[PruningConfig] = None, llm_client=None): - cfg_dict = get_pruning_config() if config is None else config.model_dump() - self.config = PruningConfig.model_validate(cfg_dict) + def __init__(self, config: Optional[PruningConfig] = None, llm_client=None, language: str = "zh", max_concurrent: int = 5): + # 如果没有提供config,使用默认配置 + if config is None: + # 使用默认的剪枝配置 + config = PruningConfig( + pruning_switch=False, # 默认关闭剪枝,保持向后兼容 + pruning_scene="education", + pruning_threshold=0.5 + ) + + self.config = config self.llm_client = llm_client + self.language = language # 保存语言配置 + self.max_concurrent = max_concurrent # 新增:最大并发数 + # Load Jinja2 template self.template = prompt_env.get_template("extracat_Pruning.jinja2") - # 对话抽取缓存:避免同一对话重复调用 LLM / 重复渲染 - self._dialog_extract_cache: dict[str, DialogExtractionResponse] = {} + + # 对话抽取缓存:使用 OrderedDict 实现 LRU 缓存 + self._dialog_extract_cache: OrderedDict[str, DialogExtractionResponse] = OrderedDict() + self._cache_max_size = 1000 # 缓存大小限制 + # 运行日志:收集关键终端输出,便于写入 JSON self.run_logs: List[str] = [] - # 采用顺序处理,移除并发配置以简化与稳定执行 + + # 扩展的填充词库(包含表情符号和网络用语) + self._extended_fillers = [ + # 基础寒暄 + "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", + "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", + "拜拜", "再见", "88", "拜", "回见", + # 口头禅 + "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", + "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", + # 确认词 + "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", + # 标点和符号 + "。。。", "...", "???", "???", "!!!", "!!!", + # 表情符号(文本形式) + "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", + "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", + "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", + "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", + # 网络用语 + "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", + "emmm", "emm", "em", "mmp", "wtf", "omg", + ] def _is_important_message(self, message: ConversationMessage) -> bool: """基于启发式规则识别重要信息消息,优先保留。 - - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午)。 - - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段。 - - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址"。 + 改进版:增强了规则覆盖范围,包括: + - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午) + - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段 + - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址" + - 新增:问句识别、决策性语句、承诺性语句 """ - import re text = message.msg.strip() if not text: return False + patterns = [ - r"\b\d{4}-\d{1,2}-\d{1,2}\b", - r"\b\d{1,2}:\d{2}\b", + # 原有模式 + r"\d{4}-\d{1,2}-\d{1,2}", # 修复:移除 \b 边界,因为中文前后没有单词边界 + r"\d{1,2}:\d{2}", # 修复:移除 \b r"\d{4}年\d{1,2}月\d{1,2}日", - r"上午|下午|AM|PM", - r"订单号|工单|申请号|编号|ID|账号|账户", - r"电话|手机号|微信|QQ|邮箱", - r"地址|地点", - r"金额|费用|价格|¥|¥|\d+元", - r"时间|日期|有效期|截止", + r"上午|下午|AM|PM|今天|明天|后天|昨天|前天|本周|下周|上周|本月|下月|上月", + r"订单号|工单|申请号|编号|ID|账号|账户|流水号|单号", + r"电话|手机号|微信|QQ|邮箱|联系方式", + r"地址|地点|位置|门牌号", + r"金额|费用|价格|¥|¥|\d+元|人民币|美元|欧元", + r"时间|日期|有效期|截止|期限|到期", + # 新增模式 + r"什么|为什么|怎么|如何|哪里|哪个|谁|多少|几点|何时", # 问句关键词 + r"必须|一定|务必|需要|要求|规定|应该", # 决策性语句 + r"承诺|保证|确保|负责|同意|答应", # 承诺性语句 + r"\d{11}", # 11位手机号 + r"\d{3,4}-\d{7,8}", # 固定电话 + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", # 邮箱 ] + for p in patterns: if re.search(p, text, flags=re.IGNORECASE): return True + + # 检查是否为问句(以问号结尾或包含疑问词) + if text.endswith("?") or text.endswith("?"): + return True + return False + def _importance_score(self, message: ConversationMessage) -> int: """为重要消息打分,用于在保留比例内优先保留更关键的内容。 - 简单启发:匹配到的类别越多、越关键分值越高。 + 改进版:更细致的评分体系(0-10分) """ - import re text = message.msg.strip() score = 0 + weights = [ - (r"\b\d{4}-\d{1,2}-\d{1,2}\b", 3), - (r"\b\d{1,2}:\d{2}\b", 2), + # 高优先级(4-5分) + (r"订单号|工单|申请号|编号|ID|账号|账户", 5), + (r"金额|费用|价格|¥|¥|\d+元", 5), + (r"\d{11}", 4), # 手机号 + (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 + + # 中优先级(2-3分) + (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 修复:移除 \b (r"\d{4}年\d{1,2}月\d{1,2}日", 3), - (r"订单号|工单|申请号|编号|ID|账号|账户", 4), - (r"电话|手机号|微信|QQ|邮箱", 3), - (r"地址|地点", 2), - (r"金额|费用|价格|¥|¥|\d+元", 4), - (r"时间|日期|有效期|截止", 2), + (r"电话|手机号|微信|QQ|联系方式", 3), + (r"地址|地点|位置", 2), + (r"时间|日期|有效期|截止|明天|后天|下周|下月", 2), # 新增时间相关词 + + # 低优先级(1分) + (r"\d{1,2}:\d{2}", 1), # 修复:移除 \b + (r"上午|下午|AM|PM", 1), ] + for p, w in weights: if re.search(p, text, flags=re.IGNORECASE): score += w - return score + + # 问句加分 + if text.endswith("?") or text.endswith("?"): + score += 2 + + # 长度加分(较长的消息通常包含更多信息) + if len(text) > 50: + score += 1 + if len(text) > 100: + score += 1 + + return min(score, 10) # 最高10分 def _is_filler_message(self, message: ConversationMessage) -> bool: """检测典型寒暄/口头禅/确认类短消息,用于跳过LLM分类以加速。 + 改进版:扩展了填充词库,支持表情符号和网络用语 满足以下之一视为填充消息: - - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体; - - 常见词:你好/您好/在吗/嗯/嗯嗯/哦/好的/好/行/可以/不可以/谢谢/拜拜/再见/哈哈/呵呵/哈哈哈/。。。/??。 + - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体 + - 在扩展填充词库中 + - 纯表情符号 """ - import re t = message.msg.strip() if not t: return True - # 常见填充语 - fillers = [ - "你好", "您好", "在吗", "嗯", "嗯嗯", "哦", "好的", "好", "行", "可以", "不可以", "谢谢", - "拜拜", "再见", "哈哈", "呵呵", "哈哈哈", "。。。", "??", "??" - ] - if t in fillers: + + # 检查是否在扩展填充词库中 + if t in self._extended_fillers: return True + + # 检查是否为纯表情符号(方括号包裹) + if re.fullmatch(r"(\[[^\]]+\])+", t): + return True + + # 检查是否为纯emoji(Unicode表情) + emoji_pattern = re.compile( + "[" + "\U0001F600-\U0001F64F" # 表情符号 + "\U0001F300-\U0001F5FF" # 符号和象形文字 + "\U0001F680-\U0001F6FF" # 交通和地图符号 + "\U0001F1E0-\U0001F1FF" # 旗帜 + "\U00002702-\U000027B0" + "\U000024C2-\U0001F251" + "]+", flags=re.UNICODE + ) + if emoji_pattern.fullmatch(t): + return True + # 长度与字符类型判断 if len(t) <= 8: # 非数字、无关键实体的短文本 if not re.search(r"[0-9]", t) and not self._is_important_message(message): # 主要是标点或简单确认词 - if re.fullmatch(r"[。!?,.!?…·\s]+", t) or t in fillers: + if re.fullmatch(r"[。!?,.!?…·\s]+", t): return True + return False + + async def _batch_evaluate_importance_with_llm( + self, + messages: List[ConversationMessage], + context: str = "" + ) -> Dict[int, int]: + """使用LLM批量评估消息的重要性(语义层面)。 + + Args: + messages: 消息列表 + context: 对话上下文(可选) + + Returns: + 消息索引到重要性分数(0-10)的映射 + """ + if not self.llm_client or not messages: + return {} + + # 构建批量评估的提示词 + msg_list = [] + for idx, msg in enumerate(messages): + msg_list.append(f"{idx}. {msg.msg}") + + msg_text = "\n".join(msg_list) + + prompt = f"""请评估以下消息的重要性,给每条消息打分(0-10分): +- 0-2分:无意义的寒暄、口头禅、纯表情 +- 3-5分:一般性对话,有一定信息量但不关键 +- 6-8分:包含重要信息(时间、地点、人物、事件等) +- 9-10分:关键决策、承诺、重要数据 + +对话上下文: +{context if context else "无"} + +待评估的消息: +{msg_text} + +请以JSON格式返回,格式为: +{{ + "importance_scores": {{ + "0": 分数, + "1": 分数, + ... + }} +}} +""" + + try: + messages_for_llm = [ + {"role": "system", "content": "你是一个专业的对话分析助手,擅长评估消息的重要性。"}, + {"role": "user", "content": prompt} + ] + + response = await self.llm_client.response_structured( + messages_for_llm, + MessageImportanceResponse + ) + + # 转换字符串键为整数键 + return {int(k): v for k, v in response.importance_scores.items()} + except Exception as e: + self._log(f"[剪枝-LLM] 批量重要性评估失败: {str(e)[:100]}") + return {} + + def _identify_qa_pairs(self, messages: List[ConversationMessage]) -> List[QAPair]: + """识别对话中的问答对,用于保护问答结构的完整性。 + + Args: + messages: 消息列表 + + Returns: + 问答对列表 + """ + qa_pairs = [] + + for i in range(len(messages) - 1): + current_msg = messages[i].msg.strip() + next_msg = messages[i + 1].msg.strip() + + # 简单规则:如果当前消息是问句,下一条消息可能是答案 + is_question = ( + current_msg.endswith("?") or + current_msg.endswith("?") or + any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗"]) + ) + + if is_question and next_msg: + # 检查下一条消息是否像答案(不是另一个问句) + is_answer = not (next_msg.endswith("?") or next_msg.endswith("?")) + + if is_answer: + qa_pairs.append(QAPair( + question_idx=i, + answer_idx=i + 1, + confidence=0.8 # 基于规则的置信度 + )) + + return qa_pairs + + def _get_protected_indices( + self, + messages: List[ConversationMessage], + qa_pairs: List[QAPair], + window_size: int = 2 + ) -> Set[int]: + """获取需要保护的消息索引集合(问答对+上下文窗口)。 + + Args: + messages: 消息列表 + qa_pairs: 问答对列表 + window_size: 上下文窗口大小(前后各保留几条消息) + + Returns: + 需要保护的消息索引集合 + """ + protected = set() + + for qa_pair in qa_pairs: + # 保护问答对本身 + protected.add(qa_pair.question_idx) + protected.add(qa_pair.answer_idx) + + # 保护上下文窗口 + for offset in range(-window_size, window_size + 1): + q_idx = qa_pair.question_idx + offset + a_idx = qa_pair.answer_idx + offset + + if 0 <= q_idx < len(messages): + protected.add(q_idx) + if 0 <= a_idx < len(messages): + protected.add(a_idx) + + return protected async def _extract_dialog_important(self, dialog_text: str) -> DialogExtractionResponse: """对话级一次性抽取:从整段对话中提取重要信息并判定相关性。 - - 仅使用 LLM 结构化输出; + 改进版: + - LRU缓存管理 + - 重试机制 + - 降级策略 """ # 缓存命中则直接返回(场景+内容作为键) cache_key = f"{self.config.pruning_scene}:" + hashlib.sha1(dialog_text.encode("utf-8")).hexdigest() + + # LRU缓存:如果命中,移到末尾(最近使用) if cache_key in self._dialog_extract_cache: + self._dialog_extract_cache.move_to_end(cache_key) return self._dialog_extract_cache[cache_key] - rendered = self.template.render(pruning_scene=self.config.pruning_scene, dialog_text=dialog_text) - log_template_rendering("extracat_Pruning.jinja2", {"pruning_scene": self.config.pruning_scene}) + # LRU缓存大小限制:超过限制时删除最旧的条目 + if len(self._dialog_extract_cache) >= self._cache_max_size: + # 删除最旧的条目(OrderedDict的第一个) + oldest_key = next(iter(self._dialog_extract_cache)) + del self._dialog_extract_cache[oldest_key] + self._log(f"[剪枝-缓存] LRU缓存已满,删除最旧条目") + + rendered = self.template.render( + pruning_scene=self.config.pruning_scene, + dialog_text=dialog_text, + language=self.language + ) + log_template_rendering("extracat_Pruning.jinja2", { + "pruning_scene": self.config.pruning_scene, + "language": self.language + }) log_prompt_rendering("pruning-extract", rendered) - # 强制使用 LLM;移除正则回退 + # 强制使用 LLM if not self.llm_client: raise RuntimeError("llm_client 未配置;请配置 LLM 以进行结构化抽取。") @@ -153,12 +417,32 @@ class SemanticPruner: {"role": "system", "content": "你是一个严谨的场景抽取助手,只输出严格 JSON。"}, {"role": "user", "content": rendered}, ] - try: - ex = await self.llm_client.response_structured(messages, DialogExtractionResponse) - self._dialog_extract_cache[cache_key] = ex - return ex - except Exception as e: - raise RuntimeError("LLM 结构化抽取失败;请检查 LLM 配置或重试。") from e + + # 重试机制 + max_retries = 3 + for attempt in range(max_retries): + try: + ex = await self.llm_client.response_structured(messages, DialogExtractionResponse) + self._dialog_extract_cache[cache_key] = ex + return ex + except Exception as e: + if attempt < max_retries - 1: + self._log(f"[剪枝-LLM] 第 {attempt + 1} 次尝试失败,重试中... 错误: {str(e)[:100]}") + await asyncio.sleep(0.5 * (attempt + 1)) # 指数退避 + continue + else: + # 降级策略:标记为相关,避免误删 + self._log(f"[剪枝-LLM] LLM 调用失败 {max_retries} 次,使用降级策略(标记为相关)") + fallback_response = DialogExtractionResponse( + is_related=True, + times=[], + ids=[], + amounts=[], + contacts=[], + addresses=[], + keywords=[] + ) + return fallback_response def _msg_matches_tokens(self, message: ConversationMessage, tokens: List[str]) -> bool: """判断消息是否包含任意抽取到的重要片段。""" @@ -248,12 +532,15 @@ class SemanticPruner: async def prune_dataset(self, dialogs: List[DialogData]) -> List[DialogData]: """数据集层面:全局消息级剪枝,保留所有对话。 - - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动。 - - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留。 - - 删除总量 = 阈值 * 全部不相关可删消息数,按可删容量比例分配;顺序删除。 - - 保证每段对话至少保留1条消息,不会删除整段对话。 + 改进版: + - 并发处理对话级相关性判断 + - 问答对识别和保护 + - 优化删除策略,保持上下文连贯性 + - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动 + - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留 + - 保证每段对话至少保留1条消息,不会删除整段对话 """ - # 如果剪枝功能关闭,直接返回原始数据集。 + # 如果剪枝功能关闭,直接返回原始数据集 if not self.config.pruning_switch: return dialogs @@ -264,29 +551,36 @@ class SemanticPruner: proportion = 0.9 if proportion < 0.0: proportion = 0.0 - evaluated_dialogs = [] # list of dicts: {dialog, is_related} self._log( f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch}" ) - # 对话级相关性分类(一次性对整段对话文本进行判断,顺序执行并复用缓存) - evaluated_dialogs = [] - for idx, dd in enumerate(dialogs): - try: - ex = await self._extract_dialog_important(dd.content) - evaluated_dialogs.append({ - "dialog": dd, - "is_related": bool(ex.is_related), - "index": idx, - "extraction": ex - }) - except Exception: - evaluated_dialogs.append({ - "dialog": dd, - "is_related": True, - "index": idx, - "extraction": None - }) + + # 并发处理对话级相关性分类 + semaphore = asyncio.Semaphore(self.max_concurrent) + + async def classify_dialog(idx: int, dd: DialogData): + async with semaphore: + try: + ex = await self._extract_dialog_important(dd.content) + return { + "dialog": dd, + "is_related": bool(ex.is_related), + "index": idx, + "extraction": ex + } + except Exception as e: + self._log(f"[剪枝-并发] 对话 {idx} 分类失败: {str(e)[:100]}") + return { + "dialog": dd, + "is_related": True, # 失败时标记为相关,避免误删 + "index": idx, + "extraction": None + } + + # 并发执行所有对话的分类 + tasks = [classify_dialog(idx, dd) for idx, dd in enumerate(dialogs)] + evaluated_dialogs = await asyncio.gather(*tasks) # 统计相关 / 不相关对话 not_related_dialogs = [d for d in evaluated_dialogs if not d["is_related"]] @@ -300,7 +594,6 @@ class SemanticPruner: inds = [i["index"] + 1 for i in items] if len(inds) <= cap: return inds - # 超过上限时只打印前cap个,并标注总数 return inds[:cap] + ["...", f"共{len(inds)}个"] rel_inds = _fmt_indices(related_dialogs) @@ -309,59 +602,83 @@ class SemanticPruner: result: List[DialogData] = [] if not_related_dialogs: - # 为每个不相关对话进行一次性抽取,识别重要/不重要(避免逐条 LLM) + # 为每个不相关对话进行分析 per_dialog_info = {} total_unrelated = 0 - total_capacity = 0 + for d in not_related_dialogs: dd = d["dialog"] extraction = d.get("extraction") if extraction is None: extraction = await self._extract_dialog_important(dd.content) + # 合并所有重要标记 tokens = extraction.times + extraction.ids + extraction.amounts + extraction.contacts + extraction.addresses + extraction.keywords msgs = dd.context.msgs - # 分类消息 - imp_unrel_msgs = [m for m in msgs if self._msg_matches_tokens(m, tokens) or self._is_important_message(m)] - unimp_unrel_msgs = [m for m in msgs if m not in imp_unrel_msgs] + + # 识别问答对 + qa_pairs = self._identify_qa_pairs(msgs) + protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=1) + + # 分类消息(考虑问答对保护) + imp_unrel_msgs = [] + unimp_unrel_msgs = [] + + for idx, m in enumerate(msgs): + # 问答对中的消息自动标记为重要 + if idx in protected_indices: + imp_unrel_msgs.append((idx, m)) + elif self._msg_matches_tokens(m, tokens) or self._is_important_message(m): + imp_unrel_msgs.append((idx, m)) + elif not self._is_filler_message(m): + unimp_unrel_msgs.append((idx, m)) + # 填充消息不加入任何列表,优先删除 + # 重要消息按重要性排序 - imp_sorted_ids = [id(m) for m in sorted(imp_unrel_msgs, key=lambda m: self._importance_score(m))] + imp_sorted = sorted(imp_unrel_msgs, key=lambda x: self._importance_score(x[1])) + imp_sorted_ids = [id(m) for _, m in imp_sorted] + info = { "dialog": dd, "total_msgs": len(msgs), "unrelated_count": len(msgs), "imp_ids_sorted": imp_sorted_ids, - "unimp_ids": [id(m) for m in unimp_unrel_msgs], + "unimp_ids": [id(m) for _, m in unimp_unrel_msgs], + "protected_indices": protected_indices, + "qa_pairs_count": len(qa_pairs), } per_dialog_info[d["index"]] = info total_unrelated += info["unrelated_count"] - # 全局删除配额:比例作用于全部不相关消息(重要+不重要) + + # 全局删除配额计算 global_delete = int(total_unrelated * proportion) if proportion > 0 and total_unrelated > 0 and global_delete == 0: global_delete = 1 - # 每段的最大可删容量:不重要全部 + 重要最多删除 floor(len(重要)*比例),且至少保留1条消息 + + # 每段的最大可删容量 capacities = [] for d in not_related_dialogs: idx = d["index"] info = per_dialog_info[idx] - # 统计重要数量 imp_count = len(info["imp_ids_sorted"]) unimp_count = len(info["unimp_ids"]) imp_cap = int(imp_count * proportion) cap = min(unimp_count + imp_cap, max(0, info["total_msgs"] - 1)) capacities.append(cap) + total_capacity = sum(capacities) if global_delete > total_capacity: - print(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}(重要消息按比例保留)。将按最大可删执行。") + self._log(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}。将按最大可删执行。") global_delete = total_capacity - # 配额分配:按不相关消息占比分配到各对话,但不超过各自容量 + # 配额分配 alloc = [] for i, d in enumerate(not_related_dialogs): idx = d["index"] info = per_dialog_info[idx] share = int(global_delete * (info["unrelated_count"] / total_unrelated)) if total_unrelated > 0 else 0 alloc.append(min(share, capacities[i])) + allocated = sum(alloc) rem = global_delete - allocated turn = 0 @@ -378,34 +695,40 @@ class SemanticPruner: break turn += 1 - # 应用删除:相关对话不动;不相关按分配先删不重要,再删重要(低分优先) + # 应用删除 total_deleted_confirm = 0 for d in evaluated_dialogs: dd = d["dialog"] msgs = dd.context.msgs original = len(msgs) + if d["is_related"]: result.append(dd) continue + idx_in_unrel = next((k for k, x in enumerate(not_related_dialogs) if x["index"] == d["index"]), None) if idx_in_unrel is None: result.append(dd) continue + quota = alloc[idx_in_unrel] info = per_dialog_info[d["index"]] - # 计算本对话重要最多可删数量 + + # 计算删除ID imp_count = len(info["imp_ids_sorted"]) imp_del_cap = int(imp_count * proportion) - # 先构造顺序删除的"不重要ID集合"(按出现顺序前 quota 条) + unimp_delete_ids = set(info["unimp_ids"][:min(quota, len(info["unimp_ids"]))]) del_unimp = min(quota, len(unimp_delete_ids)) rem_quota = quota - del_unimp - # 再从重要里选低分优先的删除ID(不超过 imp_del_cap) + imp_delete_ids = set(info["imp_ids_sorted"][:min(rem_quota, imp_del_cap)]) + deleted_here = 0 actual_unimp_deleted = 0 actual_imp_deleted = 0 kept = [] + for m in msgs: mid = id(m) if mid in unimp_delete_ids and actual_unimp_deleted < del_unimp: @@ -417,26 +740,30 @@ class SemanticPruner: deleted_here += 1 continue kept.append(m) + if not kept and msgs: kept = [msgs[0]] + dd.context.msgs = kept total_deleted_confirm += deleted_here + + qa_info = f",问答对={info['qa_pairs_count']}" if info['qa_pairs_count'] > 0 else "" self._log( - f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}" + f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}{qa_info}" ) result.append(dd) - self._log(f"[剪枝-数据集] 全局消息级顺序剪枝完成,总删除 {total_deleted_confirm} 条(不相关消息,重要按比例保留)。") + + self._log(f"[剪枝-数据集] 全局消息级剪枝完成,总删除 {total_deleted_confirm} 条(保护问答对和上下文)。") else: - # 全部相关:不执行剪枝 result = [d["dialog"] for d in evaluated_dialogs] + self._log(f"[剪枝-数据集] 剩余对话数={len(result)}") - # 将本次剪枝阶段的终端输出保存为 JSON 文件(仅在剪枝器内部完成) + # 保存日志 try: from app.core.config import settings settings.ensure_memory_output_dir() log_output_path = settings.get_memory_output_path("pruned_terminal.json") - # 去除日志前缀标签(如 [剪枝-数据集]、[剪枝-对话])后再解析为结构化字段保存 sanitized_logs = [self._sanitize_log_line(l) for l in self.run_logs] payload = self._parse_logs_to_structured(sanitized_logs) with open(log_output_path, "w", encoding="utf-8") as f: @@ -448,6 +775,7 @@ class SemanticPruner: if not result: print("警告: 语义剪枝后数据集为空,已回退为未剪枝数据以避免流程中断") return dialogs + return result def _log(self, msg: str) -> None: From f7aed9dd9807f7746bece974c7f964cfc2bbe540 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 16:45:34 +0800 Subject: [PATCH 22/55] [fix]Correct the flaws existing in the semantic segmentation method --- .../knowledge_extraction/chunk_extraction.py | 205 ++++++++++++++---- 1 file changed, 160 insertions(+), 45 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py index 40e98507..bbbf1c51 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py @@ -1,5 +1,7 @@ import os -from typing import Optional +from typing import Optional, List, Any +from enum import Enum +from pathlib import Path from app.core.logging_config import get_memory_logger from app.core.memory.models.message_models import DialogData, Chunk @@ -10,6 +12,20 @@ from app.core.memory.utils.config.config_utils import get_chunker_config logger = get_memory_logger(__name__) +class ChunkerStrategy(Enum): + """Supported chunking strategies.""" + RECURSIVE = "RecursiveChunker" + SEMANTIC = "SemanticChunker" + LATE = "LateChunker" + NEURAL = "NeuralChunker" + LLM = "LLMChunker" + + @classmethod + def get_valid_strategies(cls) -> List[str]: + """Get list of valid strategy names.""" + return [strategy.value for strategy in cls] + + class DialogueChunker: """A class that processes dialogues and fills them with chunks based on a specified strategy. @@ -17,23 +33,51 @@ class DialogueChunker: of different chunking strategies to dialogue data. """ - def __init__(self, chunker_strategy: str = "RecursiveChunker", llm_client=None): + def __init__(self, chunker_strategy: str = "RecursiveChunker", llm_client: Optional[Any] = None): """Initialize the DialogueChunker with a specific chunking strategy. Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - Options: SemanticChunker, RecursiveChunker, LateChunker, NeuralChunker + Options: SemanticChunker, RecursiveChunker, LateChunker, NeuralChunker, LLMChunker + llm_client: LLM client instance (required for LLMChunker strategy) + + Raises: + ValueError: If chunker_strategy is invalid or required parameters are missing """ - self.chunker_strategy = chunker_strategy - chunker_config_dict = get_chunker_config(chunker_strategy) - self.chunker_config = ChunkerConfig.model_validate(chunker_config_dict) + # Validate strategy + valid_strategies = ChunkerStrategy.get_valid_strategies() + if chunker_strategy not in valid_strategies: + raise ValueError( + f"Invalid chunker_strategy: '{chunker_strategy}'. " + f"Must be one of {valid_strategies}" + ) - if self.chunker_config.chunker_strategy == "LLMChunker": - self.chunker_client = ChunkerClient(self.chunker_config, llm_client) - else: - self.chunker_client = ChunkerClient(self.chunker_config) + self.chunker_strategy = chunker_strategy + logger.info(f"Initializing DialogueChunker with strategy: {chunker_strategy}") + + try: + # Load and validate configuration + chunker_config_dict = get_chunker_config(chunker_strategy) + if not chunker_config_dict: + raise ValueError(f"Failed to load configuration for strategy: {chunker_strategy}") + + self.chunker_config = ChunkerConfig.model_validate(chunker_config_dict) + + # Initialize chunker client + if self.chunker_config.chunker_strategy == "LLMChunker": + if not llm_client: + raise ValueError("llm_client is required for LLMChunker strategy") + self.chunker_client = ChunkerClient(self.chunker_config, llm_client) + else: + self.chunker_client = ChunkerClient(self.chunker_config) + + logger.info(f"DialogueChunker initialized successfully with strategy: {chunker_strategy}") + + except Exception as e: + logger.error(f"Failed to initialize DialogueChunker: {e}", exc_info=True) + raise - async def process_dialogue(self, dialogue: DialogData) -> list[Chunk]: + async def process_dialogue(self, dialogue: DialogData) -> List[Chunk]: """Process a dialogue by generating chunks and adding them to the DialogData object. Args: @@ -43,54 +87,125 @@ class DialogueChunker: A list of Chunk objects Raises: - ValueError: If chunking fails or returns empty chunks + ValueError: If dialogue is invalid or chunking fails + Exception: If chunking process encounters an error """ - result_dialogue = await self.chunker_client.generate_chunks(dialogue) - chunks = result_dialogue.chunks - - if not chunks or len(chunks) == 0: + # Validate input + if not dialogue: + raise ValueError("dialogue cannot be None") + + if not dialogue.context or not dialogue.context.msgs: raise ValueError( - f"Chunking failed: No chunks generated for dialogue {dialogue.ref_id}. " - f"Messages: {len(dialogue.context.msgs) if dialogue.context else 0}, " - f"Strategy: {self.chunker_config.chunker_strategy}" + f"Dialogue {dialogue.ref_id} has no messages to chunk. " + f"Context: {dialogue.context is not None}, " + f"Messages: {len(dialogue.context.msgs) if dialogue.context else 0}" ) + + logger.info( + f"Processing dialogue {dialogue.ref_id} with {len(dialogue.context.msgs)} messages " + f"using strategy: {self.chunker_strategy}" + ) + + try: + # Generate chunks + result_dialogue = await self.chunker_client.generate_chunks(dialogue) + chunks = result_dialogue.chunks - return chunks + # Validate results + if not chunks or len(chunks) == 0: + raise ValueError( + f"Chunking failed: No chunks generated for dialogue {dialogue.ref_id}. " + f"Messages: {len(dialogue.context.msgs)}, " + f"Content length: {len(dialogue.content) if dialogue.content else 0}, " + f"Strategy: {self.chunker_config.chunker_strategy}" + ) - def save_chunking_results(self, dialogue: DialogData, output_path: Optional[str] = None) -> str: + logger.info( + f"Successfully generated {len(chunks)} chunks for dialogue {dialogue.ref_id}. " + f"Total characters processed: {len(dialogue.content) if dialogue.content else 0}" + ) + + return chunks + + except ValueError: + # Re-raise validation errors + raise + except Exception as e: + logger.error( + f"Error processing dialogue {dialogue.ref_id} with strategy {self.chunker_strategy}: {e}", + exc_info=True + ) + raise + + def save_chunking_results( + self, + chunks: List[Chunk], + dialogue: DialogData, + output_path: Optional[str] = None, + preview_length: int = 100 + ) -> str: """Save the chunking results to a file and return the output path. Args: - dialogue: The processed DialogData object with chunks - output_path: Optional path to save the output + chunks: List of Chunk objects to save + dialogue: The DialogData object that was processed + output_path: Optional path to save the output (defaults to current directory) + preview_length: Maximum length of content preview (default: 100) Returns: The path where the output was saved + + Raises: + ValueError: If chunks or dialogue is invalid + IOError: If file writing fails """ - if not output_path: - output_path = os.path.join( - os.path.dirname(__file__), "..", "..", - f"chunker_output_{self.chunker_strategy.lower()}.txt" - ) - - output_lines = [ - f"=== Chunking Results ({self.chunker_strategy}) ===", - f"Dialogue ID: {dialogue.ref_id}", - f"Original conversation has {len(dialogue.context.msgs)} messages", - f"Total characters: {len(dialogue.content)}", - f"Generated {len(dialogue.chunks)} chunks:" - ] + # Validate input + if not chunks: + raise ValueError("chunks list cannot be empty") + if not dialogue: + raise ValueError("dialogue cannot be None") - for i, chunk in enumerate(dialogue.chunks): - output_lines.append(f" Chunk {i+1}: {len(chunk.content)} characters") - output_lines.append(f" Content preview: {chunk.content}...") - if chunk.metadata: - output_lines.append(f" Metadata: {chunk.metadata}") + # Generate default output path if not provided + if not output_path: + output_dir = Path(__file__).parent.parent.parent + output_path = str(output_dir / f"chunker_output_{self.chunker_strategy.lower()}.txt") + + logger.info(f"Saving chunking results to: {output_path}") + + try: + # Prepare output content + output_lines = [ + f"=== Chunking Results ({self.chunker_strategy}) ===", + f"Dialogue ID: {dialogue.ref_id}", + f"Original conversation has {len(dialogue.context.msgs) if dialogue.context else 0} messages", + f"Total characters: {len(dialogue.content) if dialogue.content else 0}", + f"Generated {len(chunks)} chunks:", + "" + ] + + for i, chunk in enumerate(chunks, 1): + content_preview = chunk.content[:preview_length] if chunk.content else "" + if len(chunk.content) > preview_length: + content_preview += "..." + + output_lines.append(f" Chunk {i}: {len(chunk.content)} characters") + output_lines.append(f" Content preview: {content_preview}") + if chunk.metadata: + output_lines.append(f" Metadata: {chunk.metadata}") + output_lines.append("") - with open(output_path, "w", encoding="utf-8") as f: - f.write("\n".join(output_lines)) + # Write to file + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(output_lines)) - logger.info(f"Chunking results saved to: {output_path}") - return output_path + logger.info(f"Successfully saved chunking results to: {output_path}") + return output_path + + except IOError as e: + logger.error(f"Failed to write chunking results to {output_path}: {e}", exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error saving chunking results: {e}", exc_info=True) + raise From dd9be2ed90c2e00111ff7f9d4058cf9d5f5557c4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 18:48:02 +0800 Subject: [PATCH 23/55] fix(web): AutocompletePlugin key up/down support scroll --- .../Editor/plugin/AutocompletePlugin.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 8e2687f1..f9fe097e 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type FC } from 'react'; +import { useEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; @@ -22,6 +22,26 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + const popupRef = useRef(null); + + const scrollSelectedIntoView = () => { + if (!popupRef.current) return; + + const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); + if (!selectedElement) return; + + const container = popupRef.current; + const element = selectedElement as HTMLElement; + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (elementRect.bottom > containerRect.bottom) { + container.scrollTop += elementRect.bottom - containerRect.bottom; + } else if (elementRect.top < containerRect.top) { + container.scrollTop -= containerRect.top - elementRect.top; + } + }; useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { @@ -116,7 +136,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> setShowSuggestions(false); }; - const groupedSuggestions = options.reduce((groups: Record, suggestion) => { + const groupedSuggestions = options.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; if (!groups[nodeId]) { @@ -163,7 +183,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> while (nextIndex < allOptions.length && allOptions[nextIndex].disabled) { nextIndex++; } - return nextIndex >= allOptions.length ? prev : nextIndex; + const newIndex = nextIndex >= allOptions.length ? prev : nextIndex; + setTimeout(() => scrollSelectedIntoView(), 0); + return newIndex; }); return true; } @@ -182,7 +204,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> while (prevIndex >= 0 && allOptions[prevIndex].disabled) { prevIndex--; } - return prevIndex < 0 ? prev : prevIndex; + const newIndex = prevIndex < 0 ? prev : prevIndex; + setTimeout(() => scrollSelectedIntoView(), 0); + return newIndex; }); return true; } @@ -218,6 +242,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> } return (
e.preventDefault()} style={{ @@ -248,6 +273,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> return (
Date: Fri, 27 Feb 2026 18:59:58 +0800 Subject: [PATCH 24/55] feat(web): create space storage type add recommend --- web/src/components/RadioGroupCard/index.tsx | 7 ++++++- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/views/SpaceManagement/components/SpaceModal.tsx | 8 ++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/src/components/RadioGroupCard/index.tsx b/web/src/components/RadioGroupCard/index.tsx index 41924c61..e09466cd 100644 --- a/web/src/components/RadioGroupCard/index.tsx +++ b/web/src/components/RadioGroupCard/index.tsx @@ -20,6 +20,7 @@ import { type FC, type Key, type ReactNode, useEffect } from 'react'; import { type RadioGroupProps } from 'antd'; import clsx from 'clsx' +import { useTranslation } from 'react-i18next'; /** Radio card option interface */ interface RadioCardOption { @@ -33,6 +34,8 @@ interface RadioCardOption { icon?: string; /** Whether the option is disabled */ disabled?: boolean; + /** Whether the option is recommended */ + recommend?: boolean; /** Additional properties */ [key: string]: string | number | boolean | undefined | null | Key; } @@ -63,6 +66,7 @@ const RadioGroupCard: FC = ({ allowClear = true, block = false, }) => { + const { t } = useTranslation(); /** Listen to value changes and trigger side effects via onValueChange callback */ useEffect(() => { if (onValueChange) { @@ -91,12 +95,13 @@ const RadioGroupCard: FC = ({ })}> {/* Render each option as a selectable card */} {options.map(option => ( -
handleChange(option)}> + {option.recommend &&
{t('common.recommend')}
} {/* Use custom render or default card layout */} {itemRender ? itemRender(option) : ( <> diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8dfb68db..f2b4eaa4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -452,6 +452,7 @@ export const en = { nextStep: 'Next Step', prevStep: 'Previous Step', exportSuccess: 'Export successful', + recommend: 'Recommend', }, model: { searchPlaceholder: 'search model…', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index feefc843..e2e7082a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1019,6 +1019,7 @@ export const zh = { nextStep: '下一步', prevStep: '上一步', exportSuccess: '导出成功', + recommend: '推荐', }, model: { searchPlaceholder: '搜索模型…', diff --git a/web/src/views/SpaceManagement/components/SpaceModal.tsx b/web/src/views/SpaceManagement/components/SpaceModal.tsx index a0703d81..4f37b246 100644 --- a/web/src/views/SpaceManagement/components/SpaceModal.tsx +++ b/web/src/views/SpaceManagement/components/SpaceModal.tsx @@ -34,8 +34,8 @@ interface SpaceModalProps { } /** Storage types */ const types: StorageType[] = [ - 'rag', 'neo4j', + 'rag', ] /** Type icons mapping */ const typeIcons: Record = { @@ -154,6 +154,9 @@ const SpaceModal = forwardRef(({
(({ value: type, label: t(`space.${type}`), labelDesc: t(`space.${type}Desc`), - icon: typeIcons[type] + icon: typeIcons[type], + recommend: type === 'neo4j', }))} block={true} /> From 5f211620c533068d8b2882263c6ada2219486a26 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Sat, 28 Feb 2026 14:01:49 +0800 Subject: [PATCH 25/55] fix(app): Lock the conversation with the application dialogue --- .../controllers/service/app_api_controller.py | 4 +-- api/app/version_info.json | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index bb71d831..61a919b1 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -89,7 +89,6 @@ async def chat( body = await request.json() payload = AppChatRequest(**body) - other_id = payload.user_id app = app_service.get_app(api_key_auth.resource_id, api_key_auth.workspace_id) other_id = payload.user_id workspace_id = app.workspace_id @@ -135,7 +134,8 @@ async def chat( app_id=app.id, workspace_id=workspace_id, user_id=end_user_id, - is_draft=False + is_draft=False, + conversation_id=payload.conversation_id ) if app_type == AppType.AGENT: diff --git a/api/app/version_info.json b/api/app/version_info.json index aea03dcd..7d82eabc 100644 --- a/api/app/version_info.json +++ b/api/app/version_info.json @@ -1,4 +1,34 @@ { + "v0.2.5": { + "introduction": { + "codeName": "行云", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 精炼根基,优化核心用户体验与系统稳定性", + "coreUpgrades": [ + "1. 用户体验与国际化 🎨
* 语言参数修复:语言偏好现正确保留
* 邮箱修改支持:用户可直接在用户管理系统中修改邮箱地址", + "2. 工作流可视化增强 💬
* 循环与迭代节点输出展示:实时显示执行进度和中间输出,便于调试复杂迭代过程
* 变量支持回车选择:支持回车键确认变量选择,简化工作流配置流程", + "3. 优化模型管理 ⚙️
* 模型广场移除自定义模型,优化模型使用体验", + "4. 稳健性与缺陷修复 🔧
* 知识图谱构建修复:解决知识图谱构建流程稳定性问题,确保更可靠的实体提取和关系映射", + "
", + "版本 0.2.5 通过解决国际化边界情况和改进工作流透明度,构建更具生产就绪性的平台。工作流可视化改进为更复杂的调试和监控能力奠定基础。未来将继续深化企业就绪性,扩展用户管理功能、优化知识图谱智能和增强工作流编排能力,在可观测性、性能优化和无缝集成模式方面持续改进。", + "智慧致远 🐻✨" + ] + }, + "introduction_en": { + "codeName": "Flowing Clouds", + "releaseDate": "2026-2-26", + "upgradePosition": "🐻 Refined foundations with enhanced user experience and system stability", + "coreUpgrades": [ + "1. User Experience & Internationalization 🎨
* Language parameter fix: language preferences are now correctly retained
* Email Update Support: Users can now modify email addresses directly in user management system", + "2. Workflow Visualization Enhancements 💬
* Loop & Iteration Node Output Display: Real-time display of execution progress and intermediate outputs for easier debugging
* Variable Selection with Enter Key: Enabled Enter key confirmation for streamlined variable assignment", + "3. Optimized Model Management ⚙️
* Custom models have been removed from the Model marketplace to optimize the model usage experience", + "4. Robustness & Bug Fixes 🔧
* Knowledge Graph Construction Fix: Addressed stability issues in knowledge graph pipeline for more reliable entity extraction and relationship mapping", + "
", + "Version 0.2.5 matures MemoryBear's operational foundations by addressing internationalization edge cases and improving workflow transparency. The workflow visualization improvements lay groundwork for sophisticated debugging and monitoring capabilities. Looking forward, we will deepen enterprise readiness by expanding user management features, refining knowledge graph intelligence, and enhancing workflow orchestration with continued improvements in observability, performance optimization, and seamless integration patterns.", + "Intelligent Resilience 🐻✨" + ] + } + }, "v0.2.4": { "introduction": { "codeName": "智远", From 1037729fb3457109e68315dac0309f18177381f6 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Sat, 28 Feb 2026 16:51:56 +0800 Subject: [PATCH 26/55] fix(model): The custom models in the model list can batch add APIkeys through the provider --- api/app/repositories/model_repository.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/api/app/repositories/model_repository.py b/api/app/repositories/model_repository.py index 2c513e82..f49227d3 100644 --- a/api/app/repositories/model_repository.py +++ b/api/app/repositories/model_repository.py @@ -428,19 +428,17 @@ class ModelConfigRepository: try: # 查询ModelConfig关联的ModelApiKey,筛选出匹配的model_config_id - model_config_ids = db.query(ModelConfig.id).join( - ModelBase, ModelConfig.model_id == ModelBase.id - ).filter( + model_config_ids = db.query(ModelConfig.id).filter( and_( or_( ModelConfig.tenant_id == tenant_id, ModelConfig.is_public ), - ModelBase.provider == provider, + ModelConfig.provider == provider, ModelConfig.is_active, ~ModelConfig.is_composite ) - ).distinct().all() + ).all() db_logger.debug(f"查询成功: 数量={len(model_config_ids)}") return [row[0] for row in model_config_ids] From 3a0671c661baee7000c1ab3ae43daf69bb11d811 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 17:18:42 +0800 Subject: [PATCH 27/55] [add]The semantic pruning function is activated, removing the protection of question-answer pairs. --- .../core/memory/agent/utils/get_dialogs.py | 57 +- .../data_preprocessing/data_pruning.py | 511 ++++++++---------- .../data_preprocessing/scene_config.py | 326 +++++++++++ .../extraction_orchestrator.py | 16 +- 4 files changed, 619 insertions(+), 291 deletions(-) create mode 100644 api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index bfb0f675..22555fff 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -21,7 +21,7 @@ async def get_chunked_dialogs( end_user_id: Group identifier messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference identifier - config_id: Configuration ID for processing + config_id: Configuration ID for processing (used to load pruning config) Returns: List of DialogData objects with generated chunks @@ -57,6 +57,61 @@ async def get_chunked_dialogs( end_user_id=end_user_id, config_id=config_id ) + + # 语义剪枝步骤(在分块之前) + try: + from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import SemanticPruner + from app.core.memory.models.config_models import PruningConfig + from app.db import get_db_context + from app.services.memory_config_service import MemoryConfigService + from app.core.memory.utils.llm.llm_utils import MemoryClientFactory + + # 加载剪枝配置 + pruning_config = None + if config_id: + try: + with get_db_context() as db: + # 使用 MemoryConfigService 加载完整的 MemoryConfig 对象 + config_service = MemoryConfigService(db) + memory_config = config_service.load_memory_config( + config_id=config_id, + service_name="semantic_pruning" + ) + + if memory_config: + pruning_config = PruningConfig( + pruning_switch=memory_config.pruning_enabled, + pruning_scene=memory_config.pruning_scene or "education", + pruning_threshold=memory_config.pruning_threshold + ) + logger.info(f"[剪枝] 加载配置: switch={pruning_config.pruning_switch}, scene={pruning_config.pruning_scene}, threshold={pruning_config.pruning_threshold}") + + # 获取LLM客户端用于剪枝 + if pruning_config.pruning_switch: + factory = MemoryClientFactory(db) + llm_client = factory.get_llm_client_from_config(memory_config) + + # 执行剪枝 - 使用 prune_dataset 支持消息级剪枝 + pruner = SemanticPruner(config=pruning_config, llm_client=llm_client) + original_msg_count = len(dialog_data.context.msgs) + + # 使用 prune_dataset 而不是 prune_dialog + # prune_dataset 会进行消息级剪枝,即使对话整体相关也会删除不重要消息 + pruned_dialogs = await pruner.prune_dataset([dialog_data]) + + if pruned_dialogs: + dialog_data = pruned_dialogs[0] + remaining_msg_count = len(dialog_data.context.msgs) + deleted_count = original_msg_count - remaining_msg_count + logger.info(f"[剪枝] 完成: 原始{original_msg_count}条 -> 保留{remaining_msg_count}条 (删除{deleted_count}条)") + else: + logger.warning("[剪枝] prune_dataset 返回空列表") + else: + logger.info("[剪枝] 配置中剪枝开关关闭,跳过剪枝") + except Exception as e: + logger.warning(f"[剪枝] 加载配置失败,跳过剪枝: {e}", exc_info=True) + except Exception as e: + logger.warning(f"[剪枝] 执行失败,跳过剪枝: {e}", exc_info=True) chunker = DialogueChunker(chunker_strategy) extracted_chunks = await chunker.process_dialogue(dialog_data) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index 2d0142c6..d932c542 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -22,6 +22,10 @@ from app.core.memory.models.message_models import DialogData, ConversationMessag from app.core.memory.models.config_models import PruningConfig from app.core.memory.utils.config.config_utils import get_pruning_config from app.core.memory.utils.prompt.prompt_utils import prompt_env, log_prompt_rendering, log_template_rendering +from app.core.memory.storage_services.extraction_engine.data_preprocessing.scene_config import ( + SceneConfigRegistry, + ScenePatterns +) class DialogExtractionResponse(BaseModel): @@ -78,6 +82,20 @@ class SemanticPruner: self.language = language # 保存语言配置 self.max_concurrent = max_concurrent # 新增:最大并发数 + # 加载场景特定配置 + self.scene_config: ScenePatterns = SceneConfigRegistry.get_config( + self.config.pruning_scene, + fallback_to_generic=True + ) + + # 检查场景是否有专门支持 + is_supported = SceneConfigRegistry.is_scene_supported(self.config.pruning_scene) + if is_supported: + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 使用专门配置") + else: + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 未预定义,使用通用配置(保守策略)") + self._log(f"[剪枝-初始化] 支持的场景: {SceneConfigRegistry.get_all_scenes()}") + # Load Jinja2 template self.template = prompt_env.get_template("extracat_Pruning.jinja2") @@ -87,108 +105,80 @@ class SemanticPruner: # 运行日志:收集关键终端输出,便于写入 JSON self.run_logs: List[str] = [] - - # 扩展的填充词库(包含表情符号和网络用语) - self._extended_fillers = [ - # 基础寒暄 - "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", - "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", - "拜拜", "再见", "88", "拜", "回见", - # 口头禅 - "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", - "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", - # 确认词 - "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", - # 标点和符号 - "。。。", "...", "???", "???", "!!!", "!!!", - # 表情符号(文本形式) - "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", - "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", - "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", - "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", - # 网络用语 - "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", - "emmm", "emm", "em", "mmp", "wtf", "omg", - ] def _is_important_message(self, message: ConversationMessage) -> bool: """基于启发式规则识别重要信息消息,优先保留。 - 改进版:增强了规则覆盖范围,包括: - - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午) - - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段 - - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址" - - 新增:问句识别、决策性语句、承诺性语句 + 改进版:使用场景特定的模式进行识别 + - 根据 pruning_scene 动态加载对应的识别规则 + - 支持教育、在线服务、外呼三个场景的特定模式 """ text = message.msg.strip() if not text: return False - patterns = [ - # 原有模式 - r"\d{4}-\d{1,2}-\d{1,2}", # 修复:移除 \b 边界,因为中文前后没有单词边界 - r"\d{1,2}:\d{2}", # 修复:移除 \b - r"\d{4}年\d{1,2}月\d{1,2}日", - r"上午|下午|AM|PM|今天|明天|后天|昨天|前天|本周|下周|上周|本月|下月|上月", - r"订单号|工单|申请号|编号|ID|账号|账户|流水号|单号", - r"电话|手机号|微信|QQ|邮箱|联系方式", - r"地址|地点|位置|门牌号", - r"金额|费用|价格|¥|¥|\d+元|人民币|美元|欧元", - r"时间|日期|有效期|截止|期限|到期", - # 新增模式 - r"什么|为什么|怎么|如何|哪里|哪个|谁|多少|几点|何时", # 问句关键词 - r"必须|一定|务必|需要|要求|规定|应该", # 决策性语句 - r"承诺|保证|确保|负责|同意|答应", # 承诺性语句 - r"\d{11}", # 11位手机号 - r"\d{3,4}-\d{7,8}", # 固定电话 - r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", # 邮箱 - ] + # 使用场景特定的模式 + all_patterns = ( + self.scene_config.high_priority_patterns + + self.scene_config.medium_priority_patterns + + self.scene_config.low_priority_patterns + ) - for p in patterns: - if re.search(p, text, flags=re.IGNORECASE): + for pattern, _ in all_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): return True # 检查是否为问句(以问号结尾或包含疑问词) if text.endswith("?") or text.endswith("?"): return True + + # 检查是否包含问句关键词 + if any(keyword in text for keyword in self.scene_config.question_keywords): + return True + + # 检查是否包含决策性关键词 + if any(keyword in text for keyword in self.scene_config.decision_keywords): + return True return False def _importance_score(self, message: ConversationMessage) -> int: """为重要消息打分,用于在保留比例内优先保留更关键的内容。 - 改进版:更细致的评分体系(0-10分) + 改进版:使用场景特定的权重体系(0-10分) + - 根据场景动态调整不同信息类型的权重 + - 高优先级模式:4-6分 + - 中优先级模式:2-3分 + - 低优先级模式:1分 """ text = message.msg.strip() score = 0 - weights = [ - # 高优先级(4-5分) - (r"订单号|工单|申请号|编号|ID|账号|账户", 5), - (r"金额|费用|价格|¥|¥|\d+元", 5), - (r"\d{11}", 4), # 手机号 - (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 - - # 中优先级(2-3分) - (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 修复:移除 \b - (r"\d{4}年\d{1,2}月\d{1,2}日", 3), - (r"电话|手机号|微信|QQ|联系方式", 3), - (r"地址|地点|位置", 2), - (r"时间|日期|有效期|截止|明天|后天|下周|下月", 2), # 新增时间相关词 - - # 低优先级(1分) - (r"\d{1,2}:\d{2}", 1), # 修复:移除 \b - (r"上午|下午|AM|PM", 1), - ] + # 使用场景特定的权重 + for pattern, weight in self.scene_config.high_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight - for p, w in weights: - if re.search(p, text, flags=re.IGNORECASE): - score += w + for pattern, weight in self.scene_config.medium_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight + + for pattern, weight in self.scene_config.low_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight # 问句加分 if text.endswith("?") or text.endswith("?"): score += 2 + # 包含问句关键词加分 + if any(keyword in text for keyword in self.scene_config.question_keywords): + score += 1 + + # 包含决策性关键词加分 + if any(keyword in text for keyword in self.scene_config.decision_keywords): + score += 2 + # 长度加分(较长的消息通常包含更多信息) if len(text) > 50: score += 1 @@ -198,20 +188,35 @@ class SemanticPruner: return min(score, 10) # 最高10分 def _is_filler_message(self, message: ConversationMessage) -> bool: - """检测典型寒暄/口头禅/确认类短消息,用于跳过LLM分类以加速。 + """检测典型寒暄/口头禅/确认类短消息。 - 改进版:扩展了填充词库,支持表情符号和网络用语 + 改进版:更严格的填充消息判断,避免误删场景相关内容 满足以下之一视为填充消息: - - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体 - - 在扩展填充词库中 + - 纯标点或空白 + - 在场景特定填充词库中(精确匹配) - 纯表情符号 + - 常见寒暄(精确匹配短语) + + 注意:不再使用长度判断,避免误删短但重要的消息 """ t = message.msg.strip() if not t: return True - # 检查是否在扩展填充词库中 - if t in self._extended_fillers: + # 检查是否在场景特定填充词库中(精确匹配) + if t in self.scene_config.filler_phrases: + return True + + # 常见寒暄和问候(精确匹配,避免误删) + common_greetings = { + "在吗", "在不在", "在呢", "在的", + "你好", "您好", "hello", "hi", + "拜拜", "再见", "拜", "88", "bye", + "好的", "好", "行", "可以", "嗯", "哦", "啊", + "是的", "对", "对的", "没错", "是啊", + "哈哈", "呵呵", "嘿嘿", "嗯嗯" + } + if t in common_greetings: return True # 检查是否为纯表情符号(方括号包裹) @@ -232,13 +237,9 @@ class SemanticPruner: if emoji_pattern.fullmatch(t): return True - # 长度与字符类型判断 - if len(t) <= 8: - # 非数字、无关键实体的短文本 - if not re.search(r"[0-9]", t) and not self._is_important_message(message): - # 主要是标点或简单确认词 - if re.fullmatch(r"[。!?,.!?…·\s]+", t): - return True + # 纯标点符号 + if re.fullmatch(r"[。!?,.!?…·\s]+", t): + return True return False @@ -308,6 +309,8 @@ class SemanticPruner: def _identify_qa_pairs(self, messages: List[ConversationMessage]) -> List[QAPair]: """识别对话中的问答对,用于保护问答结构的完整性。 + 改进版:使用场景特定的问句关键词,并排除寒暄类问句 + Args: messages: 消息列表 @@ -316,21 +319,39 @@ class SemanticPruner: """ qa_pairs = [] + # 寒暄类问句,不应该被保护(这些不是真正的问答) + greeting_questions = { + "在吗", "在不在", "你好吗", "怎么样", "好吗", + "有空吗", "忙吗", "睡了吗", "起床了吗" + } + for i in range(len(messages) - 1): current_msg = messages[i].msg.strip() next_msg = messages[i + 1].msg.strip() - # 简单规则:如果当前消息是问句,下一条消息可能是答案 - is_question = ( - current_msg.endswith("?") or - current_msg.endswith("?") or - any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗"]) - ) + # 排除寒暄类问句 + if current_msg in greeting_questions: + continue + + # 使用场景特定的问句关键词,但要求更严格 + is_question = False + + # 1. 以问号结尾 + if current_msg.endswith("?") or current_msg.endswith("?"): + is_question = True + # 2. 包含实质性问句关键词(排除"吗"这种太宽泛的) + elif any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时"]): + is_question = True if is_question and next_msg: - # 检查下一条消息是否像答案(不是另一个问句) + # 检查下一条消息是否像答案(不是另一个问句,也不是寒暄) is_answer = not (next_msg.endswith("?") or next_msg.endswith("?")) + # 排除寒暄类回复 + greeting_answers = {"你好", "您好", "在呢", "在的", "嗯", "哦", "好的"} + if next_msg in greeting_answers: + is_answer = False + if is_answer: qa_pairs.append(QAPair( question_idx=i, @@ -533,10 +554,9 @@ class SemanticPruner: """数据集层面:全局消息级剪枝,保留所有对话。 改进版: - - 并发处理对话级相关性判断 - - 问答对识别和保护 - - 优化删除策略,保持上下文连贯性 - - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动 + - 消息级独立判断,每条消息根据场景规则独立评估 + - 问答对保护已注释(暂不启用,留作观察) + - 优化删除策略:填充消息 → 不重要消息 → 低分重要消息 - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留 - 保证每段对话至少保留1条消息,不会删除整段对话 """ @@ -553,209 +573,122 @@ class SemanticPruner: proportion = 0.0 self._log( - f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch}" + f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch} 模式=消息级独立判断" ) - # 并发处理对话级相关性分类 - semaphore = asyncio.Semaphore(self.max_concurrent) - - async def classify_dialog(idx: int, dd: DialogData): - async with semaphore: - try: - ex = await self._extract_dialog_important(dd.content) - return { - "dialog": dd, - "is_related": bool(ex.is_related), - "index": idx, - "extraction": ex - } - except Exception as e: - self._log(f"[剪枝-并发] 对话 {idx} 分类失败: {str(e)[:100]}") - return { - "dialog": dd, - "is_related": True, # 失败时标记为相关,避免误删 - "index": idx, - "extraction": None - } - - # 并发执行所有对话的分类 - tasks = [classify_dialog(idx, dd) for idx, dd in enumerate(dialogs)] - evaluated_dialogs = await asyncio.gather(*tasks) - - # 统计相关 / 不相关对话 - not_related_dialogs = [d for d in evaluated_dialogs if not d["is_related"]] - related_dialogs = [d for d in evaluated_dialogs if d["is_related"]] - self._log( - f"[剪枝-数据集] 相关对话数={len(related_dialogs)} 不相关对话数={len(not_related_dialogs)}" - ) - - # 简洁打印第几段对话相关/不相关(索引基于1) - def _fmt_indices(items, cap: int = 10): - inds = [i["index"] + 1 for i in items] - if len(inds) <= cap: - return inds - return inds[:cap] + ["...", f"共{len(inds)}个"] - - rel_inds = _fmt_indices(related_dialogs) - nrel_inds = _fmt_indices(not_related_dialogs) - self._log(f"[剪枝-数据集] 相关对话:第{rel_inds}段;不相关对话:第{nrel_inds}段") - result: List[DialogData] = [] - if not_related_dialogs: - # 为每个不相关对话进行分析 - per_dialog_info = {} - total_unrelated = 0 + total_original_msgs = 0 + total_deleted_msgs = 0 + + for d_idx, dd in enumerate(dialogs): + msgs = dd.context.msgs + original_count = len(msgs) + total_original_msgs += original_count - for d in not_related_dialogs: - dd = d["dialog"] - extraction = d.get("extraction") - if extraction is None: - extraction = await self._extract_dialog_important(dd.content) - - # 合并所有重要标记 - tokens = extraction.times + extraction.ids + extraction.amounts + extraction.contacts + extraction.addresses + extraction.keywords - msgs = dd.context.msgs - - # 识别问答对 - qa_pairs = self._identify_qa_pairs(msgs) - protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=1) - - # 分类消息(考虑问答对保护) - imp_unrel_msgs = [] - unimp_unrel_msgs = [] - - for idx, m in enumerate(msgs): - # 问答对中的消息自动标记为重要 - if idx in protected_indices: - imp_unrel_msgs.append((idx, m)) - elif self._msg_matches_tokens(m, tokens) or self._is_important_message(m): - imp_unrel_msgs.append((idx, m)) - elif not self._is_filler_message(m): - unimp_unrel_msgs.append((idx, m)) - # 填充消息不加入任何列表,优先删除 - - # 重要消息按重要性排序 - imp_sorted = sorted(imp_unrel_msgs, key=lambda x: self._importance_score(x[1])) - imp_sorted_ids = [id(m) for _, m in imp_sorted] - - info = { - "dialog": dd, - "total_msgs": len(msgs), - "unrelated_count": len(msgs), - "imp_ids_sorted": imp_sorted_ids, - "unimp_ids": [id(m) for _, m in unimp_unrel_msgs], - "protected_indices": protected_indices, - "qa_pairs_count": len(qa_pairs), - } - per_dialog_info[d["index"]] = info - total_unrelated += info["unrelated_count"] + # ========== 问答对保护(已注释,暂不启用,留作观察) ========== + # qa_pairs = self._identify_qa_pairs(msgs) + # protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=0) + # ======================================================== - # 全局删除配额计算 - global_delete = int(total_unrelated * proportion) - if proportion > 0 and total_unrelated > 0 and global_delete == 0: - global_delete = 1 + # 消息级分类:每条消息独立判断 + important_msgs = [] # 重要消息(保留) + unimportant_msgs = [] # 不重要消息(可删除) + filler_msgs = [] # 填充消息(优先删除) - # 每段的最大可删容量 - capacities = [] - for d in not_related_dialogs: - idx = d["index"] - info = per_dialog_info[idx] - imp_count = len(info["imp_ids_sorted"]) - unimp_count = len(info["unimp_ids"]) - imp_cap = int(imp_count * proportion) - cap = min(unimp_count + imp_cap, max(0, info["total_msgs"] - 1)) - capacities.append(cap) + for idx, m in enumerate(msgs): + msg_text = m.msg.strip() + + # ========== 问答对保护判断(已注释) ========== + # if idx in protected_indices: + # important_msgs.append((idx, m)) + # self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(问答对保护)") + # ========================================== + + # 填充消息(寒暄、表情等) + if self._is_filler_message(m): + filler_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 填充") + # 重要信息(学号、成绩、时间、金额等) + elif self._is_important_message(m): + important_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(场景规则)") + # 其他消息 + else: + unimportant_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 不重要") - total_capacity = sum(capacities) - if global_delete > total_capacity: - self._log(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}。将按最大可删执行。") - global_delete = total_capacity - - # 配额分配 - alloc = [] - for i, d in enumerate(not_related_dialogs): - idx = d["index"] - info = per_dialog_info[idx] - share = int(global_delete * (info["unrelated_count"] / total_unrelated)) if total_unrelated > 0 else 0 - alloc.append(min(share, capacities[i])) + # 计算删除配额 + delete_target = int(original_count * proportion) + if proportion > 0 and original_count > 0 and delete_target == 0: + delete_target = 1 - allocated = sum(alloc) - rem = global_delete - allocated - turn = 0 - while rem > 0 and turn < 100000: - progressed = False - for i in range(len(not_related_dialogs)): - if rem <= 0: - break - if alloc[i] < capacities[i]: - alloc[i] += 1 - rem -= 1 - progressed = True - if not progressed: - break - turn += 1 - - # 应用删除 - total_deleted_confirm = 0 - for d in evaluated_dialogs: - dd = d["dialog"] - msgs = dd.context.msgs - original = len(msgs) - - if d["is_related"]: - result.append(dd) - continue - - idx_in_unrel = next((k for k, x in enumerate(not_related_dialogs) if x["index"] == d["index"]), None) - if idx_in_unrel is None: - result.append(dd) - continue - - quota = alloc[idx_in_unrel] - info = per_dialog_info[d["index"]] - - # 计算删除ID - imp_count = len(info["imp_ids_sorted"]) - imp_del_cap = int(imp_count * proportion) - - unimp_delete_ids = set(info["unimp_ids"][:min(quota, len(info["unimp_ids"]))]) - del_unimp = min(quota, len(unimp_delete_ids)) - rem_quota = quota - del_unimp - - imp_delete_ids = set(info["imp_ids_sorted"][:min(rem_quota, imp_del_cap)]) - - deleted_here = 0 - actual_unimp_deleted = 0 - actual_imp_deleted = 0 - kept = [] - - for m in msgs: - mid = id(m) - if mid in unimp_delete_ids and actual_unimp_deleted < del_unimp: - actual_unimp_deleted += 1 - deleted_here += 1 - continue - if mid in imp_delete_ids and actual_imp_deleted < len(imp_delete_ids): - actual_imp_deleted += 1 - deleted_here += 1 - continue - kept.append(m) - - if not kept and msgs: - kept = [msgs[0]] - - dd.context.msgs = kept - total_deleted_confirm += deleted_here - - qa_info = f",问答对={info['qa_pairs_count']}" if info['qa_pairs_count'] > 0 else "" - self._log( - f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}{qa_info}" - ) - result.append(dd) + # 确保至少保留1条消息 + max_deletable = max(0, original_count - 1) + delete_target = min(delete_target, max_deletable) - self._log(f"[剪枝-数据集] 全局消息级剪枝完成,总删除 {total_deleted_confirm} 条(保护问答对和上下文)。") - else: - result = [d["dialog"] for d in evaluated_dialogs] + # 删除策略:优先删除填充消息,再删除不重要消息 + to_delete_indices = set() + deleted_details = [] # 记录删除的消息详情 + + # 第一步:删除填充消息 + filler_to_delete = min(len(filler_msgs), delete_target) + for i in range(filler_to_delete): + idx, msg = filler_msgs[i] + to_delete_indices.add(idx) + deleted_details.append(f"[{idx}] 填充: '{msg.msg[:50]}'") + + # 第二步:如果还需要删除,删除不重要消息 + remaining_quota = delete_target - len(to_delete_indices) + if remaining_quota > 0: + unimp_to_delete = min(len(unimportant_msgs), remaining_quota) + for i in range(unimp_to_delete): + idx, msg = unimportant_msgs[i] + to_delete_indices.add(idx) + deleted_details.append(f"[{idx}] 不重要: '{msg.msg[:50]}'") + + # 第三步:如果还需要删除,按重要性分数删除重要消息 + remaining_quota = delete_target - len(to_delete_indices) + if remaining_quota > 0 and important_msgs: + # 按重要性分数排序(分数低的优先删除) + imp_sorted = sorted(important_msgs, key=lambda x: self._importance_score(x[1])) + imp_to_delete = min(len(imp_sorted), remaining_quota) + for i in range(imp_to_delete): + idx, msg = imp_sorted[i] + to_delete_indices.add(idx) + score = self._importance_score(msg) + deleted_details.append(f"[{idx}] 重要(分数{score}): '{msg.msg[:50]}'") + + # 执行删除 + kept_msgs = [] + for idx, m in enumerate(msgs): + if idx not in to_delete_indices: + kept_msgs.append(m) + + # 确保至少保留1条 + if not kept_msgs and msgs: + kept_msgs = [msgs[0]] + + dd.context.msgs = kept_msgs + deleted_count = original_count - len(kept_msgs) + total_deleted_msgs += deleted_count + + # 输出删除详情 + if deleted_details: + self._log(f"[剪枝-删除详情] 对话 {d_idx+1} 删除了以下消息:") + for detail in deleted_details: + self._log(f" {detail}") + + # ========== 问答对统计(已注释) ========== + # qa_info = f",问答对={len(qa_pairs)}" if qa_pairs else "" + # ======================================== + + self._log( + f"[剪枝-对话] 对话 {d_idx+1} 总消息={original_count} " + f"(重要={len(important_msgs)} 不重要={len(unimportant_msgs)} 填充={len(filler_msgs)}) " + f"删除={deleted_count} 保留={len(kept_msgs)}" + ) + + result.append(dd) self._log(f"[剪枝-数据集] 剩余对话数={len(result)}") diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py new file mode 100644 index 00000000..ed9592af --- /dev/null +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py @@ -0,0 +1,326 @@ +""" +场景特定配置 - 为不同场景提供定制化的剪枝规则 + +功能: +- 场景特定的重要信息识别模式 +- 场景特定的重要性评分权重 +- 场景特定的填充词库 +- 场景特定的问答对识别规则 +""" + +from typing import Dict, List, Set, Tuple +from dataclasses import dataclass, field + + +@dataclass +class ScenePatterns: + """场景特定的识别模式""" + + # 重要信息的正则模式(优先级从高到低) + high_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) # (pattern, weight) + medium_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) + low_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) + + # 填充词库(无意义对话) + filler_phrases: Set[str] = field(default_factory=set) + + # 问句关键词(用于识别问答对) + question_keywords: Set[str] = field(default_factory=set) + + # 决策性/承诺性关键词 + decision_keywords: Set[str] = field(default_factory=set) + + +class SceneConfigRegistry: + """场景配置注册表 - 管理所有场景的特定配置""" + + # 基础通用模式(所有场景共享) + BASE_HIGH_PRIORITY = [ + (r"订单号|工单|申请号|编号|ID|账号|账户", 5), + (r"金额|费用|价格|¥|¥|\d+元", 5), + (r"\d{11}", 4), # 手机号 + (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 + ] + + BASE_MEDIUM_PRIORITY = [ + (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 日期 + (r"\d{4}年\d{1,2}月\d{1,2}日", 3), + (r"电话|手机号|微信|QQ|联系方式", 3), + (r"地址|地点|位置", 2), + (r"时间|日期|有效期|截止", 2), + (r"今天|明天|后天|昨天|前天", 3), # 相对时间(提高权重) + (r"下周|下月|下年|上周|上月|上年|本周|本月|本年", 3), + (r"今年|去年|明年", 3), + ] + + BASE_LOW_PRIORITY = [ + (r"\d{1,2}:\d{2}", 2), # 时间点 HH:MM + (r"\d{1,2}点\d{0,2}分?", 2), # 时间点 X点Y分 或 X点 + (r"上午|下午|中午|晚上|早上|傍晚|凌晨", 2), # 时段(提高权重并扩充) + (r"AM|PM|am|pm", 1), + ] + + BASE_FILLERS = { + # 基础寒暄 + "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", + "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", + "拜拜", "再见", "88", "拜", "回见", + # 口头禅 + "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", + "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", + # 确认词 + "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", + # 标点和符号 + "。。。", "...", "???", "???", "!!!", "!!!", + # 表情符号 + "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", + "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", + "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", + "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", + # 网络用语 + "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", + "emmm", "emm", "em", "mmp", "wtf", "omg", + } + + BASE_QUESTION_KEYWORDS = { + "什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗" + } + + BASE_DECISION_KEYWORDS = { + "必须", "一定", "务必", "需要", "要求", "规定", "应该", + "承诺", "保证", "确保", "负责", "同意", "答应" + } + + @classmethod + def get_education_config(cls) -> ScenePatterns: + """教育场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 成绩相关(最高优先级) + (r"成绩|分数|得分|满分|及格|不及格", 6), + (r"GPA|绩点|学分|平均分", 6), + (r"\d+分|\d+\.?\d*分", 5), # 具体分数 + (r"排名|名次|第.{1,3}名", 5), # 支持"第三名"、"第1名"等 + + # 学籍信息 + (r"学号|学生证|教师工号|工号", 5), + (r"班级|年级|专业|院系", 4), + + # 课程相关 + (r"课程|科目|学科|必修|选修", 4), + (r"教材|课本|教科书|参考书", 4), + (r"章节|第.{1,3}章|第.{1,3}节", 3), # 支持"第三章"、"第1章"等 + + # 学科内容(新增) + (r"微积分|导数|积分|函数|极限|微分", 4), + (r"代数|几何|三角|概率|统计", 4), + (r"物理|化学|生物|历史|地理", 4), + (r"英语|语文|数学|政治|哲学", 4), + (r"定义|定理|公式|概念|原理|法则", 3), + (r"例题|解题|证明|推导|计算", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 教学活动 + (r"作业|练习|习题|题目", 3), + (r"考试|测验|测试|考核|期中|期末", 3), + (r"上课|下课|课堂|讲课", 2), + (r"提问|回答|发言|讨论", 2), + (r"问一下|请教|咨询|询问", 2), # 新增:问询相关 + (r"理解|明白|懂|掌握|学会", 2), # 新增:学习状态 + + # 时间安排 + (r"课表|课程表|时间表", 3), + (r"第.{1,3}节课|第.{1,3}周", 2), # 支持"第三节课"、"第1周"等 + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"老师|教师|同学|学生", 1), + (r"教室|实验室|图书馆", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 教育场景特有填充词(移除了"明白了"、"懂了"、"不懂"等,这些在教育场景中有意义) + "老师好", "同学们好", "上课", "下课", "起立", "坐下", + "举手", "请坐", "很好", "不错", "继续", + "下一个", "下一题", "下一位", "还有吗", "还有问题吗", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "为啥", "咋", "咋办", "怎样", "如何做", + "能不能", "可不可以", "行不行", "对不对", "是不是", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "必考", "重点", "考点", "难点", "关键", + "记住", "背诵", "掌握", "理解", "复习", + } + ) + + @classmethod + def get_online_service_config(cls) -> ScenePatterns: + """在线服务场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 工单相关(最高优先级) + (r"工单号|工单编号|ticket|TK\d+", 6), + (r"工单状态|处理中|已解决|已关闭|待处理", 5), + (r"优先级|紧急|高优先级|P0|P1|P2", 5), + + # 产品信息 + (r"产品型号|型号|SKU|产品编号", 5), + (r"序列号|SN|设备号", 5), + (r"版本号|软件版本|固件版本", 4), + + # 问题描述 + (r"故障|错误|异常|bug|问题", 4), + (r"错误代码|故障代码|error code", 5), + (r"无法|不能|失败|报错", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 服务相关 + (r"退款|退货|换货|补发", 4), + (r"发票|收据|凭证", 3), + (r"物流|快递|运单号", 3), + (r"保修|质保|售后", 3), + + # 时效相关 + (r"SLA|响应时间|处理时长", 4), + (r"超时|延迟|等待", 2), + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"客服|工程师|技术支持", 1), + (r"用户|客户|会员", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 在线服务特有填充词 + "您好", "请问", "请稍等", "稍等", "马上", "立即", + "正在查询", "正在处理", "正在为您", "帮您查一下", + "还有其他问题吗", "还需要什么帮助", "很高兴为您服务", + "感谢您的耐心等待", "抱歉让您久等了", + "已记录", "已反馈", "已转接", "已升级", + "祝您生活愉快", "再见", "欢迎下次咨询", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "能否", "可否", "是否", "有没有", "能不能", + "怎么办", "如何处理", "怎么解决", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "立即处理", "马上解决", "尽快", "优先", + "升级", "转接", "派单", "跟进", + "补偿", "赔偿", "退款", "换货", + } + ) + + @classmethod + def get_outbound_config(cls) -> ScenePatterns: + """外呼场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 意向相关(最高优先级) + (r"意向|意愿|兴趣|感兴趣", 6), + (r"A类|B类|C类|D类|高意向|低意向", 6), + (r"成交|签约|下单|购买|确认", 6), + + # 联系信息(外呼场景中更重要) + (r"预约|约定|安排|确定时间", 5), + (r"下次联系|回访|跟进", 5), + (r"方便|有空|可以|时间", 4), + + # 通话状态 + (r"接通|未接通|占线|关机|停机", 4), + (r"通话时长|通话时间", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 客户信息 + (r"姓名|称呼|先生|女士", 3), + (r"公司|单位|职位|职务", 3), + (r"需求|要求|期望", 3), + + # 跟进状态 + (r"跟进状态|进展|进度", 3), + (r"已联系|待联系|联系中", 2), + (r"拒绝|不感兴趣|考虑|再说", 3), + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"销售|客户经理|业务员", 1), + (r"产品|服务|方案", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 外呼场景特有填充词 + "您好", "喂", "hello", "打扰了", "不好意思", + "方便接电话吗", "现在方便吗", "占用您一点时间", + "我是", "我们是", "我们公司", "我们这边", + "了解一下", "介绍一下", "简单说一下", + "考虑考虑", "想一想", "再说", "再看看", + "不需要", "不感兴趣", "没兴趣", "不用了", + "好的", "行", "可以", "没问题", "那就这样", + "再联系", "回头聊", "有需要再说", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "有没有", "需不需要", "要不要", "考虑不考虑", + "了解吗", "知道吗", "听说过吗", + "方便吗", "有空吗", "在吗", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "确定", "决定", "选择", "购买", "下单", + "预约", "安排", "约定", "确认", + "跟进", "回访", "联系", "沟通", + } + ) + + @classmethod + def get_config(cls, scene: str, fallback_to_generic: bool = True) -> ScenePatterns: + """根据场景名称获取配置 + + Args: + scene: 场景名称 ('education', 'online_service', 'outbound' 或其他) + fallback_to_generic: 如果场景不存在,是否降级到通用配置 + + Returns: + 对应场景的配置,如果场景不存在: + - fallback_to_generic=True: 返回通用配置(仅基础规则) + - fallback_to_generic=False: 抛出异常 + """ + scene_map = { + 'education': cls.get_education_config, + 'online_service': cls.get_online_service_config, + 'outbound': cls.get_outbound_config, + } + + if scene in scene_map: + return scene_map[scene]() + + if fallback_to_generic: + # 返回通用配置(仅包含基础规则,不包含场景特定规则) + return cls.get_generic_config() + else: + raise ValueError(f"不支持的场景: {scene},支持的场景: {list(scene_map.keys())}") + + @classmethod + def get_generic_config(cls) -> ScenePatterns: + """通用场景配置 - 仅包含基础规则,适用于未定义的场景 + + 这是一个保守的配置,只使用最通用的规则,避免误删重要信息 + """ + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY, + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY, + low_priority_patterns=cls.BASE_LOW_PRIORITY, + filler_phrases=cls.BASE_FILLERS, + question_keywords=cls.BASE_QUESTION_KEYWORDS, + decision_keywords=cls.BASE_DECISION_KEYWORDS + ) + + @classmethod + def get_all_scenes(cls) -> List[str]: + """获取所有预定义场景的列表""" + return ['education', 'online_service', 'outbound'] + + @classmethod + def is_scene_supported(cls, scene: str) -> bool: + """检查场景是否有专门的配置支持 + + Args: + scene: 场景名称 + + Returns: + True: 有专门配置 + False: 将使用通用配置 + """ + return scene in cls.get_all_scenes() 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 a47497da..17bda0e4 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 @@ -1988,6 +1988,7 @@ async def get_chunked_dialogs_with_preprocessing( input_data_path: Optional[str] = None, llm_client: Optional[Any] = None, skip_cleaning: bool = True, + pruning_config: Optional[Dict] = None, ) -> List[DialogData]: """包含数据预处理步骤的完整分块流程 @@ -2000,6 +2001,7 @@ async def get_chunked_dialogs_with_preprocessing( input_data_path: 输入数据路径 llm_client: LLM 客户端 skip_cleaning: 是否跳过数据清洗步骤(默认False) + pruning_config: 剪枝配置字典,包含 pruning_switch, pruning_scene, pruning_threshold Returns: 带 chunks 的 DialogData 列表 @@ -2030,7 +2032,19 @@ async def get_chunked_dialogs_with_preprocessing( from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import ( SemanticPruner, ) - pruner = SemanticPruner(llm_client=llm_client) + from app.core.memory.models.config_models import PruningConfig + + # 构建剪枝配置 + if pruning_config: + # 使用传入的配置 + config = PruningConfig(**pruning_config) + print(f"[剪枝] 使用传入配置: switch={config.pruning_switch}, scene={config.pruning_scene}, threshold={config.pruning_threshold}") + else: + # 使用默认配置(关闭剪枝) + config = None + print("[剪枝] 未提供配置,使用默认配置(剪枝关闭)") + + pruner = SemanticPruner(config=config, llm_client=llm_client) # 记录单对话场景下剪枝前的消息数量 single_dialog_original_msgs = None From f81fdca62a16e5b59cabf6283395473dfbaa8f80 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Sat, 28 Feb 2026 17:28:55 +0800 Subject: [PATCH 28/55] fix(web): model logo; BasicAuthLayout fix --- web/src/components/Layout/BasicAuthLayout.tsx | 10 +++++----- web/src/store/user.ts | 8 ++++---- .../ModelManagement/components/CustomModelModal.tsx | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/Layout/BasicAuthLayout.tsx b/web/src/components/Layout/BasicAuthLayout.tsx index a73f6c69..f279a48b 100644 --- a/web/src/components/Layout/BasicAuthLayout.tsx +++ b/web/src/components/Layout/BasicAuthLayout.tsx @@ -2,10 +2,10 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:12:42 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 14:06:28 + * @Last Modified time: 2026-02-28 17:28:41 */ /** - * BasicLayout Component + * BasicAuthLayout Component * * A minimal layout wrapper that provides: * - User information initialization @@ -26,12 +26,12 @@ import { useUser } from '@/store/user'; * Basic layout component for pages without navigation UI. * Fetches user info and storage type on mount, then renders child routes. */ -const BasicLayout: FC = () => { +const BasicAuthLayout: FC = () => { const { getUserInfo } = useUser(); // Fetch user information and storage type on component mount useEffect(() => { - getUserInfo(); + getUserInfo(undefined, true); // Pass true to skip navigation jump }, [getUserInfo]); return ( @@ -42,4 +42,4 @@ const BasicLayout: FC = () => { ) }; -export default BasicLayout; \ No newline at end of file +export default BasicAuthLayout; \ No newline at end of file diff --git a/web/src/store/user.ts b/web/src/store/user.ts index c9231d9c..f5e0cb28 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:33:54 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 18:30:10 + * @Last Modified time: 2026-02-28 17:21:20 */ /** * User Store @@ -44,7 +44,7 @@ export interface UserState { /** Update login information */ updateLoginInfo: (values: LoginInfo) => void; /** Get user information */ - getUserInfo: (flag?: boolean) => void; + getUserInfo: (flag?: boolean, notNeedJump?: boolean) => void; /** Clear user information */ clearUserInfo: () => void; /** Logout user */ @@ -73,13 +73,13 @@ export const useUser = create((set, get) => ({ cookieUtils.set('refreshToken', values.refresh_token); set({ loginInfo: values }); }, - getUserInfo: async (flag?: boolean) => { + getUserInfo: async (flag?: boolean, notNeedJump?: boolean) => { if (!cookieUtils.get('authToken')) { return } const { checkJump } = get() const localUser = JSON.parse(localStorage.getItem('user') || '{}') as User; - if (localUser.id) { + if (localUser.id && !notNeedJump) { checkJump() return } diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index fb0db96e..17373a02 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:49:28 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:49:28 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-28 17:24:05 */ /** * Custom Model Modal @@ -50,7 +50,7 @@ const CustomModelModal = forwardRef( setModel(model); form.setFieldsValue({ ...model, - logo: model.logo ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined + logo: model.logo && model.logo.startsWith('http') ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined }); } else { setIsEdit(false); From 54700e6fbe571da04e750625f32deb36ddc9dc5d Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Sat, 28 Feb 2026 17:27:07 +0800 Subject: [PATCH 29/55] fix(workflow): fix exceptions when importing configs from Dify --- .../core/workflow/adapters/base_adapter.py | 2 ++ .../core/workflow/adapters/dify/converter.py | 25 +++++++++++++++---- .../workflow/adapters/dify/dify_adapter.py | 12 +++++++-- api/app/core/workflow/executor.py | 4 +-- api/app/core/workflow/nodes/base_node.py | 14 +++++------ .../workflow/utils/expression_evaluator.py | 16 ++++++++++-- .../core/workflow/utils/template_renderer.py | 13 +++++++++- api/app/schemas/workflow_schema.py | 2 +- 8 files changed, 68 insertions(+), 20 deletions(-) diff --git a/api/app/core/workflow/adapters/base_adapter.py b/api/app/core/workflow/adapters/base_adapter.py index 601c8ff2..49321b89 100644 --- a/api/app/core/workflow/adapters/base_adapter.py +++ b/api/app/core/workflow/adapters/base_adapter.py @@ -68,6 +68,8 @@ class BasePlatformAdapter(ABC): self.branch_node_cache = defaultdict(list) self.error_branch_node_cache = [] + self.node_output_map = {} + @abstractmethod def get_metadata(self) -> PlatformMetadata: """get platform metadata""" diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 0e92b2c7..18beef15 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -44,6 +44,7 @@ class DifyConverter(BaseConverter): warnings: list branch_node_cache: dict error_branch_node_cache: list + node_output_map: dict def __init__(self): self.CONFIG_CONVERT_MAP = { @@ -60,7 +61,8 @@ class DifyConverter(BaseConverter): "knowledge-retrieval": self.convert_knowledge_node_config, "parameter-extractor": self.convert_parameter_extractor_node_config, "question-classifier": self.convert_question_classifier_node_config, - "variable-aggregator": self.convert_variable_aggregator, + "variable-aggregator": self.convert_variable_aggregator_node_config, + "tool": self.convert_tool_node_config, "loop-start": lambda x: {}, "iteration-start": lambda x: {}, "loop-end": lambda x: {}, @@ -74,8 +76,7 @@ class DifyConverter(BaseConverter): def is_variable(expression) -> bool: return bool(re.match(r"\{\{#(.*?)#}}", expression)) - @staticmethod - def process_var_selector(var_selector): + def process_var_selector(self, var_selector): if not var_selector: return "" selector = var_selector.split('.') @@ -86,7 +87,7 @@ class DifyConverter(BaseConverter): var_selector = ".".join(selector) mapping = { "sys.query": "sys.message" - } + } | self.node_output_map var_selector = mapping.get(var_selector, var_selector) return var_selector @@ -124,6 +125,8 @@ class DifyConverter(BaseConverter): "checkbox": VariableType.BOOLEAN, "file-list": VariableType.ARRAY_FILE, "select": VariableType.STRING, + "integer": VariableType.NUMBER, + "float": VariableType.NUMBER, } var_type = type_map.get(source_type, source_type) return var_type @@ -160,6 +163,8 @@ class DifyConverter(BaseConverter): "≥": ComparisonOperator.GE, "≤": ComparisonOperator.LE, "not empty": ComparisonOperator.NOT_EMPTY, + "start with": ComparisonOperator.START_WITH, + "end with": ComparisonOperator.END_WITH, } return operator_map.get(operator, operator) @@ -633,7 +638,7 @@ class DifyConverter(BaseConverter): prompt=node_data["instruction"] ).model_dump() - def convert_variable_aggregator(self, node: dict) -> dict: + def convert_variable_aggregator_node_config(self, node: dict) -> dict: node_data = node["data"] group_enable = node_data["advanced_settings"]["group_enabled"] group_variables = {} @@ -657,3 +662,13 @@ class DifyConverter(BaseConverter): group_variables=group_variables, group_type=group_type, ).model_dump() + + def convert_tool_node_config(self, node: dict) -> dict: + node_data = node["data"] + self.warnings.append(ExceptionDefineition( + node_id=node["id"], + node_name=node_data["title"], + type=ExceptionType.CONFIG, + detail=f"Please reconfigure the tool node.", + )) + return {} \ No newline at end of file diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index 48a0cbd6..2ecde092 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -43,7 +43,8 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): "knowledge-retrieval": NodeType.KNOWLEDGE_RETRIEVAL, "parameter-extractor": NodeType.PARAMETER_EXTRACTOR, "question-classifier": NodeType.QUESTION_CLASSIFIER, - "variable-aggregator": NodeType.VAR_AGGREGATOR + "variable-aggregator": NodeType.VAR_AGGREGATOR, + "tool": NodeType.TOOL } def __init__(self, config: dict[str, Any]): @@ -89,6 +90,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): return True def parse_workflow(self) -> WorkflowParserResult: + self._init_node_output_map() for node in self.origin_nodes: node = self._convert_node(node) if node: @@ -128,6 +130,11 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): errors=self.errors ) + def _init_node_output_map(self): + for node in self.origin_nodes: + if self.map_node_type(node["data"]["type"]) == NodeType.LLM: + self.node_output_map[f"{node['id']}.text"] = f"{node['id']}.output" + def _convert_cycle_node_position(self, node_id: str, position: dict): for node in self.origin_nodes: if node["id"] == node_id: @@ -214,6 +221,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): type=ExceptionType.EDGE, detail=f"convert edge error - {e}", )) + logger.debug(f"convert edge error - {e}", exc_info=True) return None def _convert_variable(self, variable) -> VariableDefinition | None: @@ -221,7 +229,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): return VariableDefinition( name=variable["name"], default=variable["value"], - type=variable["value_type"], + type=self.variable_type_map(variable["value_type"]), ) except Exception as e: self.errors.append(ExceptionDefineition( diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 2b554a60..3c3137fe 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -175,7 +175,7 @@ class WorkflowExecutor: elapsed_time = (end_time - start_time).total_seconds() logger.info( - f"Workflow execution completed: execution_id={self.execution_context.execution_id}, elapsed_time={elapsed_time:.2f}s") + f"Workflow execution completed: execution_id={self.execution_context.execution_id}, elapsed_time={elapsed_time:.2f}ms") return self.result_builder.build_final_output(result, self.variable_pool, elapsed_time, full_content) @@ -322,7 +322,7 @@ class WorkflowExecutor: ) logger.info( f"Workflow execution completed (streaming), " - f"elapsed: {elapsed_time:.2f}s, execution_id: {self.execution_context.execution_id}" + f"elapsed: {elapsed_time:.2f}ms, execution_id: {self.execution_context.execution_id}" ) yield { diff --git a/api/app/core/workflow/nodes/base_node.py b/api/app/core/workflow/nodes/base_node.py index a01ffbe3..3e30c00e 100644 --- a/api/app/core/workflow/nodes/base_node.py +++ b/api/app/core/workflow/nodes/base_node.py @@ -196,7 +196,7 @@ class BaseNode(ABC): timeout=timeout ) - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 # Extract processed outputs using subclass-defined logic. extracted_output = self._extract_output(business_result) @@ -219,7 +219,7 @@ class BaseNode(ABC): } | self.trans_activate(state) except TimeoutError: - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 logger.error( f"Node {self.node_id} execution timed out ({timeout} seconds)." ) @@ -230,7 +230,7 @@ class BaseNode(ABC): variable_pool, ) except Exception as e: - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 logger.error( f"Node {self.node_id} execution failed: {e}", exc_info=True, @@ -307,10 +307,10 @@ class BaseNode(ABC): "done": done }) - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 logger.info(f"Node {self.node_id} streaming execution finished, " - f"time elapsed: {elapsed_time:.2f}s, chunks: {chunk_count}") + f"time elapsed: {elapsed_time:.2f}ms, chunks: {chunk_count}") # Extract processed output (call subclass's _extract_output) extracted_output = self._extract_output(final_result) @@ -337,7 +337,7 @@ class BaseNode(ABC): yield state_update | self.trans_activate(state) except TimeoutError: - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 logger.error(f"Node {self.node_id} execution timed out ({timeout}s)") error_output = self._wrap_error( f"Node execution timed out ({timeout}s)", @@ -347,7 +347,7 @@ class BaseNode(ABC): ) yield error_output except Exception as e: - elapsed_time = time.time() - start_time + elapsed_time = (time.time() - start_time) * 1000 logger.error(f"Node {self.node_id} execution failed: {e}", exc_info=True) error_output = self._wrap_error(str(e), elapsed_time, state, variable_pool) yield error_output diff --git a/api/app/core/workflow/utils/expression_evaluator.py b/api/app/core/workflow/utils/expression_evaluator.py index 26f0c41c..4bc5fc4c 100644 --- a/api/app/core/workflow/utils/expression_evaluator.py +++ b/api/app/core/workflow/utils/expression_evaluator.py @@ -12,9 +12,20 @@ class ExpressionEvaluator: # Reserved namespaces RESERVED_NAMESPACES = {"var", "node", "sys", "nodes"} - - @staticmethod + + @classmethod + def normalize_template(cls, template: str) -> str: + pattern = re.compile( + r"\{\{\s*(\d+)\.(\w+)\s*}}" + ) + return pattern.sub( + r'{{ node["\1"].\2 }}', + template + ) + + @classmethod def evaluate( + cls, expression: str, conv_vars: dict[str, Any], node_outputs: dict[str, Any], @@ -37,6 +48,7 @@ class ExpressionEvaluator: """ # Remove Jinja2-style brackets if present expression = expression.strip() + expression = cls.normalize_template(expression) pattern = r"\{\{\s*(.*?)\s*\}\}" expression = re.sub(pattern, r"\1", expression).strip() diff --git a/api/app/core/workflow/utils/template_renderer.py b/api/app/core/workflow/utils/template_renderer.py index 236e0840..424fdf20 100644 --- a/api/app/core/workflow/utils/template_renderer.py +++ b/api/app/core/workflow/utils/template_renderer.py @@ -5,6 +5,7 @@ """ import logging +import re from typing import Any from jinja2 import TemplateSyntaxError, UndefinedError, Environment, StrictUndefined, Undefined @@ -39,6 +40,16 @@ class TemplateRenderer: autoescape=False # 不自动转义,因为我们处理的是文本而非 HTML ) + @staticmethod + def normalize_template(template: str) -> str: + pattern = re.compile( + r"\{\{\s*(\d+)\.(\w+)\s*}}" + ) + return pattern.sub( + r'{{ node["\1"].\2 }}', + template + ) + def render( self, template: str, @@ -95,7 +106,7 @@ class TemplateRenderer: context.update(conv_vars) context["nodes"] = node_outputs or {} # 旧语法兼容 - + template = self.normalize_template(template) try: tmpl = self.env.from_string(template) return tmpl.render(**context) diff --git a/api/app/schemas/workflow_schema.py b/api/app/schemas/workflow_schema.py index 9e15f227..e580833f 100644 --- a/api/app/schemas/workflow_schema.py +++ b/api/app/schemas/workflow_schema.py @@ -68,7 +68,7 @@ class WorkflowImportSave(BaseModel): """工作流导入请求""" temp_id: str name: str - description: str + description: str | None = Field(default=None) # ==================== 工作流配置 ==================== From e6aa0e0e108e9aecc35f434ff90f052f1e1fb0fd Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 17:51:12 +0800 Subject: [PATCH 30/55] [add]New semantic pruning effect display for streaming output --- api/app/services/pilot_run_service.py | 97 +++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 34b8867e..31e4d6dd 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -101,14 +101,101 @@ async def run_pilot_extraction( ) if progress_callback: - await progress_callback("text_preprocessing", "开始预处理文本...") + await progress_callback("text_preprocessing", "开始预处理文本(语义剪枝 + 语义分块)...") + # ========== 步骤 2.1: 语义剪枝 ========== + pruned_dialogs = [dialog] + deleted_messages = [] # 记录被删除的消息 + + if memory_config.pruning_enabled: + try: + from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import ( + SemanticPruner, + ) + from app.core.memory.models.config_models import PruningConfig + + # 构建剪枝配置 + pruning_config_dict = { + "pruning_switch": memory_config.pruning_enabled, + "pruning_scene": memory_config.pruning_scene, + "pruning_threshold": memory_config.pruning_threshold, + "llm_model_id": str(memory_config.llm_model_id), + } + config = PruningConfig(**pruning_config_dict) + + logger.info(f"[PILOT_RUN] 开始语义剪枝: scene={config.pruning_scene}, threshold={config.pruning_threshold}") + + # 记录剪枝前的消息(用于对比) + original_messages = [{"role": msg.role, "content": msg.msg} for msg in dialog.context.msgs] + original_msg_count = len(original_messages) + + # 执行剪枝 + pruner = SemanticPruner(config=config, llm_client=llm_client) + pruned_dialogs = await pruner.prune_dataset([dialog]) + + # 计算剪枝结果并找出被删除的消息 + if pruned_dialogs and pruned_dialogs[0].context: + remaining_messages = [{"role": msg.role, "content": msg.msg} for msg in pruned_dialogs[0].context.msgs] + remaining_msg_count = len(remaining_messages) + deleted_msg_count = original_msg_count - remaining_msg_count + + # 找出被删除的消息(通过内容对比) + remaining_contents = {msg["content"] for msg in remaining_messages} + deleted_messages = [ + {"index": idx, "role": msg["role"], "content": msg["content"]} + for idx, msg in enumerate(original_messages) + if msg["content"] not in remaining_contents + ] + + pruning_result = { + "enabled": True, + "scene": config.pruning_scene, + "threshold": config.pruning_threshold, + "original_count": original_msg_count, + "remaining_count": remaining_msg_count, + "deleted_count": deleted_msg_count, + "deleted_messages": deleted_messages, + } + + logger.info( + f"[PILOT_RUN] 语义剪枝完成: 原始{original_msg_count}条 -> " + f"保留{remaining_msg_count}条 (删除{deleted_msg_count}条)" + ) + + if progress_callback: + await progress_callback("text_preprocessing_pruning", "语义剪枝完成", pruning_result) + else: + logger.warning("[PILOT_RUN] 剪枝后对话为空,使用原始对话") + pruned_dialogs = [dialog] + + except Exception as e: + logger.error(f"[PILOT_RUN] 语义剪枝失败,使用原始对话: {e}", exc_info=True) + pruned_dialogs = [dialog] + if progress_callback: + error_result = { + "enabled": True, + "error": str(e), + "fallback": "使用原始对话" + } + await progress_callback("text_preprocessing_pruning", "语义剪枝失败", error_result) + else: + logger.info("[PILOT_RUN] 语义剪枝已关闭,跳过") + if progress_callback: + pruning_result = { + "enabled": False, + "message": "语义剪枝已关闭" + } + await progress_callback("text_preprocessing_pruning", "语义剪枝已关闭", pruning_result) + + # ========== 步骤 2.2: 语义分块 ========== chunked_dialogs = await get_chunked_dialogs_from_preprocessed( - data=[dialog], + data=pruned_dialogs, chunker_strategy=memory_config.chunker_strategy, llm_client=llm_client, ) - logger.info(f"Processed dialogue text: {len(messages)} messages") + + remaining_msg_count = len(pruned_dialogs[0].context.msgs) if pruned_dialogs and pruned_dialogs[0].context else 0 + logger.info(f"Processed dialogue text: {remaining_msg_count} messages after pruning") # 进度回调:输出每个分块的结果 if progress_callback: @@ -121,14 +208,14 @@ async def run_pilot_extraction( "dialog_id": dlg.id, "chunker_strategy": memory_config.chunker_strategy, } - await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) + await progress_callback("text_preprocessing_chunking", f"分块 {i + 1} 处理完成", chunk_result) preprocessing_summary = { "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } - await progress_callback("text_preprocessing_complete", "预处理文本完成", preprocessing_summary) + await progress_callback("text_preprocessing_complete", "预处理文本完成(剪枝 + 分块)", preprocessing_summary) log_time("Data Loading & Chunking", time.time() - step_start, log_file) From 035464c0ac65bbc7ff0e0f6208701bcf08d25b36 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 18:19:44 +0800 Subject: [PATCH 31/55] [fix]Fix the display issue of semantic chunking for streaming output --- api/app/services/pilot_run_service.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 31e4d6dd..4cfa158d 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -200,18 +200,19 @@ async def run_pilot_extraction( # 进度回调:输出每个分块的结果 if progress_callback: for dlg in chunked_dialogs: - for i, chunk in enumerate(dlg.chunks): - chunk_result = { - "chunk_index": i + 1, - "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, - "full_length": len(chunk.content), - "dialog_id": dlg.id, - "chunker_strategy": memory_config.chunker_strategy, - } - await progress_callback("text_preprocessing_chunking", f"分块 {i + 1} 处理完成", chunk_result) + if hasattr(dlg, 'chunks') and dlg.chunks: + for i, chunk in enumerate(dlg.chunks): + chunk_result = { + "chunk_index": i + 1, + "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, + "full_length": len(chunk.content), + "dialog_id": dlg.id, + "chunker_strategy": memory_config.chunker_strategy, + } + await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) preprocessing_summary = { - "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs), + "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs if hasattr(dlg, 'chunks') and dlg.chunks), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } From 6718553bf4f4ceb105734a98a8b69645e00a34ff Mon Sep 17 00:00:00 2001 From: lixinyue11 <94037597+lixinyue11@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:47:08 +0800 Subject: [PATCH 32/55] Fix/develop memory rag (#419) * fix_rag/fast summary * fix_rag/fast summary --- .../langgraph_graph/nodes/summary_nodes.py | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) 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 0144c0e9..cf832add 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 @@ -17,6 +17,8 @@ from app.core.memory.agent.utils.llm_tools import ( from app.core.memory.agent.utils.redis_tool import store from app.core.memory.agent.utils.session_tools import SessionService from app.core.memory.agent.utils.template_tools import TemplateService +from app.core.rag.nlp.search import knowledge_retrieval + from app.db import get_db template_root = os.path.join(PROJECT_ROOT_, 'memory', 'agent', 'utils', 'prompt') @@ -32,6 +34,41 @@ class SummaryNodeService(LLMServiceMixin): # 创建全局服务实例 summary_service = SummaryNodeService() +async def rag_config(state): + user_rag_memory_id = state.get('user_rag_memory_id', '') + kb_config = { + "knowledge_bases": [ + { + "kb_id": user_rag_memory_id, + "similarity_threshold": 0.7, + "vector_similarity_weight": 0.5, + "top_k": 10, + "retrieve_type": "participle" + } + ], + "merge_strategy": "weight", + "reranker_id": os.getenv('reranker_id'), + "reranker_top_k": 10 + } + return kb_config +async def rag_knowledge(state,question): + kb_config = await rag_config(state) + 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(end_user_id)]) + try: + retrieval_knowledge = [i.page_content for i in retrieve_chunks_result] + clean_content = '\n\n'.join(retrieval_knowledge) + cleaned_query = question + raw_results = clean_content + logger.info(f" Using RAG storage with memory_id={user_rag_memory_id}") + except Exception : + retrieval_knowledge=[] + clean_content = '' + raw_results = '' + cleaned_query = question + logger.info(f"No content retrieved from knowledge base: {user_rag_memory_id}") + return retrieval_knowledge,clean_content,cleaned_query,raw_results async def summary_history(state: ReadState) -> ReadState: end_user_id = state.get("end_user_id", '') @@ -71,7 +108,7 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o ) # 验证结构化响应 if structured is None: - logger.warning(f"LLM返回None,使用默认回答") + logger.warning("LLM返回None,使用默认回答") return "信息不足,无法回答" # 根据操作类型提取答案 @@ -82,7 +119,7 @@ async def summary_llm(state: ReadState, history, retrieve_info, template_name, o if hasattr(structured, 'data') and structured.data: aimessages = getattr(structured.data, 'query_answer', None) or "信息不足,无法回答" else: - logger.warning(f"结构化响应缺少data字段") + logger.warning("结构化响应缺少data字段") aimessages = "信息不足,无法回答" # 验证答案不为空 @@ -186,12 +223,13 @@ async def Input_Summary(state: ReadState) -> ReadState: } try: - retrieve_info, question, raw_results = await SearchService().execute_hybrid_search(**search_params, memory_config=memory_config) + if storage_type!="rag": + retrieve_info, question, raw_results = await SearchService().execute_hybrid_search(**search_params, memory_config=memory_config) + else: + retrieval_knowledge, retrieve_info, question, raw_results = await rag_knowledge(state, data) except Exception as e: logger.error( f"Input_Summary: hybrid_search failed, using empty results: {e}", exc_info=True ) retrieve_info, question, raw_results = "", data, [] - - try: # aimessages=await summary_llm(state,history,retrieve_info,'Retrieve_Summary_prompt.jinja2', # 'input_summary',RetrieveSummaryResponse) @@ -290,7 +328,6 @@ async def Summary(state: ReadState)-> ReadState: summary_result = await summary_prompt(state, aimessages, retrieve_info_str) summary = summary_result[1] return {"summary":summary} - async def Summary_fails(state: ReadState)-> ReadState: storage_type=state.get("storage_type", '') user_rag_memory_id=state.get("user_rag_memory_id", '') From 4c592bf7e3f0132c72a4e94197222ed67360f773 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: Sat, 28 Feb 2026 18:58:33 +0800 Subject: [PATCH 33/55] Feature/default ontology (#424) * [add]Create a workspace and initialize the default ontology engineering scenario * [add]The language parameters for creating the workspace determine the default language for switching in the ontology project. * [changes]Standardized return format * [add]The default ontology is associated with the default configuration. * [add]Create a workspace and initialize the default ontology engineering scenario * [add]The language parameters for creating the workspace determine the default language for switching in the ontology project. * [changes]Standardized return format * [add]The default ontology is associated with the default configuration. --- api/app/config/__init__.py | 1 + api/app/config/default_ontology_config.py | 239 +++++++++++++++++ .../config/default_ontology_initializer.py | 249 ++++++++++++++++++ .../controllers/memory_agent_controller.py | 4 +- api/app/controllers/ontology_controller.py | 31 ++- .../controllers/ontology_secondary_routes.py | 32 ++- api/app/controllers/workspace_controller.py | 21 +- api/app/models/ontology_class.py | 5 +- api/app/models/ontology_scene.py | 5 +- api/app/services/memory_agent_service.py | 12 +- api/app/services/workspace_service.py | 117 +++++++- redbear-mem-benchmark | 2 +- 12 files changed, 696 insertions(+), 22 deletions(-) create mode 100644 api/app/config/__init__.py create mode 100644 api/app/config/default_ontology_config.py create mode 100644 api/app/config/default_ontology_initializer.py diff --git a/api/app/config/__init__.py b/api/app/config/__init__.py new file mode 100644 index 00000000..df675a16 --- /dev/null +++ b/api/app/config/__init__.py @@ -0,0 +1 @@ +"""Configuration module for application settings.""" diff --git a/api/app/config/default_ontology_config.py b/api/app/config/default_ontology_config.py new file mode 100644 index 00000000..157aa73e --- /dev/null +++ b/api/app/config/default_ontology_config.py @@ -0,0 +1,239 @@ +"""默认本体场景配置 + +本模块定义系统预设的本体场景和实体类型配置。 +这些配置用于在工作空间创建时自动初始化默认场景。 +支持中英文双语配置,根据用户语言偏好创建对应语言的场景。 +""" + +# 在线教育场景配置 +ONLINE_EDUCATION_SCENE = { + "name_chinese": "在线教育", + "name_english": "Online Education", + "description_chinese": "适用于在线教育平台的本体建模,包含学生、教师、课程等核心实体类型", + "description_english": "Ontology modeling for online education platforms, including core entity types such as students, teachers, and courses", + "types": [ + { + "name_chinese": "学生", + "name_english": "Student", + "description_chinese": "在教育系统中接受教育的个体,包含姓名、学号、年级、班级等属性", + "description_english": "Individuals receiving education in the education system, including attributes such as name, student ID, grade, and class" + }, + { + "name_chinese": "教师", + "name_english": "Teacher", + "description_chinese": "在教育系统中提供教学服务的个体,包含姓名、工号、任教学科、职称等属性", + "description_english": "Individuals providing teaching services in the education system, including attributes such as name, employee ID, teaching subject, and title" + }, + { + "name_chinese": "课程", + "name_english": "Course", + "description_chinese": "教育系统中的教学内容单元,包含课程名称、课程代码、学分、学时等属性", + "description_english": "Teaching content units in the education system, including attributes such as course name, course code, credits, and class hours" + }, + { + "name_chinese": "作业", + "name_english": "Assignment", + "description_chinese": "课程中布置的学习任务,包含作业标题、截止日期、所属课程、提交状态等属性", + "description_english": "Learning tasks assigned in courses, including attributes such as assignment title, deadline, course, and submission status" + }, + { + "name_chinese": "成绩", + "name_english": "Grade", + "description_chinese": "学生学习成果的评价结果,包含分数、评级、考试类型、所属课程等属性", + "description_english": "Evaluation results of student learning outcomes, including attributes such as score, rating, exam type, and course" + }, + { + "name_chinese": "考试", + "name_english": "Exam", + "description_chinese": "评估学生学习成果的测试活动,包含考试名称、时间、地点、科目等属性", + "description_english": "Test activities to assess student learning outcomes, including attributes such as exam name, time, location, and subject" + }, + { + "name_chinese": "教室", + "name_english": "Classroom", + "description_chinese": "进行教学活动的物理或虚拟空间,包含教室编号、容量、设备等属性", + "description_english": "Physical or virtual spaces for teaching activities, including attributes such as classroom number, capacity, and equipment" + }, + { + "name_chinese": "学科", + "name_english": "Subject", + "description_chinese": "知识的分类领域,包含学科名称、代码、所属院系等属性", + "description_english": "Classification domains of knowledge, including attributes such as subject name, code, and department" + }, + { + "name_chinese": "教材", + "name_english": "Textbook", + "description_chinese": "教学使用的书籍或资料,包含书名、作者、出版社、ISBN等属性", + "description_english": "Books or materials used for teaching, including attributes such as title, author, publisher, and ISBN" + }, + { + "name_chinese": "班级", + "name_english": "Class", + "description_chinese": "学生的组织单位,包含班级名称、年级、人数、班主任等属性", + "description_english": "Organizational units of students, including attributes such as class name, grade, number of students, and class teacher" + }, + { + "name_chinese": "学期", + "name_english": "Semester", + "description_chinese": "教学时间的划分单位,包含学期名称、开始时间、结束时间等属性", + "description_english": "Time division units for teaching, including attributes such as semester name, start time, and end time" + }, + { + "name_chinese": "课时", + "name_english": "Class Hour", + "description_chinese": "课程的时间单位,包含上课时间、地点、教师、课程等属性", + "description_english": "Time units of courses, including attributes such as class time, location, teacher, and course" + }, + { + "name_chinese": "教学计划", + "name_english": "Teaching Plan", + "description_chinese": "课程的教学安排,包含教学目标、内容安排、进度计划等属性", + "description_english": "Teaching arrangements for courses, including attributes such as teaching objectives, content arrangement, and progress plan" + } + ] +} + +# 情感陪伴场景配置 +EMOTIONAL_COMPANION_SCENE = { + "name_chinese": "情感陪伴", + "name_english": "Emotional Companion", + "description_chinese": "适用于情感陪伴应用的本体建模,包含用户、情绪、活动等核心实体类型", + "description_english": "Ontology modeling for emotional companion applications, including core entity types such as users, emotions, and activities", + "types": [ + { + "name_chinese": "用户", + "name_english": "User", + "description_chinese": "使用情感陪伴服务的个体,包含姓名、昵称、性格特征、偏好等属性", + "description_english": "Individuals using emotional companion services, including attributes such as name, nickname, personality traits, and preferences" + }, + { + "name_chinese": "情绪", + "name_english": "Emotion", + "description_chinese": "用户的情感状态,包含情绪类型、强度、触发原因、持续时间等属性", + "description_english": "Emotional states of users, including attributes such as emotion type, intensity, trigger cause, and duration" + }, + { + "name_chinese": "活动", + "name_english": "Activity", + "description_chinese": "用户参与的各类活动,包含活动名称、类型、参与者、时间地点等属性", + "description_english": "Various activities users participate in, including attributes such as activity name, type, participants, time, and location" + }, + { + "name_chinese": "对话", + "name_english": "Conversation", + "description_chinese": "用户之间的交流记录,包含对话主题、参与者、时间、关键内容等属性", + "description_english": "Communication records between users, including attributes such as conversation topic, participants, time, and key content" + }, + { + "name_chinese": "兴趣爱好", + "name_english": "Hobby", + "description_chinese": "用户的兴趣和爱好,包含爱好名称、类别、熟练程度、相关活动等属性", + "description_english": "User interests and hobbies, including attributes such as hobby name, category, proficiency level, and related activities" + }, + { + "name_chinese": "日常事件", + "name_english": "Daily Event", + "description_chinese": "用户日常生活中的事件,包含事件描述、时间、地点、相关人物等属性", + "description_english": "Events in users' daily lives, including attributes such as event description, time, location, and related people" + }, + { + "name_chinese": "关系", + "name_english": "Relationship", + "description_chinese": "用户之间的社会关系,包含关系类型、亲密度、建立时间等属性", + "description_english": "Social relationships between users, including attributes such as relationship type, intimacy, and establishment time" + }, + { + "name_chinese": "回忆", + "name_english": "Memory", + "description_chinese": "用户的重要记忆片段,包含回忆内容、时间、地点、相关人物等属性", + "description_english": "Important memory fragments of users, including attributes such as memory content, time, location, and related people" + }, + { + "name_chinese": "地点", + "name_english": "Location", + "description_chinese": "用户活动的地理位置,包含地点名称、地址、类型、相关事件等属性", + "description_english": "Geographic locations of user activities, including attributes such as location name, address, type, and related events" + }, + { + "name_chinese": "时间节点", + "name_english": "Time Point", + "description_chinese": "重要的时间标记,包含日期、事件、意义等属性", + "description_english": "Important time markers, including attributes such as date, event, and significance" + }, + { + "name_chinese": "目标", + "name_english": "Goal", + "description_chinese": "用户设定的目标,包含目标描述、截止时间、完成状态、相关活动等属性", + "description_english": "Goals set by users, including attributes such as goal description, deadline, completion status, and related activities" + }, + { + "name_chinese": "成就", + "name_english": "Achievement", + "description_chinese": "用户获得的成就,包含成就名称、获得时间、描述、相关目标等属性", + "description_english": "Achievements obtained by users, including attributes such as achievement name, acquisition time, description, and related goals" + } + ] +} + +# 导出默认场景列表 +DEFAULT_SCENES = [ONLINE_EDUCATION_SCENE, EMOTIONAL_COMPANION_SCENE] + + +def get_scene_name(scene_config: dict, language: str = "zh") -> str: + """获取场景名称(根据语言) + + Args: + scene_config: 场景配置字典 + language: 语言类型 ("zh" 或 "en") + + Returns: + 对应语言的场景名称 + """ + if language == "en": + return scene_config.get("name_english", scene_config.get("name_chinese")) + return scene_config.get("name_chinese") + + +def get_scene_description(scene_config: dict, language: str = "zh") -> str: + """获取场景描述(根据语言) + + Args: + scene_config: 场景配置字典 + language: 语言类型 ("zh" 或 "en") + + Returns: + 对应语言的场景描述 + """ + if language == "en": + return scene_config.get("description_english", scene_config.get("description_chinese")) + return scene_config.get("description_chinese") + + +def get_type_name(type_config: dict, language: str = "zh") -> str: + """获取类型名称(根据语言) + + Args: + type_config: 类型配置字典 + language: 语言类型 ("zh" 或 "en") + + Returns: + 对应语言的类型名称 + """ + if language == "en": + return type_config.get("name_english", type_config.get("name_chinese")) + return type_config.get("name_chinese") + + +def get_type_description(type_config: dict, language: str = "zh") -> str: + """获取类型描述(根据语言) + + Args: + type_config: 类型配置字典 + language: 语言类型 ("zh" 或 "en") + + Returns: + 对应语言的类型描述 + """ + if language == "en": + return type_config.get("description_english", type_config.get("description_chinese")) + return type_config.get("description_chinese") diff --git a/api/app/config/default_ontology_initializer.py b/api/app/config/default_ontology_initializer.py new file mode 100644 index 00000000..3d06a352 --- /dev/null +++ b/api/app/config/default_ontology_initializer.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""默认本体场景初始化器 + +本模块提供默认本体场景和类型的自动初始化功能。 +在工作空间创建时,自动添加预设的本体场景和实体类型。 + +Classes: + DefaultOntologyInitializer: 默认本体场景初始化器 +""" + +import logging +from typing import List, Optional, Tuple +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.config.default_ontology_config import ( + DEFAULT_SCENES, + get_scene_name, + get_scene_description, + get_type_name, + get_type_description, +) +from app.core.logging_config import get_business_logger +from app.repositories.ontology_scene_repository import OntologySceneRepository +from app.repositories.ontology_class_repository import OntologyClassRepository + + +class DefaultOntologyInitializer: + """默认本体场景初始化器 + + 负责在工作空间创建时自动初始化默认的本体场景和类型。 + 遵循最小侵入原则,确保初始化失败不阻止工作空间创建。 + + Attributes: + db: 数据库会话 + scene_repo: 场景Repository + class_repo: 类型Repository + logger: 业务日志记录器 + """ + + def __init__(self, db: Session): + """初始化 + + Args: + db: 数据库会话 + """ + self.db = db + self.scene_repo = OntologySceneRepository(db) + self.class_repo = OntologyClassRepository(db) + self.logger = get_business_logger() + + def initialize_default_scenes( + self, + workspace_id: UUID, + language: str = "zh" + ) -> Tuple[bool, str]: + """为工作空间初始化默认场景 + + 创建两个默认场景(在线教育、情感陪伴)及其对应的实体类型。 + 如果创建失败,记录错误日志但不抛出异常。 + + Args: + workspace_id: 工作空间ID + language: 语言类型 ("zh" 或 "en"),默认为 "zh" + + Returns: + Tuple[bool, str]: (是否成功, 错误信息) + """ + try: + self.logger.info( + f"开始初始化默认本体场景 - workspace_id={workspace_id}, language={language}" + ) + + scenes_created = 0 + total_types_created = 0 + + # 遍历默认场景配置 + for scene_config in DEFAULT_SCENES: + scene_name = get_scene_name(scene_config, language) + + # 创建场景及其类型 + scene_id = self._create_scene_with_types(workspace_id, scene_config, language) + + if scene_id: + scenes_created += 1 + # 统计类型数量 + types_count = len(scene_config.get("types", [])) + total_types_created += types_count + + self.logger.info( + f"场景创建成功 - scene_name={scene_name}, " + f"scene_id={scene_id}, types_count={types_count}, language={language}" + ) + else: + self.logger.warning( + f"场景创建失败 - scene_name={scene_name}, " + f"workspace_id={workspace_id}, language={language}" + ) + + # 记录总体结果 + self.logger.info( + f"默认场景初始化完成 - workspace_id={workspace_id}, " + f"language={language}, scenes_created={scenes_created}, " + f"total_types_created={total_types_created}" + ) + + # 如果至少创建了一个场景,视为成功 + if scenes_created > 0: + return True, "" + else: + error_msg = "所有默认场景创建失败" + self.logger.error( + f"默认场景初始化失败 - workspace_id={workspace_id}, " + f"language={language}, error={error_msg}" + ) + return False, error_msg + + except Exception as e: + error_msg = f"默认场景初始化异常: {str(e)}" + self.logger.error( + f"默认场景初始化异常 - workspace_id={workspace_id}, " + f"language={language}, error={str(e)}", + exc_info=True + ) + return False, error_msg + + def _create_scene_with_types( + self, + workspace_id: UUID, + scene_config: dict, + language: str = "zh" + ) -> Optional[UUID]: + """创建场景及其类型 + + Args: + workspace_id: 工作空间ID + scene_config: 场景配置字典 + language: 语言类型 ("zh" 或 "en") + + Returns: + Optional[UUID]: 创建的场景ID,失败返回None + """ + try: + scene_name = get_scene_name(scene_config, language) + scene_description = get_scene_description(scene_config, language) + + # 检查是否已存在同名场景(支持向后兼容) + existing_scene = self.scene_repo.get_by_name(scene_name, workspace_id) + if existing_scene: + self.logger.info( + f"场景已存在,跳过创建 - scene_name={scene_name}, " + f"workspace_id={workspace_id}, scene_id={existing_scene.scene_id}, " + f"language={language}" + ) + return None + + # 创建场景记录,设置 is_system_default=true + scene_data = { + "scene_name": scene_name, + "scene_description": scene_description + } + + scene = self.scene_repo.create(scene_data, workspace_id) + + # 设置系统默认标识 + scene.is_system_default = True + self.db.flush() + + self.logger.info( + f"场景创建成功 - scene_name={scene_name}, " + f"scene_id={scene.scene_id}, is_system_default=True, language={language}" + ) + + # 批量创建类型 + types_config = scene_config.get("types", []) + types_created = self._batch_create_types(scene.scene_id, types_config, language) + + self.logger.info( + f"场景类型创建完成 - scene_id={scene.scene_id}, " + f"types_created={types_created}/{len(types_config)}, language={language}" + ) + + return scene.scene_id + + except Exception as e: + scene_name = get_scene_name(scene_config, language) + self.logger.error( + f"场景创建失败 - scene_name={scene_name}, " + f"workspace_id={workspace_id}, language={language}, error={str(e)}", + exc_info=True + ) + return None + + def _batch_create_types( + self, + scene_id: UUID, + types_config: List[dict], + language: str = "zh" + ) -> int: + """批量创建实体类型 + + Args: + scene_id: 场景ID + types_config: 类型配置列表 + language: 语言类型 ("zh" 或 "en") + + Returns: + int: 成功创建的类型数量 + """ + created_count = 0 + + for type_config in types_config: + try: + type_name = get_type_name(type_config, language) + type_description = get_type_description(type_config, language) + + # 创建类型数据 + class_data = { + "class_name": type_name, + "class_description": type_description + } + + # 创建类型 + ontology_class = self.class_repo.create(class_data, scene_id) + + # 设置系统默认标识 + ontology_class.is_system_default = True + self.db.flush() + + created_count += 1 + + self.logger.debug( + f"类型创建成功 - class_name={type_name}, " + f"class_id={ontology_class.class_id}, " + f"scene_id={scene_id}, is_system_default=True, language={language}" + ) + + except Exception as e: + type_name = get_type_name(type_config, language) + self.logger.warning( + f"单个类型创建失败,继续创建其他类型 - " + f"class_name={type_name}, scene_id={scene_id}, " + f"language={language}, error={str(e)}" + ) + # 继续创建其他类型 + continue + + return created_count diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index 0e632fcc..ef65c679 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -633,11 +633,11 @@ async def get_knowledge_type_stats_api( current_user: User = Depends(get_current_user) ): """ - 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder | memory。 + 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder | Memory。 会对缺失类型补 0,返回字典形式。 可选按状态过滤。 - 知识库类型根据当前用户的 current_workspace_id 过滤 - - memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (end_user_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}") diff --git a/api/app/controllers/ontology_controller.py b/api/app/controllers/ontology_controller.py index 49a2fb3a..e4a87141 100644 --- a/api/app/controllers/ontology_controller.py +++ b/api/app/controllers/ontology_controller.py @@ -31,7 +31,7 @@ from sqlalchemy.orm import Session from app.core.config import settings from app.core.error_codes import BizCode from app.core.language_utils import get_language_from_header -from app.core.logging_config import get_api_logger +from app.core.logging_config import get_api_logger, get_business_logger from app.core.response_utils import fail, success from app.db import get_db from app.dependencies import get_current_user @@ -61,6 +61,7 @@ from app.repositories.ontology_scene_repository import OntologySceneRepository api_logger = get_api_logger() +business_logger = get_business_logger() logger = logging.getLogger(__name__) router = APIRouter( @@ -399,6 +400,20 @@ async def update_scene( api_logger.warning(f"User {current_user.id} has no current workspace") return fail(BizCode.BAD_REQUEST, "请求参数无效", "当前用户没有工作空间") + # 检查是否为系统默认场景 + scene_repo = OntologySceneRepository(db) + scene = scene_repo.get_by_id(scene_uuid) + if scene and scene.is_system_default: + business_logger.warning( + f"尝试修改系统默认场景: user_id={current_user.id}, " + f"scene_id={scene_id}, scene_name={scene.scene_name}" + ) + return fail( + BizCode.BAD_REQUEST, + "系统默认场景不可修改", + "该场景为系统预设场景,不允许修改" + ) + # 创建OntologyService实例 from app.core.memory.llm_tools.openai_client import OpenAIClient from app.core.models.base import RedBearModelConfig @@ -491,6 +506,20 @@ async def delete_scene( api_logger.warning(f"User {current_user.id} has no current workspace") return fail(BizCode.BAD_REQUEST, "请求参数无效", "当前用户没有工作空间") + # 检查是否为系统默认场景 + scene_repo = OntologySceneRepository(db) + scene = scene_repo.get_by_id(scene_uuid) + if scene and scene.is_system_default: + business_logger.warning( + f"尝试删除系统默认场景: user_id={current_user.id}, " + f"scene_id={scene_id}, scene_name={scene.scene_name}" + ) + return fail( + BizCode.BAD_REQUEST, + "系统默认场景不可删除", + "该场景为系统预设场景,不允许删除" + ) + # 创建OntologyService实例 from app.core.memory.llm_tools.openai_client import OpenAIClient from app.core.models.base import RedBearModelConfig diff --git a/api/app/controllers/ontology_secondary_routes.py b/api/app/controllers/ontology_secondary_routes.py index 99017eea..607a0739 100644 --- a/api/app/controllers/ontology_secondary_routes.py +++ b/api/app/controllers/ontology_secondary_routes.py @@ -11,7 +11,7 @@ from fastapi import Depends from sqlalchemy.orm import Session from app.core.error_codes import BizCode -from app.core.logging_config import get_api_logger +from app.core.logging_config import get_api_logger, get_business_logger from app.core.response_utils import fail, success from app.db import get_db from app.dependencies import get_current_user @@ -30,9 +30,11 @@ from app.schemas.response_schema import ApiResponse from app.services.ontology_service import OntologyService from app.core.memory.llm_tools.openai_client import OpenAIClient from app.core.models.base import RedBearModelConfig +from app.repositories.ontology_class_repository import OntologyClassRepository api_logger = get_api_logger() +business_logger = get_business_logger() def _get_dummy_ontology_service(db: Session) -> OntologyService: @@ -366,6 +368,20 @@ async def update_class_handler( api_logger.warning(f"User {current_user.id} has no current workspace") return fail(BizCode.BAD_REQUEST, "请求参数无效", "当前用户没有工作空间") + # 检查是否为系统默认类型 + class_repo = OntologyClassRepository(db) + ontology_class = class_repo.get_by_id(class_uuid) + if ontology_class and ontology_class.is_system_default: + business_logger.warning( + f"尝试修改系统默认类型: user_id={current_user.id}, " + f"class_id={class_id}, class_name={ontology_class.class_name}" + ) + return fail( + BizCode.BAD_REQUEST, + "系统默认类型不可修改", + "该类型为系统预设类型,不允许修改" + ) + # 创建Service service = _get_dummy_ontology_service(db) @@ -429,6 +445,20 @@ async def delete_class_handler( api_logger.warning(f"User {current_user.id} has no current workspace") return fail(BizCode.BAD_REQUEST, "请求参数无效", "当前用户没有工作空间") + # 检查是否为系统默认类型 + class_repo = OntologyClassRepository(db) + ontology_class = class_repo.get_by_id(class_uuid) + if ontology_class and ontology_class.is_system_default: + business_logger.warning( + f"尝试删除系统默认类型: user_id={current_user.id}, " + f"class_id={class_id}, class_name={ontology_class.class_name}" + ) + return fail( + BizCode.BAD_REQUEST, + "系统默认类型不可删除", + "该类型为系统预设类型,不允许删除" + ) + # 创建Service service = _get_dummy_ontology_service(db) diff --git a/api/app/controllers/workspace_controller.py b/api/app/controllers/workspace_controller.py index d2afb10f..9bcd8571 100644 --- a/api/app/controllers/workspace_controller.py +++ b/api/app/controllers/workspace_controller.py @@ -1,7 +1,7 @@ import uuid from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from sqlalchemy.orm import Session from app.core.logging_config import get_api_logger @@ -95,16 +95,29 @@ def get_workspaces( @router.post("", response_model=ApiResponse) def create_workspace( workspace: WorkspaceCreate, + language_type: str = Header(default="zh", alias="X-Language-Type"), db: Session = Depends(get_db), current_user: User = Depends(get_current_superuser), ): """创建新的工作空间""" - api_logger.info(f"用户 {current_user.username} 请求创建工作空间: {workspace.name}") + from app.core.language_utils import get_language_from_header + + # 验证并获取语言参数 + language = get_language_from_header(language_type) + + api_logger.info( + f"用户 {current_user.username} 请求创建工作空间: {workspace.name}, " + f"language={language}" + ) result = workspace_service.create_workspace( - db=db, workspace=workspace, user=current_user) + db=db, workspace=workspace, user=current_user, language=language + ) - api_logger.info(f"工作空间创建成功 - 名称: {workspace.name}, ID: {result.id}, 创建者: {current_user.username}") + api_logger.info( + f"工作空间创建成功 - 名称: {workspace.name}, ID: {result.id}, " + f"创建者: {current_user.username}, language={language}" + ) result_schema = WorkspaceResponse.model_validate(result) return success(data=result_schema, msg="工作空间创建成功") diff --git a/api/app/models/ontology_class.py b/api/app/models/ontology_class.py index 528d934e..a8468090 100644 --- a/api/app/models/ontology_class.py +++ b/api/app/models/ontology_class.py @@ -9,7 +9,7 @@ Classes: import datetime import uuid -from sqlalchemy import Column, String, DateTime, Text, ForeignKey +from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.db import Base @@ -25,6 +25,9 @@ class OntologyClass(Base): # 类型信息 class_name = Column(String(200), nullable=False, comment="类型名称") class_description = Column(Text, nullable=True, comment="类型描述") + + # 系统默认标识 + is_system_default = Column(Boolean, default=False, nullable=False, comment="是否为系统默认类型") # 外键:关联到本体场景 scene_id = Column(UUID(as_uuid=True), ForeignKey("ontology_scene.scene_id", ondelete="CASCADE"), nullable=False, index=True, comment="所属场景ID") diff --git a/api/app/models/ontology_scene.py b/api/app/models/ontology_scene.py index 350bfdd6..3ce42cad 100644 --- a/api/app/models/ontology_scene.py +++ b/api/app/models/ontology_scene.py @@ -9,7 +9,7 @@ Classes: import datetime import uuid -from sqlalchemy import Column, String, DateTime, Integer, Text, ForeignKey, UniqueConstraint +from sqlalchemy import Column, String, DateTime, Integer, Text, ForeignKey, UniqueConstraint, Boolean from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.db import Base @@ -28,6 +28,9 @@ class OntologyScene(Base): # 场景信息 scene_name = Column(String(200), nullable=False, comment="场景名称") scene_description = Column(Text, nullable=True, comment="场景描述") + + # 系统默认标识 + is_system_default = Column(Boolean, default=False, nullable=False, index=True, comment="是否为系统默认场景") # 外键:关联到工作空间 workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True, comment="所属工作空间ID") diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index da8a8e06..ad295667 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -816,11 +816,11 @@ class MemoryAgentService: """ 统计知识库类型分布,包含: 1. PostgreSQL 中的知识库类型:General, Web, Third-party, Folder(根据 workspace_id 过滤) - 2. Neo4j 中的 memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) + 2. Neo4j 中的 Memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) 3. total: 所有类型的总和 参数: - - end_user_id: 用户组ID(可选,未提供时 memory 统计为 0) + - end_user_id: 用户组ID(可选,未提供时 Memory 统计为 0) - only_active: 是否仅统计有效记录 - current_workspace_id: 当前工作空间ID(可选,未提供时知识库统计为 0) - db: 数据库会话 @@ -831,7 +831,7 @@ class MemoryAgentService: "Web": count, "Third-party": count, "Folder": count, - "memory": chunk_count, + "Memory": chunk_count, "total": sum_of_all } """ @@ -912,17 +912,17 @@ class MemoryAgentService: total_chunks += chunk_count logger.debug(f"EndUser {end_user_id_str} Chunk数量: {chunk_count}") - result["memory"] = total_chunks + result["Memory"] = total_chunks logger.info(f"Neo4j memory统计成功: 总Chunk数={total_chunks}, 宿主数={len(end_users)}") else: # 没有 workspace_id 时,返回 0 - result["memory"] = 0 + result["Memory"] = 0 logger.info("未提供 workspace_id,memory 统计为 0") except Exception as e: logger.error(f"Neo4j memory统计失败: {e}", exc_info=True) # 如果 Neo4j 查询失败,memory 设为 0 - result["memory"] = 0 + result["Memory"] = 0 # 3. 计算知识库类型总和(不包括 memory) result["total"] = ( diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 6f102695..2f8cdc70 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -30,6 +30,7 @@ from app.schemas.workspace_schema import ( WorkspaceModelsUpdate, WorkspaceUpdate, ) +from app.config.default_ontology_initializer import DefaultOntologyInitializer # 获取业务逻辑专用日志器 business_logger = get_business_logger() @@ -129,7 +130,7 @@ def _create_workspace_only( raise def create_workspace( - db: Session, workspace: WorkspaceCreate, user: User + db: Session, workspace: WorkspaceCreate, user: User, language: str = "zh" ) -> Workspace: business_logger.info( f"创建工作空间: {workspace.name}, 创建者: {user.username}, " @@ -145,10 +146,68 @@ def create_workspace( db=db, workspace=workspace, tenant_id=user.tenant_id ) business_logger.info(f"工作空间创建成功: {db_workspace.name} (ID: {db_workspace.id}), 创建者: {user.username}") - db.commit() + db.flush() # 使用 flush 而不是 commit,获取 ID 但不提交事务 db.refresh(db_workspace) + # Initialize default ontology scenes for the workspace (先创建本体场景) + default_scene_id = None + try: + initializer = DefaultOntologyInitializer(db) + success, error_msg = initializer.initialize_default_scenes( + db_workspace.id, language=language + ) + + if success: + business_logger.info( + f"为工作空间 {db_workspace.id} 创建默认本体场景成功 (language={language})" + ) + + # 获取默认场景ID,优先使用"在线教育"场景,如果不存在则使用"情感陪伴"场景 + from app.repositories.ontology_scene_repository import OntologySceneRepository + from app.config.default_ontology_config import ( + ONLINE_EDUCATION_SCENE, + EMOTIONAL_COMPANION_SCENE, + get_scene_name + ) + + scene_repo = OntologySceneRepository(db) + + # 优先尝试获取教育场景 + education_scene_name = get_scene_name(ONLINE_EDUCATION_SCENE, language) + education_scene = scene_repo.get_by_name(education_scene_name, db_workspace.id) + + if education_scene: + default_scene_id = education_scene.scene_id + business_logger.info( + f"获取到教育场景ID用于默认记忆配置: {default_scene_id} (scene_name={education_scene_name})" + ) + else: + # 如果教育场景不存在,尝试获取情感陪伴场景 + companion_scene_name = get_scene_name(EMOTIONAL_COMPANION_SCENE, language) + companion_scene = scene_repo.get_by_name(companion_scene_name, db_workspace.id) + + if companion_scene: + default_scene_id = companion_scene.scene_id + business_logger.info( + f"教育场景不存在,使用情感陪伴场景ID用于默认记忆配置: {default_scene_id} (scene_name={companion_scene_name})" + ) + else: + business_logger.warning( + f"未找到任何默认场景 (education={education_scene_name}, companion={companion_scene_name})" + ) + else: + business_logger.warning( + f"为工作空间 {db_workspace.id} 创建默认本体场景失败: {error_msg} (language={language})" + ) + except Exception as ontology_error: + business_logger.error( + f"为工作空间 {db_workspace.id} 创建默认本体场景异常: {str(ontology_error)} (language={language})" + ) + # Don't fail workspace creation if default ontology initialization fails + # The workspace can still function without default ontology scenes + # Create default memory config for the workspace (only for neo4j storage types) + # 将默认场景ID(教育场景或情感陪伴场景)关联到记忆配置 if workspace.storage_type == 'neo4j': try: _create_default_memory_config( @@ -158,9 +217,10 @@ def create_workspace( llm_id=llm, embedding_id=embedding, rerank_id=rerank, + scene_id=default_scene_id, # 传入默认场景ID(优先教育场景,其次情感陪伴场景) ) business_logger.info( - f"为工作空间 {db_workspace.id} 创建默认记忆配置成功" + f"为工作空间 {db_workspace.id} 创建默认记忆配置成功 (scene_id={default_scene_id})" ) except Exception as mc_error: business_logger.error( @@ -209,7 +269,6 @@ def create_workspace( db=db, knowledge=knowledge_data ) - db.commit() business_logger.info( f"为工作空间 {db_workspace.id} 自动创建知识库成功: " f"{db_knowledge.name} (ID: {db_knowledge.id})" @@ -224,6 +283,12 @@ def create_workspace( BizCode.INTERNAL_ERROR ) + # 统一提交所有更改 + db.commit() + business_logger.info( + f"工作空间 {db_workspace.id} 及相关资源创建完成并已提交" + ) + return db_workspace except Exception as e: @@ -919,6 +984,43 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: f"Workspace {workspace.id} missing default memory config, creating one" ) + # 尝试获取默认场景ID,优先教育场景,其次情感陪伴场景 + default_scene_id = None + try: + from app.repositories.ontology_scene_repository import OntologySceneRepository + from app.config.default_ontology_config import ( + ONLINE_EDUCATION_SCENE, + EMOTIONAL_COMPANION_SCENE, + get_scene_name + ) + + scene_repo = OntologySceneRepository(db) + # 尝试中文和英文场景名称 + for language in ["zh", "en"]: + # 优先尝试教育场景 + education_scene_name = get_scene_name(ONLINE_EDUCATION_SCENE, language) + education_scene = scene_repo.get_by_name(education_scene_name, workspace.id) + if education_scene: + default_scene_id = education_scene.scene_id + business_logger.info( + f"找到教育场景用于默认记忆配置: scene_id={default_scene_id}, scene_name={education_scene_name}" + ) + break + + # 如果教育场景不存在,尝试情感陪伴场景 + companion_scene_name = get_scene_name(EMOTIONAL_COMPANION_SCENE, language) + companion_scene = scene_repo.get_by_name(companion_scene_name, workspace.id) + if companion_scene: + default_scene_id = companion_scene.scene_id + business_logger.info( + f"教育场景不存在,找到情感陪伴场景用于默认记忆配置: scene_id={default_scene_id}, scene_name={companion_scene_name}" + ) + break + except Exception as scene_error: + business_logger.warning( + f"获取默认场景失败,将创建不关联场景的记忆配置: {str(scene_error)}" + ) + try: _create_default_memory_config( db=db, @@ -927,6 +1029,7 @@ def _ensure_default_memory_config(db: Session, workspace: Workspace) -> None: llm_id=uuid.UUID(workspace.llm) if workspace.llm else None, embedding_id=uuid.UUID(workspace.embedding) if workspace.embedding else None, rerank_id=uuid.UUID(workspace.rerank) if workspace.rerank else None, + scene_id=default_scene_id, # 传入默认场景ID(优先教育场景,其次情感陪伴场景) ) except Exception as e: business_logger.error( @@ -1008,6 +1111,7 @@ def _create_default_memory_config( llm_id: Optional[uuid.UUID] = None, embedding_id: Optional[uuid.UUID] = None, rerank_id: Optional[uuid.UUID] = None, + scene_id: Optional[uuid.UUID] = None, ) -> None: """Create a default memory config for a newly created workspace. @@ -1018,6 +1122,7 @@ def _create_default_memory_config( llm_id: Optional LLM model ID embedding_id: Optional embedding model ID rerank_id: Optional rerank model ID + scene_id: Optional ontology scene ID (默认关联教育场景) """ from app.models.memory_config_model import MemoryConfig @@ -1031,12 +1136,13 @@ def _create_default_memory_config( llm_id=str(llm_id) if llm_id else None, embedding_id=str(embedding_id) if embedding_id else None, rerank_id=str(rerank_id) if rerank_id else None, + scene_id=scene_id, # 关联本体场景ID state=True, # Active by default is_default=True, # Mark as workspace default ) db.add(default_config) - db.commit() + db.flush() # 使用 flush 而不是 commit,让调用者统一提交 business_logger.info( "Created default memory config for workspace", @@ -1044,5 +1150,6 @@ def _create_default_memory_config( "workspace_id": str(workspace_id), "config_id": str(config_id), "config_name": default_config.config_name, + "scene_id": str(scene_id) if scene_id else None, } ) diff --git a/redbear-mem-benchmark b/redbear-mem-benchmark index 4b0257bb..8494e824 160000 --- a/redbear-mem-benchmark +++ b/redbear-mem-benchmark @@ -1 +1 @@ -Subproject commit 4b0257bb4e7dc384b2aaf849b0bd6eae4b39835d +Subproject commit 8494e82498cb99c70ac67a64a544ff872432363a From 77ea0680fb2c39285a0fa1ce0862b045ef6b91a1 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 28 Feb 2026 19:22:13 +0800 Subject: [PATCH 34/55] [add] migration script --- .../versions/4bf27c66ae63_202602281918.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 api/migrations/versions/4bf27c66ae63_202602281918.py diff --git a/api/migrations/versions/4bf27c66ae63_202602281918.py b/api/migrations/versions/4bf27c66ae63_202602281918.py new file mode 100644 index 00000000..78b13435 --- /dev/null +++ b/api/migrations/versions/4bf27c66ae63_202602281918.py @@ -0,0 +1,44 @@ +"""202602281918 + +Revision ID: 4bf27c66ae63 +Revises: 7672d8f0f939 +Create Date: 2026-02-28 19:18:38.332468 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4bf27c66ae63' +down_revision: Union[str, None] = '7672d8f0f939' +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! ### + # Add columns as nullable first + op.add_column('ontology_class', sa.Column('is_system_default', sa.Boolean(), nullable=True, comment='是否为系统默认类型')) + op.add_column('ontology_scene', sa.Column('is_system_default', sa.Boolean(), nullable=True, comment='是否为系统默认场景')) + + # Set default value for existing rows + op.execute("UPDATE ontology_class SET is_system_default = false WHERE is_system_default IS NULL") + op.execute("UPDATE ontology_scene SET is_system_default = false WHERE is_system_default IS NULL") + + # Now make columns NOT NULL + op.alter_column('ontology_class', 'is_system_default', nullable=False) + op.alter_column('ontology_scene', 'is_system_default', nullable=False) + + op.create_index(op.f('ix_ontology_scene_is_system_default'), 'ontology_scene', ['is_system_default'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_ontology_scene_is_system_default'), table_name='ontology_scene') + op.drop_column('ontology_scene', 'is_system_default') + op.drop_column('ontology_class', 'is_system_default') + # ### end Alembic commands ### From 8b546b73669c455e7ded0ccc0ecd4cbf2a2a181f Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 19:26:16 +0800 Subject: [PATCH 35/55] [add]Complete the interface integration for the display of semantic pruning for streaming output. --- api/app/services/pilot_run_service.py | 33 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 4cfa158d..c39d089e 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -106,6 +106,7 @@ async def run_pilot_extraction( # ========== 步骤 2.1: 语义剪枝 ========== pruned_dialogs = [dialog] deleted_messages = [] # 记录被删除的消息 + pruning_stats = None # 保存剪枝统计信息,用于最终汇总 if memory_config.pruning_enabled: try: @@ -147,13 +148,17 @@ async def run_pilot_extraction( if msg["content"] not in remaining_contents ] - pruning_result = { + # 保存剪枝统计信息(用于最终汇总,只保留deleted_count) + pruning_stats = { "enabled": True, "scene": config.pruning_scene, "threshold": config.pruning_threshold, - "original_count": original_msg_count, - "remaining_count": remaining_msg_count, "deleted_count": deleted_msg_count, + } + + # 输出剪枝结果(显示删除的消息详情) + pruning_result = { + "type": "pruning", "deleted_messages": deleted_messages, } @@ -163,7 +168,7 @@ async def run_pilot_extraction( ) if progress_callback: - await progress_callback("text_preprocessing_pruning", "语义剪枝完成", pruning_result) + await progress_callback("text_preprocessing_result", "语义剪枝完成", pruning_result) else: logger.warning("[PILOT_RUN] 剪枝后对话为空,使用原始对话") pruned_dialogs = [dialog] @@ -173,19 +178,16 @@ async def run_pilot_extraction( pruned_dialogs = [dialog] if progress_callback: error_result = { - "enabled": True, + "type": "pruning", "error": str(e), "fallback": "使用原始对话" } - await progress_callback("text_preprocessing_pruning", "语义剪枝失败", error_result) + await progress_callback("text_preprocessing_result", "语义剪枝失败", error_result) else: logger.info("[PILOT_RUN] 语义剪枝已关闭,跳过") - if progress_callback: - pruning_result = { - "enabled": False, - "message": "语义剪枝已关闭" - } - await progress_callback("text_preprocessing_pruning", "语义剪枝已关闭", pruning_result) + pruning_stats = { + "enabled": False, + } # ========== 步骤 2.2: 语义分块 ========== chunked_dialogs = await get_chunked_dialogs_from_preprocessed( @@ -203,6 +205,7 @@ async def run_pilot_extraction( if hasattr(dlg, 'chunks') and dlg.chunks: for i, chunk in enumerate(dlg.chunks): chunk_result = { + "type": "chunking", "chunk_index": i + 1, "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, "full_length": len(chunk.content), @@ -211,11 +214,17 @@ async def run_pilot_extraction( } await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) + # 构建预处理完成总结(包含剪枝统计) preprocessing_summary = { "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs if hasattr(dlg, 'chunks') and dlg.chunks), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } + + # 添加剪枝统计信息 + if pruning_stats: + preprocessing_summary["pruning"] = pruning_stats + await progress_callback("text_preprocessing_complete", "预处理文本完成(剪枝 + 分块)", preprocessing_summary) log_time("Data Loading & Chunking", time.time() - step_start, log_file) From 87df352adc4306411b41315dcba698e8ae525be7 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 2 Mar 2026 11:42:46 +0800 Subject: [PATCH 36/55] feat(web): memoryExtractionEngine add pruning --- web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + .../components/Result.tsx | 96 +++++++++++-------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index fdbde290..02add0ec 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1640,6 +1640,10 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re scene_type_distribution: 'Scene Type Distribution', general_type_distribution: 'General Type Distribution', unmatched: 'Unmatched', + disagreementCase: 'Disagreement Case', + Pruned: 'Pruned', + pruning: 'Pruning', + pruning_desc: 'Text pruning {{count}} fragments' }, memoryConversation: { searchPlaceholder: 'Enter user ID...', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c855667f..06abf63a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1637,6 +1637,10 @@ export const zh = { scene_type_distribution: '场景类型', general_type_distribution: '通用类型', unmatched: '未匹配', + disagreementCase: '不一致案例', + Pruned: '已剪枝', + pruning: '剪枝', + pruning_desc: '文本剪枝{{count}}个片段' }, memoryConversation: { chatEmpty:'有什么我可以帮您的吗?', diff --git a/web/src/views/MemoryExtractionEngine/components/Result.tsx b/web/src/views/MemoryExtractionEngine/components/Result.tsx index 68ff397b..6504f571 100644 --- a/web/src/views/MemoryExtractionEngine/components/Result.tsx +++ b/web/src/views/MemoryExtractionEngine/components/Result.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:30:11 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-09 21:04:14 + * @Last Modified time: 2026-03-02 11:41:12 */ /** * Result Component @@ -91,7 +91,7 @@ const Result: FC = ({ loading, handleSave }) => { setDeduplication({...initObj} as ModuleItem) setTestResult({} as TestResult) const handleStreamMessage = (list: SSEMessage[]) => { - + list.forEach((data: AnyObject) => { switch(data.event) { case 'text_preprocessing': // Start text preprocessing @@ -104,7 +104,7 @@ const Result: FC = ({ loading, handleSave }) => { case 'text_preprocessing_result': // Text preprocessing in progress setTextPreprocessing(prev => ({ ...prev, - data: [...prev.data, data.data?.data] + data: [...prev.data, data.data?.deleted_messages ? { deleted_messages: data.data?.deleted_messages } : data.data?.data], })) break case 'text_preprocessing_complete': // Text preprocessing complete @@ -193,9 +193,9 @@ const Result: FC = ({ loading, handleSave }) => { dialogue_text: t('memoryExtractionEngine.exampleText'), custom_text: runForm.getFieldValue('custom_text') }, handleStreamMessage) - .finally(() => { - setRunLoading(false) - }) + .finally(() => { + setRunLoading(false) + }) } const completedNum = [textPreprocessing, knowledgeExtraction, creatingNodesEdges, deduplication].filter(item => item.status === 'completed').length const deduplicationData = groupDataByType(deduplication.data, 'result_type') @@ -251,10 +251,10 @@ const Result: FC = ({ loading, handleSave }) => {
: !testResult || Object.keys(testResult).length === 0 - ? } className="rb:mb-3.5"> - {t('memoryExtractionEngine.warning')} - - : } className="rb:mb-3.5"> + ? } className="rb:mb-3.5"> + {t('memoryExtractionEngine.warning')} + + : } className="rb:mb-3.5"> {t('memoryExtractionEngine.success')} } @@ -266,15 +266,28 @@ const Result: FC = ({ loading, handleSave }) => { headerType="borderL" headerClassName="rb:before:bg-[#155EEF]!" > - {textPreprocessing.data.map((vo, index) => ( -
- -
- ))} + {textPreprocessing.data.map((vo, index) => { + if (vo.deleted_messages) { + return
+
{t('memoryExtractionEngine.Pruned')}
+ {vo.deleted_messages.map((msg: any, idx: number) => ( +
+ +
+ ))} +
+ } + return ( +
+ +
+ ) + })} {formatTime(textPreprocessing)} {textPreprocessing.result && } className="rb:mt-3"> - {t('memoryExtractionEngine.text_preprocessing_desc', { count: textPreprocessing.result.total_chunks })}, + {t('memoryExtractionEngine.pruning_desc', { count: textPreprocessing.result.pruning.deleted_count || 0 })}, + {t('memoryExtractionEngine.text_preprocessing_desc', { count: textPreprocessing.result.total_chunks })}, {t('memoryExtractionEngine.chunkerStrategy')}: {t(`memoryExtractionEngine.${lowercaseFirst(textPreprocessing.result.chunker_strategy)}`)} } @@ -286,7 +299,7 @@ const Result: FC = ({ loading, handleSave }) => { headerType="borderL" headerClassName="rb:before:bg-[#155EEF]!" > - {knowledgeExtraction.data.map((vo, index) => + {knowledgeExtraction.data.map((vo, index) =>
{vo.statement}
)} {formatTime(knowledgeExtraction)} @@ -345,31 +358,30 @@ const Result: FC = ({ loading, handleSave }) => { {Object.keys(resultObj).map((key, index) => { const keys = (resultObj as Record)[key].split('.') return ( -
-
{(testResult?.[keys[0] as keyof TestResult] as any)?.[keys[1]]}
-
{t(`memoryExtractionEngine.${key}`)}
-
- {} - {key === 'extractTheNumberOfEntities' && testResult.dedup - ? t(`memoryExtractionEngine.${key}Desc`, { - num: testResult.dedup.total_merged_count, - exact: testResult.dedup.breakdown.exact, - fuzzy: testResult.dedup.breakdown.fuzzy, - llm: testResult.dedup.breakdown.llm, - }) - : key === 'numberOfEntityDisambiguation' && testResult.disambiguation - ? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.disambiguation.effects?.length, block_count: testResult.disambiguation.block_count }) - : key === 'numberOfRelationalTriples' && testResult.triplets - ? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.triplets.count }) - :t(`memoryExtractionEngine.${key}Desc`) - } +
+
{(testResult?.[keys[0] as keyof TestResult] as any)?.[keys[1]]}
+
{t(`memoryExtractionEngine.${key}`)}
+
+ {key === 'extractTheNumberOfEntities' && testResult.dedup + ? t(`memoryExtractionEngine.${key}Desc`, { + num: testResult.dedup.total_merged_count, + exact: testResult.dedup.breakdown.exact, + fuzzy: testResult.dedup.breakdown.fuzzy, + llm: testResult.dedup.breakdown.llm, + }) + : key === 'numberOfEntityDisambiguation' && testResult.disambiguation + ? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.disambiguation.effects?.length, block_count: testResult.disambiguation.block_count }) + : key === 'numberOfRelationalTriples' && testResult.triplets + ? t(`memoryExtractionEngine.${key}Desc`, { num: testResult.triplets.count }) + :t(`memoryExtractionEngine.${key}Desc`) + } +
-
- )})} + )})}
} - + {testResult?.dedup?.impact && testResult.dedup.impact?.length > 0 && = ({ loading, handleSave }) => {
} - + {testResult?.disambiguation && testResult.disambiguation?.effects?.length > 0 && = ({ loading, handleSave }) => {
0, })}> -
Disagreement Case {index +1}:
+
{t('memoryExtractionEngine.disagreementCase')} {index +1}:
-{item.left.name}({item.left.type}) vs {item.right.name}({item.right.type}) → {item.result}
))} @@ -409,7 +421,7 @@ const Result: FC = ({ loading, handleSave }) => {
} - + {testResult?.core_entities && testResult?.core_entities.length > 0 && = ({ loading, handleSave }) => {
} - + {testResult?.triplet_samples && testResult?.triplet_samples.length > 0 && Date: Fri, 27 Feb 2026 11:06:00 +0800 Subject: [PATCH 37/55] [fix]Complete the API call logic for the homepage --- .../controllers/memory_agent_controller.py | 5 +- .../memory_dashboard_controller.py | 50 +++++++++++++---- api/app/services/memory_agent_service.py | 53 ++----------------- 3 files changed, 46 insertions(+), 62 deletions(-) diff --git a/api/app/controllers/memory_agent_controller.py b/api/app/controllers/memory_agent_controller.py index ef65c679..b88e65ff 100644 --- a/api/app/controllers/memory_agent_controller.py +++ b/api/app/controllers/memory_agent_controller.py @@ -633,12 +633,11 @@ async def get_knowledge_type_stats_api( current_user: User = Depends(get_current_user) ): """ - 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder | Memory。 + 统计当前空间下各知识库类型的数量,包含 General | Web | Third-party | Folder。 会对缺失类型补 0,返回字典形式。 可选按状态过滤。 - 知识库类型根据当前用户的 current_workspace_id 过滤 - - Memory 是 Neo4j 中 Chunk 的数量,根据 end_user_id (end_user_id) 过滤 - - 如果用户没有当前工作空间或未提供 end_user_id,对应的统计返回 0 + - 如果用户没有当前工作空间,对应的统计返回 0 """ api_logger.info(f"Knowledge type stats requested for workspace_id: {current_user.current_workspace_id}, end_user_id: {end_user_id}") try: diff --git a/api/app/controllers/memory_dashboard_controller.py b/api/app/controllers/memory_dashboard_controller.py index 88684a39..475d184e 100644 --- a/api/app/controllers/memory_dashboard_controller.py +++ b/api/app/controllers/memory_dashboard_controller.py @@ -9,6 +9,7 @@ from app.schemas.response_schema import ApiResponse from app.services import memory_dashboard_service, memory_storage_service, workspace_service from app.services.memory_agent_service import get_end_users_connected_configs_batch +from app.services.app_statistics_service import AppStatisticsService from app.core.logging_config import get_api_logger # 获取API专用日志器 @@ -469,6 +470,8 @@ async def get_chunk_insight( @router.get("/dashboard_data", response_model=ApiResponse) async def dashboard_data( end_user_id: Optional[str] = Query(None, description="可选的用户ID"), + start_date: Optional[int] = Query(None, description="开始时间戳(毫秒)"), + end_date: Optional[int] = Query(None, description="结束时间戳(毫秒)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -503,6 +506,15 @@ async def dashboard_data( workspace_id = current_user.current_workspace_id api_logger.info(f"用户 {current_user.username} 请求获取工作空间 {workspace_id} 的dashboard整合数据") + # 如果没有提供时间范围,默认使用最近30天 + if start_date is None or end_date is None: + from datetime import datetime, timedelta + end_dt = datetime.now() + start_dt = end_dt - timedelta(days=30) + end_date = int(end_dt.timestamp() * 1000) + start_date = int(start_dt.timestamp() * 1000) + api_logger.info(f"使用默认时间范围: {start_dt} 到 {end_dt}") + # 获取 storage_type,如果为 None 则使用默认值 storage_type = workspace_service.get_workspace_storage_type( db=db, @@ -563,17 +575,22 @@ async def dashboard_data( except Exception as e: api_logger.warning(f"获取知识库类型统计失败: {str(e)}") - # 3. 获取API调用增量(total_api_call,转换为整数) + # 3. 获取API调用统计(total_api_call) try: - api_increment = memory_dashboard_service.get_workspace_api_increment( - db=db, + # 使用 AppStatisticsService 获取真实的API调用统计 + app_stats_service = AppStatisticsService(db) + api_stats = app_stats_service.get_workspace_api_statistics( workspace_id=workspace_id, - current_user=current_user + start_date=start_date, + end_date=end_date ) - neo4j_data["total_api_call"] = api_increment - api_logger.info(f"成功获取API调用增量: {neo4j_data['total_api_call']}") + # 计算总调用次数 + total_api_calls = sum(item.get("total_calls", 0) for item in api_stats) + neo4j_data["total_api_call"] = total_api_calls + api_logger.info(f"成功获取API调用统计: {neo4j_data['total_api_call']}") except Exception as e: - api_logger.warning(f"获取API调用增量失败: {str(e)}") + api_logger.error(f"获取API调用统计失败: {str(e)}") + neo4j_data["total_api_call"] = 0 result["neo4j_data"] = neo4j_data api_logger.info("成功获取neo4j_data") @@ -602,10 +619,23 @@ async def dashboard_data( total_kb = memory_dashboard_service.get_rag_total_kb(db, current_user) rag_data["total_knowledge"] = total_kb - # total_api_call: 固定值 - rag_data["total_api_call"] = 1024 + # total_api_call: 使用 AppStatisticsService 获取真实的API调用统计 + try: + app_stats_service = AppStatisticsService(db) + api_stats = app_stats_service.get_workspace_api_statistics( + workspace_id=workspace_id, + start_date=start_date, + end_date=end_date + ) + # 计算总调用次数 + total_api_calls = sum(item.get("total_calls", 0) for item in api_stats) + rag_data["total_api_call"] = total_api_calls + api_logger.info(f"成功获取RAG模式API调用统计: {rag_data['total_api_call']}") + except Exception as e: + api_logger.warning(f"获取RAG模式API调用统计失败,使用默认值: {str(e)}") + rag_data["total_api_call"] = 0 - api_logger.info(f"成功获取RAG相关数据: memory={total_chunk}, app={len(apps_orm)}, knowledge={total_kb}") + api_logger.info(f"成功获取RAG相关数据: memory={total_chunk}, app={len(apps_orm)}, knowledge={total_kb}, api_calls={rag_data['total_api_call']}") except Exception as e: api_logger.warning(f"获取RAG相关数据失败: {str(e)}") diff --git a/api/app/services/memory_agent_service.py b/api/app/services/memory_agent_service.py index ad295667..1f3667a6 100644 --- a/api/app/services/memory_agent_service.py +++ b/api/app/services/memory_agent_service.py @@ -816,11 +816,10 @@ class MemoryAgentService: """ 统计知识库类型分布,包含: 1. PostgreSQL 中的知识库类型:General, Web, Third-party, Folder(根据 workspace_id 过滤) - 2. Neo4j 中的 Memory 类型(仅统计 Chunk 数量,根据 end_user_id/end_user_id 过滤) - 3. total: 所有类型的总和 + 2. total: 所有类型的总和 参数: - - end_user_id: 用户组ID(可选,未提供时 Memory 统计为 0) + - end_user_id: 用户组ID(可选,保留参数以保持接口兼容性) - only_active: 是否仅统计有效记录 - current_workspace_id: 当前工作空间ID(可选,未提供时知识库统计为 0) - db: 数据库会话 @@ -831,7 +830,6 @@ class MemoryAgentService: "Web": count, "Third-party": count, "Folder": count, - "Memory": chunk_count, "total": sum_of_all } """ @@ -878,51 +876,8 @@ class MemoryAgentService: logger.error(f"知识库类型统计失败: {e}") raise Exception(f"知识库类型统计失败: {e}") - # 2. 统计 Neo4j 中的 memory 总量(统计当前空间下所有宿主的 Chunk 总数) - try: - if current_workspace_id: - # 获取当前空间下的所有宿主 - from app.repositories import app_repository, end_user_repository - from app.schemas.app_schema import App as AppSchema - from app.schemas.end_user_schema import EndUser as EndUserSchema - - # 查询应用并转换为 Pydantic 模型 - apps_orm = app_repository.get_apps_by_workspace_id(db, current_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(h for h in end_user_orm_list) - - # 统计所有宿主的 Chunk 总数 - total_chunks = 0 - for end_user in end_users: - end_user_id_str = str(end_user.id) - memory_query = """ - 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, - end_user_id=end_user_id_str, - ) - chunk_count = neo4j_result[0]["Count"] if neo4j_result else 0 - total_chunks += chunk_count - logger.debug(f"EndUser {end_user_id_str} Chunk数量: {chunk_count}") - - result["Memory"] = total_chunks - logger.info(f"Neo4j memory统计成功: 总Chunk数={total_chunks}, 宿主数={len(end_users)}") - else: - # 没有 workspace_id 时,返回 0 - result["Memory"] = 0 - logger.info("未提供 workspace_id,memory 统计为 0") - - except Exception as e: - logger.error(f"Neo4j memory统计失败: {e}", exc_info=True) - # 如果 Neo4j 查询失败,memory 设为 0 - result["Memory"] = 0 + # 2. 统计 Neo4j 中的 memory 总量已移除 + # memory 字段不再返回 # 3. 计算知识库类型总和(不包括 memory) result["total"] = ( From 3a36d038eee85425ba75dafc7073ff643cd67827 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 12:20:51 +0800 Subject: [PATCH 38/55] [fix]Reconstructing memory incremental statistical scheduling task --- api/app/celery_app.py | 17 ++-- api/app/core/config.py | 1 - api/app/tasks.py | 197 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) diff --git a/api/app/celery_app.py b/api/app/celery_app.py index 8ef44975..f422f4a0 100644 --- a/api/app/celery_app.py +++ b/api/app/celery_app.py @@ -82,7 +82,7 @@ celery_app.conf.update( 'app.tasks.workspace_reflection_task': {'queue': 'periodic_tasks'}, 'app.tasks.regenerate_memory_cache': {'queue': 'periodic_tasks'}, 'app.tasks.run_forgetting_cycle_task': {'queue': 'periodic_tasks'}, - 'app.controllers.memory_storage_controller.search_all': {'queue': 'periodic_tasks'}, + 'app.tasks.write_all_workspaces_memory_task': {'queue': 'periodic_tasks'}, }, ) @@ -115,16 +115,11 @@ beat_schedule_config = { "config_id": None, # 使用默认配置,可以通过环境变量配置 }, }, + "write-all-workspaces-memory": { + "task": "app.tasks.write_all_workspaces_memory_task", + "schedule": memory_increment_schedule, + "args": (), + }, } -#如果配置了默认工作空间ID,则添加记忆总量统计任务 -if settings.DEFAULT_WORKSPACE_ID: - beat_schedule_config["write-total-memory"] = { - "task": "app.controllers.memory_storage_controller.search_all", - "schedule": memory_increment_schedule, - "kwargs": { - "workspace_id": settings.DEFAULT_WORKSPACE_ID, - }, - } - celery_app.conf.beat_schedule = beat_schedule_config diff --git a/api/app/core/config.py b/api/app/core/config.py index 0962b545..19998d32 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -201,7 +201,6 @@ class Settings: REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) MEMORY_INCREMENT_INTERVAL_HOURS: float = float(os.getenv("MEMORY_INCREMENT_INTERVAL_HOURS", "24")) - DEFAULT_WORKSPACE_ID: Optional[str] = os.getenv("DEFAULT_WORKSPACE_ID", None) REFLECTION_INTERVAL_TIME: Optional[str] = int(os.getenv("REFLECTION_INTERVAL_TIME", 30)) # Memory Cache Regeneration Configuration diff --git a/api/app/tasks.py b/api/app/tasks.py index d408a0da..8e3aea85 100644 --- a/api/app/tasks.py +++ b/api/app/tasks.py @@ -1304,6 +1304,203 @@ def write_total_memory_task(workspace_id: str) -> Dict[str, Any]: "workspace_id": workspace_id, "elapsed_time": elapsed_time, } +@celery_app.task( + name="app.tasks.write_all_workspaces_memory_task", + bind=True, + ignore_result=False, + max_retries=3, + acks_late=True, + time_limit=3600, + soft_time_limit=3300, +) +def write_all_workspaces_memory_task(self) -> Dict[str, Any]: + """定时任务:遍历所有工作空间,统计并写入记忆增量 + + 此任务会: + 1. 查询所有活跃的工作空间 + 2. 对每个工作空间统计记忆总量 + 3. 将统计结果写入 memory_increments 表 + + Returns: + 包含任务执行结果的字典 + """ + start_time = time.time() + + async def _run() -> Dict[str, Any]: + from app.core.logging_config import get_api_logger + from app.models.workspace_model import Workspace + from app.models.app_model import App + from app.models.end_user_model import EndUser + from app.repositories.memory_increment_repository import write_memory_increment + from app.services.memory_storage_service import search_all + + api_logger = get_api_logger() + + with get_db_context() as db: + try: + # 获取所有活跃的工作空间 + workspaces = db.query(Workspace).filter( + Workspace.is_active.is_(True) + ).all() + + if not workspaces: + api_logger.warning("没有找到活跃的工作空间") + return { + "status": "SUCCESS", + "message": "没有找到活跃的工作空间", + "workspace_count": 0, + "workspace_results": [] + } + + api_logger.info(f"开始统计 {len(workspaces)} 个工作空间的记忆增量") + all_workspace_results = [] + + # 遍历每个工作空间 + for workspace in workspaces: + workspace_id = workspace.id + api_logger.info(f"开始处理工作空间: {workspace.name} (ID: {workspace_id})") + + try: + # 1. 查询当前workspace下的所有app(仅未删除的) + apps = db.query(App).filter( + App.workspace_id == workspace_id, + App.is_active.is_(True) + ).all() + + if not apps: + # 如果没有app,总量为0 + memory_increment = write_memory_increment( + db=db, + workspace_id=workspace_id, + total_num=0 + ) + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "SUCCESS", + "total_num": 0, + "end_user_count": 0, + "memory_increment_id": str(memory_increment.id), + "created_at": memory_increment.created_at.isoformat(), + }) + api_logger.info(f"工作空间 {workspace.name} 没有应用,记录总量为0") + continue + + # 2. 查询所有app下的end_user_id(去重) + app_ids = [app.id for app in apps] + end_users = db.query(EndUser.id).filter( + EndUser.app_id.in_(app_ids) + ).distinct().all() + + # 3. 遍历所有end_user,查询每个宿主的记忆总量并累加 + total_num = 0 + end_user_details = [] + + for (end_user_id,) in end_users: + try: + # 调用 search_all 接口查询该宿主的总量 + result = await search_all(str(end_user_id)) + user_total = result.get("total", 0) + total_num += user_total + end_user_details.append({ + "end_user_id": str(end_user_id), + "total": user_total + }) + except Exception as e: + # 记录单个用户查询失败,但继续处理其他用户 + api_logger.warning(f"查询用户 {end_user_id} 记忆失败: {str(e)}") + end_user_details.append({ + "end_user_id": str(end_user_id), + "total": 0, + "error": str(e) + }) + + # 4. 写入数据库 + memory_increment = write_memory_increment( + db=db, + workspace_id=workspace_id, + total_num=total_num + ) + + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "SUCCESS", + "total_num": total_num, + "end_user_count": len(end_users), + "memory_increment_id": str(memory_increment.id), + "created_at": memory_increment.created_at.isoformat(), + }) + + api_logger.info( + f"工作空间 {workspace.name} 统计完成: 总量={total_num}, 用户数={len(end_users)}" + ) + + except Exception as e: + db.rollback() # 回滚失败的事务,允许继续处理下一个工作空间 + api_logger.error(f"处理工作空间 {workspace.name} (ID: {workspace_id}) 失败: {str(e)}") + all_workspace_results.append({ + "workspace_id": str(workspace_id), + "workspace_name": workspace.name, + "status": "FAILURE", + "error": str(e), + "total_num": 0, + "end_user_count": 0, + }) + + total_memory = sum(r.get("total_num", 0) for r in all_workspace_results) + success_count = sum(1 for r in all_workspace_results if r.get("status") == "SUCCESS") + + return { + "status": "SUCCESS", + "message": f"成功处理 {success_count}/{len(workspaces)} 个工作空间,总记忆量: {total_memory}", + "workspace_count": len(workspaces), + "success_count": success_count, + "total_memory": total_memory, + "workspace_results": all_workspace_results + } + + except Exception as e: + api_logger.error(f"记忆增量统计任务执行失败: {str(e)}") + return { + "status": "FAILURE", + "error": str(e), + "workspace_count": 0, + "workspace_results": [] + } + + try: + # 使用 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 + + return result + except Exception as e: + elapsed_time = time.time() - start_time + return { + "status": "FAILURE", + "error": str(e), + "elapsed_time": elapsed_time, + "task_id": self.request.id + } @celery_app.task( From ed0d963aeca622a44577fc0c0e3bb1602db456d5 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 14:47:23 +0800 Subject: [PATCH 39/55] [fix]Modify the person who generates the user summary --- .../core/memory/utils/prompt/prompt_utils.py | 14 +++++++--- .../utils/prompt/prompts/user_summary.jinja2 | 26 ++++++++++++++----- api/app/services/user_memory_service.py | 24 ++++++++++++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/api/app/core/memory/utils/prompt/prompt_utils.py b/api/app/core/memory/utils/prompt/prompt_utils.py index 50d31f2a..d88f50cf 100644 --- a/api/app/core/memory/utils/prompt/prompt_utils.py +++ b/api/app/core/memory/utils/prompt/prompt_utils.py @@ -400,7 +400,8 @@ async def render_user_summary_prompt( user_id: str, entities: str, statements: str, - language: str = "zh" + language: str = "zh", + user_display_name: str = None ) -> str: """ Renders the user summary prompt using the user_summary.jinja2 template. @@ -410,16 +411,22 @@ async def render_user_summary_prompt( entities: Core entities with frequency information statements: Representative statement samples language: The language to use for summary generation ("zh" for Chinese, "en" for English) + user_display_name: Display name for the user (e.g., other_name or "该用户"/"the user") Returns: Rendered prompt content as string """ + # 如果没有提供 user_display_name,使用默认值 + if user_display_name is None: + user_display_name = "该用户" if language == "zh" else "the user" + template = prompt_env.get_template("user_summary.jinja2") rendered_prompt = template.render( user_id=user_id, entities=entities, statements=statements, - language=language + language=language, + user_display_name=user_display_name ) # 记录渲染结果到提示日志 @@ -429,7 +436,8 @@ async def render_user_summary_prompt( 'user_id': user_id, 'entities_len': len(entities), 'statements_len': len(statements), - 'language': language + 'language': language, + 'user_display_name': user_display_name }) return rendered_prompt diff --git a/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 b/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 index 35619112..30b48719 100644 --- a/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 +++ b/api/app/core/memory/utils/prompt/prompts/user_summary.jinja2 @@ -14,8 +14,8 @@ Your task is to generate a comprehensive user profile based on the provided enti {% endif %} ===Inputs=== -{% if user_id %} -- User ID: {{ user_id }} +{% if user_display_name %} +- User Display Name: {{ user_display_name }} {% endif %} {% if entities %} - Core Entities & Frequency: {{ entities }} @@ -33,6 +33,20 @@ Your task is to generate a comprehensive user profile based on the provided enti 3. Avoid excessive adjectives and empty phrases 4. Strictly follow the output format specified below +{% if language == "zh" %} +**【严格人称规定】** +- 在描述用户时,必须使用"{{ user_display_name }}"作为人称 +- 绝对禁止使用用户ID(如 {{ user_id }})来称呼用户 +- 绝对禁止在摘要中出现任何形式的UUID或ID字符串 +- 如果需要指代用户,只能使用"{{ user_display_name }}"或相应的代词(他/她/TA) +{% else %} +**【STRICT PRONOUN RULES】** +- When describing the user, you MUST use "{{ user_display_name }}" as the reference +- It is ABSOLUTELY FORBIDDEN to use the user ID (such as {{ user_id }}) to refer to the user +- It is ABSOLUTELY FORBIDDEN to include any form of UUID or ID string in the summary +- If you need to refer to the user, you can ONLY use "{{ user_display_name }}" or appropriate pronouns (he/she/they) +{% endif %} + **Section-Specific Requirements:** {% if language == "zh" %} @@ -103,13 +117,13 @@ Your task is to generate a comprehensive user profile based on the provided enti {% if language == "zh" %} Example Input: -- User ID: user_12345 +- User Display Name: 张三 - Core Entities & Frequency: 产品经理 (15), AI (12), 深圳 (10), 数据分析 (8), 团队协作 (7) - Representative Statement Samples: 我在深圳从事产品经理工作已经5年了 | 我相信好的产品源于对用户需求的深刻理解 | 我喜欢在团队中起到协调作用 | 数据驱动决策是我的工作原则 Example Output: 【基本介绍】 -我是张三,一名充满热情的高级产品经理。在过去的5年里,我专注于AI和数据驱动的产品设计,致力于创造能够真正改善用户生活的产品。我相信好的产品源于对用户需求的深刻理解和对技术可能性的不断探索。 +张三是一名充满热情的高级产品经理,在深圳工作。在过去的5年里,张三专注于AI和数据驱动的产品设计,致力于创造能够真正改善用户生活的产品。张三相信好的产品源于对用户需求的深刻理解和对技术可能性的不断探索。 【性格特点】 性格开朗,善于沟通,注重细节。喜欢在团队中起到协调作用,帮助大家达成共识。面对挑战时保持乐观,相信每个问题都有解决方案。 @@ -121,13 +135,13 @@ Example Output: "让每一个产品决策都充满温度。" {% else %} Example Input: -- User ID: user_12345 +- User Display Name: John - Core Entities & Frequency: Product Manager (15), AI (12), San Francisco (10), Data Analysis (8), Team Collaboration (7) - Representative Statement Samples: I have been working as a product manager in San Francisco for 5 years | I believe good products come from deep understanding of user needs | I enjoy playing a coordinating role in teams | Data-driven decision making is my work principle Example Output: 【Basic Introduction】 -This is a passionate senior product manager based in San Francisco. Over the past 5 years, they have focused on AI and data-driven product design, dedicated to creating products that truly improve users' lives. They believe good products stem from deep understanding of user needs and continuous exploration of technological possibilities. +John is a passionate senior product manager based in San Francisco. Over the past 5 years, John has focused on AI and data-driven product design, dedicated to creating products that truly improve users' lives. John believes good products stem from deep understanding of user needs and continuous exploration of technological possibilities. 【Personality Traits】 Outgoing personality with excellent communication skills and attention to detail. Enjoys playing a coordinating role in teams, helping everyone reach consensus. Maintains optimism when facing challenges, believing every problem has a solution. diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index 80413c12..e34756b9 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1163,11 +1163,32 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st """ from app.core.memory.utils.prompt.prompt_utils import render_user_summary_prompt from app.core.language_utils import validate_language + from app.repositories.end_user_repository import EndUserRepository + from app.db import get_db import re # 验证语言参数 language = validate_language(language) + # 获取用户的 other_name 字段 + user_display_name = "该用户" if language == "zh" else "the user" + if end_user_id: + try: + # 获取数据库会话并查询用户信息 + db = next(get_db()) + try: + repo = EndUserRepository(db) + end_user = repo.get_by_id(uuid.UUID(end_user_id)) + if end_user and end_user.other_name: + user_display_name = end_user.other_name + logger.info(f"使用 other_name 作为用户显示名称: {user_display_name}") + else: + logger.info(f"用户 {end_user_id} 的 other_name 为空,使用默认称呼: {user_display_name}") + finally: + db.close() + except Exception as e: + logger.warning(f"获取用户 other_name 失败,使用默认称呼: {str(e)}") + # 创建 UserSummaryHelper 实例 user_summary_tool = UserSummaryHelper(end_user_id or os.getenv("SELECTED_end_user_id", "group_123")) @@ -1184,7 +1205,8 @@ async def analytics_user_summary(end_user_id: Optional[str] = None, language: st user_id=user_summary_tool.user_id, entities=", ".join(entity_lines) if entity_lines else "(空)" if language == "zh" else "(empty)", statements=" | ".join(statement_samples) if statement_samples else "(空)" if language == "zh" else "(empty)", - language=language + language=language, + user_display_name=user_display_name ) messages = [ From 6db6c33564c9cd85573662294239e83e9d43fde5 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 14:59:28 +0800 Subject: [PATCH 40/55] [fix]Reduce the default number of items returned for popular tags --- api/app/core/memory/analytics/hot_memory_tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index f99b811e..5ffc6fed 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -139,10 +139,10 @@ async def get_raw_tags_from_db( return [(record["name"], record["frequency"]) for record in results] -async def get_hot_memory_tags(end_user_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 = 10, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 - 查询更多的标签(limit=40)给LLM提供更丰富的上下文进行筛选。 + 查询更多的标签(limit=10)给LLM提供更丰富的上下文进行筛选。 Args: end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id From 4d59e04abad24d242269cf46f966fa1dfa8c25b4 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 15:08:06 +0800 Subject: [PATCH 41/55] [changes]Ensure that there are sufficient labels for LLM to process, and control the number of label returns. --- api/app/core/memory/analytics/hot_memory_tags.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/app/core/memory/analytics/hot_memory_tags.py b/api/app/core/memory/analytics/hot_memory_tags.py index 5ffc6fed..abb0f138 100644 --- a/api/app/core/memory/analytics/hot_memory_tags.py +++ b/api/app/core/memory/analytics/hot_memory_tags.py @@ -142,11 +142,11 @@ async def get_raw_tags_from_db( async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = False) -> List[Tuple[str, int]]: """ 获取原始标签,然后使用LLM进行筛选,返回最终的热门标签列表。 - 查询更多的标签(limit=10)给LLM提供更丰富的上下文进行筛选。 + 查询更多的标签(40条)给LLM提供更丰富的上下文进行筛选,但最终返回数量由limit参数控制。 Args: end_user_id: 必需参数。如果by_user=False,则为end_user_id;如果by_user=True,则为user_id - limit: 返回的标签数量限制 + limit: 最终返回的标签数量限制(默认10) by_user: 是否按user_id查询(默认False,按end_user_id查询) Raises: @@ -161,8 +161,9 @@ async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = # 使用项目的Neo4jConnector connector = Neo4jConnector() try: - # 1. 从数据库获取原始排名靠前的标签 - raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, limit, by_user=by_user) + # 1. 从数据库获取原始排名靠前的标签(查询40条给LLM提供更丰富的上下文) + query_limit = 40 + raw_tags_with_freq = await get_raw_tags_from_db(connector, end_user_id, query_limit, by_user=by_user) if not raw_tags_with_freq: return [] @@ -177,7 +178,8 @@ async def get_hot_memory_tags(end_user_id: str, limit: int = 10, by_user: bool = if tag in meaningful_tag_names: final_tags.append((tag, freq)) - return final_tags + # 4. 限制返回的标签数量 + return final_tags[:limit] finally: # 确保关闭连接 await connector.close() From 0ba370052ecddf7a3d697bc5a4acf825c5dd0c4e Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 16:09:22 +0800 Subject: [PATCH 42/55] [fix]Address the shortcomings of intelligent pruning --- .../data_preprocessing/data_pruning.py | 518 ++++++++++++++---- 1 file changed, 423 insertions(+), 95 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index d19e511b..2d0142c6 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -5,14 +5,17 @@ - 对话级一次性抽取判定相关性 - 仅对"不相关对话"的消息按比例删除 - 重要信息(时间、编号、金额、联系方式、地址等)优先保留 +- 改进版:增强重要性判断、智能填充消息识别、问答对保护、并发优化 """ +import asyncio import os import hashlib import json import re +from collections import OrderedDict from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Dict, Tuple, Set from pydantic import BaseModel, Field from app.core.memory.models.message_models import DialogData, ConversationMessage, ConversationContext @@ -36,6 +39,23 @@ class DialogExtractionResponse(BaseModel): keywords: List[str] = Field(default_factory=list) +class MessageImportanceResponse(BaseModel): + """消息重要性批量判断的结构化返回(用于LLM语义判断)。 + + - importance_scores: 消息索引到重要性分数的映射 (0-10分) + - reasons: 可选的判断理由 + """ + importance_scores: Dict[int, int] = Field(default_factory=dict, description="消息索引到重要性分数(0-10)的映射") + reasons: Optional[Dict[int, str]] = Field(default_factory=dict, description="可选的判断理由") + + +class QAPair(BaseModel): + """问答对模型,用于识别和保护对话中的问答结构。""" + question_idx: int = Field(..., description="问题消息的索引") + answer_idx: int = Field(..., description="答案消息的索引") + confidence: float = Field(default=1.0, description="问答对的置信度(0-1)") + + class SemanticPruner: """语义剪枝:在预处理与分块之间过滤与场景不相关内容。 @@ -43,109 +63,353 @@ class SemanticPruner: 重要信息(时间、编号、金额、联系方式、地址等)优先保留。 """ - def __init__(self, config: Optional[PruningConfig] = None, llm_client=None): - cfg_dict = get_pruning_config() if config is None else config.model_dump() - self.config = PruningConfig.model_validate(cfg_dict) + def __init__(self, config: Optional[PruningConfig] = None, llm_client=None, language: str = "zh", max_concurrent: int = 5): + # 如果没有提供config,使用默认配置 + if config is None: + # 使用默认的剪枝配置 + config = PruningConfig( + pruning_switch=False, # 默认关闭剪枝,保持向后兼容 + pruning_scene="education", + pruning_threshold=0.5 + ) + + self.config = config self.llm_client = llm_client + self.language = language # 保存语言配置 + self.max_concurrent = max_concurrent # 新增:最大并发数 + # Load Jinja2 template self.template = prompt_env.get_template("extracat_Pruning.jinja2") - # 对话抽取缓存:避免同一对话重复调用 LLM / 重复渲染 - self._dialog_extract_cache: dict[str, DialogExtractionResponse] = {} + + # 对话抽取缓存:使用 OrderedDict 实现 LRU 缓存 + self._dialog_extract_cache: OrderedDict[str, DialogExtractionResponse] = OrderedDict() + self._cache_max_size = 1000 # 缓存大小限制 + # 运行日志:收集关键终端输出,便于写入 JSON self.run_logs: List[str] = [] - # 采用顺序处理,移除并发配置以简化与稳定执行 + + # 扩展的填充词库(包含表情符号和网络用语) + self._extended_fillers = [ + # 基础寒暄 + "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", + "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", + "拜拜", "再见", "88", "拜", "回见", + # 口头禅 + "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", + "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", + # 确认词 + "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", + # 标点和符号 + "。。。", "...", "???", "???", "!!!", "!!!", + # 表情符号(文本形式) + "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", + "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", + "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", + "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", + # 网络用语 + "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", + "emmm", "emm", "em", "mmp", "wtf", "omg", + ] def _is_important_message(self, message: ConversationMessage) -> bool: """基于启发式规则识别重要信息消息,优先保留。 - - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午)。 - - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段。 - - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址"。 + 改进版:增强了规则覆盖范围,包括: + - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午) + - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段 + - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址" + - 新增:问句识别、决策性语句、承诺性语句 """ - import re text = message.msg.strip() if not text: return False + patterns = [ - r"\b\d{4}-\d{1,2}-\d{1,2}\b", - r"\b\d{1,2}:\d{2}\b", + # 原有模式 + r"\d{4}-\d{1,2}-\d{1,2}", # 修复:移除 \b 边界,因为中文前后没有单词边界 + r"\d{1,2}:\d{2}", # 修复:移除 \b r"\d{4}年\d{1,2}月\d{1,2}日", - r"上午|下午|AM|PM", - r"订单号|工单|申请号|编号|ID|账号|账户", - r"电话|手机号|微信|QQ|邮箱", - r"地址|地点", - r"金额|费用|价格|¥|¥|\d+元", - r"时间|日期|有效期|截止", + r"上午|下午|AM|PM|今天|明天|后天|昨天|前天|本周|下周|上周|本月|下月|上月", + r"订单号|工单|申请号|编号|ID|账号|账户|流水号|单号", + r"电话|手机号|微信|QQ|邮箱|联系方式", + r"地址|地点|位置|门牌号", + r"金额|费用|价格|¥|¥|\d+元|人民币|美元|欧元", + r"时间|日期|有效期|截止|期限|到期", + # 新增模式 + r"什么|为什么|怎么|如何|哪里|哪个|谁|多少|几点|何时", # 问句关键词 + r"必须|一定|务必|需要|要求|规定|应该", # 决策性语句 + r"承诺|保证|确保|负责|同意|答应", # 承诺性语句 + r"\d{11}", # 11位手机号 + r"\d{3,4}-\d{7,8}", # 固定电话 + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", # 邮箱 ] + for p in patterns: if re.search(p, text, flags=re.IGNORECASE): return True + + # 检查是否为问句(以问号结尾或包含疑问词) + if text.endswith("?") or text.endswith("?"): + return True + return False + def _importance_score(self, message: ConversationMessage) -> int: """为重要消息打分,用于在保留比例内优先保留更关键的内容。 - 简单启发:匹配到的类别越多、越关键分值越高。 + 改进版:更细致的评分体系(0-10分) """ - import re text = message.msg.strip() score = 0 + weights = [ - (r"\b\d{4}-\d{1,2}-\d{1,2}\b", 3), - (r"\b\d{1,2}:\d{2}\b", 2), + # 高优先级(4-5分) + (r"订单号|工单|申请号|编号|ID|账号|账户", 5), + (r"金额|费用|价格|¥|¥|\d+元", 5), + (r"\d{11}", 4), # 手机号 + (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 + + # 中优先级(2-3分) + (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 修复:移除 \b (r"\d{4}年\d{1,2}月\d{1,2}日", 3), - (r"订单号|工单|申请号|编号|ID|账号|账户", 4), - (r"电话|手机号|微信|QQ|邮箱", 3), - (r"地址|地点", 2), - (r"金额|费用|价格|¥|¥|\d+元", 4), - (r"时间|日期|有效期|截止", 2), + (r"电话|手机号|微信|QQ|联系方式", 3), + (r"地址|地点|位置", 2), + (r"时间|日期|有效期|截止|明天|后天|下周|下月", 2), # 新增时间相关词 + + # 低优先级(1分) + (r"\d{1,2}:\d{2}", 1), # 修复:移除 \b + (r"上午|下午|AM|PM", 1), ] + for p, w in weights: if re.search(p, text, flags=re.IGNORECASE): score += w - return score + + # 问句加分 + if text.endswith("?") or text.endswith("?"): + score += 2 + + # 长度加分(较长的消息通常包含更多信息) + if len(text) > 50: + score += 1 + if len(text) > 100: + score += 1 + + return min(score, 10) # 最高10分 def _is_filler_message(self, message: ConversationMessage) -> bool: """检测典型寒暄/口头禅/确认类短消息,用于跳过LLM分类以加速。 + 改进版:扩展了填充词库,支持表情符号和网络用语 满足以下之一视为填充消息: - - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体; - - 常见词:你好/您好/在吗/嗯/嗯嗯/哦/好的/好/行/可以/不可以/谢谢/拜拜/再见/哈哈/呵呵/哈哈哈/。。。/??。 + - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体 + - 在扩展填充词库中 + - 纯表情符号 """ - import re t = message.msg.strip() if not t: return True - # 常见填充语 - fillers = [ - "你好", "您好", "在吗", "嗯", "嗯嗯", "哦", "好的", "好", "行", "可以", "不可以", "谢谢", - "拜拜", "再见", "哈哈", "呵呵", "哈哈哈", "。。。", "??", "??" - ] - if t in fillers: + + # 检查是否在扩展填充词库中 + if t in self._extended_fillers: return True + + # 检查是否为纯表情符号(方括号包裹) + if re.fullmatch(r"(\[[^\]]+\])+", t): + return True + + # 检查是否为纯emoji(Unicode表情) + emoji_pattern = re.compile( + "[" + "\U0001F600-\U0001F64F" # 表情符号 + "\U0001F300-\U0001F5FF" # 符号和象形文字 + "\U0001F680-\U0001F6FF" # 交通和地图符号 + "\U0001F1E0-\U0001F1FF" # 旗帜 + "\U00002702-\U000027B0" + "\U000024C2-\U0001F251" + "]+", flags=re.UNICODE + ) + if emoji_pattern.fullmatch(t): + return True + # 长度与字符类型判断 if len(t) <= 8: # 非数字、无关键实体的短文本 if not re.search(r"[0-9]", t) and not self._is_important_message(message): # 主要是标点或简单确认词 - if re.fullmatch(r"[。!?,.!?…·\s]+", t) or t in fillers: + if re.fullmatch(r"[。!?,.!?…·\s]+", t): return True + return False + + async def _batch_evaluate_importance_with_llm( + self, + messages: List[ConversationMessage], + context: str = "" + ) -> Dict[int, int]: + """使用LLM批量评估消息的重要性(语义层面)。 + + Args: + messages: 消息列表 + context: 对话上下文(可选) + + Returns: + 消息索引到重要性分数(0-10)的映射 + """ + if not self.llm_client or not messages: + return {} + + # 构建批量评估的提示词 + msg_list = [] + for idx, msg in enumerate(messages): + msg_list.append(f"{idx}. {msg.msg}") + + msg_text = "\n".join(msg_list) + + prompt = f"""请评估以下消息的重要性,给每条消息打分(0-10分): +- 0-2分:无意义的寒暄、口头禅、纯表情 +- 3-5分:一般性对话,有一定信息量但不关键 +- 6-8分:包含重要信息(时间、地点、人物、事件等) +- 9-10分:关键决策、承诺、重要数据 + +对话上下文: +{context if context else "无"} + +待评估的消息: +{msg_text} + +请以JSON格式返回,格式为: +{{ + "importance_scores": {{ + "0": 分数, + "1": 分数, + ... + }} +}} +""" + + try: + messages_for_llm = [ + {"role": "system", "content": "你是一个专业的对话分析助手,擅长评估消息的重要性。"}, + {"role": "user", "content": prompt} + ] + + response = await self.llm_client.response_structured( + messages_for_llm, + MessageImportanceResponse + ) + + # 转换字符串键为整数键 + return {int(k): v for k, v in response.importance_scores.items()} + except Exception as e: + self._log(f"[剪枝-LLM] 批量重要性评估失败: {str(e)[:100]}") + return {} + + def _identify_qa_pairs(self, messages: List[ConversationMessage]) -> List[QAPair]: + """识别对话中的问答对,用于保护问答结构的完整性。 + + Args: + messages: 消息列表 + + Returns: + 问答对列表 + """ + qa_pairs = [] + + for i in range(len(messages) - 1): + current_msg = messages[i].msg.strip() + next_msg = messages[i + 1].msg.strip() + + # 简单规则:如果当前消息是问句,下一条消息可能是答案 + is_question = ( + current_msg.endswith("?") or + current_msg.endswith("?") or + any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗"]) + ) + + if is_question and next_msg: + # 检查下一条消息是否像答案(不是另一个问句) + is_answer = not (next_msg.endswith("?") or next_msg.endswith("?")) + + if is_answer: + qa_pairs.append(QAPair( + question_idx=i, + answer_idx=i + 1, + confidence=0.8 # 基于规则的置信度 + )) + + return qa_pairs + + def _get_protected_indices( + self, + messages: List[ConversationMessage], + qa_pairs: List[QAPair], + window_size: int = 2 + ) -> Set[int]: + """获取需要保护的消息索引集合(问答对+上下文窗口)。 + + Args: + messages: 消息列表 + qa_pairs: 问答对列表 + window_size: 上下文窗口大小(前后各保留几条消息) + + Returns: + 需要保护的消息索引集合 + """ + protected = set() + + for qa_pair in qa_pairs: + # 保护问答对本身 + protected.add(qa_pair.question_idx) + protected.add(qa_pair.answer_idx) + + # 保护上下文窗口 + for offset in range(-window_size, window_size + 1): + q_idx = qa_pair.question_idx + offset + a_idx = qa_pair.answer_idx + offset + + if 0 <= q_idx < len(messages): + protected.add(q_idx) + if 0 <= a_idx < len(messages): + protected.add(a_idx) + + return protected async def _extract_dialog_important(self, dialog_text: str) -> DialogExtractionResponse: """对话级一次性抽取:从整段对话中提取重要信息并判定相关性。 - - 仅使用 LLM 结构化输出; + 改进版: + - LRU缓存管理 + - 重试机制 + - 降级策略 """ # 缓存命中则直接返回(场景+内容作为键) cache_key = f"{self.config.pruning_scene}:" + hashlib.sha1(dialog_text.encode("utf-8")).hexdigest() + + # LRU缓存:如果命中,移到末尾(最近使用) if cache_key in self._dialog_extract_cache: + self._dialog_extract_cache.move_to_end(cache_key) return self._dialog_extract_cache[cache_key] - rendered = self.template.render(pruning_scene=self.config.pruning_scene, dialog_text=dialog_text) - log_template_rendering("extracat_Pruning.jinja2", {"pruning_scene": self.config.pruning_scene}) + # LRU缓存大小限制:超过限制时删除最旧的条目 + if len(self._dialog_extract_cache) >= self._cache_max_size: + # 删除最旧的条目(OrderedDict的第一个) + oldest_key = next(iter(self._dialog_extract_cache)) + del self._dialog_extract_cache[oldest_key] + self._log(f"[剪枝-缓存] LRU缓存已满,删除最旧条目") + + rendered = self.template.render( + pruning_scene=self.config.pruning_scene, + dialog_text=dialog_text, + language=self.language + ) + log_template_rendering("extracat_Pruning.jinja2", { + "pruning_scene": self.config.pruning_scene, + "language": self.language + }) log_prompt_rendering("pruning-extract", rendered) - # 强制使用 LLM;移除正则回退 + # 强制使用 LLM if not self.llm_client: raise RuntimeError("llm_client 未配置;请配置 LLM 以进行结构化抽取。") @@ -153,12 +417,32 @@ class SemanticPruner: {"role": "system", "content": "你是一个严谨的场景抽取助手,只输出严格 JSON。"}, {"role": "user", "content": rendered}, ] - try: - ex = await self.llm_client.response_structured(messages, DialogExtractionResponse) - self._dialog_extract_cache[cache_key] = ex - return ex - except Exception as e: - raise RuntimeError("LLM 结构化抽取失败;请检查 LLM 配置或重试。") from e + + # 重试机制 + max_retries = 3 + for attempt in range(max_retries): + try: + ex = await self.llm_client.response_structured(messages, DialogExtractionResponse) + self._dialog_extract_cache[cache_key] = ex + return ex + except Exception as e: + if attempt < max_retries - 1: + self._log(f"[剪枝-LLM] 第 {attempt + 1} 次尝试失败,重试中... 错误: {str(e)[:100]}") + await asyncio.sleep(0.5 * (attempt + 1)) # 指数退避 + continue + else: + # 降级策略:标记为相关,避免误删 + self._log(f"[剪枝-LLM] LLM 调用失败 {max_retries} 次,使用降级策略(标记为相关)") + fallback_response = DialogExtractionResponse( + is_related=True, + times=[], + ids=[], + amounts=[], + contacts=[], + addresses=[], + keywords=[] + ) + return fallback_response def _msg_matches_tokens(self, message: ConversationMessage, tokens: List[str]) -> bool: """判断消息是否包含任意抽取到的重要片段。""" @@ -248,12 +532,15 @@ class SemanticPruner: async def prune_dataset(self, dialogs: List[DialogData]) -> List[DialogData]: """数据集层面:全局消息级剪枝,保留所有对话。 - - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动。 - - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留。 - - 删除总量 = 阈值 * 全部不相关可删消息数,按可删容量比例分配;顺序删除。 - - 保证每段对话至少保留1条消息,不会删除整段对话。 + 改进版: + - 并发处理对话级相关性判断 + - 问答对识别和保护 + - 优化删除策略,保持上下文连贯性 + - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动 + - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留 + - 保证每段对话至少保留1条消息,不会删除整段对话 """ - # 如果剪枝功能关闭,直接返回原始数据集。 + # 如果剪枝功能关闭,直接返回原始数据集 if not self.config.pruning_switch: return dialogs @@ -264,29 +551,36 @@ class SemanticPruner: proportion = 0.9 if proportion < 0.0: proportion = 0.0 - evaluated_dialogs = [] # list of dicts: {dialog, is_related} self._log( f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch}" ) - # 对话级相关性分类(一次性对整段对话文本进行判断,顺序执行并复用缓存) - evaluated_dialogs = [] - for idx, dd in enumerate(dialogs): - try: - ex = await self._extract_dialog_important(dd.content) - evaluated_dialogs.append({ - "dialog": dd, - "is_related": bool(ex.is_related), - "index": idx, - "extraction": ex - }) - except Exception: - evaluated_dialogs.append({ - "dialog": dd, - "is_related": True, - "index": idx, - "extraction": None - }) + + # 并发处理对话级相关性分类 + semaphore = asyncio.Semaphore(self.max_concurrent) + + async def classify_dialog(idx: int, dd: DialogData): + async with semaphore: + try: + ex = await self._extract_dialog_important(dd.content) + return { + "dialog": dd, + "is_related": bool(ex.is_related), + "index": idx, + "extraction": ex + } + except Exception as e: + self._log(f"[剪枝-并发] 对话 {idx} 分类失败: {str(e)[:100]}") + return { + "dialog": dd, + "is_related": True, # 失败时标记为相关,避免误删 + "index": idx, + "extraction": None + } + + # 并发执行所有对话的分类 + tasks = [classify_dialog(idx, dd) for idx, dd in enumerate(dialogs)] + evaluated_dialogs = await asyncio.gather(*tasks) # 统计相关 / 不相关对话 not_related_dialogs = [d for d in evaluated_dialogs if not d["is_related"]] @@ -300,7 +594,6 @@ class SemanticPruner: inds = [i["index"] + 1 for i in items] if len(inds) <= cap: return inds - # 超过上限时只打印前cap个,并标注总数 return inds[:cap] + ["...", f"共{len(inds)}个"] rel_inds = _fmt_indices(related_dialogs) @@ -309,59 +602,83 @@ class SemanticPruner: result: List[DialogData] = [] if not_related_dialogs: - # 为每个不相关对话进行一次性抽取,识别重要/不重要(避免逐条 LLM) + # 为每个不相关对话进行分析 per_dialog_info = {} total_unrelated = 0 - total_capacity = 0 + for d in not_related_dialogs: dd = d["dialog"] extraction = d.get("extraction") if extraction is None: extraction = await self._extract_dialog_important(dd.content) + # 合并所有重要标记 tokens = extraction.times + extraction.ids + extraction.amounts + extraction.contacts + extraction.addresses + extraction.keywords msgs = dd.context.msgs - # 分类消息 - imp_unrel_msgs = [m for m in msgs if self._msg_matches_tokens(m, tokens) or self._is_important_message(m)] - unimp_unrel_msgs = [m for m in msgs if m not in imp_unrel_msgs] + + # 识别问答对 + qa_pairs = self._identify_qa_pairs(msgs) + protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=1) + + # 分类消息(考虑问答对保护) + imp_unrel_msgs = [] + unimp_unrel_msgs = [] + + for idx, m in enumerate(msgs): + # 问答对中的消息自动标记为重要 + if idx in protected_indices: + imp_unrel_msgs.append((idx, m)) + elif self._msg_matches_tokens(m, tokens) or self._is_important_message(m): + imp_unrel_msgs.append((idx, m)) + elif not self._is_filler_message(m): + unimp_unrel_msgs.append((idx, m)) + # 填充消息不加入任何列表,优先删除 + # 重要消息按重要性排序 - imp_sorted_ids = [id(m) for m in sorted(imp_unrel_msgs, key=lambda m: self._importance_score(m))] + imp_sorted = sorted(imp_unrel_msgs, key=lambda x: self._importance_score(x[1])) + imp_sorted_ids = [id(m) for _, m in imp_sorted] + info = { "dialog": dd, "total_msgs": len(msgs), "unrelated_count": len(msgs), "imp_ids_sorted": imp_sorted_ids, - "unimp_ids": [id(m) for m in unimp_unrel_msgs], + "unimp_ids": [id(m) for _, m in unimp_unrel_msgs], + "protected_indices": protected_indices, + "qa_pairs_count": len(qa_pairs), } per_dialog_info[d["index"]] = info total_unrelated += info["unrelated_count"] - # 全局删除配额:比例作用于全部不相关消息(重要+不重要) + + # 全局删除配额计算 global_delete = int(total_unrelated * proportion) if proportion > 0 and total_unrelated > 0 and global_delete == 0: global_delete = 1 - # 每段的最大可删容量:不重要全部 + 重要最多删除 floor(len(重要)*比例),且至少保留1条消息 + + # 每段的最大可删容量 capacities = [] for d in not_related_dialogs: idx = d["index"] info = per_dialog_info[idx] - # 统计重要数量 imp_count = len(info["imp_ids_sorted"]) unimp_count = len(info["unimp_ids"]) imp_cap = int(imp_count * proportion) cap = min(unimp_count + imp_cap, max(0, info["total_msgs"] - 1)) capacities.append(cap) + total_capacity = sum(capacities) if global_delete > total_capacity: - print(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}(重要消息按比例保留)。将按最大可删执行。") + self._log(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}。将按最大可删执行。") global_delete = total_capacity - # 配额分配:按不相关消息占比分配到各对话,但不超过各自容量 + # 配额分配 alloc = [] for i, d in enumerate(not_related_dialogs): idx = d["index"] info = per_dialog_info[idx] share = int(global_delete * (info["unrelated_count"] / total_unrelated)) if total_unrelated > 0 else 0 alloc.append(min(share, capacities[i])) + allocated = sum(alloc) rem = global_delete - allocated turn = 0 @@ -378,34 +695,40 @@ class SemanticPruner: break turn += 1 - # 应用删除:相关对话不动;不相关按分配先删不重要,再删重要(低分优先) + # 应用删除 total_deleted_confirm = 0 for d in evaluated_dialogs: dd = d["dialog"] msgs = dd.context.msgs original = len(msgs) + if d["is_related"]: result.append(dd) continue + idx_in_unrel = next((k for k, x in enumerate(not_related_dialogs) if x["index"] == d["index"]), None) if idx_in_unrel is None: result.append(dd) continue + quota = alloc[idx_in_unrel] info = per_dialog_info[d["index"]] - # 计算本对话重要最多可删数量 + + # 计算删除ID imp_count = len(info["imp_ids_sorted"]) imp_del_cap = int(imp_count * proportion) - # 先构造顺序删除的"不重要ID集合"(按出现顺序前 quota 条) + unimp_delete_ids = set(info["unimp_ids"][:min(quota, len(info["unimp_ids"]))]) del_unimp = min(quota, len(unimp_delete_ids)) rem_quota = quota - del_unimp - # 再从重要里选低分优先的删除ID(不超过 imp_del_cap) + imp_delete_ids = set(info["imp_ids_sorted"][:min(rem_quota, imp_del_cap)]) + deleted_here = 0 actual_unimp_deleted = 0 actual_imp_deleted = 0 kept = [] + for m in msgs: mid = id(m) if mid in unimp_delete_ids and actual_unimp_deleted < del_unimp: @@ -417,26 +740,30 @@ class SemanticPruner: deleted_here += 1 continue kept.append(m) + if not kept and msgs: kept = [msgs[0]] + dd.context.msgs = kept total_deleted_confirm += deleted_here + + qa_info = f",问答对={info['qa_pairs_count']}" if info['qa_pairs_count'] > 0 else "" self._log( - f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}" + f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}{qa_info}" ) result.append(dd) - self._log(f"[剪枝-数据集] 全局消息级顺序剪枝完成,总删除 {total_deleted_confirm} 条(不相关消息,重要按比例保留)。") + + self._log(f"[剪枝-数据集] 全局消息级剪枝完成,总删除 {total_deleted_confirm} 条(保护问答对和上下文)。") else: - # 全部相关:不执行剪枝 result = [d["dialog"] for d in evaluated_dialogs] + self._log(f"[剪枝-数据集] 剩余对话数={len(result)}") - # 将本次剪枝阶段的终端输出保存为 JSON 文件(仅在剪枝器内部完成) + # 保存日志 try: from app.core.config import settings settings.ensure_memory_output_dir() log_output_path = settings.get_memory_output_path("pruned_terminal.json") - # 去除日志前缀标签(如 [剪枝-数据集]、[剪枝-对话])后再解析为结构化字段保存 sanitized_logs = [self._sanitize_log_line(l) for l in self.run_logs] payload = self._parse_logs_to_structured(sanitized_logs) with open(log_output_path, "w", encoding="utf-8") as f: @@ -448,6 +775,7 @@ class SemanticPruner: if not result: print("警告: 语义剪枝后数据集为空,已回退为未剪枝数据以避免流程中断") return dialogs + return result def _log(self, msg: str) -> None: From 0655ff4a9133322592bdd69cb3feb807d14f1765 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Fri, 27 Feb 2026 16:45:34 +0800 Subject: [PATCH 43/55] [fix]Correct the flaws existing in the semantic segmentation method --- .../knowledge_extraction/chunk_extraction.py | 205 ++++++++++++++---- 1 file changed, 160 insertions(+), 45 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py index 40e98507..bbbf1c51 100644 --- a/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py +++ b/api/app/core/memory/storage_services/extraction_engine/knowledge_extraction/chunk_extraction.py @@ -1,5 +1,7 @@ import os -from typing import Optional +from typing import Optional, List, Any +from enum import Enum +from pathlib import Path from app.core.logging_config import get_memory_logger from app.core.memory.models.message_models import DialogData, Chunk @@ -10,6 +12,20 @@ from app.core.memory.utils.config.config_utils import get_chunker_config logger = get_memory_logger(__name__) +class ChunkerStrategy(Enum): + """Supported chunking strategies.""" + RECURSIVE = "RecursiveChunker" + SEMANTIC = "SemanticChunker" + LATE = "LateChunker" + NEURAL = "NeuralChunker" + LLM = "LLMChunker" + + @classmethod + def get_valid_strategies(cls) -> List[str]: + """Get list of valid strategy names.""" + return [strategy.value for strategy in cls] + + class DialogueChunker: """A class that processes dialogues and fills them with chunks based on a specified strategy. @@ -17,23 +33,51 @@ class DialogueChunker: of different chunking strategies to dialogue data. """ - def __init__(self, chunker_strategy: str = "RecursiveChunker", llm_client=None): + def __init__(self, chunker_strategy: str = "RecursiveChunker", llm_client: Optional[Any] = None): """Initialize the DialogueChunker with a specific chunking strategy. Args: chunker_strategy: The chunking strategy to use (default: RecursiveChunker) - Options: SemanticChunker, RecursiveChunker, LateChunker, NeuralChunker + Options: SemanticChunker, RecursiveChunker, LateChunker, NeuralChunker, LLMChunker + llm_client: LLM client instance (required for LLMChunker strategy) + + Raises: + ValueError: If chunker_strategy is invalid or required parameters are missing """ - self.chunker_strategy = chunker_strategy - chunker_config_dict = get_chunker_config(chunker_strategy) - self.chunker_config = ChunkerConfig.model_validate(chunker_config_dict) + # Validate strategy + valid_strategies = ChunkerStrategy.get_valid_strategies() + if chunker_strategy not in valid_strategies: + raise ValueError( + f"Invalid chunker_strategy: '{chunker_strategy}'. " + f"Must be one of {valid_strategies}" + ) - if self.chunker_config.chunker_strategy == "LLMChunker": - self.chunker_client = ChunkerClient(self.chunker_config, llm_client) - else: - self.chunker_client = ChunkerClient(self.chunker_config) + self.chunker_strategy = chunker_strategy + logger.info(f"Initializing DialogueChunker with strategy: {chunker_strategy}") + + try: + # Load and validate configuration + chunker_config_dict = get_chunker_config(chunker_strategy) + if not chunker_config_dict: + raise ValueError(f"Failed to load configuration for strategy: {chunker_strategy}") + + self.chunker_config = ChunkerConfig.model_validate(chunker_config_dict) + + # Initialize chunker client + if self.chunker_config.chunker_strategy == "LLMChunker": + if not llm_client: + raise ValueError("llm_client is required for LLMChunker strategy") + self.chunker_client = ChunkerClient(self.chunker_config, llm_client) + else: + self.chunker_client = ChunkerClient(self.chunker_config) + + logger.info(f"DialogueChunker initialized successfully with strategy: {chunker_strategy}") + + except Exception as e: + logger.error(f"Failed to initialize DialogueChunker: {e}", exc_info=True) + raise - async def process_dialogue(self, dialogue: DialogData) -> list[Chunk]: + async def process_dialogue(self, dialogue: DialogData) -> List[Chunk]: """Process a dialogue by generating chunks and adding them to the DialogData object. Args: @@ -43,54 +87,125 @@ class DialogueChunker: A list of Chunk objects Raises: - ValueError: If chunking fails or returns empty chunks + ValueError: If dialogue is invalid or chunking fails + Exception: If chunking process encounters an error """ - result_dialogue = await self.chunker_client.generate_chunks(dialogue) - chunks = result_dialogue.chunks - - if not chunks or len(chunks) == 0: + # Validate input + if not dialogue: + raise ValueError("dialogue cannot be None") + + if not dialogue.context or not dialogue.context.msgs: raise ValueError( - f"Chunking failed: No chunks generated for dialogue {dialogue.ref_id}. " - f"Messages: {len(dialogue.context.msgs) if dialogue.context else 0}, " - f"Strategy: {self.chunker_config.chunker_strategy}" + f"Dialogue {dialogue.ref_id} has no messages to chunk. " + f"Context: {dialogue.context is not None}, " + f"Messages: {len(dialogue.context.msgs) if dialogue.context else 0}" ) + + logger.info( + f"Processing dialogue {dialogue.ref_id} with {len(dialogue.context.msgs)} messages " + f"using strategy: {self.chunker_strategy}" + ) + + try: + # Generate chunks + result_dialogue = await self.chunker_client.generate_chunks(dialogue) + chunks = result_dialogue.chunks - return chunks + # Validate results + if not chunks or len(chunks) == 0: + raise ValueError( + f"Chunking failed: No chunks generated for dialogue {dialogue.ref_id}. " + f"Messages: {len(dialogue.context.msgs)}, " + f"Content length: {len(dialogue.content) if dialogue.content else 0}, " + f"Strategy: {self.chunker_config.chunker_strategy}" + ) - def save_chunking_results(self, dialogue: DialogData, output_path: Optional[str] = None) -> str: + logger.info( + f"Successfully generated {len(chunks)} chunks for dialogue {dialogue.ref_id}. " + f"Total characters processed: {len(dialogue.content) if dialogue.content else 0}" + ) + + return chunks + + except ValueError: + # Re-raise validation errors + raise + except Exception as e: + logger.error( + f"Error processing dialogue {dialogue.ref_id} with strategy {self.chunker_strategy}: {e}", + exc_info=True + ) + raise + + def save_chunking_results( + self, + chunks: List[Chunk], + dialogue: DialogData, + output_path: Optional[str] = None, + preview_length: int = 100 + ) -> str: """Save the chunking results to a file and return the output path. Args: - dialogue: The processed DialogData object with chunks - output_path: Optional path to save the output + chunks: List of Chunk objects to save + dialogue: The DialogData object that was processed + output_path: Optional path to save the output (defaults to current directory) + preview_length: Maximum length of content preview (default: 100) Returns: The path where the output was saved + + Raises: + ValueError: If chunks or dialogue is invalid + IOError: If file writing fails """ - if not output_path: - output_path = os.path.join( - os.path.dirname(__file__), "..", "..", - f"chunker_output_{self.chunker_strategy.lower()}.txt" - ) - - output_lines = [ - f"=== Chunking Results ({self.chunker_strategy}) ===", - f"Dialogue ID: {dialogue.ref_id}", - f"Original conversation has {len(dialogue.context.msgs)} messages", - f"Total characters: {len(dialogue.content)}", - f"Generated {len(dialogue.chunks)} chunks:" - ] + # Validate input + if not chunks: + raise ValueError("chunks list cannot be empty") + if not dialogue: + raise ValueError("dialogue cannot be None") - for i, chunk in enumerate(dialogue.chunks): - output_lines.append(f" Chunk {i+1}: {len(chunk.content)} characters") - output_lines.append(f" Content preview: {chunk.content}...") - if chunk.metadata: - output_lines.append(f" Metadata: {chunk.metadata}") + # Generate default output path if not provided + if not output_path: + output_dir = Path(__file__).parent.parent.parent + output_path = str(output_dir / f"chunker_output_{self.chunker_strategy.lower()}.txt") + + logger.info(f"Saving chunking results to: {output_path}") + + try: + # Prepare output content + output_lines = [ + f"=== Chunking Results ({self.chunker_strategy}) ===", + f"Dialogue ID: {dialogue.ref_id}", + f"Original conversation has {len(dialogue.context.msgs) if dialogue.context else 0} messages", + f"Total characters: {len(dialogue.content) if dialogue.content else 0}", + f"Generated {len(chunks)} chunks:", + "" + ] + + for i, chunk in enumerate(chunks, 1): + content_preview = chunk.content[:preview_length] if chunk.content else "" + if len(chunk.content) > preview_length: + content_preview += "..." + + output_lines.append(f" Chunk {i}: {len(chunk.content)} characters") + output_lines.append(f" Content preview: {content_preview}") + if chunk.metadata: + output_lines.append(f" Metadata: {chunk.metadata}") + output_lines.append("") - with open(output_path, "w", encoding="utf-8") as f: - f.write("\n".join(output_lines)) + # Write to file + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(output_lines)) - logger.info(f"Chunking results saved to: {output_path}") - return output_path + logger.info(f"Successfully saved chunking results to: {output_path}") + return output_path + + except IOError as e: + logger.error(f"Failed to write chunking results to {output_path}: {e}", exc_info=True) + raise + except Exception as e: + logger.error(f"Unexpected error saving chunking results: {e}", exc_info=True) + raise From 96590941cf81e1de21c54d411c890cb1d8195f51 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 17:18:42 +0800 Subject: [PATCH 44/55] [add]The semantic pruning function is activated, removing the protection of question-answer pairs. --- .../core/memory/agent/utils/get_dialogs.py | 57 +- .../data_preprocessing/data_pruning.py | 511 ++++++++---------- .../data_preprocessing/scene_config.py | 326 +++++++++++ .../extraction_orchestrator.py | 16 +- 4 files changed, 619 insertions(+), 291 deletions(-) create mode 100644 api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py diff --git a/api/app/core/memory/agent/utils/get_dialogs.py b/api/app/core/memory/agent/utils/get_dialogs.py index bfb0f675..22555fff 100644 --- a/api/app/core/memory/agent/utils/get_dialogs.py +++ b/api/app/core/memory/agent/utils/get_dialogs.py @@ -21,7 +21,7 @@ async def get_chunked_dialogs( end_user_id: Group identifier messages: Structured message list [{"role": "user", "content": "..."}, ...] ref_id: Reference identifier - config_id: Configuration ID for processing + config_id: Configuration ID for processing (used to load pruning config) Returns: List of DialogData objects with generated chunks @@ -57,6 +57,61 @@ async def get_chunked_dialogs( end_user_id=end_user_id, config_id=config_id ) + + # 语义剪枝步骤(在分块之前) + try: + from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import SemanticPruner + from app.core.memory.models.config_models import PruningConfig + from app.db import get_db_context + from app.services.memory_config_service import MemoryConfigService + from app.core.memory.utils.llm.llm_utils import MemoryClientFactory + + # 加载剪枝配置 + pruning_config = None + if config_id: + try: + with get_db_context() as db: + # 使用 MemoryConfigService 加载完整的 MemoryConfig 对象 + config_service = MemoryConfigService(db) + memory_config = config_service.load_memory_config( + config_id=config_id, + service_name="semantic_pruning" + ) + + if memory_config: + pruning_config = PruningConfig( + pruning_switch=memory_config.pruning_enabled, + pruning_scene=memory_config.pruning_scene or "education", + pruning_threshold=memory_config.pruning_threshold + ) + logger.info(f"[剪枝] 加载配置: switch={pruning_config.pruning_switch}, scene={pruning_config.pruning_scene}, threshold={pruning_config.pruning_threshold}") + + # 获取LLM客户端用于剪枝 + if pruning_config.pruning_switch: + factory = MemoryClientFactory(db) + llm_client = factory.get_llm_client_from_config(memory_config) + + # 执行剪枝 - 使用 prune_dataset 支持消息级剪枝 + pruner = SemanticPruner(config=pruning_config, llm_client=llm_client) + original_msg_count = len(dialog_data.context.msgs) + + # 使用 prune_dataset 而不是 prune_dialog + # prune_dataset 会进行消息级剪枝,即使对话整体相关也会删除不重要消息 + pruned_dialogs = await pruner.prune_dataset([dialog_data]) + + if pruned_dialogs: + dialog_data = pruned_dialogs[0] + remaining_msg_count = len(dialog_data.context.msgs) + deleted_count = original_msg_count - remaining_msg_count + logger.info(f"[剪枝] 完成: 原始{original_msg_count}条 -> 保留{remaining_msg_count}条 (删除{deleted_count}条)") + else: + logger.warning("[剪枝] prune_dataset 返回空列表") + else: + logger.info("[剪枝] 配置中剪枝开关关闭,跳过剪枝") + except Exception as e: + logger.warning(f"[剪枝] 加载配置失败,跳过剪枝: {e}", exc_info=True) + except Exception as e: + logger.warning(f"[剪枝] 执行失败,跳过剪枝: {e}", exc_info=True) chunker = DialogueChunker(chunker_strategy) extracted_chunks = await chunker.process_dialogue(dialog_data) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index 2d0142c6..d932c542 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -22,6 +22,10 @@ from app.core.memory.models.message_models import DialogData, ConversationMessag from app.core.memory.models.config_models import PruningConfig from app.core.memory.utils.config.config_utils import get_pruning_config from app.core.memory.utils.prompt.prompt_utils import prompt_env, log_prompt_rendering, log_template_rendering +from app.core.memory.storage_services.extraction_engine.data_preprocessing.scene_config import ( + SceneConfigRegistry, + ScenePatterns +) class DialogExtractionResponse(BaseModel): @@ -78,6 +82,20 @@ class SemanticPruner: self.language = language # 保存语言配置 self.max_concurrent = max_concurrent # 新增:最大并发数 + # 加载场景特定配置 + self.scene_config: ScenePatterns = SceneConfigRegistry.get_config( + self.config.pruning_scene, + fallback_to_generic=True + ) + + # 检查场景是否有专门支持 + is_supported = SceneConfigRegistry.is_scene_supported(self.config.pruning_scene) + if is_supported: + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 使用专门配置") + else: + self._log(f"[剪枝-初始化] 场景={self.config.pruning_scene} 未预定义,使用通用配置(保守策略)") + self._log(f"[剪枝-初始化] 支持的场景: {SceneConfigRegistry.get_all_scenes()}") + # Load Jinja2 template self.template = prompt_env.get_template("extracat_Pruning.jinja2") @@ -87,108 +105,80 @@ class SemanticPruner: # 运行日志:收集关键终端输出,便于写入 JSON self.run_logs: List[str] = [] - - # 扩展的填充词库(包含表情符号和网络用语) - self._extended_fillers = [ - # 基础寒暄 - "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", - "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", - "拜拜", "再见", "88", "拜", "回见", - # 口头禅 - "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", - "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", - # 确认词 - "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", - # 标点和符号 - "。。。", "...", "???", "???", "!!!", "!!!", - # 表情符号(文本形式) - "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", - "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", - "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", - "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", - # 网络用语 - "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", - "emmm", "emm", "em", "mmp", "wtf", "omg", - ] def _is_important_message(self, message: ConversationMessage) -> bool: """基于启发式规则识别重要信息消息,优先保留。 - 改进版:增强了规则覆盖范围,包括: - - 含日期/时间(如YYYY-MM-DD、HH:MM、2024年11月10日、上午/下午) - - 含编号/ID/订单号/申请号/账号/电话/金额等关键字段 - - 关键词:"时间"、"日期"、"编号"、"订单"、"流水"、"金额"、"¥"、"元"、"电话"、"手机号"、"邮箱"、"地址" - - 新增:问句识别、决策性语句、承诺性语句 + 改进版:使用场景特定的模式进行识别 + - 根据 pruning_scene 动态加载对应的识别规则 + - 支持教育、在线服务、外呼三个场景的特定模式 """ text = message.msg.strip() if not text: return False - patterns = [ - # 原有模式 - r"\d{4}-\d{1,2}-\d{1,2}", # 修复:移除 \b 边界,因为中文前后没有单词边界 - r"\d{1,2}:\d{2}", # 修复:移除 \b - r"\d{4}年\d{1,2}月\d{1,2}日", - r"上午|下午|AM|PM|今天|明天|后天|昨天|前天|本周|下周|上周|本月|下月|上月", - r"订单号|工单|申请号|编号|ID|账号|账户|流水号|单号", - r"电话|手机号|微信|QQ|邮箱|联系方式", - r"地址|地点|位置|门牌号", - r"金额|费用|价格|¥|¥|\d+元|人民币|美元|欧元", - r"时间|日期|有效期|截止|期限|到期", - # 新增模式 - r"什么|为什么|怎么|如何|哪里|哪个|谁|多少|几点|何时", # 问句关键词 - r"必须|一定|务必|需要|要求|规定|应该", # 决策性语句 - r"承诺|保证|确保|负责|同意|答应", # 承诺性语句 - r"\d{11}", # 11位手机号 - r"\d{3,4}-\d{7,8}", # 固定电话 - r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", # 邮箱 - ] + # 使用场景特定的模式 + all_patterns = ( + self.scene_config.high_priority_patterns + + self.scene_config.medium_priority_patterns + + self.scene_config.low_priority_patterns + ) - for p in patterns: - if re.search(p, text, flags=re.IGNORECASE): + for pattern, _ in all_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): return True # 检查是否为问句(以问号结尾或包含疑问词) if text.endswith("?") or text.endswith("?"): return True + + # 检查是否包含问句关键词 + if any(keyword in text for keyword in self.scene_config.question_keywords): + return True + + # 检查是否包含决策性关键词 + if any(keyword in text for keyword in self.scene_config.decision_keywords): + return True return False def _importance_score(self, message: ConversationMessage) -> int: """为重要消息打分,用于在保留比例内优先保留更关键的内容。 - 改进版:更细致的评分体系(0-10分) + 改进版:使用场景特定的权重体系(0-10分) + - 根据场景动态调整不同信息类型的权重 + - 高优先级模式:4-6分 + - 中优先级模式:2-3分 + - 低优先级模式:1分 """ text = message.msg.strip() score = 0 - weights = [ - # 高优先级(4-5分) - (r"订单号|工单|申请号|编号|ID|账号|账户", 5), - (r"金额|费用|价格|¥|¥|\d+元", 5), - (r"\d{11}", 4), # 手机号 - (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 - - # 中优先级(2-3分) - (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 修复:移除 \b - (r"\d{4}年\d{1,2}月\d{1,2}日", 3), - (r"电话|手机号|微信|QQ|联系方式", 3), - (r"地址|地点|位置", 2), - (r"时间|日期|有效期|截止|明天|后天|下周|下月", 2), # 新增时间相关词 - - # 低优先级(1分) - (r"\d{1,2}:\d{2}", 1), # 修复:移除 \b - (r"上午|下午|AM|PM", 1), - ] + # 使用场景特定的权重 + for pattern, weight in self.scene_config.high_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight - for p, w in weights: - if re.search(p, text, flags=re.IGNORECASE): - score += w + for pattern, weight in self.scene_config.medium_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight + + for pattern, weight in self.scene_config.low_priority_patterns: + if re.search(pattern, text, flags=re.IGNORECASE): + score += weight # 问句加分 if text.endswith("?") or text.endswith("?"): score += 2 + # 包含问句关键词加分 + if any(keyword in text for keyword in self.scene_config.question_keywords): + score += 1 + + # 包含决策性关键词加分 + if any(keyword in text for keyword in self.scene_config.decision_keywords): + score += 2 + # 长度加分(较长的消息通常包含更多信息) if len(text) > 50: score += 1 @@ -198,20 +188,35 @@ class SemanticPruner: return min(score, 10) # 最高10分 def _is_filler_message(self, message: ConversationMessage) -> bool: - """检测典型寒暄/口头禅/确认类短消息,用于跳过LLM分类以加速。 + """检测典型寒暄/口头禅/确认类短消息。 - 改进版:扩展了填充词库,支持表情符号和网络用语 + 改进版:更严格的填充消息判断,避免误删场景相关内容 满足以下之一视为填充消息: - - 纯标点或长度很短(<= 4 个汉字或 <= 8 个字符)且不包含数字或关键实体 - - 在扩展填充词库中 + - 纯标点或空白 + - 在场景特定填充词库中(精确匹配) - 纯表情符号 + - 常见寒暄(精确匹配短语) + + 注意:不再使用长度判断,避免误删短但重要的消息 """ t = message.msg.strip() if not t: return True - # 检查是否在扩展填充词库中 - if t in self._extended_fillers: + # 检查是否在场景特定填充词库中(精确匹配) + if t in self.scene_config.filler_phrases: + return True + + # 常见寒暄和问候(精确匹配,避免误删) + common_greetings = { + "在吗", "在不在", "在呢", "在的", + "你好", "您好", "hello", "hi", + "拜拜", "再见", "拜", "88", "bye", + "好的", "好", "行", "可以", "嗯", "哦", "啊", + "是的", "对", "对的", "没错", "是啊", + "哈哈", "呵呵", "嘿嘿", "嗯嗯" + } + if t in common_greetings: return True # 检查是否为纯表情符号(方括号包裹) @@ -232,13 +237,9 @@ class SemanticPruner: if emoji_pattern.fullmatch(t): return True - # 长度与字符类型判断 - if len(t) <= 8: - # 非数字、无关键实体的短文本 - if not re.search(r"[0-9]", t) and not self._is_important_message(message): - # 主要是标点或简单确认词 - if re.fullmatch(r"[。!?,.!?…·\s]+", t): - return True + # 纯标点符号 + if re.fullmatch(r"[。!?,.!?…·\s]+", t): + return True return False @@ -308,6 +309,8 @@ class SemanticPruner: def _identify_qa_pairs(self, messages: List[ConversationMessage]) -> List[QAPair]: """识别对话中的问答对,用于保护问答结构的完整性。 + 改进版:使用场景特定的问句关键词,并排除寒暄类问句 + Args: messages: 消息列表 @@ -316,21 +319,39 @@ class SemanticPruner: """ qa_pairs = [] + # 寒暄类问句,不应该被保护(这些不是真正的问答) + greeting_questions = { + "在吗", "在不在", "你好吗", "怎么样", "好吗", + "有空吗", "忙吗", "睡了吗", "起床了吗" + } + for i in range(len(messages) - 1): current_msg = messages[i].msg.strip() next_msg = messages[i + 1].msg.strip() - # 简单规则:如果当前消息是问句,下一条消息可能是答案 - is_question = ( - current_msg.endswith("?") or - current_msg.endswith("?") or - any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗"]) - ) + # 排除寒暄类问句 + if current_msg in greeting_questions: + continue + + # 使用场景特定的问句关键词,但要求更严格 + is_question = False + + # 1. 以问号结尾 + if current_msg.endswith("?") or current_msg.endswith("?"): + is_question = True + # 2. 包含实质性问句关键词(排除"吗"这种太宽泛的) + elif any(word in current_msg for word in ["什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时"]): + is_question = True if is_question and next_msg: - # 检查下一条消息是否像答案(不是另一个问句) + # 检查下一条消息是否像答案(不是另一个问句,也不是寒暄) is_answer = not (next_msg.endswith("?") or next_msg.endswith("?")) + # 排除寒暄类回复 + greeting_answers = {"你好", "您好", "在呢", "在的", "嗯", "哦", "好的"} + if next_msg in greeting_answers: + is_answer = False + if is_answer: qa_pairs.append(QAPair( question_idx=i, @@ -533,10 +554,9 @@ class SemanticPruner: """数据集层面:全局消息级剪枝,保留所有对话。 改进版: - - 并发处理对话级相关性判断 - - 问答对识别和保护 - - 优化删除策略,保持上下文连贯性 - - 仅在"不相关对话"的范围内执行消息剪枝;相关对话不动 + - 消息级独立判断,每条消息根据场景规则独立评估 + - 问答对保护已注释(暂不启用,留作观察) + - 优化删除策略:填充消息 → 不重要消息 → 低分重要消息 - 只删除"不重要的不相关消息",重要信息(时间、编号等)强制保留 - 保证每段对话至少保留1条消息,不会删除整段对话 """ @@ -553,209 +573,122 @@ class SemanticPruner: proportion = 0.0 self._log( - f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch}" + f"[剪枝-数据集] 对话总数={len(dialogs)} 场景={self.config.pruning_scene} 删除比例={proportion} 开关={self.config.pruning_switch} 模式=消息级独立判断" ) - # 并发处理对话级相关性分类 - semaphore = asyncio.Semaphore(self.max_concurrent) - - async def classify_dialog(idx: int, dd: DialogData): - async with semaphore: - try: - ex = await self._extract_dialog_important(dd.content) - return { - "dialog": dd, - "is_related": bool(ex.is_related), - "index": idx, - "extraction": ex - } - except Exception as e: - self._log(f"[剪枝-并发] 对话 {idx} 分类失败: {str(e)[:100]}") - return { - "dialog": dd, - "is_related": True, # 失败时标记为相关,避免误删 - "index": idx, - "extraction": None - } - - # 并发执行所有对话的分类 - tasks = [classify_dialog(idx, dd) for idx, dd in enumerate(dialogs)] - evaluated_dialogs = await asyncio.gather(*tasks) - - # 统计相关 / 不相关对话 - not_related_dialogs = [d for d in evaluated_dialogs if not d["is_related"]] - related_dialogs = [d for d in evaluated_dialogs if d["is_related"]] - self._log( - f"[剪枝-数据集] 相关对话数={len(related_dialogs)} 不相关对话数={len(not_related_dialogs)}" - ) - - # 简洁打印第几段对话相关/不相关(索引基于1) - def _fmt_indices(items, cap: int = 10): - inds = [i["index"] + 1 for i in items] - if len(inds) <= cap: - return inds - return inds[:cap] + ["...", f"共{len(inds)}个"] - - rel_inds = _fmt_indices(related_dialogs) - nrel_inds = _fmt_indices(not_related_dialogs) - self._log(f"[剪枝-数据集] 相关对话:第{rel_inds}段;不相关对话:第{nrel_inds}段") - result: List[DialogData] = [] - if not_related_dialogs: - # 为每个不相关对话进行分析 - per_dialog_info = {} - total_unrelated = 0 + total_original_msgs = 0 + total_deleted_msgs = 0 + + for d_idx, dd in enumerate(dialogs): + msgs = dd.context.msgs + original_count = len(msgs) + total_original_msgs += original_count - for d in not_related_dialogs: - dd = d["dialog"] - extraction = d.get("extraction") - if extraction is None: - extraction = await self._extract_dialog_important(dd.content) - - # 合并所有重要标记 - tokens = extraction.times + extraction.ids + extraction.amounts + extraction.contacts + extraction.addresses + extraction.keywords - msgs = dd.context.msgs - - # 识别问答对 - qa_pairs = self._identify_qa_pairs(msgs) - protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=1) - - # 分类消息(考虑问答对保护) - imp_unrel_msgs = [] - unimp_unrel_msgs = [] - - for idx, m in enumerate(msgs): - # 问答对中的消息自动标记为重要 - if idx in protected_indices: - imp_unrel_msgs.append((idx, m)) - elif self._msg_matches_tokens(m, tokens) or self._is_important_message(m): - imp_unrel_msgs.append((idx, m)) - elif not self._is_filler_message(m): - unimp_unrel_msgs.append((idx, m)) - # 填充消息不加入任何列表,优先删除 - - # 重要消息按重要性排序 - imp_sorted = sorted(imp_unrel_msgs, key=lambda x: self._importance_score(x[1])) - imp_sorted_ids = [id(m) for _, m in imp_sorted] - - info = { - "dialog": dd, - "total_msgs": len(msgs), - "unrelated_count": len(msgs), - "imp_ids_sorted": imp_sorted_ids, - "unimp_ids": [id(m) for _, m in unimp_unrel_msgs], - "protected_indices": protected_indices, - "qa_pairs_count": len(qa_pairs), - } - per_dialog_info[d["index"]] = info - total_unrelated += info["unrelated_count"] + # ========== 问答对保护(已注释,暂不启用,留作观察) ========== + # qa_pairs = self._identify_qa_pairs(msgs) + # protected_indices = self._get_protected_indices(msgs, qa_pairs, window_size=0) + # ======================================================== - # 全局删除配额计算 - global_delete = int(total_unrelated * proportion) - if proportion > 0 and total_unrelated > 0 and global_delete == 0: - global_delete = 1 + # 消息级分类:每条消息独立判断 + important_msgs = [] # 重要消息(保留) + unimportant_msgs = [] # 不重要消息(可删除) + filler_msgs = [] # 填充消息(优先删除) - # 每段的最大可删容量 - capacities = [] - for d in not_related_dialogs: - idx = d["index"] - info = per_dialog_info[idx] - imp_count = len(info["imp_ids_sorted"]) - unimp_count = len(info["unimp_ids"]) - imp_cap = int(imp_count * proportion) - cap = min(unimp_count + imp_cap, max(0, info["total_msgs"] - 1)) - capacities.append(cap) + for idx, m in enumerate(msgs): + msg_text = m.msg.strip() + + # ========== 问答对保护判断(已注释) ========== + # if idx in protected_indices: + # important_msgs.append((idx, m)) + # self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(问答对保护)") + # ========================================== + + # 填充消息(寒暄、表情等) + if self._is_filler_message(m): + filler_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 填充") + # 重要信息(学号、成绩、时间、金额等) + elif self._is_important_message(m): + important_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(场景规则)") + # 其他消息 + else: + unimportant_msgs.append((idx, m)) + self._log(f" [{idx}] '{msg_text[:30]}...' → 不重要") - total_capacity = sum(capacities) - if global_delete > total_capacity: - self._log(f"[剪枝-数据集] 不相关消息总数={total_unrelated},目标删除={global_delete},最大可删={total_capacity}。将按最大可删执行。") - global_delete = total_capacity - - # 配额分配 - alloc = [] - for i, d in enumerate(not_related_dialogs): - idx = d["index"] - info = per_dialog_info[idx] - share = int(global_delete * (info["unrelated_count"] / total_unrelated)) if total_unrelated > 0 else 0 - alloc.append(min(share, capacities[i])) + # 计算删除配额 + delete_target = int(original_count * proportion) + if proportion > 0 and original_count > 0 and delete_target == 0: + delete_target = 1 - allocated = sum(alloc) - rem = global_delete - allocated - turn = 0 - while rem > 0 and turn < 100000: - progressed = False - for i in range(len(not_related_dialogs)): - if rem <= 0: - break - if alloc[i] < capacities[i]: - alloc[i] += 1 - rem -= 1 - progressed = True - if not progressed: - break - turn += 1 - - # 应用删除 - total_deleted_confirm = 0 - for d in evaluated_dialogs: - dd = d["dialog"] - msgs = dd.context.msgs - original = len(msgs) - - if d["is_related"]: - result.append(dd) - continue - - idx_in_unrel = next((k for k, x in enumerate(not_related_dialogs) if x["index"] == d["index"]), None) - if idx_in_unrel is None: - result.append(dd) - continue - - quota = alloc[idx_in_unrel] - info = per_dialog_info[d["index"]] - - # 计算删除ID - imp_count = len(info["imp_ids_sorted"]) - imp_del_cap = int(imp_count * proportion) - - unimp_delete_ids = set(info["unimp_ids"][:min(quota, len(info["unimp_ids"]))]) - del_unimp = min(quota, len(unimp_delete_ids)) - rem_quota = quota - del_unimp - - imp_delete_ids = set(info["imp_ids_sorted"][:min(rem_quota, imp_del_cap)]) - - deleted_here = 0 - actual_unimp_deleted = 0 - actual_imp_deleted = 0 - kept = [] - - for m in msgs: - mid = id(m) - if mid in unimp_delete_ids and actual_unimp_deleted < del_unimp: - actual_unimp_deleted += 1 - deleted_here += 1 - continue - if mid in imp_delete_ids and actual_imp_deleted < len(imp_delete_ids): - actual_imp_deleted += 1 - deleted_here += 1 - continue - kept.append(m) - - if not kept and msgs: - kept = [msgs[0]] - - dd.context.msgs = kept - total_deleted_confirm += deleted_here - - qa_info = f",问答对={info['qa_pairs_count']}" if info['qa_pairs_count'] > 0 else "" - self._log( - f"[剪枝-对话] 对话 {d['index']+1} 总消息={original} 分配删除={quota} 实删={deleted_here} 保留={len(kept)}{qa_info}" - ) - result.append(dd) + # 确保至少保留1条消息 + max_deletable = max(0, original_count - 1) + delete_target = min(delete_target, max_deletable) - self._log(f"[剪枝-数据集] 全局消息级剪枝完成,总删除 {total_deleted_confirm} 条(保护问答对和上下文)。") - else: - result = [d["dialog"] for d in evaluated_dialogs] + # 删除策略:优先删除填充消息,再删除不重要消息 + to_delete_indices = set() + deleted_details = [] # 记录删除的消息详情 + + # 第一步:删除填充消息 + filler_to_delete = min(len(filler_msgs), delete_target) + for i in range(filler_to_delete): + idx, msg = filler_msgs[i] + to_delete_indices.add(idx) + deleted_details.append(f"[{idx}] 填充: '{msg.msg[:50]}'") + + # 第二步:如果还需要删除,删除不重要消息 + remaining_quota = delete_target - len(to_delete_indices) + if remaining_quota > 0: + unimp_to_delete = min(len(unimportant_msgs), remaining_quota) + for i in range(unimp_to_delete): + idx, msg = unimportant_msgs[i] + to_delete_indices.add(idx) + deleted_details.append(f"[{idx}] 不重要: '{msg.msg[:50]}'") + + # 第三步:如果还需要删除,按重要性分数删除重要消息 + remaining_quota = delete_target - len(to_delete_indices) + if remaining_quota > 0 and important_msgs: + # 按重要性分数排序(分数低的优先删除) + imp_sorted = sorted(important_msgs, key=lambda x: self._importance_score(x[1])) + imp_to_delete = min(len(imp_sorted), remaining_quota) + for i in range(imp_to_delete): + idx, msg = imp_sorted[i] + to_delete_indices.add(idx) + score = self._importance_score(msg) + deleted_details.append(f"[{idx}] 重要(分数{score}): '{msg.msg[:50]}'") + + # 执行删除 + kept_msgs = [] + for idx, m in enumerate(msgs): + if idx not in to_delete_indices: + kept_msgs.append(m) + + # 确保至少保留1条 + if not kept_msgs and msgs: + kept_msgs = [msgs[0]] + + dd.context.msgs = kept_msgs + deleted_count = original_count - len(kept_msgs) + total_deleted_msgs += deleted_count + + # 输出删除详情 + if deleted_details: + self._log(f"[剪枝-删除详情] 对话 {d_idx+1} 删除了以下消息:") + for detail in deleted_details: + self._log(f" {detail}") + + # ========== 问答对统计(已注释) ========== + # qa_info = f",问答对={len(qa_pairs)}" if qa_pairs else "" + # ======================================== + + self._log( + f"[剪枝-对话] 对话 {d_idx+1} 总消息={original_count} " + f"(重要={len(important_msgs)} 不重要={len(unimportant_msgs)} 填充={len(filler_msgs)}) " + f"删除={deleted_count} 保留={len(kept_msgs)}" + ) + + result.append(dd) self._log(f"[剪枝-数据集] 剩余对话数={len(result)}") diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py new file mode 100644 index 00000000..ed9592af --- /dev/null +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/scene_config.py @@ -0,0 +1,326 @@ +""" +场景特定配置 - 为不同场景提供定制化的剪枝规则 + +功能: +- 场景特定的重要信息识别模式 +- 场景特定的重要性评分权重 +- 场景特定的填充词库 +- 场景特定的问答对识别规则 +""" + +from typing import Dict, List, Set, Tuple +from dataclasses import dataclass, field + + +@dataclass +class ScenePatterns: + """场景特定的识别模式""" + + # 重要信息的正则模式(优先级从高到低) + high_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) # (pattern, weight) + medium_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) + low_priority_patterns: List[Tuple[str, int]] = field(default_factory=list) + + # 填充词库(无意义对话) + filler_phrases: Set[str] = field(default_factory=set) + + # 问句关键词(用于识别问答对) + question_keywords: Set[str] = field(default_factory=set) + + # 决策性/承诺性关键词 + decision_keywords: Set[str] = field(default_factory=set) + + +class SceneConfigRegistry: + """场景配置注册表 - 管理所有场景的特定配置""" + + # 基础通用模式(所有场景共享) + BASE_HIGH_PRIORITY = [ + (r"订单号|工单|申请号|编号|ID|账号|账户", 5), + (r"金额|费用|价格|¥|¥|\d+元", 5), + (r"\d{11}", 4), # 手机号 + (r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", 4), # 邮箱 + ] + + BASE_MEDIUM_PRIORITY = [ + (r"\d{4}-\d{1,2}-\d{1,2}", 3), # 日期 + (r"\d{4}年\d{1,2}月\d{1,2}日", 3), + (r"电话|手机号|微信|QQ|联系方式", 3), + (r"地址|地点|位置", 2), + (r"时间|日期|有效期|截止", 2), + (r"今天|明天|后天|昨天|前天", 3), # 相对时间(提高权重) + (r"下周|下月|下年|上周|上月|上年|本周|本月|本年", 3), + (r"今年|去年|明年", 3), + ] + + BASE_LOW_PRIORITY = [ + (r"\d{1,2}:\d{2}", 2), # 时间点 HH:MM + (r"\d{1,2}点\d{0,2}分?", 2), # 时间点 X点Y分 或 X点 + (r"上午|下午|中午|晚上|早上|傍晚|凌晨", 2), # 时段(提高权重并扩充) + (r"AM|PM|am|pm", 1), + ] + + BASE_FILLERS = { + # 基础寒暄 + "你好", "您好", "在吗", "在的", "在呢", "嗯", "嗯嗯", "哦", "哦哦", + "好的", "好", "行", "可以", "不可以", "谢谢", "多谢", "感谢", + "拜拜", "再见", "88", "拜", "回见", + # 口头禅 + "哈哈", "呵呵", "哈哈哈", "嘿嘿", "嘻嘻", "hiahia", + "额", "呃", "啊", "诶", "唉", "哎", "嗯哼", + # 确认词 + "是的", "对", "对的", "没错", "嗯嗯", "好嘞", "收到", "明白", "了解", "知道了", + # 标点和符号 + "。。。", "...", "???", "???", "!!!", "!!!", + # 表情符号 + "[微笑]", "[呲牙]", "[发呆]", "[得意]", "[流泪]", "[害羞]", "[闭嘴]", + "[睡]", "[大哭]", "[尴尬]", "[发怒]", "[调皮]", "[龇牙]", "[惊讶]", + "[难过]", "[酷]", "[冷汗]", "[抓狂]", "[吐]", "[偷笑]", "[可爱]", + "[白眼]", "[傲慢]", "[饥饿]", "[困]", "[惊恐]", "[流汗]", "[憨笑]", + # 网络用语 + "hhh", "hhhh", "2333", "666", "gg", "ok", "OK", "okok", + "emmm", "emm", "em", "mmp", "wtf", "omg", + } + + BASE_QUESTION_KEYWORDS = { + "什么", "为什么", "怎么", "如何", "哪里", "哪个", "谁", "多少", "几点", "何时", "吗" + } + + BASE_DECISION_KEYWORDS = { + "必须", "一定", "务必", "需要", "要求", "规定", "应该", + "承诺", "保证", "确保", "负责", "同意", "答应" + } + + @classmethod + def get_education_config(cls) -> ScenePatterns: + """教育场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 成绩相关(最高优先级) + (r"成绩|分数|得分|满分|及格|不及格", 6), + (r"GPA|绩点|学分|平均分", 6), + (r"\d+分|\d+\.?\d*分", 5), # 具体分数 + (r"排名|名次|第.{1,3}名", 5), # 支持"第三名"、"第1名"等 + + # 学籍信息 + (r"学号|学生证|教师工号|工号", 5), + (r"班级|年级|专业|院系", 4), + + # 课程相关 + (r"课程|科目|学科|必修|选修", 4), + (r"教材|课本|教科书|参考书", 4), + (r"章节|第.{1,3}章|第.{1,3}节", 3), # 支持"第三章"、"第1章"等 + + # 学科内容(新增) + (r"微积分|导数|积分|函数|极限|微分", 4), + (r"代数|几何|三角|概率|统计", 4), + (r"物理|化学|生物|历史|地理", 4), + (r"英语|语文|数学|政治|哲学", 4), + (r"定义|定理|公式|概念|原理|法则", 3), + (r"例题|解题|证明|推导|计算", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 教学活动 + (r"作业|练习|习题|题目", 3), + (r"考试|测验|测试|考核|期中|期末", 3), + (r"上课|下课|课堂|讲课", 2), + (r"提问|回答|发言|讨论", 2), + (r"问一下|请教|咨询|询问", 2), # 新增:问询相关 + (r"理解|明白|懂|掌握|学会", 2), # 新增:学习状态 + + # 时间安排 + (r"课表|课程表|时间表", 3), + (r"第.{1,3}节课|第.{1,3}周", 2), # 支持"第三节课"、"第1周"等 + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"老师|教师|同学|学生", 1), + (r"教室|实验室|图书馆", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 教育场景特有填充词(移除了"明白了"、"懂了"、"不懂"等,这些在教育场景中有意义) + "老师好", "同学们好", "上课", "下课", "起立", "坐下", + "举手", "请坐", "很好", "不错", "继续", + "下一个", "下一题", "下一位", "还有吗", "还有问题吗", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "为啥", "咋", "咋办", "怎样", "如何做", + "能不能", "可不可以", "行不行", "对不对", "是不是", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "必考", "重点", "考点", "难点", "关键", + "记住", "背诵", "掌握", "理解", "复习", + } + ) + + @classmethod + def get_online_service_config(cls) -> ScenePatterns: + """在线服务场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 工单相关(最高优先级) + (r"工单号|工单编号|ticket|TK\d+", 6), + (r"工单状态|处理中|已解决|已关闭|待处理", 5), + (r"优先级|紧急|高优先级|P0|P1|P2", 5), + + # 产品信息 + (r"产品型号|型号|SKU|产品编号", 5), + (r"序列号|SN|设备号", 5), + (r"版本号|软件版本|固件版本", 4), + + # 问题描述 + (r"故障|错误|异常|bug|问题", 4), + (r"错误代码|故障代码|error code", 5), + (r"无法|不能|失败|报错", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 服务相关 + (r"退款|退货|换货|补发", 4), + (r"发票|收据|凭证", 3), + (r"物流|快递|运单号", 3), + (r"保修|质保|售后", 3), + + # 时效相关 + (r"SLA|响应时间|处理时长", 4), + (r"超时|延迟|等待", 2), + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"客服|工程师|技术支持", 1), + (r"用户|客户|会员", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 在线服务特有填充词 + "您好", "请问", "请稍等", "稍等", "马上", "立即", + "正在查询", "正在处理", "正在为您", "帮您查一下", + "还有其他问题吗", "还需要什么帮助", "很高兴为您服务", + "感谢您的耐心等待", "抱歉让您久等了", + "已记录", "已反馈", "已转接", "已升级", + "祝您生活愉快", "再见", "欢迎下次咨询", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "能否", "可否", "是否", "有没有", "能不能", + "怎么办", "如何处理", "怎么解决", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "立即处理", "马上解决", "尽快", "优先", + "升级", "转接", "派单", "跟进", + "补偿", "赔偿", "退款", "换货", + } + ) + + @classmethod + def get_outbound_config(cls) -> ScenePatterns: + """外呼场景配置""" + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY + [ + # 意向相关(最高优先级) + (r"意向|意愿|兴趣|感兴趣", 6), + (r"A类|B类|C类|D类|高意向|低意向", 6), + (r"成交|签约|下单|购买|确认", 6), + + # 联系信息(外呼场景中更重要) + (r"预约|约定|安排|确定时间", 5), + (r"下次联系|回访|跟进", 5), + (r"方便|有空|可以|时间", 4), + + # 通话状态 + (r"接通|未接通|占线|关机|停机", 4), + (r"通话时长|通话时间", 3), + ], + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY + [ + # 客户信息 + (r"姓名|称呼|先生|女士", 3), + (r"公司|单位|职位|职务", 3), + (r"需求|要求|期望", 3), + + # 跟进状态 + (r"跟进状态|进展|进度", 3), + (r"已联系|待联系|联系中", 2), + (r"拒绝|不感兴趣|考虑|再说", 3), + ], + low_priority_patterns=cls.BASE_LOW_PRIORITY + [ + (r"销售|客户经理|业务员", 1), + (r"产品|服务|方案", 1), + ], + filler_phrases=cls.BASE_FILLERS | { + # 外呼场景特有填充词 + "您好", "喂", "hello", "打扰了", "不好意思", + "方便接电话吗", "现在方便吗", "占用您一点时间", + "我是", "我们是", "我们公司", "我们这边", + "了解一下", "介绍一下", "简单说一下", + "考虑考虑", "想一想", "再说", "再看看", + "不需要", "不感兴趣", "没兴趣", "不用了", + "好的", "行", "可以", "没问题", "那就这样", + "再联系", "回头聊", "有需要再说", + }, + question_keywords=cls.BASE_QUESTION_KEYWORDS | { + "有没有", "需不需要", "要不要", "考虑不考虑", + "了解吗", "知道吗", "听说过吗", + "方便吗", "有空吗", "在吗", + }, + decision_keywords=cls.BASE_DECISION_KEYWORDS | { + "确定", "决定", "选择", "购买", "下单", + "预约", "安排", "约定", "确认", + "跟进", "回访", "联系", "沟通", + } + ) + + @classmethod + def get_config(cls, scene: str, fallback_to_generic: bool = True) -> ScenePatterns: + """根据场景名称获取配置 + + Args: + scene: 场景名称 ('education', 'online_service', 'outbound' 或其他) + fallback_to_generic: 如果场景不存在,是否降级到通用配置 + + Returns: + 对应场景的配置,如果场景不存在: + - fallback_to_generic=True: 返回通用配置(仅基础规则) + - fallback_to_generic=False: 抛出异常 + """ + scene_map = { + 'education': cls.get_education_config, + 'online_service': cls.get_online_service_config, + 'outbound': cls.get_outbound_config, + } + + if scene in scene_map: + return scene_map[scene]() + + if fallback_to_generic: + # 返回通用配置(仅包含基础规则,不包含场景特定规则) + return cls.get_generic_config() + else: + raise ValueError(f"不支持的场景: {scene},支持的场景: {list(scene_map.keys())}") + + @classmethod + def get_generic_config(cls) -> ScenePatterns: + """通用场景配置 - 仅包含基础规则,适用于未定义的场景 + + 这是一个保守的配置,只使用最通用的规则,避免误删重要信息 + """ + return ScenePatterns( + high_priority_patterns=cls.BASE_HIGH_PRIORITY, + medium_priority_patterns=cls.BASE_MEDIUM_PRIORITY, + low_priority_patterns=cls.BASE_LOW_PRIORITY, + filler_phrases=cls.BASE_FILLERS, + question_keywords=cls.BASE_QUESTION_KEYWORDS, + decision_keywords=cls.BASE_DECISION_KEYWORDS + ) + + @classmethod + def get_all_scenes(cls) -> List[str]: + """获取所有预定义场景的列表""" + return ['education', 'online_service', 'outbound'] + + @classmethod + def is_scene_supported(cls, scene: str) -> bool: + """检查场景是否有专门的配置支持 + + Args: + scene: 场景名称 + + Returns: + True: 有专门配置 + False: 将使用通用配置 + """ + return scene in cls.get_all_scenes() 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 a47497da..17bda0e4 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 @@ -1988,6 +1988,7 @@ async def get_chunked_dialogs_with_preprocessing( input_data_path: Optional[str] = None, llm_client: Optional[Any] = None, skip_cleaning: bool = True, + pruning_config: Optional[Dict] = None, ) -> List[DialogData]: """包含数据预处理步骤的完整分块流程 @@ -2000,6 +2001,7 @@ async def get_chunked_dialogs_with_preprocessing( input_data_path: 输入数据路径 llm_client: LLM 客户端 skip_cleaning: 是否跳过数据清洗步骤(默认False) + pruning_config: 剪枝配置字典,包含 pruning_switch, pruning_scene, pruning_threshold Returns: 带 chunks 的 DialogData 列表 @@ -2030,7 +2032,19 @@ async def get_chunked_dialogs_with_preprocessing( from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import ( SemanticPruner, ) - pruner = SemanticPruner(llm_client=llm_client) + from app.core.memory.models.config_models import PruningConfig + + # 构建剪枝配置 + if pruning_config: + # 使用传入的配置 + config = PruningConfig(**pruning_config) + print(f"[剪枝] 使用传入配置: switch={config.pruning_switch}, scene={config.pruning_scene}, threshold={config.pruning_threshold}") + else: + # 使用默认配置(关闭剪枝) + config = None + print("[剪枝] 未提供配置,使用默认配置(剪枝关闭)") + + pruner = SemanticPruner(config=config, llm_client=llm_client) # 记录单对话场景下剪枝前的消息数量 single_dialog_original_msgs = None From 2d5c2de613ad9a5f4c3f3af5e3c05102917be4f5 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 17:51:12 +0800 Subject: [PATCH 45/55] [add]New semantic pruning effect display for streaming output --- api/app/services/pilot_run_service.py | 97 +++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 34b8867e..31e4d6dd 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -101,14 +101,101 @@ async def run_pilot_extraction( ) if progress_callback: - await progress_callback("text_preprocessing", "开始预处理文本...") + await progress_callback("text_preprocessing", "开始预处理文本(语义剪枝 + 语义分块)...") + # ========== 步骤 2.1: 语义剪枝 ========== + pruned_dialogs = [dialog] + deleted_messages = [] # 记录被删除的消息 + + if memory_config.pruning_enabled: + try: + from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_pruning import ( + SemanticPruner, + ) + from app.core.memory.models.config_models import PruningConfig + + # 构建剪枝配置 + pruning_config_dict = { + "pruning_switch": memory_config.pruning_enabled, + "pruning_scene": memory_config.pruning_scene, + "pruning_threshold": memory_config.pruning_threshold, + "llm_model_id": str(memory_config.llm_model_id), + } + config = PruningConfig(**pruning_config_dict) + + logger.info(f"[PILOT_RUN] 开始语义剪枝: scene={config.pruning_scene}, threshold={config.pruning_threshold}") + + # 记录剪枝前的消息(用于对比) + original_messages = [{"role": msg.role, "content": msg.msg} for msg in dialog.context.msgs] + original_msg_count = len(original_messages) + + # 执行剪枝 + pruner = SemanticPruner(config=config, llm_client=llm_client) + pruned_dialogs = await pruner.prune_dataset([dialog]) + + # 计算剪枝结果并找出被删除的消息 + if pruned_dialogs and pruned_dialogs[0].context: + remaining_messages = [{"role": msg.role, "content": msg.msg} for msg in pruned_dialogs[0].context.msgs] + remaining_msg_count = len(remaining_messages) + deleted_msg_count = original_msg_count - remaining_msg_count + + # 找出被删除的消息(通过内容对比) + remaining_contents = {msg["content"] for msg in remaining_messages} + deleted_messages = [ + {"index": idx, "role": msg["role"], "content": msg["content"]} + for idx, msg in enumerate(original_messages) + if msg["content"] not in remaining_contents + ] + + pruning_result = { + "enabled": True, + "scene": config.pruning_scene, + "threshold": config.pruning_threshold, + "original_count": original_msg_count, + "remaining_count": remaining_msg_count, + "deleted_count": deleted_msg_count, + "deleted_messages": deleted_messages, + } + + logger.info( + f"[PILOT_RUN] 语义剪枝完成: 原始{original_msg_count}条 -> " + f"保留{remaining_msg_count}条 (删除{deleted_msg_count}条)" + ) + + if progress_callback: + await progress_callback("text_preprocessing_pruning", "语义剪枝完成", pruning_result) + else: + logger.warning("[PILOT_RUN] 剪枝后对话为空,使用原始对话") + pruned_dialogs = [dialog] + + except Exception as e: + logger.error(f"[PILOT_RUN] 语义剪枝失败,使用原始对话: {e}", exc_info=True) + pruned_dialogs = [dialog] + if progress_callback: + error_result = { + "enabled": True, + "error": str(e), + "fallback": "使用原始对话" + } + await progress_callback("text_preprocessing_pruning", "语义剪枝失败", error_result) + else: + logger.info("[PILOT_RUN] 语义剪枝已关闭,跳过") + if progress_callback: + pruning_result = { + "enabled": False, + "message": "语义剪枝已关闭" + } + await progress_callback("text_preprocessing_pruning", "语义剪枝已关闭", pruning_result) + + # ========== 步骤 2.2: 语义分块 ========== chunked_dialogs = await get_chunked_dialogs_from_preprocessed( - data=[dialog], + data=pruned_dialogs, chunker_strategy=memory_config.chunker_strategy, llm_client=llm_client, ) - logger.info(f"Processed dialogue text: {len(messages)} messages") + + remaining_msg_count = len(pruned_dialogs[0].context.msgs) if pruned_dialogs and pruned_dialogs[0].context else 0 + logger.info(f"Processed dialogue text: {remaining_msg_count} messages after pruning") # 进度回调:输出每个分块的结果 if progress_callback: @@ -121,14 +208,14 @@ async def run_pilot_extraction( "dialog_id": dlg.id, "chunker_strategy": memory_config.chunker_strategy, } - await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) + await progress_callback("text_preprocessing_chunking", f"分块 {i + 1} 处理完成", chunk_result) preprocessing_summary = { "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } - await progress_callback("text_preprocessing_complete", "预处理文本完成", preprocessing_summary) + await progress_callback("text_preprocessing_complete", "预处理文本完成(剪枝 + 分块)", preprocessing_summary) log_time("Data Loading & Chunking", time.time() - step_start, log_file) From 4aeb653ed2633acba37a9c58890f7f254aca637e Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 18:19:44 +0800 Subject: [PATCH 46/55] [fix]Fix the display issue of semantic chunking for streaming output --- api/app/services/pilot_run_service.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 31e4d6dd..4cfa158d 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -200,18 +200,19 @@ async def run_pilot_extraction( # 进度回调:输出每个分块的结果 if progress_callback: for dlg in chunked_dialogs: - for i, chunk in enumerate(dlg.chunks): - chunk_result = { - "chunk_index": i + 1, - "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, - "full_length": len(chunk.content), - "dialog_id": dlg.id, - "chunker_strategy": memory_config.chunker_strategy, - } - await progress_callback("text_preprocessing_chunking", f"分块 {i + 1} 处理完成", chunk_result) + if hasattr(dlg, 'chunks') and dlg.chunks: + for i, chunk in enumerate(dlg.chunks): + chunk_result = { + "chunk_index": i + 1, + "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, + "full_length": len(chunk.content), + "dialog_id": dlg.id, + "chunker_strategy": memory_config.chunker_strategy, + } + await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) preprocessing_summary = { - "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs), + "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs if hasattr(dlg, 'chunks') and dlg.chunks), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } From 4ac63e1c239374abaaeb9325685af8f9ef0a63c3 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Sat, 28 Feb 2026 19:26:16 +0800 Subject: [PATCH 47/55] [add]Complete the interface integration for the display of semantic pruning for streaming output. --- api/app/services/pilot_run_service.py | 33 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index 4cfa158d..c39d089e 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -106,6 +106,7 @@ async def run_pilot_extraction( # ========== 步骤 2.1: 语义剪枝 ========== pruned_dialogs = [dialog] deleted_messages = [] # 记录被删除的消息 + pruning_stats = None # 保存剪枝统计信息,用于最终汇总 if memory_config.pruning_enabled: try: @@ -147,13 +148,17 @@ async def run_pilot_extraction( if msg["content"] not in remaining_contents ] - pruning_result = { + # 保存剪枝统计信息(用于最终汇总,只保留deleted_count) + pruning_stats = { "enabled": True, "scene": config.pruning_scene, "threshold": config.pruning_threshold, - "original_count": original_msg_count, - "remaining_count": remaining_msg_count, "deleted_count": deleted_msg_count, + } + + # 输出剪枝结果(显示删除的消息详情) + pruning_result = { + "type": "pruning", "deleted_messages": deleted_messages, } @@ -163,7 +168,7 @@ async def run_pilot_extraction( ) if progress_callback: - await progress_callback("text_preprocessing_pruning", "语义剪枝完成", pruning_result) + await progress_callback("text_preprocessing_result", "语义剪枝完成", pruning_result) else: logger.warning("[PILOT_RUN] 剪枝后对话为空,使用原始对话") pruned_dialogs = [dialog] @@ -173,19 +178,16 @@ async def run_pilot_extraction( pruned_dialogs = [dialog] if progress_callback: error_result = { - "enabled": True, + "type": "pruning", "error": str(e), "fallback": "使用原始对话" } - await progress_callback("text_preprocessing_pruning", "语义剪枝失败", error_result) + await progress_callback("text_preprocessing_result", "语义剪枝失败", error_result) else: logger.info("[PILOT_RUN] 语义剪枝已关闭,跳过") - if progress_callback: - pruning_result = { - "enabled": False, - "message": "语义剪枝已关闭" - } - await progress_callback("text_preprocessing_pruning", "语义剪枝已关闭", pruning_result) + pruning_stats = { + "enabled": False, + } # ========== 步骤 2.2: 语义分块 ========== chunked_dialogs = await get_chunked_dialogs_from_preprocessed( @@ -203,6 +205,7 @@ async def run_pilot_extraction( if hasattr(dlg, 'chunks') and dlg.chunks: for i, chunk in enumerate(dlg.chunks): chunk_result = { + "type": "chunking", "chunk_index": i + 1, "content": chunk.content[:200] + "..." if len(chunk.content) > 200 else chunk.content, "full_length": len(chunk.content), @@ -211,11 +214,17 @@ async def run_pilot_extraction( } await progress_callback("text_preprocessing_result", f"分块 {i + 1} 处理完成", chunk_result) + # 构建预处理完成总结(包含剪枝统计) preprocessing_summary = { "total_chunks": sum(len(dlg.chunks) for dlg in chunked_dialogs if hasattr(dlg, 'chunks') and dlg.chunks), "total_dialogs": len(chunked_dialogs), "chunker_strategy": memory_config.chunker_strategy, } + + # 添加剪枝统计信息 + if pruning_stats: + preprocessing_summary["pruning"] = pruning_stats + await progress_callback("text_preprocessing_complete", "预处理文本完成(剪枝 + 分块)", preprocessing_summary) log_time("Data Loading & Chunking", time.time() - step_start, log_file) From 8e15a340f685c9e448675694d80d3f56a3c40322 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 2 Mar 2026 12:09:10 +0800 Subject: [PATCH 48/55] [changes]Correct log output, log level, and pruning conditions --- .../data_preprocessing/data_pruning.py | 18 ++++++++++++--- .../extraction_orchestrator.py | 22 +++++++++---------- api/app/services/pilot_run_service.py | 16 +++++++++++--- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py index d932c542..0a913633 100644 --- a/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py +++ b/api/app/core/memory/storage_services/extraction_engine/data_preprocessing/data_pruning.py @@ -82,6 +82,10 @@ class SemanticPruner: self.language = language # 保存语言配置 self.max_concurrent = max_concurrent # 新增:最大并发数 + # 详细日志配置:限制逐条消息日志的数量 + self._detailed_prune_logging = True # 是否启用详细日志 + self._max_debug_msgs_per_dialog = 20 # 每个对话最多记录前N条消息的详细日志 + # 加载场景特定配置 self.scene_config: ScenePatterns = SceneConfigRegistry.get_config( self.config.pruning_scene, @@ -595,6 +599,11 @@ class SemanticPruner: unimportant_msgs = [] # 不重要消息(可删除) filler_msgs = [] # 填充消息(优先删除) + # 判断是否需要详细日志(仅对前N条消息记录) + should_log_details = self._detailed_prune_logging and original_count <= self._max_debug_msgs_per_dialog + if self._detailed_prune_logging and original_count > self._max_debug_msgs_per_dialog: + self._log(f" 对话[{d_idx}]消息数={original_count},仅采样前{self._max_debug_msgs_per_dialog}条进行详细日志") + for idx, m in enumerate(msgs): msg_text = m.msg.strip() @@ -607,15 +616,18 @@ class SemanticPruner: # 填充消息(寒暄、表情等) if self._is_filler_message(m): filler_msgs.append((idx, m)) - self._log(f" [{idx}] '{msg_text[:30]}...' → 填充") + if should_log_details or idx < self._max_debug_msgs_per_dialog: + self._log(f" [{idx}] '{msg_text[:30]}...' → 填充") # 重要信息(学号、成绩、时间、金额等) elif self._is_important_message(m): important_msgs.append((idx, m)) - self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(场景规则)") + if should_log_details or idx < self._max_debug_msgs_per_dialog: + self._log(f" [{idx}] '{msg_text[:30]}...' → 重要(场景规则)") # 其他消息 else: unimportant_msgs.append((idx, m)) - self._log(f" [{idx}] '{msg_text[:30]}...' → 不重要") + if should_log_details or idx < self._max_debug_msgs_per_dialog: + self._log(f" [{idx}] '{msg_text[:30]}...' → 不重要") # 计算删除配额 delete_target = int(original_count * proportion) 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 17bda0e4..1242e4e6 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 @@ -1932,17 +1932,17 @@ def preprocess_data( Returns: 经过清洗转换后的 DialogData 列表 """ - print("\n=== 数据预处理 ===") + logger.debug("=== 数据预处理 ===") from app.core.memory.storage_services.extraction_engine.data_preprocessing.data_preprocessor import ( DataPreprocessor, ) preprocessor = DataPreprocessor() try: cleaned_data = preprocessor.preprocess(input_path=input_path, output_path=output_path, skip_cleaning=skip_cleaning, indices=indices) - print(f"数据预处理完成!共处理了 {len(cleaned_data)} 条对话数据") + logger.debug(f"数据预处理完成!共处理了 {len(cleaned_data)} 条对话数据") return cleaned_data except Exception as e: - print(f"数据预处理过程中出现错误: {e}") + logger.error(f"数据预处理过程中出现错误: {e}") raise @@ -1961,7 +1961,7 @@ async def get_chunked_dialogs_from_preprocessed( Returns: 带 chunks 的 DialogData 列表 """ - print(f"\n=== 批量对话分块处理 (使用 {chunker_strategy}) ===") + logger.debug(f"=== 批量对话分块处理 (使用 {chunker_strategy}) ===") if not data: raise ValueError("预处理数据为空,无法进行分块") @@ -2006,7 +2006,7 @@ async def get_chunked_dialogs_with_preprocessing( Returns: 带 chunks 的 DialogData 列表 """ - print("\n=== 完整数据处理流程(包含预处理)===") + logger.debug("=== 完整数据处理流程(包含预处理)===") if input_data_path is None: input_data_path = os.path.join( @@ -2038,11 +2038,11 @@ async def get_chunked_dialogs_with_preprocessing( if pruning_config: # 使用传入的配置 config = PruningConfig(**pruning_config) - print(f"[剪枝] 使用传入配置: switch={config.pruning_switch}, scene={config.pruning_scene}, threshold={config.pruning_threshold}") + logger.debug(f"[剪枝] 使用传入配置: switch={config.pruning_switch}, scene={config.pruning_scene}, threshold={config.pruning_threshold}") else: # 使用默认配置(关闭剪枝) config = None - print("[剪枝] 未提供配置,使用默认配置(剪枝关闭)") + logger.debug("[剪枝] 未提供配置,使用默认配置(剪枝关闭)") pruner = SemanticPruner(config=config, llm_client=llm_client) @@ -2057,12 +2057,12 @@ async def get_chunked_dialogs_with_preprocessing( if len(preprocessed_data) == 1 and single_dialog_original_msgs is not None: remaining_msgs = len(preprocessed_data[0].context.msgs) if preprocessed_data[0].context else 0 deleted_msgs = max(0, single_dialog_original_msgs - remaining_msgs) - print( + logger.debug( f"语义剪枝完成!剩余 1 条对话!原始消息数:{single_dialog_original_msgs}," f"保留消息数:{remaining_msgs},删除 {deleted_msgs} 条。" ) else: - print(f"语义剪枝完成!剩余 {len(preprocessed_data)} 条对话") + logger.debug(f"语义剪枝完成!剩余 {len(preprocessed_data)} 条对话") # 保存剪枝后的数据 try: @@ -2073,9 +2073,9 @@ async def get_chunked_dialogs_with_preprocessing( dp = DataPreprocessor(output_file_path=pruned_output_path) dp.save_data(preprocessed_data, output_path=pruned_output_path) except Exception as se: - print(f"保存剪枝结果失败:{se}") + logger.error(f"保存剪枝结果失败:{se}") except Exception as e: - print(f"语义剪枝过程中出现错误,跳过剪枝: {e}") + logger.error(f"语义剪枝过程中出现错误,跳过剪枝: {e}") # 步骤3: 对话分块 return await get_chunked_dialogs_from_preprocessed( diff --git a/api/app/services/pilot_run_service.py b/api/app/services/pilot_run_service.py index c39d089e..4d9cbb5e 100644 --- a/api/app/services/pilot_run_service.py +++ b/api/app/services/pilot_run_service.py @@ -140,12 +140,22 @@ async def run_pilot_extraction( remaining_msg_count = len(remaining_messages) deleted_msg_count = original_msg_count - remaining_msg_count - # 找出被删除的消息(通过内容对比) - remaining_contents = {msg["content"] for msg in remaining_messages} + # 找出被删除的消息(基于索引精确匹配) + # 为剩余消息创建带索引的列表,用于精确追踪 + remaining_with_index = [] + remaining_idx = 0 + for orig_idx, orig_msg in enumerate(original_messages): + if remaining_idx < len(remaining_messages) and \ + orig_msg["role"] == remaining_messages[remaining_idx]["role"] and \ + orig_msg["content"] == remaining_messages[remaining_idx]["content"]: + remaining_with_index.append(orig_idx) + remaining_idx += 1 + + # 找出未在保留列表中的消息索引 deleted_messages = [ {"index": idx, "role": msg["role"], "content": msg["content"]} for idx, msg in enumerate(original_messages) - if msg["content"] not in remaining_contents + if idx not in remaining_with_index ] # 保存剪枝统计信息(用于最终汇总,只保留deleted_count) From 2ff9000d2527094d0b36e6d939b90a5447cdbfb9 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 27 Feb 2026 13:45:31 +0800 Subject: [PATCH 49/55] feat(web): form add rules --- web/src/components/SearchInput/index.tsx | 2 + web/src/i18n/en.ts | 5 +- web/src/i18n/zh.ts | 5 +- web/src/utils/validator.ts | 52 +++++++++++++++++++ .../components/ApiKeyModal.tsx | 10 +++- web/src/views/ApplicationConfig/Agent.tsx | 10 ++-- .../components/ApplicationModal.tsx | 8 ++- .../components/MemberModal.tsx | 6 ++- .../components/MemoryForm.tsx | 8 ++- .../components/CustomModelModal.tsx | 20 +++++-- .../components/GroupModelModal.tsx | 23 ++++++-- web/src/views/ModelManagement/index.tsx | 1 + .../components/OntologyClassExtractModal.tsx | 5 +- .../components/OntologyClassModal.tsx | 8 ++- .../Ontology/components/OntologyModal.tsx | 8 ++- web/src/views/Skills/pages/SkillConfig.tsx | 10 +++- web/src/views/Skills/types.ts | 2 + .../SpaceManagement/components/SpaceModal.tsx | 12 +++-- 18 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 web/src/utils/validator.ts diff --git a/web/src/components/SearchInput/index.tsx b/web/src/components/SearchInput/index.tsx index 32a64310..476c2cbb 100644 --- a/web/src/components/SearchInput/index.tsx +++ b/web/src/components/SearchInput/index.tsx @@ -41,6 +41,8 @@ interface SearchInputProps { className?: string; /** Input size */ size?: InputProps['size'] + /** Maximum length of the input value */ + maxLength?: number; } /** Search input component with debounce and throttle support */ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 02add0ec..c5c81f13 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -453,6 +453,8 @@ export const en = { prevStep: 'Previous Step', exportSuccess: 'Export successful', recommend: 'Recommend', + logoTip: `Supported image formats: JPG, PNG \n Suggested size: square ratio \n Maximum size: ≤ 2MB`, + imageSquareRequired: 'Please upload a square image', }, model: { searchPlaceholder: 'search model…', @@ -542,7 +544,8 @@ export const en = { ollama: "Ollama", xinference: "Xinference", gpustack: "Gpustack", - bedrock: "Bedrock" + bedrock: "Bedrock", + nameInvalid: 'Model name can only contain letters, numbers, underscores and spaces, cannot be empty or pure whitespace', }, modelNew: { group: 'Model Group', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 06abf63a..f63e5981 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1029,6 +1029,8 @@ export const zh = { prevStep: '上一步', exportSuccess: '导出成功', recommend: '推荐', + logoTip: `支持图片格式(JPG、PNG)\n 尺寸:正方形比例 \n 文件大小限制:≤ 2MB`, + imageSquareRequired: '请上传正方形比例图片', }, model: { searchPlaceholder: '搜索模型…', @@ -1176,7 +1178,8 @@ export const zh = { ollama: "Ollama", xinference: "Xinference", gpustack: "Gpustack", - bedrock: "Bedrock" + bedrock: "Bedrock", + nameInvalid: '模型名称只能包含字母、数字、下划线和空格, 不能为空或纯空格', }, timezones: { 'Asia/Shanghai': '中国标准时间 (UTC+8)', diff --git a/web/src/utils/validator.ts b/web/src/utils/validator.ts new file mode 100644 index 00000000..70f0cb02 --- /dev/null +++ b/web/src/utils/validator.ts @@ -0,0 +1,52 @@ +/* + * @Author: zhaoying yzhao96@best-inc.com + * @Date: 2026-03-02 13:46:53 + * @LastEditors: zhaoying yzhao96@best-inc.com + * @LastEditTime: 2026-03-02 14:38:33 + * @FilePath: /web/src/utils/validator.ts + * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE + */ +/** + * Form validation utilities + */ + +interface UploadFile { + originFileObj: Blob; + [key: string]: unknown; +} + +/** + * Validate if uploaded image is square (width === height) + * @param errorMessage - Error message to display when validation fails + * @returns Ant Design form validator + */ +export const validateSquareImage = (errorMessage: string = 'Image must be square') => { + return (_: unknown, value: UploadFile | UploadFile[] | undefined) => { + if (!value || (Array.isArray(value) && value.length === 0)) { + return Promise.resolve(); + } + + const file = Array.isArray(value) ? value[0] : value; + + if (file?.originFileObj) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + if (img.width === img.height) { + resolve(); + } else { + reject(new Error(errorMessage)); + } + }; + img.onerror = () => reject(new Error('Failed to load image')); + img.src = URL.createObjectURL(file.originFileObj); + }); + } + + return Promise.resolve(); + }; +}; + +// - Cannot be empty or pure whitespace +// - Cannot start with a space +export const stringRegExp = /^[a-zA-Z0-9\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fa5\s]*$/ \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx index 9395df43..05e73992 100644 --- a/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx +++ b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx @@ -12,6 +12,7 @@ import dayjs from 'dayjs' import type { ApiKey, ApiKeyModalRef } from '../types'; import RbModal from '@/components/RbModal' import { createApiKey, updateApiKey } from '@/api/apiKey'; +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -78,7 +79,7 @@ const ApiKeyModal = forwardRef(({ form.validateFields() .then((values) => { const { memory, rag, expires_at, ...rest } = values - let scopes = [] + const scopes = [] if (memory) { scopes.push('memory') @@ -130,7 +131,11 @@ const ApiKeyModal = forwardRef(({ @@ -138,6 +143,7 @@ const ApiKeyModal = forwardRef(({ diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 2ece4b6e..4bee291b 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -169,8 +169,8 @@ const Agent = forwardRef((_props, ref) => { getApplicationConfig(id as string).then(res => { const response = res as Config const { skills, variables } = response - let allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : [] - let allTools = Array.isArray(response.tools) ? response.tools : [] + const allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : [] + const allTools = Array.isArray(response.tools) ? response.tools : [] const memoryContent = response.memory?.memory_config_id const parsedMemoryContent = memoryContent === null || memoryContent === '' ? undefined @@ -431,7 +431,11 @@ const Agent = forwardRef((_props, ref) => {
- + ( diff --git a/web/src/views/MemberManagement/components/MemberModal.tsx b/web/src/views/MemberManagement/components/MemberModal.tsx index 002c8632..e16c60ba 100644 --- a/web/src/views/MemberManagement/components/MemberModal.tsx +++ b/web/src/views/MemberManagement/components/MemberModal.tsx @@ -152,7 +152,11 @@ const MemberModal = forwardRef(({ diff --git a/web/src/views/MemoryManagement/components/MemoryForm.tsx b/web/src/views/MemoryManagement/components/MemoryForm.tsx index 93246ca9..282199af 100644 --- a/web/src/views/MemoryManagement/components/MemoryForm.tsx +++ b/web/src/views/MemoryManagement/components/MemoryForm.tsx @@ -18,6 +18,7 @@ import RbModal from '@/components/RbModal' import { createMemoryConfig, updateMemoryConfig } from '@/api/memory' import { getOntologyScenesSimpleUrl } from '@/api/ontology' import CustomSelect from '@/components/CustomSelect'; +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -110,7 +111,11 @@ const MemoryForm = forwardRef(({ @@ -118,6 +123,7 @@ const MemoryForm = forwardRef(({ diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index 17373a02..112534a5 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -20,6 +20,7 @@ import CustomSelect from '@/components/CustomSelect' import UploadImages from '@/components/Upload/UploadImages' import { updateCustomModel, addCustomModel, modelTypeUrl, modelProviderUrl } from '@/api/models' import { getFileLink } from '@/api/fileStorage' +import { validateSquareImage, stringRegExp } from '@/utils/validator' /** * Custom model modal component @@ -65,7 +66,7 @@ const CustomModelModal = forwardRef( const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data) res.then(() => { - refresh && refresh(isEdit) + refresh?.(isEdit) handleClose() message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) }) @@ -79,7 +80,7 @@ const CustomModelModal = forwardRef( .validateFields() .then((values) => { const { logo, ...rest } = values; - let formData: CustomModelForm = { + const formData: CustomModelForm = { ...rest } @@ -125,14 +126,22 @@ const CustomModelModal = forwardRef( name="logo" label={t('modelNew.logo')} valuePropName="fileList" - rules={[{ required: true, message: t('common.pleaseSelect') }]} + rules={[ + { required: true, message: t('common.pleaseSelect') }, + { validator: validateSquareImage(t('common.imageSquareRequired')) } + ]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
@@ -166,6 +175,7 @@ const CustomModelModal = forwardRef( diff --git a/web/src/views/ModelManagement/components/GroupModelModal.tsx b/web/src/views/ModelManagement/components/GroupModelModal.tsx index efcd77f6..5ca46548 100644 --- a/web/src/views/ModelManagement/components/GroupModelModal.tsx +++ b/web/src/views/ModelManagement/components/GroupModelModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:49:33 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:49:33 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-02 12:23:13 */ /** * Group Model Modal @@ -21,6 +21,7 @@ import { updateCompositeModel, modelTypeUrl, addCompositeModel } from '@/api/mod import UploadImages from '@/components/Upload/UploadImages' import ModelImplement from './ModelImplement' import { getFileLink } from '@/api/fileStorage' +import { validateSquareImage, stringRegExp } from '@/utils/validator' /** * Group model modal component @@ -133,15 +134,26 @@ const GroupModelModal = forwardRef(({ name="logo" label={t('modelNew.logo')} valuePropName="fileList" - rules={[{ required: true, message: t('common.pleaseSelect') }]} + rules={[ + { required: true, message: t('common.pleaseSelect') }, + { validator: validateSquareImage(t('common.imageSquareRequired')) } + ]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
@@ -165,6 +177,7 @@ const GroupModelModal = forwardRef(({ diff --git a/web/src/views/ModelManagement/index.tsx b/web/src/views/ModelManagement/index.tsx index 35d7d864..539ff5e3 100644 --- a/web/src/views/ModelManagement/index.tsx +++ b/web/src/views/ModelManagement/index.tsx @@ -121,6 +121,7 @@ const tabKeys = ['group', 'list', 'square'] {activeTab !== 'list' && diff --git a/web/src/views/Ontology/components/OntologyClassExtractModal.tsx b/web/src/views/Ontology/components/OntologyClassExtractModal.tsx index 802202ef..2fd305c6 100644 --- a/web/src/views/Ontology/components/OntologyClassExtractModal.tsx +++ b/web/src/views/Ontology/components/OntologyClassExtractModal.tsx @@ -182,7 +182,10 @@ const OntologyClassExtractModal = forwardRef diff --git a/web/src/views/Ontology/components/OntologyClassModal.tsx b/web/src/views/Ontology/components/OntologyClassModal.tsx index 087e542c..a467294e 100644 --- a/web/src/views/Ontology/components/OntologyClassModal.tsx +++ b/web/src/views/Ontology/components/OntologyClassModal.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import type { AddClassItem, OntologyClassModalRef } from '../types' import RbModal from '@/components/RbModal' import { createOntologyClass } from '@/api/ontology' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -105,7 +106,11 @@ const OntologyClassModal = forwardRef @@ -113,6 +118,7 @@ const OntologyClassModal = forwardRef diff --git a/web/src/views/Ontology/components/OntologyModal.tsx b/web/src/views/Ontology/components/OntologyModal.tsx index a4c203ed..92e94bb6 100644 --- a/web/src/views/Ontology/components/OntologyModal.tsx +++ b/web/src/views/Ontology/components/OntologyModal.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import type { OntologyItem, OntologyModalData, OntologyModalRef } from '../types' import RbModal from '@/components/RbModal' import { createOntologyScene, updateOntologyScene } from '@/api/ontology' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -109,7 +110,11 @@ const OntologyModal = forwardRef(({ @@ -117,6 +122,7 @@ const OntologyModal = forwardRef(({ diff --git a/web/src/views/Skills/pages/SkillConfig.tsx b/web/src/views/Skills/pages/SkillConfig.tsx index 6e12e72d..f9f76dea 100644 --- a/web/src/views/Skills/pages/SkillConfig.tsx +++ b/web/src/views/Skills/pages/SkillConfig.tsx @@ -17,6 +17,7 @@ import type { AiPromptModalRef } from '@/views/ApplicationConfig/types' import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import type { SkillFormData } from '../types' import { getSkillDetail, createSkill, updateSkill } from '@/api/skill' +import { stringRegExp } from '@/utils/validator'; /** * Skill Configuration Page Component @@ -110,7 +111,7 @@ const SkillConfig: FC = () => { // Format tools data for API const formData = { ...rest, - tools: tools?.map((item: any) => ({ + tools: tools?.map((item) => ({ tool_id: item.tool_id, operation: item.operation })) @@ -144,13 +145,18 @@ const SkillConfig: FC = () => { diff --git a/web/src/views/Skills/types.ts b/web/src/views/Skills/types.ts index 950bfb03..c0df3fbf 100644 --- a/web/src/views/Skills/types.ts +++ b/web/src/views/Skills/types.ts @@ -17,6 +17,8 @@ export interface SkillFormData { tools: Array<{ /** Tool identifier */ tool_id: string; + /** Tool operation/action */ + operation?: string; }>; /** Skill configuration settings */ config: { diff --git a/web/src/views/SpaceManagement/components/SpaceModal.tsx b/web/src/views/SpaceManagement/components/SpaceModal.tsx index 4f37b246..5a639244 100644 --- a/web/src/views/SpaceManagement/components/SpaceModal.tsx +++ b/web/src/views/SpaceManagement/components/SpaceModal.tsx @@ -23,6 +23,7 @@ import UploadImages from '@/components/Upload/UploadImages' import { getFileLink } from '@/api/fileStorage' import ragIcon from '@/assets/images/space/rag.png' import neo4jIcon from '@/assets/images/space/neo4j.png' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -91,7 +92,7 @@ const SpaceModal = forwardRef(({ setCurrentStep(1) } else { const { icon, ...rest } = values - let formData: SpaceModalData = { + const formData: SpaceModalData = { ...rest } if (icon?.response?.data.file_id) { @@ -164,14 +165,19 @@ const SpaceModal = forwardRef(({ valuePropName="fileList" hidden={currentStep === 1} rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('space.spaceIcon') }) }]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
From 62b2ecdfc2eff71c372a03d5b1634cf16693f703 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 2 Mar 2026 14:41:58 +0800 Subject: [PATCH 50/55] feat(web): form add rules --- web/src/components/SearchInput/index.tsx | 2 + web/src/i18n/en.ts | 5 +- web/src/i18n/zh.ts | 5 +- web/src/utils/validator.ts | 50 +++++++++++++++++++ .../components/ApiKeyModal.tsx | 10 +++- web/src/views/ApplicationConfig/Agent.tsx | 10 ++-- .../components/ApplicationModal.tsx | 8 ++- .../components/MemberModal.tsx | 6 ++- .../components/MemoryForm.tsx | 8 ++- .../components/CustomModelModal.tsx | 20 ++++++-- .../components/GroupModelModal.tsx | 23 +++++++-- web/src/views/ModelManagement/index.tsx | 1 + .../components/OntologyClassExtractModal.tsx | 5 +- .../components/OntologyClassModal.tsx | 8 ++- .../Ontology/components/OntologyModal.tsx | 8 ++- web/src/views/Skills/pages/SkillConfig.tsx | 10 +++- web/src/views/Skills/types.ts | 2 + .../SpaceManagement/components/SpaceModal.tsx | 12 +++-- 18 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 web/src/utils/validator.ts diff --git a/web/src/components/SearchInput/index.tsx b/web/src/components/SearchInput/index.tsx index 32a64310..476c2cbb 100644 --- a/web/src/components/SearchInput/index.tsx +++ b/web/src/components/SearchInput/index.tsx @@ -41,6 +41,8 @@ interface SearchInputProps { className?: string; /** Input size */ size?: InputProps['size'] + /** Maximum length of the input value */ + maxLength?: number; } /** Search input component with debounce and throttle support */ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 02add0ec..c5c81f13 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -453,6 +453,8 @@ export const en = { prevStep: 'Previous Step', exportSuccess: 'Export successful', recommend: 'Recommend', + logoTip: `Supported image formats: JPG, PNG \n Suggested size: square ratio \n Maximum size: ≤ 2MB`, + imageSquareRequired: 'Please upload a square image', }, model: { searchPlaceholder: 'search model…', @@ -542,7 +544,8 @@ export const en = { ollama: "Ollama", xinference: "Xinference", gpustack: "Gpustack", - bedrock: "Bedrock" + bedrock: "Bedrock", + nameInvalid: 'Model name can only contain letters, numbers, underscores and spaces, cannot be empty or pure whitespace', }, modelNew: { group: 'Model Group', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 06abf63a..f63e5981 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1029,6 +1029,8 @@ export const zh = { prevStep: '上一步', exportSuccess: '导出成功', recommend: '推荐', + logoTip: `支持图片格式(JPG、PNG)\n 尺寸:正方形比例 \n 文件大小限制:≤ 2MB`, + imageSquareRequired: '请上传正方形比例图片', }, model: { searchPlaceholder: '搜索模型…', @@ -1176,7 +1178,8 @@ export const zh = { ollama: "Ollama", xinference: "Xinference", gpustack: "Gpustack", - bedrock: "Bedrock" + bedrock: "Bedrock", + nameInvalid: '模型名称只能包含字母、数字、下划线和空格, 不能为空或纯空格', }, timezones: { 'Asia/Shanghai': '中国标准时间 (UTC+8)', diff --git a/web/src/utils/validator.ts b/web/src/utils/validator.ts new file mode 100644 index 00000000..c55c52ca --- /dev/null +++ b/web/src/utils/validator.ts @@ -0,0 +1,50 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-03-02 13:46:53 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-02 14:38:33 + */ +/** + * Form validation utilities + */ + +interface UploadFile { + originFileObj: Blob; + [key: string]: unknown; +} + +/** + * Validate if uploaded image is square (width === height) + * @param errorMessage - Error message to display when validation fails + * @returns Ant Design form validator + */ +export const validateSquareImage = (errorMessage: string = 'Image must be square') => { + return (_: unknown, value: UploadFile | UploadFile[] | undefined) => { + if (!value || (Array.isArray(value) && value.length === 0)) { + return Promise.resolve(); + } + + const file = Array.isArray(value) ? value[0] : value; + + if (file?.originFileObj) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + if (img.width === img.height) { + resolve(); + } else { + reject(new Error(errorMessage)); + } + }; + img.onerror = () => reject(new Error('Failed to load image')); + img.src = URL.createObjectURL(file.originFileObj); + }); + } + + return Promise.resolve(); + }; +}; + +// - Cannot be empty or pure whitespace +// - Cannot start with a space +export const stringRegExp = /^[a-zA-Z0-9\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fa5\s]*$/ \ No newline at end of file diff --git a/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx index 9395df43..05e73992 100644 --- a/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx +++ b/web/src/views/ApiKeyManagement/components/ApiKeyModal.tsx @@ -12,6 +12,7 @@ import dayjs from 'dayjs' import type { ApiKey, ApiKeyModalRef } from '../types'; import RbModal from '@/components/RbModal' import { createApiKey, updateApiKey } from '@/api/apiKey'; +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -78,7 +79,7 @@ const ApiKeyModal = forwardRef(({ form.validateFields() .then((values) => { const { memory, rag, expires_at, ...rest } = values - let scopes = [] + const scopes = [] if (memory) { scopes.push('memory') @@ -130,7 +131,11 @@ const ApiKeyModal = forwardRef(({ @@ -138,6 +143,7 @@ const ApiKeyModal = forwardRef(({ diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx index 2ece4b6e..4bee291b 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -169,8 +169,8 @@ const Agent = forwardRef((_props, ref) => { getApplicationConfig(id as string).then(res => { const response = res as Config const { skills, variables } = response - let allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : [] - let allTools = Array.isArray(response.tools) ? response.tools : [] + const allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : [] + const allTools = Array.isArray(response.tools) ? response.tools : [] const memoryContent = response.memory?.memory_config_id const parsedMemoryContent = memoryContent === null || memoryContent === '' ? undefined @@ -431,7 +431,11 @@ const Agent = forwardRef((_props, ref) => { - + ( diff --git a/web/src/views/MemberManagement/components/MemberModal.tsx b/web/src/views/MemberManagement/components/MemberModal.tsx index 002c8632..e16c60ba 100644 --- a/web/src/views/MemberManagement/components/MemberModal.tsx +++ b/web/src/views/MemberManagement/components/MemberModal.tsx @@ -152,7 +152,11 @@ const MemberModal = forwardRef(({ diff --git a/web/src/views/MemoryManagement/components/MemoryForm.tsx b/web/src/views/MemoryManagement/components/MemoryForm.tsx index 93246ca9..282199af 100644 --- a/web/src/views/MemoryManagement/components/MemoryForm.tsx +++ b/web/src/views/MemoryManagement/components/MemoryForm.tsx @@ -18,6 +18,7 @@ import RbModal from '@/components/RbModal' import { createMemoryConfig, updateMemoryConfig } from '@/api/memory' import { getOntologyScenesSimpleUrl } from '@/api/ontology' import CustomSelect from '@/components/CustomSelect'; +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -110,7 +111,11 @@ const MemoryForm = forwardRef(({ @@ -118,6 +123,7 @@ const MemoryForm = forwardRef(({ diff --git a/web/src/views/ModelManagement/components/CustomModelModal.tsx b/web/src/views/ModelManagement/components/CustomModelModal.tsx index 17373a02..112534a5 100644 --- a/web/src/views/ModelManagement/components/CustomModelModal.tsx +++ b/web/src/views/ModelManagement/components/CustomModelModal.tsx @@ -20,6 +20,7 @@ import CustomSelect from '@/components/CustomSelect' import UploadImages from '@/components/Upload/UploadImages' import { updateCustomModel, addCustomModel, modelTypeUrl, modelProviderUrl } from '@/api/models' import { getFileLink } from '@/api/fileStorage' +import { validateSquareImage, stringRegExp } from '@/utils/validator' /** * Custom model modal component @@ -65,7 +66,7 @@ const CustomModelModal = forwardRef( const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data) res.then(() => { - refresh && refresh(isEdit) + refresh?.(isEdit) handleClose() message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess')) }) @@ -79,7 +80,7 @@ const CustomModelModal = forwardRef( .validateFields() .then((values) => { const { logo, ...rest } = values; - let formData: CustomModelForm = { + const formData: CustomModelForm = { ...rest } @@ -125,14 +126,22 @@ const CustomModelModal = forwardRef( name="logo" label={t('modelNew.logo')} valuePropName="fileList" - rules={[{ required: true, message: t('common.pleaseSelect') }]} + rules={[ + { required: true, message: t('common.pleaseSelect') }, + { validator: validateSquareImage(t('common.imageSquareRequired')) } + ]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
@@ -166,6 +175,7 @@ const CustomModelModal = forwardRef( diff --git a/web/src/views/ModelManagement/components/GroupModelModal.tsx b/web/src/views/ModelManagement/components/GroupModelModal.tsx index efcd77f6..5ca46548 100644 --- a/web/src/views/ModelManagement/components/GroupModelModal.tsx +++ b/web/src/views/ModelManagement/components/GroupModelModal.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-03 16:49:33 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-03 16:49:33 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-02 12:23:13 */ /** * Group Model Modal @@ -21,6 +21,7 @@ import { updateCompositeModel, modelTypeUrl, addCompositeModel } from '@/api/mod import UploadImages from '@/components/Upload/UploadImages' import ModelImplement from './ModelImplement' import { getFileLink } from '@/api/fileStorage' +import { validateSquareImage, stringRegExp } from '@/utils/validator' /** * Group model modal component @@ -133,15 +134,26 @@ const GroupModelModal = forwardRef(({ name="logo" label={t('modelNew.logo')} valuePropName="fileList" - rules={[{ required: true, message: t('common.pleaseSelect') }]} + rules={[ + { required: true, message: t('common.pleaseSelect') }, + { validator: validateSquareImage(t('common.imageSquareRequired')) } + ]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
@@ -165,6 +177,7 @@ const GroupModelModal = forwardRef(({ diff --git a/web/src/views/ModelManagement/index.tsx b/web/src/views/ModelManagement/index.tsx index 35d7d864..539ff5e3 100644 --- a/web/src/views/ModelManagement/index.tsx +++ b/web/src/views/ModelManagement/index.tsx @@ -121,6 +121,7 @@ const tabKeys = ['group', 'list', 'square'] {activeTab !== 'list' && diff --git a/web/src/views/Ontology/components/OntologyClassExtractModal.tsx b/web/src/views/Ontology/components/OntologyClassExtractModal.tsx index 802202ef..2fd305c6 100644 --- a/web/src/views/Ontology/components/OntologyClassExtractModal.tsx +++ b/web/src/views/Ontology/components/OntologyClassExtractModal.tsx @@ -182,7 +182,10 @@ const OntologyClassExtractModal = forwardRef diff --git a/web/src/views/Ontology/components/OntologyClassModal.tsx b/web/src/views/Ontology/components/OntologyClassModal.tsx index 087e542c..a467294e 100644 --- a/web/src/views/Ontology/components/OntologyClassModal.tsx +++ b/web/src/views/Ontology/components/OntologyClassModal.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import type { AddClassItem, OntologyClassModalRef } from '../types' import RbModal from '@/components/RbModal' import { createOntologyClass } from '@/api/ontology' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -105,7 +106,11 @@ const OntologyClassModal = forwardRef @@ -113,6 +118,7 @@ const OntologyClassModal = forwardRef diff --git a/web/src/views/Ontology/components/OntologyModal.tsx b/web/src/views/Ontology/components/OntologyModal.tsx index a4c203ed..92e94bb6 100644 --- a/web/src/views/Ontology/components/OntologyModal.tsx +++ b/web/src/views/Ontology/components/OntologyModal.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import type { OntologyItem, OntologyModalData, OntologyModalRef } from '../types' import RbModal from '@/components/RbModal' import { createOntologyScene, updateOntologyScene } from '@/api/ontology' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -109,7 +110,11 @@ const OntologyModal = forwardRef(({ @@ -117,6 +122,7 @@ const OntologyModal = forwardRef(({ diff --git a/web/src/views/Skills/pages/SkillConfig.tsx b/web/src/views/Skills/pages/SkillConfig.tsx index 6e12e72d..f9f76dea 100644 --- a/web/src/views/Skills/pages/SkillConfig.tsx +++ b/web/src/views/Skills/pages/SkillConfig.tsx @@ -17,6 +17,7 @@ import type { AiPromptModalRef } from '@/views/ApplicationConfig/types' import exitIcon from '@/assets/images/knowledgeBase/exit.png'; import type { SkillFormData } from '../types' import { getSkillDetail, createSkill, updateSkill } from '@/api/skill' +import { stringRegExp } from '@/utils/validator'; /** * Skill Configuration Page Component @@ -110,7 +111,7 @@ const SkillConfig: FC = () => { // Format tools data for API const formData = { ...rest, - tools: tools?.map((item: any) => ({ + tools: tools?.map((item) => ({ tool_id: item.tool_id, operation: item.operation })) @@ -144,13 +145,18 @@ const SkillConfig: FC = () => { diff --git a/web/src/views/Skills/types.ts b/web/src/views/Skills/types.ts index 950bfb03..c0df3fbf 100644 --- a/web/src/views/Skills/types.ts +++ b/web/src/views/Skills/types.ts @@ -17,6 +17,8 @@ export interface SkillFormData { tools: Array<{ /** Tool identifier */ tool_id: string; + /** Tool operation/action */ + operation?: string; }>; /** Skill configuration settings */ config: { diff --git a/web/src/views/SpaceManagement/components/SpaceModal.tsx b/web/src/views/SpaceManagement/components/SpaceModal.tsx index 4f37b246..5a639244 100644 --- a/web/src/views/SpaceManagement/components/SpaceModal.tsx +++ b/web/src/views/SpaceManagement/components/SpaceModal.tsx @@ -23,6 +23,7 @@ import UploadImages from '@/components/Upload/UploadImages' import { getFileLink } from '@/api/fileStorage' import ragIcon from '@/assets/images/space/rag.png' import neo4jIcon from '@/assets/images/space/neo4j.png' +import { stringRegExp } from '@/utils/validator'; const FormItem = Form.Item; @@ -91,7 +92,7 @@ const SpaceModal = forwardRef(({ setCurrentStep(1) } else { const { icon, ...rest } = values - let formData: SpaceModalData = { + const formData: SpaceModalData = { ...rest } if (icon?.response?.data.file_id) { @@ -164,14 +165,19 @@ const SpaceModal = forwardRef(({ valuePropName="fileList" hidden={currentStep === 1} rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('space.spaceIcon') }) }]} + extra={t('common.logoTip')?.split('\n').map((vo, index) =>
{vo}
)} > - +
From 9be1c01b70a4ea741c00803894ae03184151ec7d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 2 Mar 2026 14:43:44 +0800 Subject: [PATCH 51/55] feat(web): chat content support scroll --- web/src/components/Chat/ChatContent.tsx | 35 ++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index 32e6ae23..c1f5223c 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -27,12 +27,45 @@ const ChatContent: FC = ({ }) => { // Scroll container reference for controlling auto-scroll to bottom const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) + const prevDataLengthRef = useRef(data.length); + const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom + + // Track scroll position to determine if user is at bottom + useEffect(() => { + const handleScroll = () => { + if (scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + // Consider user is at bottom if within 20px of the bottom + isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 20; + } + }; + + const container = scrollContainerRef.current; + if (container) { + container.addEventListener('scroll', handleScroll); + // Initial check + handleScroll(); + } + + return () => { + if (container) { + container.removeEventListener('scroll', handleScroll); + } + }; + }, []); // Auto-scroll to bottom when data changes to show latest messages + // When data array length remains unchanged, if data is updated and user manually scrolled up, don't auto-scroll to bottom + // When data array length changes, auto-scroll to bottom + // If already scrolled to bottom, will auto-scroll to bottom useEffect(() => { setTimeout(() => { if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + // Auto-scroll if data length changed OR user is currently at bottom + if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + } + prevDataLengthRef.current = data.length; } }, 0); }, [data]) From 5cf2b087771af886debc1f681c5ee89f5e21028b Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 2 Mar 2026 14:52:51 +0800 Subject: [PATCH 52/55] fix(workflow): handle non-stream output field changes, add file type support to HTTP node, fix iteration node flattening bug --- .../core/workflow/adapters/dify/converter.py | 2 +- api/app/core/workflow/engine/variable_pool.py | 16 +++++++- .../core/workflow/nodes/cycle_graph/node.py | 2 +- .../core/workflow/nodes/http_request/node.py | 35 ++++++++++++---- .../workflow/variable/variable_objects.py | 41 ++++++++++++++----- api/app/services/workflow_service.py | 1 + 6 files changed, 76 insertions(+), 21 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 18beef15..798b78b7 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -671,4 +671,4 @@ class DifyConverter(BaseConverter): type=ExceptionType.CONFIG, detail=f"Please reconfigure the tool node.", )) - return {} \ No newline at end of file + return {} diff --git a/api/app/core/workflow/engine/variable_pool.py b/api/app/core/workflow/engine/variable_pool.py index 22be08c8..fd28eba5 100644 --- a/api/app/core/workflow/engine/variable_pool.py +++ b/api/app/core/workflow/engine/variable_pool.py @@ -73,7 +73,7 @@ class VariableStruct(BaseModel, Generic[T]): instance: The concrete variable object. The actual Python type is represented by the generic parameter ``T`` (e.g. StringVariable, - NumberVariable, ArrayObject[StringVariable]). + NumberVariable, ArrayVariable[StringVariable]). mut: Whether the variable is mutable. """ @@ -152,6 +152,20 @@ class VariablePool: return None return var_instance + def get_instance( + self, + selector: str, + default: Any = None, + strict: bool = True + ): + variable_struct = self._get_variable_struct(selector) + if variable_struct is None: + if strict: + raise KeyError(f"{selector} not exist") + return default + + return variable_struct.instance + def get_value( self, selector: str, diff --git a/api/app/core/workflow/nodes/cycle_graph/node.py b/api/app/core/workflow/nodes/cycle_graph/node.py index f2912e2c..71e0dbdb 100644 --- a/api/app/core/workflow/nodes/cycle_graph/node.py +++ b/api/app/core/workflow/nodes/cycle_graph/node.py @@ -66,7 +66,7 @@ class CycleGraphNode(BaseNode): if config.flatten: outputs['output'] = config.output_type else: - outputs['output'] = VariableType.ARRAY_STRING + outputs['output'] = VariableType.NESTED_ARRAY else: outputs['output'] = VariableType(f"array[{config.output_type}]") return outputs diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index df899940..e6c00eff 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import uuid from typing import Any, Callable, Coroutine import httpx @@ -13,6 +14,7 @@ from app.core.workflow.nodes.base_node import BaseNode from app.core.workflow.nodes.enums import HttpRequestMethod, HttpErrorHandle, HttpAuthType, HttpContentType from app.core.workflow.nodes.http_request.config import HttpRequestNodeConfig, HttpRequestNodeOutput from app.core.workflow.variable.base_variable import VariableType +from app.core.workflow.variable.variable_objects import FileVariable, ArrayVariable logger = logging.getLogger(__file__) @@ -115,7 +117,7 @@ class HttpRequestNode(BaseNode): params[self._render_template(key, variable_pool)] = self._render_template(value, variable_pool) return params - def _build_content(self, variable_pool: VariablePool) -> dict[str, Any]: + async def _build_content(self, variable_pool: VariablePool) -> dict[str, Any]: """ Build HTTP request body arguments for httpx request methods. @@ -135,16 +137,35 @@ class HttpRequestNode(BaseNode): )) case HttpContentType.FROM_DATA: data = {} + content["files"] = {} for item in self.typed_config.body.data: if item.type == "text": - data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, variable_pool) + data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, + variable_pool) elif item.type == "file": - # TODO: File support (Feature) - pass + content["files"][self._render_template(item.key, variable_pool)] = ( + uuid.uuid4().hex, + await variable_pool.get_instance(item.value).get_content() + ) content["data"] = data case HttpContentType.BINARY: - # TODO: File support (Feature) - pass + content["files"] = [] + file_instence = variable_pool.get_instance(self.typed_config.body.data) + if isinstance(file_instence, ArrayVariable): + for v in file_instence.value: + if isinstance(v, FileVariable): + content["files"].append( + ( + "files", (uuid.uuid4().hex, await v.get_content()) + ) + ) + elif isinstance(file_instence, FileVariable): + content["files"].append( + ( + "file", (uuid.uuid4().hex, await file_instence.get_content()) + ) + ) + case HttpContentType.WWW_FORM: content["data"] = json.loads(self._render_template( json.dumps(self.typed_config.body.data), variable_pool @@ -207,7 +228,7 @@ class HttpRequestNode(BaseNode): request_func = self._get_client_method(client) resp = await request_func( url=self._render_template(self.typed_config.url, variable_pool), - **self._build_content(variable_pool) + **(await self._build_content(variable_pool)) ) resp.raise_for_status() logger.info(f"Node {self.node_id}: HTTP request succeeded") diff --git a/api/app/core/workflow/variable/variable_objects.py b/api/app/core/workflow/variable/variable_objects.py index 7a39835c..49541afc 100644 --- a/api/app/core/workflow/variable/variable_objects.py +++ b/api/app/core/workflow/variable/variable_objects.py @@ -1,8 +1,10 @@ from typing import Any, TypeVar, Type, Generic +import httpx from deprecated import deprecated from app.core.workflow.variable.base_variable import BaseVariable, VariableType, FileObject, FileType +from app.core.config import settings T = TypeVar("T", bound=BaseVariable) @@ -80,8 +82,23 @@ class FileVariable(BaseVariable): def get_value(self) -> Any: return self.value.model_dump() + async def get_content(self): + total_bytes = 0 + chunks = [] -class ArrayObject(BaseVariable, Generic[T]): + async with httpx.AsyncClient() as client: + async with client.stream("GET", self.value.url) as resp: + resp.raise_for_status() + async for chunk in resp.aiter_bytes(8192): + total_bytes += len(chunk) + if total_bytes > settings.MAX_FILE_SIZE: + raise ValueError(f"File too large: {total_bytes} bytes") + chunks.append(chunk) + + return b"".join(chunks) + + +class ArrayVariable(BaseVariable, Generic[T]): type = 'array' def __init__(self, child_type: Type[T], value: list[Any]): @@ -108,7 +125,7 @@ class ArrayObject(BaseVariable, Generic[T]): return [v.get_value() for v in self.value] -class NestedArrayObject(BaseVariable): +class NestedArrayVariable(BaseVariable): type = 'array_nest' def valid_value(self, value: list[T]) -> list[T]: @@ -116,23 +133,23 @@ class NestedArrayObject(BaseVariable): raise TypeError(f"Value must be a list - {type(value)}:{value}") final_value = [] for v in value: - if not isinstance(v, ArrayObject): + if not isinstance(v, list): raise TypeError("All elements must be of type list") - final_value.append(v) + final_value.append(make_array(AnyVariable, v)) return final_value def to_literal(self) -> str: - return "\n".join(["\n".join([item.to_literal() for item in row]) for row in self.value]) + return "\n".join(["\n".join([str(item) for item in row.get_value()]) for row in self.value]) def get_value(self) -> Any: - return [[item.get_value() for item in row] for row in self.value] + return [[item for item in row.get_value()] for row in self.value] @deprecated( reason="Using arbitrary-type values may cause unexpected errors; please switch to strongly-typed values.", category=RuntimeWarning ) -class AnyObject(BaseVariable): +class AnyVariable(BaseVariable): type = 'any' def valid_value(self, value: Any) -> Any: @@ -142,10 +159,10 @@ class AnyObject(BaseVariable): return str(self.value) -def make_array(child_type: Type[T], value: list[Any]) -> ArrayObject[T]: - """简化 ArrayObject 创建,不需要重复写类型""" +def make_array(child_type: Type[T], value: list[Any]) -> ArrayVariable[T]: + """简化 ArrayVariable 创建,不需要重复写类型""" - return ArrayObject(child_type, value) + return ArrayVariable(child_type, value) def create_variable_instance(var_type: VariableType, value: Any) -> T: @@ -168,7 +185,9 @@ def create_variable_instance(var_type: VariableType, value: Any) -> T: return make_array(DictVariable, value) case VariableType.ARRAY_FILE: return make_array(FileVariable, value) + case VariableType.NESTED_ARRAY: + return NestedArrayVariable(value) case VariableType.ANY: - return AnyObject(value) + return AnyVariable(value) case _: raise TypeError(f"Invalid type - {var_type}") diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index 188ef6cd..ffcf8b0c 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -580,6 +580,7 @@ class WorkflowService: # "variables": result.get("variables"), # "messages": result.get("messages"), "output": result.get("output"), # 最终输出(字符串) + "message": result.get("output"), # 最终输出(字符串) # "output_data": result.get("node_outputs", {}), # 所有节点输出(详细数据) "conversation_id": result.get("conversation_id"), # 所有节点输出(详细数据)payload., # 会话 ID "error_message": result.get("error"), From 9962a61c21ecf54c7040f6a34bcef8807699ff7f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 2 Mar 2026 15:54:35 +0800 Subject: [PATCH 53/55] feat(web): update app api --- web/src/views/ApplicationConfig/Api.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/ApplicationConfig/Api.tsx b/web/src/views/ApplicationConfig/Api.tsx index c4b0fefb..22cec3e8 100644 --- a/web/src/views/ApplicationConfig/Api.tsx +++ b/web/src/views/ApplicationConfig/Api.tsx @@ -29,7 +29,7 @@ const Api: FC<{ application: Application | null }> = ({ application }) => { const { t } = useTranslation(); const activeMethods = ['POST']; const { message, modal } = App.useApp() - const copyContent = window.location.origin + '/v1/chat' + const copyContent = window.location.origin + '/v1/app/chat' const apiKeyModalRef = useRef(null); const apiKeyConfigModalRef = useRef(null); const [apiKeyList, setApiKeyList] = useState([]) From 574ab4506b755bb346d3c238ef5da6872d450921 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Mon, 2 Mar 2026 16:05:25 +0800 Subject: [PATCH 54/55] feat(workflow): add placeholder node for unknown types --- .../core/workflow/adapters/dify/converter.py | 152 ++++++++++++------ .../workflow/adapters/dify/dify_adapter.py | 11 +- api/app/core/workflow/engine/variable_pool.py | 16 ++ api/app/core/workflow/executor.py | 64 ++++---- api/app/core/workflow/nodes/enums.py | 2 + api/app/core/workflow/nodes/node_factory.py | 4 +- 6 files changed, 163 insertions(+), 86 deletions(-) diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index 798b78b7..2014b4c3 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -12,7 +12,7 @@ from app.core.workflow.adapters.errors import UnsupportVariableType, UnknowModel ExceptionType from app.core.workflow.nodes.assigner import AssignerNodeConfig from app.core.workflow.nodes.assigner.config import AssignmentItem -from app.core.workflow.nodes.base_config import VariableDefinition +from app.core.workflow.nodes.base_config import VariableDefinition, BaseNodeConfig from app.core.workflow.nodes.code import CodeNodeConfig from app.core.workflow.nodes.code.config import InputVariable, OutputVariable from app.core.workflow.nodes.configs import StartNodeConfig, LLMNodeConfig @@ -69,9 +69,27 @@ class DifyConverter(BaseConverter): } def get_node_convert(self, node_type): - func = self.CONFIG_CONVERT_MAP.get(node_type, None) + func = self.CONFIG_CONVERT_MAP.get(node_type, lambda x: {}) return func + def config_validate( + self, + node_id: str, + node_name: str, + config: type[BaseNodeConfig], + value: dict + ): + try: + return config.model_validate(value) + except Exception as e: + self.errors.append(ExceptionDefineition( + type=ExceptionType.CONFIG, + node_id=node_id, + node_name=node_name, + detail=str(e) + )) + return None + @staticmethod def is_variable(expression) -> bool: return bool(re.match(r"\{\{#(.*?)#}}", expression)) @@ -80,14 +98,16 @@ class DifyConverter(BaseConverter): if not var_selector: return "" selector = var_selector.split('.') - if len(selector) != 2: + if len(selector) not in [2, 3]: raise Exception(f"invalid variable selector: {var_selector}") + if len(selector) == 3: + selector = selector[1:] if selector[0] == "conversation": selector[0] = "conv" var_selector = ".".join(selector) mapping = { - "sys.query": "sys.message" - } | self.node_output_map + "sys.query": "sys.message" + } | self.node_output_map var_selector = mapping.get(var_selector, var_selector) return var_selector @@ -237,7 +257,7 @@ class DifyConverter(BaseConverter): node_id=node["id"], node_name=node_data["title"], name=var["variable"], - detail=f"Unsupport Variable type for start node: {var_type}" + detail=f"Unsupported Variable type for start node: {var_type}" ) ) continue @@ -253,9 +273,11 @@ class DifyConverter(BaseConverter): max_length=var.get("max_length"), ) start_vars.append(var_def) - return StartNodeConfig( + result = StartNodeConfig.model_construct( variables=start_vars ).model_dump() + self.config_validate(node["id"], node["data"]["title"], StartNodeConfig, result) + return result def convert_question_classifier_node_config(self, node: dict) -> dict: node_data = node["data"] @@ -270,16 +292,18 @@ class DifyConverter(BaseConverter): for category in node_data["classes"]: self.branch_node_cache[node["id"]].append(category["id"]) categories.append( - ClassifierConfig( + ClassifierConfig.model_construct( class_name=category["name"], ) ) - return QuestionClassifierNodeConfig.model_construct( - input_variable=self._process_list_variable_litearl(node_data["query_variable_selector"]), - user_supplement_prompt=self.trans_variable_format(node_data["instructions"]), + result = QuestionClassifierNodeConfig.model_construct( + input_variable=self._process_list_variable_litearl(node_data.get("query_variable_selector")), + user_supplement_prompt=self.trans_variable_format(node_data.get("instructions", "")), categories=categories, ).model_dump() + self.config_validate(node["id"], node["data"]["title"], QuestionClassifierNodeConfig, result) + return result def convert_llm_node_config(self, node: dict) -> dict: node_data = node["data"] @@ -315,7 +339,7 @@ class DifyConverter(BaseConverter): vision_input = self._process_list_variable_litearl( node_data["vision"]["configs"]["variable_selector"] ) if vision else None - return LLMNodeConfig.model_construct( + result = LLMNodeConfig.model_construct( model_id=None, context=context, memory=memory, @@ -323,12 +347,16 @@ class DifyConverter(BaseConverter): vision_input=vision_input, messages=messages ).model_dump() + self.config_validate(node["id"], node["data"]["title"], LLMNodeConfig, result) + return result def convert_end_node_config(self, node: dict) -> dict: node_data = node["data"] - return EndNodeConfig( - output=self.trans_variable_format(node_data["answer"]), + result = EndNodeConfig.model_construct( + output=self.trans_variable_format(node_data.get("answer", "")), ).model_dump() + self.config_validate(node["id"], node["data"]["title"], EndNodeConfig, result) + return result def convert_if_else_node_config(self, node: dict) -> dict: node_data = node["data"] @@ -359,9 +387,11 @@ class DifyConverter(BaseConverter): ) ) self.branch_node_cache[node["id"]].append(case_id) - return IfElseNodeConfig( + result = IfElseNodeConfig.model_construct( cases=cases ).model_dump() + self.config_validate(node["id"], node["data"]["title"], IfElseNodeConfig, result) + return result def convert_loop_node_config(self, node: dict) -> dict: node_data = node["data"] @@ -370,7 +400,7 @@ class DifyConverter(BaseConverter): for condition in node_data["break_conditions"]: right_value = condition["value"] conditions.append( - LoopConditionDetail( + LoopConditionDetail.model_construct( operator=self.convert_compare_operator(condition["comparison_operator"]), left=self._process_list_variable_litearl(condition["variable_selector"]), right=self.trans_variable_format( @@ -383,7 +413,7 @@ class DifyConverter(BaseConverter): if isinstance(right_value, str) and self.is_variable(right_value) else ValueInputType.CONSTANT, ) ) - condition_config = ConditionsConfig( + condition_config = ConditionsConfig.model_construct( logical_operator=logical_operator, expressions=conditions ) @@ -392,9 +422,9 @@ class DifyConverter(BaseConverter): right_input_type = variable["value_type"] right_value_type = self.variable_type_map(variable["var_type"]) if right_input_type == ValueInputType.VARIABLE: - right_value = self._process_list_variable_litearl(variable["value"]) + right_value = self._process_list_variable_litearl(variable.get("value", "")) else: - right_value = self.convert_variable_type(right_value_type, variable["value"]) + right_value = self.convert_variable_type(right_value_type, variable.get("value", "")) loop_variables.append( CycleVariable( name=variable["label"], @@ -403,23 +433,28 @@ class DifyConverter(BaseConverter): input_type=right_input_type ) ) - return LoopNodeConfig( + result = LoopNodeConfig.model_construct( condition=condition_config, cycle_vars=loop_variables, - max_loop=node_data["loop_count"] + max_loop=node_data.get("loop_count", 10) ).model_dump() + self.config_validate(node["id"], node["data"]["title"], LoopNodeConfig, result) + return result def convert_iteration_node_config(self, node: dict) -> dict: node_data = node["data"] - return IterationNodeConfig( + result = IterationNodeConfig.model_construct( input=self._process_list_variable_litearl(node_data["iterator_selector"]), parallel=node_data["is_parallel"], parallel_count=node_data["parallel_nums"], output=self._process_list_variable_litearl(node_data["output_selector"]), - output_type=self.variable_type_map(node_data["output_type"]), + output_type=self.variable_type_map(node_data.get("output_type")), flatten=node_data["flatten_output"], ).model_dump() + self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result) + return result + def convert_assigner_node_config(self, node: dict) -> dict: node_data = node["data"] assignments = [] @@ -435,16 +470,18 @@ class DifyConverter(BaseConverter): operation=self.convert_assignment_operator(assignment["operation"]) ) ) - return AssignerNodeConfig( + result = AssignerNodeConfig.model_construct( assignments=assignments ).model_dump() + self.config_validate(node["id"], node["data"]["title"], AssignerNodeConfig, result) + return result def convert_code_node_config(self, node: dict) -> dict: node_data = node["data"] input_variables = [] for input_variable in node_data["variables"]: input_variables.append( - InputVariable( + InputVariable.model_construct( name=input_variable["variable"], variable=self._process_list_variable_litearl(input_variable["value_selector"]), ) @@ -453,7 +490,7 @@ class DifyConverter(BaseConverter): output_variables = [] for output_variable in node_data["outputs"]: output_variables.append( - OutputVariable( + OutputVariable.model_construct( name=output_variable, type=node_data["outputs"][output_variable]["type"], ) @@ -461,18 +498,20 @@ class DifyConverter(BaseConverter): code = base64.b64encode(quote(node_data["code"]).encode("utf-8")).decode("utf-8") - return CodeNodeConfig( + result = CodeNodeConfig.model_construct( input_variables=input_variables, language=node_data["code_language"], output_variables=output_variables, code=code ).model_dump() + self.config_validate(node["id"], node["data"]["title"], CodeNodeConfig, result) + return result def convert_http_node_config(self, node: dict) -> dict: node_data = node["data"] - if node_data["authorization"] != 'no-auth': + if node_data["authorization"]["type"] != 'no-auth': auth_type = self.convert_http_auth_type(node_data["authorization"]["config"]["type"]) - auth_config = HttpAuthConfig( + auth_config = HttpAuthConfig.model_construct( auth_type=auth_type, header=node_data["authorization"]["config"].get("header"), api_key=node_data["authorization"]["config"].get("api_key"), @@ -504,7 +543,7 @@ class DifyConverter(BaseConverter): body_content = "" headers = {} - for header in node_data["headers"].split("\n"): + for header in node_data.get("headers", "").split("\n"): if not header: continue @@ -522,7 +561,7 @@ class DifyConverter(BaseConverter): )) params = {} - for param in node_data["params"].split("\n"): + for param in node_data.get("params", "").split("\n"): if not param: continue @@ -547,7 +586,7 @@ class DifyConverter(BaseConverter): default_body = "" default_header = {} default_status_code = 0 - for var in node_data["default_value"]: + for var in node_data.get("default_value") or []: if var["key"] == "body": default_body = var["value"] elif var["key"] == "header": @@ -561,45 +600,50 @@ class DifyConverter(BaseConverter): ) self.error_branch_node_cache.append(node['id']) - return HttpRequestNodeConfig( + result = HttpRequestNodeConfig.model_construct( method=node_data["method"].upper(), url=node_data["url"], auth=auth_config, - body=HttpContentTypeConfig( + body=HttpContentTypeConfig.model_construct( content_type=self.convert_http_content_type(node_data["body"]["type"]), data=body_content, ), headers=headers, params=params, verify_ssl=node_data["ssl_verify"], - timeouts=HttpTimeOutConfig( + timeouts=HttpTimeOutConfig.model_construct( connect_timeout=node_data["timeout"]["max_connect_timeout"] or 5, read_timeout=node_data["timeout"]["max_read_timeout"] or 5, write_timeout=node_data["timeout"]["max_write_timeout"] or 5, ), - retry=HttpRetryConfig( + retry=HttpRetryConfig.model_construct( enable=node_data["retry_config"]["retry_enabled"], max_attempts=node_data["retry_config"]["max_retries"], retry_interval=node_data["retry_config"]["retry_interval"], ), - error_handle=HttpErrorHandleConfig( + error_handle=HttpErrorHandleConfig.model_construct( method=error_handle_type, default=default_value, ) ).model_dump() + self.config_validate(node["id"], node["data"]["title"], HttpRequestNodeConfig, result) + return result + def convert_jinja_render_node_config(self, node: dict) -> dict: node_data = node["data"] mapping = [] for variable in node_data["variables"]: - mapping.append(VariablesMappingConfig( + mapping.append(VariablesMappingConfig.model_construct( name=variable["variable"], value=self._process_list_variable_litearl(variable["value_selector"]) )) - return JinjaRenderNodeConfig( + result = JinjaRenderNodeConfig.model_construct( template=node_data["template"], mapping=mapping, ).model_dump() + self.config_validate(node["id"], node["data"]["title"], JinjaRenderNodeConfig, result) + return result def convert_knowledge_node_config(self, node: dict) -> dict: node_data = node["data"] @@ -609,10 +653,13 @@ class DifyConverter(BaseConverter): type=ExceptionType.CONFIG, detail=f"Please reconfigure the Knowledge Retrieval node.", )) - return KnowledgeRetrievalNodeConfig.model_construct( + result = KnowledgeRetrievalNodeConfig.model_construct( query=self._process_list_variable_litearl(node_data["query_variable_selector"]), ).model_dump() + self.config_validate(node["id"], node["data"]["title"], KnowledgeRetrievalNodeConfig, result) + return result + def convert_parameter_extractor_node_config(self, node: dict) -> dict: node_data = node["data"] self.warnings.append( @@ -623,46 +670,53 @@ class DifyConverter(BaseConverter): ) ) params = [] - for param in node_data["parameters"]: + for param in node_data.get("parameters", []): params.append( - ParamsConfig( + ParamsConfig.model_construct( name=param["name"], desc=param["description"], required=param["required"], type=param["type"], ) ) - return ParameterExtractorNodeConfig.model_construct( + result = ParameterExtractorNodeConfig.model_construct( text=self._process_list_variable_litearl(node_data["query"]), params=params, - prompt=node_data["instruction"] + prompt=node_data.get("instruction") ).model_dump() + self.config_validate(node["id"], node["data"]["title"], ParameterExtractorNodeConfig, result) + return result + def convert_variable_aggregator_node_config(self, node: dict) -> dict: node_data = node["data"] - group_enable = node_data["advanced_settings"]["group_enabled"] + advanced_settings = node_data.get("advanced_settings", {}) group_variables = {} group_type = {} - if not group_enable: + if not advanced_settings or not advanced_settings["group_enabled"]: group_variables["output"] = [ self._process_list_variable_litearl(variable) for variable in node_data["variables"] ] group_type["output"] = node_data["output_type"] else: - for group in node_data["advanced_settings"]["groups"]: + for group in advanced_settings["groups"]: group_variables[group["group_name"]] = [ self._process_list_variable_litearl(variable) for variable in group["variables"] ] group_type[group["group_name"]] = group["output_type"] - return VariableAggregatorNodeConfig( - group=group_enable, + result = VariableAggregatorNodeConfig.model_construct( + group=advanced_settings.get("group_enabled", False), group_variables=group_variables, group_type=group_type, ).model_dump() + self.config_validate(node["id"], node["data"]["title"], VariableAggregatorNodeConfig, result) + + return result + def convert_tool_node_config(self, node: dict) -> dict: node_data = node["data"] self.warnings.append(ExceptionDefineition( diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index 2ecde092..dcd14c7f 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -59,7 +59,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): ) def map_node_type(self, platform_node_type) -> str: - return self.NODE_TYPE_MAPPING.get(platform_node_type) + return self.NODE_TYPE_MAPPING.get(platform_node_type, NodeType.UNKNOWN) @property def origin_nodes(self): @@ -179,8 +179,13 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): node_type = node_data["type"] try: converter = self.get_node_convert(node_type) - if converter is None: - raise Exception(f"node type not supported - {node_type}") + if node_type not in self.CONFIG_CONVERT_MAP: + self.errors.append(ExceptionDefineition( + type=ExceptionType.NODE, + node_id=node["id"], + node_name=node["data"]["title"], + detail=f"node type {node_type} is unsupported", + )) return converter(node) except Exception as e: self.errors.append(ExceptionDefineition( diff --git a/api/app/core/workflow/engine/variable_pool.py b/api/app/core/workflow/engine/variable_pool.py index fd28eba5..d08f47e5 100644 --- a/api/app/core/workflow/engine/variable_pool.py +++ b/api/app/core/workflow/engine/variable_pool.py @@ -158,6 +158,22 @@ class VariablePool: default: Any = None, strict: bool = True ): + """Retrieve a variable instance from the variable pool. + + Args: + selector: + Variable selector as a string variable literal (e.g. "{{ sys.message }}"). + default: + The value to return if the variable does not exist. + strict: + If True, raises KeyError when the variable does not exist. + + Returns: + The variable instance object if it exists; otherwise returns `default`. + + Raises: + KeyError: If strict is True and the variable does not exist. + """ variable_struct = self._get_variable_struct(selector) if variable_struct is None: if strict: diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 3c3137fe..e781b6c4 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -132,24 +132,24 @@ class WorkflowExecutor: start_time = datetime.datetime.now() - # Build the workflow graph - graph = self.build_graph() - - # Initialize the variable pool with input data - await self.variable_initializer.initialize( - variable_pool=self.variable_pool, - input_data=input_data, - execution_context=self.execution_context - ) - initial_state = self.state_manager.create_initial_state( - workflow_config=self.workflow_config, - input_data=input_data, - execution_context=self.execution_context, - start_node_id=self.start_node_id - ) - # Execute the workflow try: + # Build the workflow graph + graph = self.build_graph() + + # Initialize the variable pool with input data + await self.variable_initializer.initialize( + variable_pool=self.variable_pool, + input_data=input_data, + execution_context=self.execution_context + ) + initial_state = self.state_manager.create_initial_state( + workflow_config=self.workflow_config, + input_data=input_data, + execution_context=self.execution_context, + start_node_id=self.start_node_id + ) + result = await graph.ainvoke(initial_state, config=self.execution_context.checkpoint_config) # Aggregate output from all End nodes @@ -231,23 +231,23 @@ class WorkflowExecutor: } } - # Build the workflow graph in streaming mode - graph = self.build_graph(stream=True) - - # Initialize the variable pool and system variables - await self.variable_initializer.initialize( - variable_pool=self.variable_pool, - input_data=input_data, - execution_context=self.execution_context - ) - initial_state = self.state_manager.create_initial_state( - workflow_config=self.workflow_config, - input_data=input_data, - execution_context=self.execution_context, - start_node_id=self.start_node_id - ) - try: + # Build the workflow graph in streaming mode + graph = self.build_graph(stream=True) + + # Initialize the variable pool and system variables + await self.variable_initializer.initialize( + variable_pool=self.variable_pool, + input_data=input_data, + execution_context=self.execution_context + ) + initial_state = self.state_manager.create_initial_state( + workflow_config=self.workflow_config, + input_data=input_data, + execution_context=self.execution_context, + start_node_id=self.start_node_id + ) + full_content = '' self.stream_coordinator.update_scope_activation("sys") diff --git a/api/app/core/workflow/nodes/enums.py b/api/app/core/workflow/nodes/enums.py index 0579bdf5..ae9b81ff 100644 --- a/api/app/core/workflow/nodes/enums.py +++ b/api/app/core/workflow/nodes/enums.py @@ -24,6 +24,8 @@ class NodeType(StrEnum): MEMORY_READ = "memory-read" MEMORY_WRITE = "memory-write" + UNKNOWN = "unknown" + BRANCH_NODES = [NodeType.IF_ELSE, NodeType.HTTP_REQUEST, NodeType.QUESTION_CLASSIFIER] diff --git a/api/app/core/workflow/nodes/node_factory.py b/api/app/core/workflow/nodes/node_factory.py index 00120ca0..864e3251 100644 --- a/api/app/core/workflow/nodes/node_factory.py +++ b/api/app/core/workflow/nodes/node_factory.py @@ -123,10 +123,10 @@ class NodeFactory: # 获取节点类 node_class = cls._node_types.get(node_type) if not node_class: - raise ValueError(f"不支持的节点类型: {node_type}") + raise ValueError(f"Unsupported node type: {node_type}") # 创建节点实例 - logger.debug(f"创建节点: {node_config.get('id')} (type={node_type})") + logger.debug(f"create node instance: {node_config.get('id')} (type={node_type})") return node_class(node_config, workflow_config) @classmethod From c9a8753473bcae908c9fa77281634ea21b43dea5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 2 Mar 2026 18:38:08 +0800 Subject: [PATCH 55/55] revert(web): revert file --- web/.gitignore | 8 +- web/vite.config.ts | 14 +- web/变量信息.md | 654 --------------------------------------------- 3 files changed, 3 insertions(+), 673 deletions(-) delete mode 100644 web/变量信息.md diff --git a/web/.gitignore b/web/.gitignore index 2a94c851..89a253b3 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -23,10 +23,4 @@ dist-ssr *.sln *.sw? vite.config.js -package-lock.json - -src/test/* -src/*/__tests__/* -vitest.config.ts -public/vitest-auto-imports.d.ts -package_test.json \ No newline at end of file +package-lock.json \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index cf3f5013..88b3cd75 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,7 +3,6 @@ import react from '@vitejs/plugin-react' import { resolve } from 'path' import AutoImport from 'unplugin-auto-import/vite' import tailwindcss from '@tailwindcss/vite' -import svgr from 'vite-plugin-svgr' // https://vite.dev/config/ export default defineConfig({ @@ -12,15 +11,7 @@ export default defineConfig({ proxy: { // 主要API代理,支持 /api 和 /api/* 格式 '/api': { - // target: 'http://192.168.110.86:8000', // lxy - // target: 'http://192.168.110.25:8000', // xjn - // target: 'http://192.168.110.217:8000', // llq - target: 'http://192.168.110.111:8000', // myh - // target: 'https://devmemorybear.redbearai.com/', // 开发后端服务地址 - // target: 'https://devcopymemorybear.redbearai.com/', // 开发sass后端服务地址 - // target: 'https://testmemorybear.redbearai.com/', // 测试后端服务地址 - // target: 'https://memorybear.redbearai.com/', // 预发服务地址 - // target: 'https://cloud.memorybear.ai/', // AMAZON 生产地址 + target: 'http://0.0.0.0:5173', // 后端服务地址 changeOrigin: true, // 匹配所有以/api开头的请求,包括/api/token @@ -35,7 +26,6 @@ export default defineConfig({ }, plugins: [ tailwindcss(), - svgr({ svgrOptions: { icon: true } }), react(), AutoImport({ imports: ['react', 'react-router-dom'], @@ -98,4 +88,4 @@ export default defineConfig({ }, }, }, -}) +}) \ No newline at end of file diff --git a/web/变量信息.md b/web/变量信息.md deleted file mode 100644 index 008af6b7..00000000 --- a/web/变量信息.md +++ /dev/null @@ -1,654 +0,0 @@ -# 系统变量:需和开始节点拆分 - -# end: string/number/boolean/object/array[file]/array[object]/array[number]/array[string] - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __is_success / __reason - - memory-read: answer / intermediate_outputs - - - - question-classifier: class_name / output - - iteration: output - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - jinja-render: output - -# llm: 不能选 boolean 类型 -## 上下文:string/number/array[file]/array[object]/array[string]/array[number]; 不要object - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __is_success / __reason - - memory-read: answer / intermediate_outputs - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -## 提示词: string/number/array[file]/array[number]/array[string]; 不要object,boolean - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __is_success / __reason - - memory-read: answer / intermediate_outputs - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# knowledge-retrieval: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - -# parameter-extractor: -## 输入变量: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output -## 指令:string/number - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __is_success / __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# memory-read: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - - -# memory-write: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - -# if-else: boolean/string/number/array[file]/array[object]/array[string]/object - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __is_success / __reason - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# question-classifier -## 输入变量: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output -## 分类: string - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - -# iteration -## 输入变量: array[file] | array[object] | array[string] | array[number] | array[boolean] - - - - - - knowledge-retrieval: output - - parameter-extractor: array类型的提取参数 params - - - - - - iteration: output - - loop: cycle_vars - - - - - - code: output_variables - -## 输出变量 - - 系统变量 - - - - - - - - - - - - - - - - - - - 子节点的输出变量 - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason, params - - memory-read: answer - - memory-write - - - question-classifier: class_name - - - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - -# loop -## 循环变量 - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __is_success / __reason / params - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output -## 循环终止条件 -### left - - 系统变量 - - 会话变量 - - - - - - - - - - - loop: cycle_vars 当前loop节点的 - - - - - - code: output_variables - - - 子节点的输出变量 - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason - - memory-read: answer - - memory-write - - - question-classifier: class_name - - - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - jinja-render: output -### right: number - - 系统变量 - - 会话变量 - - start: variables / sys - - - - parameter-extractor: __is_success - - - - - - - loop: cycle_vars 当前loop节点的 - - - - http-request: status_code - - - code: output_variables - - -# var-aggregator: string/number/boolean - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# assigner -## variable_selector - - - 会话变量 - - - - - - - - - - - loop: cycle_vars 当前loop节点的 - - - - - - -## value - - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason / __is_success - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body - - tool: data - - code: output_variables - - jinja-render: output - -# http-request -## url/headers/params: string/number - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason / __is_success - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output -## ['body', 'data'] -### body?.content_type = form-data/x-www-form-urlencoded/json/raw: string/number -### body?.content_type = binary: file/array[file] - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - - parameter-extractor: __reason / __is_success - - memory-read: answer - - - - question-classifier: class_name - - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# tool: 不需要 - -# jinja-render -## mappingList 输入变量 - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason / __is_success - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - code: output_variables - - jinja-render: output - -# code -## input_variables - - 系统变量 - - 会话变量 - - start: variables / sys - - llm: output - - knowledge-retrieval: output - - parameter-extractor: __reason / __is_success - - memory-read: answer - - - - question-classifier: class_name - - iteration: output - - loop: cycle_vars 当前loop节点的 - - var-aggregator - - group = false 时,output - - group = true 时,group_variables - - - http-request: body / status_code - - tool: data - - jinja-render: output - - code: output_variables - - code: output_variables - - -# 迭代子节点 -- llm -- if-else -- parameter-extractor && prompt -- var-aggregator -- assigner -- http-request && body.content_type !== 'binary' -- tool -- jinja-render - - iteration: item / index - -- knowledge-retrieval -- parameter-extractor && !prompt -- memory-read -- memory-write -- question-classifier - - iteration的输入变量是array[string]时,可选item - -- iteration -- loop - - 不可添加此类节点 - -# 循环子节点 -- llm -- knowledge-retrieval -- parameter-extractor -- memory-read -- memory-write -- if-else -- question-classifier -- var-aggregator -- assigner -- http-request -- tool -- jinja-render - - loop: cycle_vars - -- iteration -- loop - - 不可添加此类节点 - - - -# TODO - -## 需要后端支持的需求 -1. 集群调试:对话过程数据输出【需后端】 - -3. 应用调试、分享增加变量配置【需后端】 -4. 应用导入导出,导出已完成,导入【需后端】 -6. 单个节点的运行【需后端】 -7. 列表 节点的配置【需后端】 -9. 对话支持附件(非图片)【需后端】 - -## 前端需求 -1. 工作流整理布局、历史撤销、回退 -2. 问题分类节点,分类中英文 -3. 感知记忆:文本类型增加片段展示 -- variableConfig -4. 工作流UI - - - - - 变量聚合器 -7. 记忆萃取 - - 本体场景不可编辑 -- rb:truncate -- 注释翻译 - - RbCard - - src/views/KnowledgeBase/index.tsx - - src/components/Upload/UploadFiles.tsx - - src/components/Chat - - -# 分支 - -## 0.2.6 -- feature/workflow_import_zy - - 工作流导入 | 导出 - - input_type: Constant / Variable 统一成小写 - - 结束节点内容被覆写 - - 增加未知节点 - - http 节点 - - 变量下拉列表替换成编辑器 - - body form-data file时,值支持选择sys.files -- feature/form_zy - - 表单校验规则 - - 流式输出时,向上滚动后,自动滚动到最底部的效果失效 - - 应用 API URL更新 -- feature/memory_zy - - 记忆萃取增加剪枝 - -## 20260212 -1. A2A 协议适配 -2. 日志跟踪系统 -3. Agent、集群、工作流共享 -4. 试运行、分享会话支持文件(包含语言、其他附件)【待联调】 -2. 导入 Agent、工作流 - 合并到应用管理创建方式 - - -a7da914dcbb80186b9aaf9ac4d21a9881e60ecb5 -e115353811b34de2fd359962860fdafe87fef503 \ No newline at end of file