From 5cef61137cddb64b12f2fe8572894c2fd254746b Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 14:36:45 +0800 Subject: [PATCH 01/14] feat(workflow): add document metadata configuration for Knowledge Base node Implement comprehensive document metadata support in Knowledge Base workflow node, allowing users to configure metadata values through both constants and variables. Backend changes: - Add DocMetadata model with support for constant values and variable selectors - Implement metadata processing in KnowledgeIndexNode with variable resolution - Add batch query optimization to prevent N+1 queries - Implement metadata validation and binding creation - Add comprehensive unit tests for node and service layers Frontend changes: - Add MetadataSection component with type-aware input controls - String type: text input - Number type: number input with validation - Time type: date picker (Unix timestamp) - Implement variable filtering based on metadata data type - String metadata: only string variables - Number metadata: only number/integer variables - Time metadata: only time-related number variables (timestamp, *time*, *date*, *at*) - Add VarReferencePicker with 360px min-width for better UX - Standardize font size to text-[13px] across all inputs - Add i18n support for all user-facing strings Technical improvements: - Use SQLAlchemy attributes.flag_modified() for JSON field updates - Optimize logging to follow project standards (warnings and exceptions only) - Add type safety with proper TypeScript definitions - Implement proper error handling with user-friendly messages Co-Authored-By: Claude --- .../nodes/knowledge_index/entities.py | 10 + .../knowledge_index/knowledge_index_node.py | 116 +++++- api/services/dataset_service.py | 53 +++ .../knowledge_entities/knowledge_entities.py | 7 + api/services/metadata_service.py | 59 +++ .../test_knowledge_index_node.py | 117 ++++++ .../services/test_dataset_service_metadata.py | 137 +++++++ .../datasets/metadata/base/date-picker.tsx | 2 +- .../components/metadata-section.tsx | 346 ++++++++++++++++++ .../nodes/knowledge-base/hooks/use-config.ts | 15 + .../workflow/nodes/knowledge-base/panel.tsx | 22 ++ .../workflow/nodes/knowledge-base/types.ts | 8 + web/i18n/en-US/dataset-creation.json | 2 + web/i18n/en-US/workflow.json | 2 + web/i18n/zh-Hans/dataset-creation.json | 2 + web/i18n/zh-Hans/workflow.json | 2 + 16 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py create mode 100644 api/tests/unit_tests/services/test_dataset_service_metadata.py create mode 100644 web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx diff --git a/api/core/workflow/nodes/knowledge_index/entities.py b/api/core/workflow/nodes/knowledge_index/entities.py index 3daca90b9be62a..339ffd3e40dfb2 100644 --- a/api/core/workflow/nodes/knowledge_index/entities.py +++ b/api/core/workflow/nodes/knowledge_index/entities.py @@ -150,6 +150,15 @@ class ParentChildStructureChunk(BaseModel): data_source_info: Union[FileInfo, OnlineDocumentInfo, WebsiteInfo] +class DocMetadata(BaseModel): + """ + Doc Metadata. + """ + + metadata_id: str + value: str | int | float | list[str] + + class KnowledgeIndexNodeData(BaseNodeData): """ Knowledge index Node Data. @@ -158,3 +167,4 @@ class KnowledgeIndexNodeData(BaseNodeData): type: str = "knowledge-index" chunk_structure: str index_chunk_variable_selector: list[str] + doc_metadata: list[DocMetadata] | None = None diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index 17ca4bef7bcb81..ad227fa59a2d76 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -1,10 +1,11 @@ import datetime import logging import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any from sqlalchemy import func, select +from sqlalchemy.orm import attributes from core.app.entities.app_invoke_entities import InvokeFrom from core.rag.index_processor.index_processor_factory import IndexProcessorFactory @@ -16,7 +17,7 @@ from core.workflow.nodes.base.template import Template from core.workflow.runtime import VariablePool from extensions.ext_database import db -from models.dataset import Dataset, Document, DocumentSegment +from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document, DocumentSegment from .entities import KnowledgeIndexNodeData from .exc import ( @@ -25,6 +26,9 @@ logger = logging.getLogger(__name__) +# Constant for built-in metadata identifier +BUILT_IN_METADATA_ID = "built-in" + default_retrieval_model = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, @@ -161,6 +165,88 @@ def _invoke_knowledge_index( } ) + # Process doc_metadata before commit to ensure it's saved with the same document object + if node_data.doc_metadata: + try: + # Fetch metadata definitions for name mapping + metadata_name_map: dict[str, str] = {} + dataset_metadatas = db.session.scalars( + select(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset.id) + ).all() + for md in dataset_metadatas: + metadata_name_map[md.id] = md.name + + # Collect valid metadata IDs (excluding built-in) + valid_metadata_ids = [ + item.metadata_id + for item in node_data.doc_metadata + if item.metadata_id != BUILT_IN_METADATA_ID and item.metadata_id in metadata_name_map + ] + + # Batch fetch existing bindings to avoid N+1 query + existing_binding_ids: set[str] = set() + if valid_metadata_ids: + existing_bindings = db.session.scalars( + select(DatasetMetadataBinding.metadata_id).where( + DatasetMetadataBinding.dataset_id == dataset.id, + DatasetMetadataBinding.document_id == doc_id_value, + DatasetMetadataBinding.metadata_id.in_(valid_metadata_ids), + ) + ).all() + existing_binding_ids = set(existing_bindings) + + doc_metadata_dict = document.doc_metadata or {} + + for item in node_data.doc_metadata: + # Skip built-in fields + if item.metadata_id == BUILT_IN_METADATA_ID: + continue + + # Resolve Name + md_name = metadata_name_map.get(item.metadata_id) + if not md_name: + logger.warning( + "[KnowledgeIndexNode] metadata_id %s not found, skipping", item.metadata_id + ) + continue + + # Resolve Value + value = item.value + if isinstance(value, list): + var_obj = variable_pool.get(value) + if var_obj: + value = var_obj.to_object() + else: + # Variable not found - raise error to notify user of configuration issue + variable_path = ".".join(value) + raise KnowledgeIndexNodeError( + f"Variable '{variable_path}' not found for metadata '{md_name}'. " + f"Please check your variable configuration." + ) + + if value is not None: + doc_metadata_dict[md_name] = value + + # Create DatasetMetadataBinding if not exists + if item.metadata_id not in existing_binding_ids: + binding = DatasetMetadataBinding( + tenant_id=dataset.tenant_id, + dataset_id=dataset.id, + metadata_id=item.metadata_id, + document_id=doc_id_value, + created_by=self.user_id, + ) + db.session.add(binding) + existing_binding_ids.add(item.metadata_id) # Prevent duplicate in same batch + + document.doc_metadata = doc_metadata_dict + # Force SQLAlchemy to recognize the change to the JSON field + attributes.flag_modified(document, "doc_metadata") + + except Exception as e: + logger.exception("[KnowledgeIndexNode] Failed to process doc_metadata") + raise KnowledgeIndexNodeError(f"Failed to process document metadata: {e}") from e + db.session.commit() return { @@ -189,3 +275,29 @@ def get_streaming_template(self) -> Template: Template instance for this knowledge index node """ return Template(segments=[]) + + @classmethod + def _extract_variable_selector_to_variable_mapping( + cls, *, graph_config: Mapping[str, Any], node_id: str, node_data: Mapping[str, Any] + ) -> Mapping[str, Sequence[str]]: + """ + Extract variable selector to variable mapping + :param graph_config: graph config + :param node_id: node id + :param node_data: node data + :return: + """ + variable_mapping = {} + node_data_obj = KnowledgeIndexNodeData(**node_data) + + # index chunk variable + variable_mapping[node_id + ".index_chunk_variable_selector"] = node_data_obj.index_chunk_variable_selector + + # doc_metadata variables + if node_data_obj.doc_metadata: + for item in node_data_obj.doc_metadata: + if isinstance(item.value, list): + variable_mapping[node_id + "." + item.metadata_id] = item.value + + return variable_mapping + diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 18e56134382fdf..893baa53ccd2ce 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -39,6 +39,8 @@ Dataset, DatasetAutoDisableLog, DatasetCollectionBinding, + DatasetMetadata, + DatasetMetadataBinding, DatasetPermission, DatasetPermissionEnum, DatasetProcessRule, @@ -1595,6 +1597,36 @@ def save_document_with_dataset_id( else default_retrieval_model ) + # Handle metadata configuration + # 1. Enable built-in metadata if requested + if knowledge_config.enable_built_in_metadata and not dataset.built_in_field_enabled: + dataset.built_in_field_enabled = True + db.session.add(dataset) + + # 2. Process custom metadata - validate and build dict + custom_metadata: dict = {} + metadata_bindings_to_create: list[tuple[str, str]] = [] # (metadata_id, metadata_name) + if knowledge_config.doc_metadata: + # Batch fetch all metadata definitions to avoid N+1 query + metadata_ids = [item.metadata_id for item in knowledge_config.doc_metadata] + metadata_defs = ( + db.session.query(DatasetMetadata) + .filter( + DatasetMetadata.id.in_(metadata_ids), + DatasetMetadata.dataset_id == dataset.id, + ) + .all() + ) + metadata_map = {md.id: md for md in metadata_defs} + + for item in knowledge_config.doc_metadata: + # Validate metadata_id belongs to this dataset + metadata_def = metadata_map.get(item.metadata_id) + if not metadata_def: + raise ValueError(f"Metadata with id '{item.metadata_id}' not found in this dataset") + custom_metadata[metadata_def.name] = item.value + metadata_bindings_to_create.append((item.metadata_id, metadata_def.name)) + documents = [] if knowledge_config.original_document_id: document = DocumentService.update_document_with_dataset_id(dataset, knowledge_config, account) @@ -1717,6 +1749,7 @@ def save_document_with_dataset_id( account, file.name, batch, + custom_metadata=custom_metadata or None, ) db.session.add(document) db.session.flush() @@ -1769,6 +1802,7 @@ def save_document_with_dataset_id( account, truncated_page_name, batch, + custom_metadata=custom_metadata or None, ) db.session.add(document) db.session.flush() @@ -1809,6 +1843,7 @@ def save_document_with_dataset_id( account, document_name, batch, + custom_metadata=custom_metadata or None, ) db.session.add(document) db.session.flush() @@ -1817,6 +1852,20 @@ def save_document_with_dataset_id( position += 1 db.session.commit() + # Create DatasetMetadataBinding records for custom metadata + if metadata_bindings_to_create and document_ids: + for doc_id in document_ids: + for metadata_id, _ in metadata_bindings_to_create: + binding = DatasetMetadataBinding( + tenant_id=dataset.tenant_id, + dataset_id=dataset.id, + document_id=doc_id, + metadata_id=metadata_id, + created_by=account.id, + ) + db.session.add(binding) + db.session.commit() + # trigger async task if document_ids: DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay() @@ -2127,6 +2176,7 @@ def build_document( account: Account, name: str, batch: str, + custom_metadata: dict | None = None, ): document = Document( tenant_id=dataset.tenant_id, @@ -2151,6 +2201,9 @@ def build_document( BuiltInField.last_update_date: datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M:%S"), BuiltInField.source: data_source_type, } + # Merge custom metadata if provided + if custom_metadata: + doc_metadata.update(custom_metadata) if doc_metadata: document.doc_metadata = doc_metadata return document diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index 7959734e89b7e0..7b90374f95dd53 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -112,6 +112,11 @@ class MetaDataConfig(BaseModel): doc_metadata: dict +class DocumentMetadataInput(BaseModel): + metadata_id: str + value: str | int | float | None = None + + class KnowledgeConfig(BaseModel): original_document_id: str | None = None duplicate: bool = True @@ -125,6 +130,8 @@ class KnowledgeConfig(BaseModel): embedding_model_provider: str | None = None name: str | None = None is_multimodal: bool = False + enable_built_in_metadata: bool = False + doc_metadata: list[DocumentMetadataInput] | None = None class SegmentCreateArgs(BaseModel): diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 3329ac349c3f5b..8538dac7925ecb 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -7,6 +7,8 @@ from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding +from models.model import App +from models.workflow import Workflow from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ( MetadataArgs, @@ -95,6 +97,52 @@ def update_metadata_name(dataset_id: str, metadata_id: str, name: str) -> Datase finally: redis_client.delete(lock_key) + @staticmethod + def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[bool, str | None]: + """ + Check if a metadata is used in the associated Pipeline's Knowledge Base node. + + Returns: + tuple[bool, str | None]: (is_used, pipeline_name) - True if used, with pipeline name + """ + # Get the dataset + dataset = db.session.query(Dataset).filter_by(id=dataset_id).first() + if not dataset or not dataset.pipeline_id: + return False, None + + # Get the draft workflow directly using pipeline_id as app_id + workflow = db.session.query(Workflow).filter_by( + app_id=dataset.pipeline_id, + version=Workflow.VERSION_DRAFT + ).first() + if not workflow: + return False, None + + # Get pipeline name from App if exists + app = db.session.query(App).filter_by(id=dataset.pipeline_id).first() + pipeline_name = app.name if app else "Pipeline" + + # Walk through nodes to find Knowledge Index node (type is "knowledge-index") + try: + graph_dict = workflow.graph_dict + if "nodes" not in graph_dict: + return False, None + + for node in graph_dict["nodes"]: + node_data = node.get("data", {}) + # Check if this is a knowledge-index node + if node_data.get("type") == "knowledge-index": + doc_metadata = node_data.get("doc_metadata", []) + if doc_metadata: + for item in doc_metadata: + if item.get("metadata_id") == metadata_id: + return True, pipeline_name + except Exception: + logger.exception("Error checking metadata usage in pipeline") + return False, None + + return False, None + @staticmethod def delete_metadata(dataset_id: str, metadata_id: str): lock_key = f"dataset_metadata_lock_{dataset_id}" @@ -103,6 +151,15 @@ def delete_metadata(dataset_id: str, metadata_id: str): metadata = db.session.query(DatasetMetadata).filter_by(id=metadata_id).first() if metadata is None: raise ValueError("Metadata not found.") + + # Check if metadata is used in Pipeline before deletion + is_used, pipeline_name = MetadataService.check_metadata_used_in_pipeline(dataset_id, metadata_id) + if is_used: + raise ValueError( + f"Cannot delete metadata '{metadata.name}' because it is currently used in " + f"Pipeline '{pipeline_name}'." + ) + db.session.delete(metadata) # deal related documents @@ -122,6 +179,8 @@ def delete_metadata(dataset_id: str, metadata_id: str): db.session.add(document) db.session.commit() return metadata + except ValueError: + raise except Exception: logger.exception("Delete metadata failed") finally: diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py new file mode 100644 index 00000000000000..c7d514c2433709 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -0,0 +1,117 @@ + +import unittest +import uuid +from unittest.mock import MagicMock, patch + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.workflow.enums import SystemVariableKey +from core.workflow.nodes.knowledge_index.entities import DocMetadata, KnowledgeIndexNodeData +from core.workflow.nodes.knowledge_index.knowledge_index_node import KnowledgeIndexNode +from core.workflow.runtime import VariablePool +from models.dataset import Dataset, DatasetMetadata, Document +from models.enums import UserFrom + + +class TestKnowledgeIndexNode(unittest.TestCase): + def setUp(self): + self.dataset_id = str(uuid.uuid4()) + self.document_id = str(uuid.uuid4()) + self.mock_dataset = MagicMock(spec=Dataset) + self.mock_dataset.id = self.dataset_id + self.mock_dataset.built_in_field_enabled = False + + self.mock_document = MagicMock(spec=Document) + self.mock_document.id = self.document_id + self.mock_document.doc_metadata = {} + + @patch('core.workflow.nodes.knowledge_index.knowledge_index_node.db.session') + @patch('core.workflow.nodes.knowledge_index.knowledge_index_node.IndexProcessorFactory') + def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_session): + # Mock DB queries + mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [ + self.mock_dataset, # For dataset query + self.mock_document # For document query + ] + + # Mock Dataset Metadata + mock_metadata = MagicMock(spec=DatasetMetadata) + mock_metadata.id = "meta_uuid_1" + mock_metadata.name = "Category" + mock_db_session.scalars.return_value.all.return_value = [mock_metadata] + # Simpler mock for the scalar query - switched to bulk fetch + mock_db_session.scalar.return_value = "Category" + + # Mock Variable Pool + pool = MagicMock(spec=VariablePool) + + # System variables + pool.get.side_effect = lambda selector: { + ("sys", SystemVariableKey.DATASET_ID): MagicMock(value=self.dataset_id), + ("sys", SystemVariableKey.DOCUMENT_ID): MagicMock(value=self.document_id), + ("sys", SystemVariableKey.INVOKE_FROM): None, + ("Start", "category"): MagicMock(to_object=lambda: "Financial"), + # handle list as key? get takes list + frozenset(["Start", "category"]): MagicMock(to_object=lambda: "Financial"), + }.get(tuple(selector) if isinstance(selector, list) else selector) + + # Handle the chunk variable specifically first + chunk_var_mock = MagicMock() + chunk_var_mock.value = {"chunk": "data"} + + # Override side_effect to handle list lookups correctly + def variable_pool_get(selector): + if selector == ["sys", SystemVariableKey.DATASET_ID]: + return MagicMock(value=self.dataset_id) + if selector == ["sys", SystemVariableKey.DOCUMENT_ID]: + return MagicMock(value=self.document_id) + if selector == ["Start", "category"]: + var = MagicMock() + var.to_object.return_value = "Financial" + return var + if selector == ["sys", SystemVariableKey.INVOKE_FROM]: + return None + if selector == ["sys", "chunks"]: # whatever index_chunk_variable_selector is + return chunk_var_mock + return None + + pool.get.side_effect = variable_pool_get + + # Node Configuration + node_data = KnowledgeIndexNodeData( + id="node1", + title="Knowledge", + chunk_structure="chunk", + index_chunk_variable_selector=["sys", "chunks"], + doc_metadata=[ + DocMetadata(metadata_id="meta_uuid_1", value=["Start", "category"]) + ] + ) + + # Initialize Node + graph_init_params = MagicMock() + graph_init_params.user_from = UserFrom.ACCOUNT + graph_init_params.invoke_from = InvokeFrom.WEB_APP + + config = { + "id": "node1", + "data": node_data.model_dump() + } + + node = KnowledgeIndexNode( + id="node1", + graph_init_params=graph_init_params, + graph_runtime_state=MagicMock(variable_pool=pool), + config=config + ) + + # Mock _invoke_knowledge_index to avoid calling specific index logic + node._invoke_knowledge_index = MagicMock() + + # Execute + result = node._run() + + # Verify + assert self.mock_document.doc_metadata["Category"] == "Financial" + mock_db_session.add.assert_called_with(self.mock_document) + mock_db_session.commit.assert_called() + diff --git a/api/tests/unit_tests/services/test_dataset_service_metadata.py b/api/tests/unit_tests/services/test_dataset_service_metadata.py new file mode 100644 index 00000000000000..98d12317a5a9d7 --- /dev/null +++ b/api/tests/unit_tests/services/test_dataset_service_metadata.py @@ -0,0 +1,137 @@ +from unittest.mock import Mock, patch +from uuid import uuid4 + +import pytest + +from models.account import Account +from models.dataset import Dataset, DatasetMetadata, Document +from models.model import UploadFile +from services.dataset_service import DocumentService +from services.entities.knowledge_entities.knowledge_entities import ( + DataSource, + DocumentMetadataInput, + FileInfo, + InfoList, + KnowledgeConfig, +) + + +class TestDocumentServiceMetadata: + @pytest.fixture + def mock_dependencies(self): + with ( + patch("services.dataset_service.db.session") as mock_db, + patch("services.dataset_service.DatasetService.get_dataset") as mock_get_dataset, + patch("services.dataset_service.redis_client") as mock_redis, + patch("services.dataset_service.DocumentService.build_document") as mock_build_document, + patch("services.dataset_service.current_user") as mock_current_user, + patch("services.dataset_service.DocumentIndexingTaskProxy") as mock_indexing_task, + # We don't patch DocumentService.save_document_with_dataset_id as that's what we are testing + ): + # Hack to pass isinstance check + mock_current_user.__class__ = Account + mock_current_user.current_tenant_id = "tenant-123" + + yield { + "db": mock_db, + "get_dataset": mock_get_dataset, + "redis": mock_redis, + "build_document": mock_build_document, + "current_user": mock_current_user, + } + + def test_save_document_with_metadata(self, mock_dependencies): + # Arrange + dataset_id = str(uuid4()) + tenant_id = str(uuid4()) + account = Mock(spec=Account) + account.id = "account-1" + account.current_tenant_id = tenant_id + + dataset = Mock(spec=Dataset) + dataset.id = dataset_id + dataset.tenant_id = tenant_id + dataset.built_in_field_enabled = False + dataset.doc_form = "text_model" + mock_dependencies["get_dataset"].return_value = dataset + + # Define metadata inputs + metadata_id = str(uuid4()) + doc_metadata_inputs = [ + DocumentMetadataInput(metadata_id=metadata_id, value="custom_value") + ] + + # Knowledge config + knowledge_config = KnowledgeConfig( + data_source_type="upload_file", + data_source=DataSource( + info_list=InfoList( + data_source_type="upload_file", + file_info_list=FileInfo(file_ids=["file-1"]) + ) + ), + doc_form="text_model", + doc_language="en", + indexing_technique="high_quality", + enable_built_in_metadata=True, + doc_metadata=doc_metadata_inputs + ) + + # Mock local file for upload_file type + with patch("services.dataset_service.db.session.query") as mock_query: + # Mock DatasetMetadata lookup + mock_metadata_def = Mock(spec=DatasetMetadata) + mock_metadata_def.id = metadata_id + mock_metadata_def.name = "custom_field" + mock_metadata_def.field_type = "text" + + # Create a side effect for query(Model) + def query_side_effect(model): + m = Mock() + if model == DatasetMetadata: + m.filter.return_value.filter.return_value.first.return_value = mock_metadata_def + # handle the specific chain in code + m.filter_by.return_value.first.return_value = mock_metadata_def + return m + if model == Document: + doc_mock = Mock() + doc_mock.position = 1 + # For get_documents_position + m.filter_by.return_value.order_by.return_value.first.return_value = doc_mock + # For duplicate check + m.where.return_value.all.return_value = [] + return m + if model == UploadFile: + m.where.return_value.all.return_value = [Mock(id="file-1", tenant_id=tenant_id)] + return m + + return m + + mock_query.side_effect = query_side_effect + + # Mock build_document to return a document + mock_document = Mock(spec=Document) + mock_document.id = "doc-123" + mock_document.doc_metadata = {} + mock_dependencies["build_document"].return_value = mock_document + + # Act + DocumentService.save_document_with_dataset_id( + dataset=dataset, + knowledge_config=knowledge_config, + account=account + ) + + # Assert + # 1. Check built-in metadata enabled + assert dataset.built_in_field_enabled is True + + # 2. Check custom metadata passed to build_document + call_args = mock_dependencies["build_document"].call_args + assert call_args is not None + _, kwargs = call_args + assert "custom_metadata" in kwargs + assert kwargs["custom_metadata"] == {"custom_field": "custom_value"} + + # 3. Check DatasetMetadataBinding creation + assert mock_dependencies["db"].add.call_count >= 1 diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index 2f615498592a12..6839af6232ae08 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -38,7 +38,7 @@ const WrappedDatePicker = ({
diff --git a/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx new file mode 100644 index 00000000000000..ef5fbb99a64efd --- /dev/null +++ b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx @@ -0,0 +1,346 @@ +'use client' +import type { FC } from 'react' +import type { DocMetadataItem } from '../types' +import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import type { ValueSelector, Var } from '@/app/components/workflow/types' +import { RiAddLine, RiCloseLine, RiDraftLine, RiEditLine } from '@remixicon/react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { InputNumber } from '@/app/components/base/input-number' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import Datepicker from '@/app/components/datasets/metadata/base/date-picker' +import DatasetMetadataDrawer from '@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer' +import { DataType } from '@/app/components/datasets/metadata/types' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import { VarType } from '@/app/components/workflow/types' +import { + useBuiltInMetaDataFields, + useCreateMetaData, + useDeleteMetaData, + useRenameMeta, + useUpdateBuiltInStatus, +} from '@/service/knowledge/use-metadata' +import { cn } from '@/utils/classnames' + +type MetadataSectionProps = { + nodeId: string + datasetId?: string + enableBuiltInMetadata: boolean + onEnableBuiltInMetadataChange: (enabled: boolean) => void + userMetadata?: MetadataItemWithValueLength[] + docMetadata?: DocMetadataItem[] + onDocMetadataChange?: (metadata: DocMetadataItem[]) => void + onMetadataListChange?: () => void + readonly?: boolean + className?: string +} + +const MetadataSection: FC = ({ + nodeId, + datasetId, + enableBuiltInMetadata, + onEnableBuiltInMetadataChange, + userMetadata = [], + docMetadata = [], + onDocMetadataChange, + onMetadataListChange, + readonly, + className, +}) => { + const { t } = useTranslation() + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + + // Get built-in metadata fields from API + const { data: builtInFieldsData } = useBuiltInMetaDataFields() + const builtInFields = builtInFieldsData?.fields || [] + + // Mutations for drawer + const createMetadataMutation = useCreateMetaData(datasetId || '') + const renameMetadataMutation = useRenameMeta(datasetId || '') + const deleteMetadataMutation = useDeleteMetaData(datasetId || '') + const updateBuiltInStatus = useUpdateBuiltInStatus(datasetId || '') + + // Drawer handlers + const handleAddMetadata = useCallback(async (data: BuiltInMetadataItem) => { + await createMetadataMutation.mutateAsync(data) + Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) }) + onMetadataListChange?.() + }, [createMetadataMutation, t, onMetadataListChange]) + + const handleRenameMetadata = useCallback(async (data: MetadataItemWithValueLength) => { + await renameMetadataMutation.mutateAsync(data) + Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) }) + onMetadataListChange?.() + }, [renameMetadataMutation, t, onMetadataListChange]) + + const handleDeleteMetadata = useCallback(async (id: string) => { + await deleteMetadataMutation.mutateAsync(id) + Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) }) + onMetadataListChange?.() + }, [deleteMetadataMutation, t, onMetadataListChange]) + + const handleBuiltInEnabledChange = useCallback(async (enabled: boolean) => { + onEnableBuiltInMetadataChange(enabled) + if (datasetId) { + await updateBuiltInStatus.mutateAsync(enabled) + } + }, [datasetId, updateBuiltInStatus, onEnableBuiltInMetadataChange]) + + // Document metadata value handlers + const handleAddDocMetadata = useCallback(() => { + if (onDocMetadataChange) { + onDocMetadataChange([...docMetadata, { metadata_id: '', value: '' }]) + } + }, [docMetadata, onDocMetadataChange]) + + const handleRemoveDocMetadata = useCallback((index: number) => { + if (onDocMetadataChange) { + const newMetadata = [...docMetadata] + newMetadata.splice(index, 1) + onDocMetadataChange(newMetadata) + } + }, [docMetadata, onDocMetadataChange]) + + const handleDocMetadataIdChange = useCallback((index: number, metadataId: string) => { + if (onDocMetadataChange) { + const newMetadata = [...docMetadata] + newMetadata[index] = { ...newMetadata[index], metadata_id: metadataId } + onDocMetadataChange(newMetadata) + } + }, [docMetadata, onDocMetadataChange]) + + const handleDocMetadataValueChange = useCallback((index: number, value: string | number | ValueSelector) => { + if (onDocMetadataChange) { + const newMetadata = [...docMetadata] + newMetadata[index] = { ...newMetadata[index], value } + onDocMetadataChange(newMetadata) + } + }, [docMetadata, onDocMetadataChange]) + + const getAvailableMetadataOptions = useCallback((currentId: string) => { + const usedIds = docMetadata.map(m => m.metadata_id).filter(id => id !== currentId) + return userMetadata.filter(m => !usedIds.includes(m.id)) + }, [userMetadata, docMetadata]) + + const getMetadataType = useCallback((metadataId: string): DataType | undefined => { + return userMetadata.find(m => m.id === metadataId)?.type + }, [userMetadata]) + + // Filter variables based on metadata type + const createVarFilter = useCallback((metadataId: string) => { + return (variable: Var): boolean => { + const metadataType = getMetadataType(metadataId) + + if (!metadataType) + return false + + // Type mapping: Metadata DataType -> Workflow VarType + switch (metadataType) { + case DataType.string: + return variable.type === VarType.string + case DataType.number: + return variable.type === VarType.number || variable.type === VarType.integer + case DataType.time: { + // Only allow number variables with time-related names + const varName = variable.variable.toLowerCase() + const isTimeRelated + = varName === 'timestamp' // sys.timestamp + || varName.includes('time') // current_time, expiry_time + || varName.includes('date') // created_date, updated_date + || varName.includes('at') // created_at, updated_at + + return (variable.type === VarType.number || variable.type === VarType.integer) + && isTimeRelated + } + default: + return false + } + } + }, [getMetadataType]) + + return ( +
+
+
+ {t('metadata.metadata', { ns: 'dataset' })} +
+ {datasetId && !readonly && ( + + )} +
+ + {/* Document Metadata Values Section */} + {userMetadata.length > 0 && ( +
+
+ {!readonly && ( + + )} +
+ + {docMetadata.length > 0 + ? ( +
+ {docMetadata.map((item, index) => { + const isVariable = Array.isArray(item.value) + const itemKey = item.metadata_id ? `metadata-${item.metadata_id}` : `new-${index}` + return ( +
+
+
+ +
+
+
+ +
!readonly && handleDocMetadataValueChange(index, [])} + > + +
+
+ +
!readonly && handleDocMetadataValueChange(index, '')} + > + +
+
+
+
+
+ {isVariable + ? ( + handleDocMetadataValueChange(index, value)} + isSupportConstantValue={false} + placeholder={t('placeholder.input', { ns: 'common' }) || ''} + className="h-full border-none !bg-transparent p-0" + zIndex={1000} + isShowNodeName + minWidth={360} + filterVar={createVarFilter(item.metadata_id)} + /> + ) + : ( +
+ {(() => { + const metadataType = getMetadataType(item.metadata_id) + + // Time type - use Datepicker + if (metadataType === DataType.time) { + return ( + handleDocMetadataValueChange(index, value || 0)} + /> + ) + } + + // Number type - use InputNumber + if (metadataType === DataType.number) { + return ( + handleDocMetadataValueChange(index, value)} + readOnly={readonly} + size="regular" + /> + ) + } + + // String type (default) - use text input + return ( + handleDocMetadataValueChange(index, e.target.value)} + placeholder={t('placeholder.input', { ns: 'common' }) || ''} + disabled={readonly} + className="h-full w-full bg-transparent text-[13px] text-text-primary outline-none placeholder:text-text-placeholder disabled:opacity-50" + /> + ) + })()} +
+ )} +
+
+
+ {!readonly && ( + + )} +
+ ) + })} +
+ ) + : ( +
+ {t('stepTwo.metadata.noValues', { ns: 'datasetCreation' })} +
+ )} +
+ )} + + {/* Metadata Drawer */} + {isDrawerOpen && datasetId && ( + setIsDrawerOpen(false)} + onAdd={handleAddMetadata} + onRename={handleRenameMetadata} + onRemove={handleDeleteMetadata} + /> + )} +
+ ) +} + +export default MetadataSection diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts index f2a27d338e2fc9..9c189251172e8e 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts @@ -1,4 +1,5 @@ import type { + DocMetadataItem, KnowledgeBaseNodeType, RerankingModel, } from '../types' @@ -246,6 +247,18 @@ export const useConfig = (id: string) => { }) }, [handleNodeDataUpdate]) + const handleEnableBuiltInMetadataChange = useCallback((enabled: boolean) => { + handleNodeDataUpdate({ + enable_built_in_metadata: enabled, + }) + }, [handleNodeDataUpdate]) + + const handleDocMetadataChange = useCallback((docMetadata: DocMetadataItem[]) => { + handleNodeDataUpdate({ + doc_metadata: docMetadata, + }) + }, [handleNodeDataUpdate]) + return { handleChunkStructureChange, handleIndexMethodChange, @@ -260,5 +273,7 @@ export const useConfig = (id: string) => { handleScoreThresholdChange, handleScoreThresholdEnabledChange, handleInputVariableChange, + handleEnableBuiltInMetadataChange, + handleDocMetadataChange, } } diff --git a/web/app/components/workflow/nodes/knowledge-base/panel.tsx b/web/app/components/workflow/nodes/knowledge-base/panel.tsx index f32278fc227558..cd7c9ecec13029 100644 --- a/web/app/components/workflow/nodes/knowledge-base/panel.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/panel.tsx @@ -17,10 +17,13 @@ import { Group, } from '@/app/components/workflow/nodes/_base/components/layout' import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { useDatasetMetaData } from '@/service/knowledge/use-metadata' import Split from '../_base/components/split' import ChunkStructure from './components/chunk-structure' import EmbeddingModel from './components/embedding-model' import IndexMethod from './components/index-method' +import MetadataSection from './components/metadata-section' import RetrievalSetting from './components/retrieval-setting' import { useConfig } from './hooks/use-config' import { @@ -37,6 +40,10 @@ const Panel: FC> = ({ const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank) + // Get datasetId from context and fetch metadata + const datasetId = useDatasetDetailContextWithSelector(s => s.dataset?.id) + const { data: metadataList, refetch: refetchMetadataList } = useDatasetMetaData(datasetId || '') + const { handleChunkStructureChange, handleIndexMethodChange, @@ -51,6 +58,8 @@ const Panel: FC> = ({ handleScoreThresholdChange, handleScoreThresholdEnabledChange, handleInputVariableChange, + handleEnableBuiltInMetadataChange, + handleDocMetadataChange, } = useConfig(id) const filterVar = useCallback((variable: Var) => { @@ -190,6 +199,19 @@ const Panel: FC> = ({ />
+ + + ) } diff --git a/web/app/components/workflow/nodes/knowledge-base/types.ts b/web/app/components/workflow/nodes/knowledge-base/types.ts index b54e8e2b2ff3f5..7f0bb3f18e4f96 100644 --- a/web/app/components/workflow/nodes/knowledge-base/types.ts +++ b/web/app/components/workflow/nodes/knowledge-base/types.ts @@ -42,6 +42,12 @@ export type RetrievalSetting = { score_threshold: number reranking_mode?: RerankingModeEnum } + +export type DocMetadataItem = { + metadata_id: string + value: string | number | string[] // string[] for ValueSelector +} + export type KnowledgeBaseNodeType = CommonNodeType & { index_chunk_variable_selector: string[] chunk_structure?: ChunkStructureEnum @@ -50,6 +56,8 @@ export type KnowledgeBaseNodeType = CommonNodeType & { embedding_model_provider?: string keyword_number: number retrieval_model: RetrievalSetting + enable_built_in_metadata?: boolean + doc_metadata?: DocMetadataItem[] _embeddingModelList?: Model[] _rerankModelList?: Model[] } diff --git a/web/i18n/en-US/dataset-creation.json b/web/i18n/en-US/dataset-creation.json index e544aaa09783cc..e1ac975b9702ed 100644 --- a/web/i18n/en-US/dataset-creation.json +++ b/web/i18n/en-US/dataset-creation.json @@ -121,6 +121,8 @@ "stepTwo.indexSettingTip": "To change the index method & embedding model, please go to the ", "stepTwo.maxLength": "Maximum chunk length", "stepTwo.maxLengthCheck": "Maximum chunk length should be less than {{limit}}", + "stepTwo.metadata.customValues": "Custom Values", + "stepTwo.metadata.noValues": "No values configured", "stepTwo.nextStep": "Save & Process", "stepTwo.notAvailableForParentChild": "Not available for Parent-child Index", "stepTwo.notAvailableForQA": "Not available for Q&A Index", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 107dad5b288db5..ebf6eea08e6e5f 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -447,6 +447,8 @@ "nodes.common.retry.times": "times", "nodes.common.typeSwitch.input": "Input value", "nodes.common.typeSwitch.variable": "Use variable", + "nodes.common.valueType.constant": "Constant", + "nodes.common.valueType.variable": "Variable", "nodes.dataSource.add": "Add data source", "nodes.dataSource.supportedFileFormats": "Supported file formats", "nodes.dataSource.supportedFileFormatsPlaceholder": "File extension, e.g. doc", diff --git a/web/i18n/zh-Hans/dataset-creation.json b/web/i18n/zh-Hans/dataset-creation.json index 102f64e5e75d85..9a2ecbf5f12641 100644 --- a/web/i18n/zh-Hans/dataset-creation.json +++ b/web/i18n/zh-Hans/dataset-creation.json @@ -121,6 +121,8 @@ "stepTwo.indexSettingTip": "要更改索引方法和 embedding 模型,请转到", "stepTwo.maxLength": "分段最大长度", "stepTwo.maxLengthCheck": "分段最大长度不能大于 {{limit}}", + "stepTwo.metadata.customValues": "自定义值", + "stepTwo.metadata.noValues": "未配置任何值", "stepTwo.nextStep": "保存并处理", "stepTwo.notAvailableForParentChild": "不支持父子索引", "stepTwo.notAvailableForQA": "不支持 Q&A 索引", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 7787c9db4b618e..3b076014ffa41b 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -447,6 +447,8 @@ "nodes.common.retry.times": "次", "nodes.common.typeSwitch.input": "输入值", "nodes.common.typeSwitch.variable": "使用变量", + "nodes.common.valueType.constant": "常量", + "nodes.common.valueType.variable": "变量", "nodes.dataSource.add": "添加数据源", "nodes.dataSource.supportedFileFormats": "支持的文件格式", "nodes.dataSource.supportedFileFormatsPlaceholder": "文件格式,例如:doc", From 8a31d522253517e4a6369cf0061d66bd08989f94 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:23:21 +0000 Subject: [PATCH 02/14] [autofix.ci] apply automated fixes --- .../knowledge_index/knowledge_index_node.py | 7 ++--- api/services/metadata_service.py | 23 +++++++------- .../test_knowledge_index_node.py | 31 +++++++------------ .../services/test_dataset_service_metadata.py | 25 ++++++--------- 4 files changed, 34 insertions(+), 52 deletions(-) diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index ad227fa59a2d76..6ad1bd1d3ec09b 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -205,9 +205,7 @@ def _invoke_knowledge_index( # Resolve Name md_name = metadata_name_map.get(item.metadata_id) if not md_name: - logger.warning( - "[KnowledgeIndexNode] metadata_id %s not found, skipping", item.metadata_id - ) + logger.warning("[KnowledgeIndexNode] metadata_id %s not found, skipping", item.metadata_id) continue # Resolve Value @@ -298,6 +296,5 @@ def _extract_variable_selector_to_variable_mapping( for item in node_data_obj.doc_metadata: if isinstance(item.value, list): variable_mapping[node_id + "." + item.metadata_id] = item.value - - return variable_mapping + return variable_mapping diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 8538dac7925ecb..487628c87bf536 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -101,7 +101,7 @@ def update_metadata_name(dataset_id: str, metadata_id: str, name: str) -> Datase def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[bool, str | None]: """ Check if a metadata is used in the associated Pipeline's Knowledge Base node. - + Returns: tuple[bool, str | None]: (is_used, pipeline_name) - True if used, with pipeline name """ @@ -109,25 +109,24 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ dataset = db.session.query(Dataset).filter_by(id=dataset_id).first() if not dataset or not dataset.pipeline_id: return False, None - + # Get the draft workflow directly using pipeline_id as app_id - workflow = db.session.query(Workflow).filter_by( - app_id=dataset.pipeline_id, - version=Workflow.VERSION_DRAFT - ).first() + workflow = ( + db.session.query(Workflow).filter_by(app_id=dataset.pipeline_id, version=Workflow.VERSION_DRAFT).first() + ) if not workflow: return False, None - + # Get pipeline name from App if exists app = db.session.query(App).filter_by(id=dataset.pipeline_id).first() pipeline_name = app.name if app else "Pipeline" - + # Walk through nodes to find Knowledge Index node (type is "knowledge-index") try: graph_dict = workflow.graph_dict if "nodes" not in graph_dict: return False, None - + for node in graph_dict["nodes"]: node_data = node.get("data", {}) # Check if this is a knowledge-index node @@ -140,7 +139,7 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ except Exception: logger.exception("Error checking metadata usage in pipeline") return False, None - + return False, None @staticmethod @@ -151,7 +150,7 @@ def delete_metadata(dataset_id: str, metadata_id: str): metadata = db.session.query(DatasetMetadata).filter_by(id=metadata_id).first() if metadata is None: raise ValueError("Metadata not found.") - + # Check if metadata is used in Pipeline before deletion is_used, pipeline_name = MetadataService.check_metadata_used_in_pipeline(dataset_id, metadata_id) if is_used: @@ -159,7 +158,7 @@ def delete_metadata(dataset_id: str, metadata_id: str): f"Cannot delete metadata '{metadata.name}' because it is currently used in " f"Pipeline '{pipeline_name}'." ) - + db.session.delete(metadata) # deal related documents diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index c7d514c2433709..486b015fbb3401 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -1,4 +1,3 @@ - import unittest import uuid from unittest.mock import MagicMock, patch @@ -19,18 +18,18 @@ def setUp(self): self.mock_dataset = MagicMock(spec=Dataset) self.mock_dataset.id = self.dataset_id self.mock_dataset.built_in_field_enabled = False - + self.mock_document = MagicMock(spec=Document) self.mock_document.id = self.document_id self.mock_document.doc_metadata = {} - @patch('core.workflow.nodes.knowledge_index.knowledge_index_node.db.session') - @patch('core.workflow.nodes.knowledge_index.knowledge_index_node.IndexProcessorFactory') + @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.db.session") + @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.IndexProcessorFactory") def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_session): # Mock DB queries mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [ self.mock_dataset, # For dataset query - self.mock_document # For document query + self.mock_document, # For document query ] # Mock Dataset Metadata @@ -40,10 +39,10 @@ def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_se mock_db_session.scalars.return_value.all.return_value = [mock_metadata] # Simpler mock for the scalar query - switched to bulk fetch mock_db_session.scalar.return_value = "Category" - + # Mock Variable Pool pool = MagicMock(spec=VariablePool) - + # System variables pool.get.side_effect = lambda selector: { ("sys", SystemVariableKey.DATASET_ID): MagicMock(value=self.dataset_id), @@ -57,7 +56,7 @@ def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_se # Handle the chunk variable specifically first chunk_var_mock = MagicMock() chunk_var_mock.value = {"chunk": "data"} - + # Override side_effect to handle list lookups correctly def variable_pool_get(selector): if selector == ["sys", SystemVariableKey.DATASET_ID]: @@ -82,26 +81,21 @@ def variable_pool_get(selector): title="Knowledge", chunk_structure="chunk", index_chunk_variable_selector=["sys", "chunks"], - doc_metadata=[ - DocMetadata(metadata_id="meta_uuid_1", value=["Start", "category"]) - ] + doc_metadata=[DocMetadata(metadata_id="meta_uuid_1", value=["Start", "category"])], ) # Initialize Node graph_init_params = MagicMock() graph_init_params.user_from = UserFrom.ACCOUNT graph_init_params.invoke_from = InvokeFrom.WEB_APP - - config = { - "id": "node1", - "data": node_data.model_dump() - } - + + config = {"id": "node1", "data": node_data.model_dump()} + node = KnowledgeIndexNode( id="node1", graph_init_params=graph_init_params, graph_runtime_state=MagicMock(variable_pool=pool), - config=config + config=config, ) # Mock _invoke_knowledge_index to avoid calling specific index logic @@ -114,4 +108,3 @@ def variable_pool_get(selector): assert self.mock_document.doc_metadata["Category"] == "Financial" mock_db_session.add.assert_called_with(self.mock_document) mock_db_session.commit.assert_called() - diff --git a/api/tests/unit_tests/services/test_dataset_service_metadata.py b/api/tests/unit_tests/services/test_dataset_service_metadata.py index 98d12317a5a9d7..b1da9a55bdde6b 100644 --- a/api/tests/unit_tests/services/test_dataset_service_metadata.py +++ b/api/tests/unit_tests/services/test_dataset_service_metadata.py @@ -31,7 +31,7 @@ def mock_dependencies(self): # Hack to pass isinstance check mock_current_user.__class__ = Account mock_current_user.current_tenant_id = "tenant-123" - + yield { "db": mock_db, "get_dataset": mock_get_dataset, @@ -57,24 +57,19 @@ def test_save_document_with_metadata(self, mock_dependencies): # Define metadata inputs metadata_id = str(uuid4()) - doc_metadata_inputs = [ - DocumentMetadataInput(metadata_id=metadata_id, value="custom_value") - ] + doc_metadata_inputs = [DocumentMetadataInput(metadata_id=metadata_id, value="custom_value")] # Knowledge config knowledge_config = KnowledgeConfig( data_source_type="upload_file", data_source=DataSource( - info_list=InfoList( - data_source_type="upload_file", - file_info_list=FileInfo(file_ids=["file-1"]) - ) + info_list=InfoList(data_source_type="upload_file", file_info_list=FileInfo(file_ids=["file-1"])) ), doc_form="text_model", doc_language="en", indexing_technique="high_quality", enable_built_in_metadata=True, - doc_metadata=doc_metadata_inputs + doc_metadata=doc_metadata_inputs, ) # Mock local file for upload_file type @@ -84,7 +79,7 @@ def test_save_document_with_metadata(self, mock_dependencies): mock_metadata_def.id = metadata_id mock_metadata_def.name = "custom_field" mock_metadata_def.field_type = "text" - + # Create a side effect for query(Model) def query_side_effect(model): m = Mock() @@ -106,9 +101,9 @@ def query_side_effect(model): return m return m - + mock_query.side_effect = query_side_effect - + # Mock build_document to return a document mock_document = Mock(spec=Document) mock_document.id = "doc-123" @@ -117,15 +112,13 @@ def query_side_effect(model): # Act DocumentService.save_document_with_dataset_id( - dataset=dataset, - knowledge_config=knowledge_config, - account=account + dataset=dataset, knowledge_config=knowledge_config, account=account ) # Assert # 1. Check built-in metadata enabled assert dataset.built_in_field_enabled is True - + # 2. Check custom metadata passed to build_document call_args = mock_dependencies["build_document"].call_args assert call_args is not None From eb1b8c50f71915e731c9817835d2cafb3da709c2 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 16:29:43 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=90=9B=20fix(api):=20fix=20failing?= =?UTF-8?q?=20metadata=20unit=20tests=20and=20enhance=20pipeline=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix test_delete_metadata_not_found: use pytest.raises() since service raises ValueError instead of returning None - Fix test_run_with_custom_metadata: remove mock of _invoke_knowledge_index that bypassed metadata processing logic, add missing BATCH/ORIGINAL_DOCUMENT_ID variable mocks, remove redundant side_effect code - Fix test_save_document_with_dataset_id_ignores_lock_not_owned: add missing enable_built_in_metadata and doc_metadata attributes to knowledge_config - Fix test_save_document_with_metadata: add .filter().all() mock chain for DatasetMetadata query - Enhance check_metadata_used_in_pipeline to check both draft and current published workflows (via pipeline.workflow_id) to prevent deletion of metadata actively used in production Co-Authored-By: Claude --- api/services/metadata_service.py | 63 +++++++++++-------- .../services/test_metadata_service.py | 8 +-- .../test_knowledge_index_node.py | 22 ++----- .../test_dataset_service_lock_not_owned.py | 2 + .../services/test_dataset_service_metadata.py | 1 + 5 files changed, 49 insertions(+), 47 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 487628c87bf536..033960ffd55ab1 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -1,13 +1,14 @@ import copy import logging +from sqlalchemy import or_ + from core.rag.index_processor.constant.built_in_field import BuiltInField, MetadataDataSource from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.datetime_utils import naive_utc_now from libs.login import current_account_with_tenant -from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding -from models.model import App +from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Pipeline from models.workflow import Workflow from services.dataset_service import DocumentService from services.entities.knowledge_entities.knowledge_entities import ( @@ -102,6 +103,9 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ """ Check if a metadata is used in the associated Pipeline's Knowledge Base node. + Checks both draft and current published workflows to prevent deletion of metadata + that is actively used in production. + Returns: tuple[bool, str | None]: (is_used, pipeline_name) - True if used, with pipeline name """ @@ -110,36 +114,43 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ if not dataset or not dataset.pipeline_id: return False, None - # Get the draft workflow directly using pipeline_id as app_id - workflow = ( - db.session.query(Workflow).filter_by(app_id=dataset.pipeline_id, version=Workflow.VERSION_DRAFT).first() - ) - if not workflow: + # Get the pipeline to access workflow_id (current published version) + pipeline = db.session.query(Pipeline).filter_by(id=dataset.pipeline_id).first() + if not pipeline: return False, None - # Get pipeline name from App if exists - app = db.session.query(App).filter_by(id=dataset.pipeline_id).first() - pipeline_name = app.name if app else "Pipeline" + # Build conditions for draft and current published workflows only + workflow_conditions = [ + (Workflow.app_id == pipeline.id) & (Workflow.version == Workflow.VERSION_DRAFT) + ] + if pipeline.workflow_id: + workflow_conditions.append(Workflow.id == pipeline.workflow_id) - # Walk through nodes to find Knowledge Index node (type is "knowledge-index") - try: - graph_dict = workflow.graph_dict - if "nodes" not in graph_dict: - return False, None + workflows = db.session.query(Workflow).filter(or_(*workflow_conditions)).all() - for node in graph_dict["nodes"]: - node_data = node.get("data", {}) - # Check if this is a knowledge-index node - if node_data.get("type") == "knowledge-index": - doc_metadata = node_data.get("doc_metadata", []) - if doc_metadata: - for item in doc_metadata: - if item.get("metadata_id") == metadata_id: - return True, pipeline_name - except Exception: - logger.exception("Error checking metadata usage in pipeline") + if not workflows: return False, None + # Check each workflow for metadata usage + for workflow in workflows: + try: + graph_dict = workflow.graph_dict + if "nodes" not in graph_dict: + continue + + for node in graph_dict["nodes"]: + node_data = node.get("data", {}) + # Check if this is a knowledge-index node + if node_data.get("type") == "knowledge-index": + doc_metadata = node_data.get("doc_metadata", []) + if doc_metadata: + for item in doc_metadata: + if item.get("metadata_id") == metadata_id: + return True, pipeline.name + except Exception: + logger.exception("Error checking metadata usage in pipeline workflow %s", workflow.id) + continue + return False, None @staticmethod diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_service.py b/api/tests/test_containers_integration_tests/services/test_metadata_service.py index c8ced3f3a5a4f1..130cd759e75803 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_service.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_service.py @@ -460,11 +460,9 @@ def test_delete_metadata_not_found(self, db_session_with_containers, mock_extern fake_metadata_id = str(uuid.uuid4()) # Use valid UUID format - # Act: Execute the method under test - result = MetadataService.delete_metadata(dataset.id, fake_metadata_id) - - # Assert: Verify the method returns None when metadata is not found - assert result is None + # Act & Assert: Verify the method raises ValueError when metadata is not found + with pytest.raises(ValueError, match="Metadata not found."): + MetadataService.delete_metadata(dataset.id, fake_metadata_id) def test_delete_metadata_with_document_bindings( self, db_session_with_containers, mock_external_service_dependencies diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 486b015fbb3401..174116317d9a45 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -43,33 +43,26 @@ def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_se # Mock Variable Pool pool = MagicMock(spec=VariablePool) - # System variables - pool.get.side_effect = lambda selector: { - ("sys", SystemVariableKey.DATASET_ID): MagicMock(value=self.dataset_id), - ("sys", SystemVariableKey.DOCUMENT_ID): MagicMock(value=self.document_id), - ("sys", SystemVariableKey.INVOKE_FROM): None, - ("Start", "category"): MagicMock(to_object=lambda: "Financial"), - # handle list as key? get takes list - frozenset(["Start", "category"]): MagicMock(to_object=lambda: "Financial"), - }.get(tuple(selector) if isinstance(selector, list) else selector) - - # Handle the chunk variable specifically first + # Handle the chunk variable chunk_var_mock = MagicMock() chunk_var_mock.value = {"chunk": "data"} - # Override side_effect to handle list lookups correctly def variable_pool_get(selector): if selector == ["sys", SystemVariableKey.DATASET_ID]: return MagicMock(value=self.dataset_id) if selector == ["sys", SystemVariableKey.DOCUMENT_ID]: return MagicMock(value=self.document_id) + if selector == ["sys", SystemVariableKey.BATCH]: + return MagicMock(value="test-batch") + if selector == ["sys", SystemVariableKey.ORIGINAL_DOCUMENT_ID]: + return None if selector == ["Start", "category"]: var = MagicMock() var.to_object.return_value = "Financial" return var if selector == ["sys", SystemVariableKey.INVOKE_FROM]: return None - if selector == ["sys", "chunks"]: # whatever index_chunk_variable_selector is + if selector == ["sys", "chunks"]: return chunk_var_mock return None @@ -98,9 +91,6 @@ def variable_pool_get(selector): config=config, ) - # Mock _invoke_knowledge_index to avoid calling specific index logic - node._invoke_knowledge_index = MagicMock() - # Execute result = node._run() diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index bd226f7536d6d8..136a0af8ff7b97 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -86,6 +86,8 @@ def test_save_document_with_dataset_id_ignores_lock_not_owned( process_rule=None, duplicate=False, doc_language="en", + enable_built_in_metadata=False, + doc_metadata=None, ) account = fake_current_user diff --git a/api/tests/unit_tests/services/test_dataset_service_metadata.py b/api/tests/unit_tests/services/test_dataset_service_metadata.py index b1da9a55bdde6b..a3de25ed300c4f 100644 --- a/api/tests/unit_tests/services/test_dataset_service_metadata.py +++ b/api/tests/unit_tests/services/test_dataset_service_metadata.py @@ -87,6 +87,7 @@ def query_side_effect(model): m.filter.return_value.filter.return_value.first.return_value = mock_metadata_def # handle the specific chain in code m.filter_by.return_value.first.return_value = mock_metadata_def + m.filter.return_value.all.return_value = [mock_metadata_def] return m if model == Document: doc_mock = Mock() From 7997ef22af5ae0848c338061a026a4e63f3a3878 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 16:39:16 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=8E=A8=20style(web):=20add=20border?= =?UTF-8?q?=20and=20background=20to=20metadata=20value=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper input styling (border, rounded corners, background) to the metadata value container in Knowledge Base node for visual consistency with other form inputs. Co-Authored-By: Claude --- .../nodes/knowledge-base/components/metadata-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx index ef5fbb99a64efd..3ed4076fa828f3 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx @@ -217,7 +217,7 @@ const MetadataSection: FC = ({ )}
-
+
Date: Fri, 23 Jan 2026 08:44:39 +0000 Subject: [PATCH 05/14] [autofix.ci] apply automated fixes --- api/services/metadata_service.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 033960ffd55ab1..90436f6d464d56 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -120,13 +120,11 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ return False, None # Build conditions for draft and current published workflows only - workflow_conditions = [ - (Workflow.app_id == pipeline.id) & (Workflow.version == Workflow.VERSION_DRAFT) - ] + workflow_conditions = [(Workflow.app_id == pipeline.id) & (Workflow.version == Workflow.VERSION_DRAFT)] if pipeline.workflow_id: workflow_conditions.append(Workflow.id == pipeline.workflow_id) - workflows = db.session.query(Workflow).filter(or_(*workflow_conditions)).all() + workflows = db.session.query(Workflow).where(or_(*workflow_conditions)).all() if not workflows: return False, None From 15777a6172ecb9029557bc5985b825c170a458e8 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 16:57:27 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=90=9B=20fix(api):=20fix=20type=20e?= =?UTF-8?q?rror=20in=20check=5Fmetadata=5Fused=5Fin=5Fpipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite workflow query conditions to avoid type checking error with list.append() having inconsistent types. Co-Authored-By: Claude --- api/services/metadata_service.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 90436f6d464d56..b04d98d2812b8e 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -120,11 +120,21 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ return False, None # Build conditions for draft and current published workflows only - workflow_conditions = [(Workflow.app_id == pipeline.id) & (Workflow.version == Workflow.VERSION_DRAFT)] - if pipeline.workflow_id: - workflow_conditions.append(Workflow.id == pipeline.workflow_id) + draft_condition = (Workflow.app_id == pipeline.id) & (Workflow.version == Workflow.VERSION_DRAFT) - workflows = db.session.query(Workflow).where(or_(*workflow_conditions)).all() + if pipeline.workflow_id: + workflows = ( + db.session.query(Workflow) + .filter( + or_( + draft_condition, + Workflow.id == pipeline.workflow_id, + ) + ) + .all() + ) + else: + workflows = db.session.query(Workflow).filter(draft_condition).all() if not workflows: return False, None From cf198efa3469be650328ad3fde81c5aa63816d9d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:03:18 +0000 Subject: [PATCH 07/14] [autofix.ci] apply automated fixes --- api/services/metadata_service.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index b04d98d2812b8e..586ec27243c8bf 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -124,17 +124,14 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ if pipeline.workflow_id: workflows = ( - db.session.query(Workflow) - .filter( - or_( - draft_condition, - Workflow.id == pipeline.workflow_id, - ) - ) + db.session.query(Workflow).where(or_( + draft_condition, + Workflow.id == pipeline.workflow_id, + )) .all() ) else: - workflows = db.session.query(Workflow).filter(draft_condition).all() + workflows = db.session.query(Workflow).where(draft_condition).all() if not workflows: return False, None From f566da58f7cf23135d50636570379558ba46acda Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:07:12 +0000 Subject: [PATCH 08/14] [autofix.ci] apply automated fixes (attempt 2/3) --- api/services/metadata_service.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py index 586ec27243c8bf..beb47f01042a35 100644 --- a/api/services/metadata_service.py +++ b/api/services/metadata_service.py @@ -124,10 +124,13 @@ def check_metadata_used_in_pipeline(dataset_id: str, metadata_id: str) -> tuple[ if pipeline.workflow_id: workflows = ( - db.session.query(Workflow).where(or_( - draft_condition, - Workflow.id == pipeline.workflow_id, - )) + db.session.query(Workflow) + .where( + or_( + draft_condition, + Workflow.id == pipeline.workflow_id, + ) + ) .all() ) else: From 1df04fb16f1ed43d47cb8655907175fb12114a0b Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 20:10:31 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=85=20test(api):=20fix=20mock=20set?= =?UTF-8?q?up=20in=20knowledge=5Findex=5Fnode=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock attributes.flag_modified to avoid SQLAlchemy internal state requirements. Update assertion to verify flag_modified call instead of db.session.add call. --- .../knowledge_index/test_knowledge_index_node.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 174116317d9a45..9743a7694dacdd 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -7,7 +7,7 @@ from core.workflow.nodes.knowledge_index.entities import DocMetadata, KnowledgeIndexNodeData from core.workflow.nodes.knowledge_index.knowledge_index_node import KnowledgeIndexNode from core.workflow.runtime import VariablePool -from models.dataset import Dataset, DatasetMetadata, Document +from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document from models.enums import UserFrom @@ -23,9 +23,12 @@ def setUp(self): self.mock_document.id = self.document_id self.mock_document.doc_metadata = {} + @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.attributes.flag_modified") @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.db.session") @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.IndexProcessorFactory") - def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_session): + def test_run_with_custom_metadata( + self, mock_index_processor_factory, mock_db_session, mock_flag_modified + ): # Mock DB queries mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [ self.mock_dataset, # For dataset query @@ -94,7 +97,9 @@ def variable_pool_get(selector): # Execute result = node._run() - # Verify + # Verify metadata was set on document assert self.mock_document.doc_metadata["Category"] == "Financial" - mock_db_session.add.assert_called_with(self.mock_document) + # Verify flag_modified was called for the doc_metadata field + mock_flag_modified.assert_called_with(self.mock_document, "doc_metadata") + # Verify commit was called mock_db_session.commit.assert_called() From 7759ab08e951f5837d562e4397de6b1a5a2b40a5 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Fri, 23 Jan 2026 20:10:57 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=8E=A8=20test(api):=20remove=20unus?= =?UTF-8?q?ed=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/nodes/knowledge_index/test_knowledge_index_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 9743a7694dacdd..0ffd5dd41c414e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -7,7 +7,7 @@ from core.workflow.nodes.knowledge_index.entities import DocMetadata, KnowledgeIndexNodeData from core.workflow.nodes.knowledge_index.knowledge_index_node import KnowledgeIndexNode from core.workflow.runtime import VariablePool -from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document +from models.dataset import Dataset, DatasetMetadata, Document from models.enums import UserFrom From 96b7f2c2916b5b3d78d948d969745d57b6034f5c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:15:51 +0000 Subject: [PATCH 11/14] [autofix.ci] apply automated fixes --- .../nodes/knowledge_index/test_knowledge_index_node.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index 0ffd5dd41c414e..3d6da66a0c978a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -26,9 +26,7 @@ def setUp(self): @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.attributes.flag_modified") @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.db.session") @patch("core.workflow.nodes.knowledge_index.knowledge_index_node.IndexProcessorFactory") - def test_run_with_custom_metadata( - self, mock_index_processor_factory, mock_db_session, mock_flag_modified - ): + def test_run_with_custom_metadata(self, mock_index_processor_factory, mock_db_session, mock_flag_modified): # Mock DB queries mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [ self.mock_dataset, # For dataset query From eba2f06bd443ea82597662993052466b830007fd Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 2 Feb 2026 11:07:26 +0800 Subject: [PATCH 12/14] refactor: format dataset model imports for readability --- .../nodes/knowledge_index/knowledge_index_node.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index b68f5cca50adb2..28d0aaf4e0406c 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -19,7 +19,14 @@ from core.workflow.nodes.base.template import Template from core.workflow.runtime import VariablePool from extensions.ext_database import db -from models.dataset import Dataset, DatasetMetadata, DatasetMetadataBinding, Document, DocumentSegment, DocumentSegmentSummary +from models.dataset import ( + Dataset, + DatasetMetadata, + DatasetMetadataBinding, + Document, + DocumentSegment, + DocumentSegmentSummary, +) from services.summary_index_service import SummaryIndexService from tasks.generate_summary_index_task import generate_summary_index_task From 26248e3d80b8e4b211c353f4f94f0ffc8f78d838 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 2 Feb 2026 14:54:46 +0800 Subject: [PATCH 13/14] style: remove fixed font size from date picker text --- web/app/components/datasets/metadata/base/date-picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index 6839af6232ae08..2f615498592a12 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -38,7 +38,7 @@ const WrappedDatePicker = ({
From a4cd1fb4c41bf73f20ee30b643f1e976b186dff2 Mon Sep 17 00:00:00 2001 From: GuanMu Date: Mon, 2 Feb 2026 16:11:30 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=8E=A8=20style:=20fix=20metadata=20?= =?UTF-8?q?date=20picker=20styling=20and=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add system-xs-regular and truncate to date-picker for consistent font size - Fix value container alignment with w-0 grow overflow-hidden pattern - Change delete button from × to trash icon with destructive hover - Align condition-date layout with other condition components Co-Authored-By: Claude Opus 4.5 --- .../datasets/metadata/base/date-picker.tsx | 2 +- .../edit-metadata-batch/input-combined.tsx | 6 +++--- .../knowledge-base/components/metadata-section.tsx | 14 +++++++------- .../metadata/condition-list/condition-date.tsx | 6 +++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index 2f615498592a12..3c99323599dd57 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -38,7 +38,7 @@ const WrappedDatePicker = ({
diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx index aec74bcfefbfb1..4b34f4772a7812 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx @@ -22,7 +22,7 @@ const InputCombined: FC = ({ onChange, readOnly, }) => { - const className = cn('h-6 grow p-0.5 text-xs') + const className = cn('h-6 grow p-0.5') if (type === DataType.time) { return ( = ({ return (
= ({ return ( onChange(e.target.value)} readOnly={readOnly} diff --git a/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx index 3ed4076fa828f3..4867ba48e3d9e1 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/metadata-section.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { DocMetadataItem } from '../types' import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' import type { ValueSelector, Var } from '@/app/components/workflow/types' -import { RiAddLine, RiCloseLine, RiDraftLine, RiEditLine } from '@remixicon/react' +import { RiAddLine, RiDeleteBinLine, RiDraftLine, RiEditLine } from '@remixicon/react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -241,7 +241,7 @@ const MetadataSection: FC = ({
-
+
{isVariable ? ( = ({ /> ) : ( -
+
{(() => { const metadataType = getMetadataType(item.metadata_id) @@ -267,7 +267,7 @@ const MetadataSection: FC = ({ if (metadataType === DataType.time) { return ( handleDocMetadataValueChange(index, value || 0)} /> @@ -278,7 +278,7 @@ const MetadataSection: FC = ({ if (metadataType === DataType.number) { return ( handleDocMetadataValueChange(index, value)} readOnly={readonly} @@ -308,9 +308,9 @@ const MetadataSection: FC = ({ )}
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx index b34093b7b094e8..2511571d78a553 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx @@ -32,10 +32,10 @@ const ConditionDate = ({ handleClickTrigger, }: TriggerProps) => { return ( -
+
@@ -71,7 +71,7 @@ const ConditionDate = ({ }, [value, handleDateChange, timezone, t]) return ( -
+