Files
dify/api/services/knowledge/dataset_service.py
Byron.wangandautofix-ci[bot] 95373bc4fd refactor(api): consolidate account modules and shared errors (#42570)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-30 07:26:44 +00:00

3942 lines
175 KiB
Python

import copy
import datetime
import json
import logging
import secrets
import time
import uuid
from collections.abc import Sequence
from contextlib import nullcontext
from dataclasses import replace
from typing import Any, Literal, TypedDict, cast
import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import ColumnElement, case, delete, exists, func, select, tuple_, update
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.helper.name_generator import generate_incremental_name
from core.model_manager import ModelManager
from core.rag.index_processor.constant.built_in_field import BuiltInField
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from enums import CloudPlan, DeploymentEdition
from events.dataset_event import dataset_was_deleted
from events.document_event import document_was_deleted
from extensions.ext_redis import redis_client
from graphon.file import helpers as file_helpers
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType
from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel
from libs import helper
from libs.datetime_utils import naive_utc_now
from libs.login import current_user
from libs.pagination import paginate_query
from models import Account, TenantAccountRole
from models.dataset import (
AppDatasetJoin,
ChildChunk,
Dataset,
DatasetAutoDisableLog,
DatasetCollectionBinding,
DatasetPermission,
DatasetPermissionEnum,
DatasetProcessRule,
DatasetQuery,
Document,
DocumentSegment,
ExternalKnowledgeBindings,
Pipeline,
)
from models.enums import (
DatasetRuntimeMode,
DataSourceType,
DocumentCreatedFrom,
IndexingStatus,
ProcessRuleMode,
SegmentStatus,
)
from models.model import UploadFile
from models.provider_ids import ModelProviderID
from models.source import DataSourceOauthBinding
from models.workflow import Workflow
from repositories.knowledge import dataset_api_key_bindings
from repositories.knowledge.dataset_read_repository import get_dataset_doc_form, get_latest_dataset_process_rule
from repositories.knowledge.segment_repository import query_child_chunks
from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy
from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.feature_entities import FeatureModel
from services.entities.knowledge_entities.rag_pipeline_entities import (
KnowledgeConfiguration,
RagPipelineDatasetCreateEntity,
)
from services.errors.base import NoPermissionError
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
from services.errors.dataset import DatasetNameDuplicateError
from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.feature_service import FeatureService
from services.file_service import FileService
from services.knowledge.dataset_access import DatasetAccessRecord, DatasetAccessSnapshot, can_access_dataset
from services.knowledge.entities.document_creation import DocumentCreationDataset, DocumentIndexingJobs
from services.knowledge.entities.knowledge_entities import (
KnowledgeConfig,
RerankingModel,
RetrievalModel,
)
from services.knowledge.entities.segments import SegmentUpdateArgs
from services.knowledge.external.service import ExternalDatasetService
from services.knowledge.resource_scope import DatasetRef, SegmentRef
from services.knowledge.segments.application import (
ChildChunkDeleteIndexApplicationError,
ChildChunkIndexingApplicationError,
SegmentDatasetRecord,
SegmentDocumentRecord,
SegmentMutationScope,
SegmentMutationService,
validate_segment_values,
)
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_application_service import TagTargetQuery
from tasks.add_document_to_index_task import add_document_to_index_task
from tasks.batch_clean_document_task import batch_clean_document_task
from tasks.clean_notion_document_task import clean_notion_document_task
from tasks.deal_dataset_index_update_task import deal_dataset_index_update_task
from tasks.deal_dataset_vector_index_task import deal_dataset_vector_index_task
from tasks.document_indexing_update_task import document_indexing_update_task
from tasks.recover_document_indexing_task import recover_document_indexing_task
from tasks.regenerate_summary_index_task import regenerate_summary_index_task
from tasks.remove_document_from_index_task import remove_document_from_index_task
from tasks.retry_document_indexing_task import retry_document_indexing_task
from tasks.sync_website_document_indexing_task import sync_website_document_indexing_task
logger = logging.getLogger(__name__)
class ProcessRulesDict(TypedDict):
mode: ProcessRuleMode
rules: dict[str, Any]
class AutoDisableLogsDict(TypedDict):
document_ids: list[str]
count: int
class DatasetService:
@staticmethod
def _can_manage_all_datasets(tenant_id: str, account_id: str, *, session: Session) -> bool:
if not dify_config.RBAC_ENABLED:
return False
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id, session=session)
workspace_permission_keys = getattr(getattr(permissions, "workspace", None), "permission_keys", []) or []
return "dataset.create_and_management" in workspace_permission_keys
@staticmethod
def get_datasets(
page: int,
per_page: int,
session: Session,
tenant_id=None,
user=None,
search=None,
tag_ids=None,
include_all=False,
accessible_dataset_ids: list[str] | None = None,
include_own_datasets: bool = False,
*,
tags: TagTargetQuery,
):
"""Return visible datasets for a tenant, using the injected session for auxiliary permission lookups."""
query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id)
if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None:
accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids)
if include_own_datasets and user:
accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter)
query = query.where(accessible_filter)
if user:
# get permitted dataset ids
dataset_permission = session.scalars(
select(DatasetPermission).where(
DatasetPermission.account_id == user.id, DatasetPermission.tenant_id == tenant_id
)
).all()
permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else None
if not dify_config.RBAC_ENABLED and user.current_role == TenantAccountRole.DATASET_OPERATOR:
# only show datasets that the user has permission to access
# Check if permitted_dataset_ids is not empty to avoid WHERE false condition
if permitted_dataset_ids and len(permitted_dataset_ids) > 0:
query = query.where(Dataset.id.in_(permitted_dataset_ids))
else:
return [], 0
else:
if dify_config.RBAC_ENABLED:
can_manage_all_datasets = DatasetService._can_manage_all_datasets(
str(tenant_id), str(user.id), session=session
)
should_show_all_datasets = include_all and can_manage_all_datasets
else:
should_show_all_datasets = user.current_role == TenantAccountRole.OWNER and include_all
if not should_show_all_datasets:
if dify_config.RBAC_ENABLED:
# RBAC mode: show all datasets. Permission control is enforced
# via permission_keys on each item and @rbac_permission_required decorators.
pass
else:
# Keep legacy visibility rules when RBAC is disabled.
if permitted_dataset_ids and len(permitted_dataset_ids) > 0:
query = query.where(
sa.or_(
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
sa.and_(
Dataset.permission == DatasetPermissionEnum.ONLY_ME,
Dataset.maintainer == user.id,
),
sa.and_(
Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM,
Dataset.id.in_(permitted_dataset_ids),
),
)
)
else:
query = query.where(
sa.or_(
Dataset.permission == DatasetPermissionEnum.ALL_TEAM,
sa.and_(
Dataset.permission == DatasetPermissionEnum.ONLY_ME,
Dataset.maintainer == user.id,
),
)
)
else:
if dify_config.RBAC_ENABLED:
# Without an account we cannot resolve RBAC resource visibility.
query = query.where(sa.false())
else:
# if no user, only show datasets that are shared with all team members
query = query.where(Dataset.permission == DatasetPermissionEnum.ALL_TEAM)
if search:
escaped_search = helper.escape_like_pattern(search)
query = query.where(Dataset.name.ilike(f"%{escaped_search}%", escape="\\"))
# Check if tag_ids is not empty to avoid WHERE false condition
if tag_ids and len(tag_ids) > 0:
if tenant_id is not None:
target_ids = tags.find_target_ids(
tag_type="knowledge",
tenant_id=tenant_id,
tag_ids=tag_ids,
match_all=True,
)
else:
target_ids = []
if target_ids and len(target_ids) > 0:
query = query.where(Dataset.id.in_(target_ids))
else:
return [], 0
datasets = paginate_query(query, session=session, page=page, per_page=per_page, max_per_page=100)
return datasets.items, datasets.total
@staticmethod
def get_process_rules(dataset_id, session: Session) -> ProcessRulesDict:
# get the latest process rule
dataset_process_rule = session.execute(
select(DatasetProcessRule)
.where(DatasetProcessRule.dataset_id == dataset_id)
.order_by(DatasetProcessRule.created_at.desc())
.limit(1)
).scalar_one_or_none()
if dataset_process_rule:
mode = dataset_process_rule.mode
rules = dataset_process_rule.rules_dict or {}
else:
mode = ProcessRuleMode(DocumentService.DEFAULT_RULES["mode"])
rules = dict(DocumentService.DEFAULT_RULES.get("rules") or {})
return {"mode": mode, "rules": rules}
@staticmethod
def get_datasets_by_ids(
ids: list[str] | None,
tenant_id: str,
user=None,
accessible_dataset_ids: list[str] | None = None,
include_own_datasets: bool = False,
*,
session: Session,
):
# Check if ids is not empty to avoid WHERE false condition
if not ids:
return [], 0
stmt = select(Dataset).where(Dataset.id.in_(ids), Dataset.tenant_id == tenant_id)
if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None:
requested_dataset_ids = set(ids)
accessible_dataset_ids = [
dataset_id for dataset_id in accessible_dataset_ids if dataset_id in requested_dataset_ids
]
accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids)
if include_own_datasets and user:
accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter)
stmt = stmt.where(accessible_filter)
datasets = paginate_query(stmt, session=session, page=1, per_page=len(ids), max_per_page=len(ids))
return datasets.items, datasets.total
@staticmethod
def create_empty_dataset(
tenant_id: str,
name: str,
description: str | None,
indexing_technique: str | None,
account: Account,
permission: str | None = None,
provider: str = "vendor",
external_knowledge_api_id: str | None = None,
external_knowledge_id: str | None = None,
embedding_model_provider: str | None = None,
embedding_model_name: str | None = None,
retrieval_model: RetrievalModel | None = None,
summary_index_setting: dict[str, Any] | None = None,
*,
session: Session,
):
# check if dataset name already exists
if session.scalar(select(Dataset).where(Dataset.name == name, Dataset.tenant_id == tenant_id).limit(1)):
raise DatasetNameDuplicateError(f"Dataset with name {name} already exists.")
embedding_model = None
if indexing_technique == IndexTechniqueType.HIGH_QUALITY:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
if embedding_model_provider and embedding_model_name:
# check if embedding model setting is valid
DatasetService.check_embedding_model_setting(tenant_id, embedding_model_provider, embedding_model_name)
embedding_model = model_manager.get_model_instance(
tenant_id=tenant_id,
provider=embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=embedding_model_name,
)
else:
embedding_model = model_manager.get_default_model_instance(
tenant_id=tenant_id, model_type=ModelType.TEXT_EMBEDDING
)
if retrieval_model and retrieval_model.reranking_model:
if (
retrieval_model.reranking_model.reranking_provider_name
and retrieval_model.reranking_model.reranking_model_name
):
# check if reranking model setting is valid
DatasetService.check_reranking_model_setting(
tenant_id,
retrieval_model.reranking_model.reranking_provider_name,
retrieval_model.reranking_model.reranking_model_name,
)
dataset = Dataset(
name=name,
indexing_technique=IndexTechniqueType(indexing_technique) if indexing_technique else None,
)
# dataset = Dataset(name=name, provider=provider, config=config)
dataset.description = description
dataset.created_by = account.id
dataset.maintainer = account.id
dataset.updated_by = account.id
dataset.tenant_id = tenant_id
dataset.embedding_model_provider = embedding_model.provider if embedding_model else None
dataset.embedding_model = embedding_model.model_name if embedding_model else None
dataset.retrieval_model = retrieval_model.model_dump() if retrieval_model else None
dataset.permission = DatasetPermissionEnum(permission) if permission else DatasetPermissionEnum.ONLY_ME
dataset.provider = provider
if summary_index_setting is not None:
dataset.summary_index_setting = summary_index_setting
session.add(dataset)
session.flush()
if provider == "external" and external_knowledge_api_id:
external_knowledge_api = ExternalDatasetService.get_external_knowledge_api(
external_knowledge_api_id, tenant_id, session=session
)
if not external_knowledge_api:
raise ValueError("External API template not found.")
if external_knowledge_id is None:
raise ValueError("external_knowledge_id is required")
external_knowledge_binding = ExternalKnowledgeBindings(
tenant_id=tenant_id,
dataset_id=dataset.id,
external_knowledge_api_id=external_knowledge_api_id,
external_knowledge_id=external_knowledge_id,
created_by=account.id,
)
session.add(external_knowledge_binding)
session.commit()
enterprise_rbac_service.try_sync_creator_access_policy_member_bindings(
tenant_id,
account.id,
enterprise_rbac_service.RBACResourceType.DATASET,
dataset.id,
)
return dataset
@staticmethod
def create_empty_rag_pipeline_dataset(
tenant_id: str,
rag_pipeline_dataset_create_entity: RagPipelineDatasetCreateEntity,
session: Session,
):
if rag_pipeline_dataset_create_entity.name:
# check if dataset name already exists
if session.scalar(
select(Dataset)
.where(Dataset.name == rag_pipeline_dataset_create_entity.name, Dataset.tenant_id == tenant_id)
.limit(1)
):
raise DatasetNameDuplicateError(
f"Dataset with name {rag_pipeline_dataset_create_entity.name} already exists."
)
else:
# generate a random name as Untitled 1 2 3 ...
datasets = session.scalars(select(Dataset).where(Dataset.tenant_id == tenant_id)).all()
names = [dataset.name for dataset in datasets]
rag_pipeline_dataset_create_entity.name = generate_incremental_name(
names,
"Untitled",
)
if not current_user or not current_user.id:
raise ValueError("Current user or current user id not found")
pipeline = Pipeline(
tenant_id=tenant_id,
name=rag_pipeline_dataset_create_entity.name,
description=rag_pipeline_dataset_create_entity.description,
created_by=current_user.id,
)
session.add(pipeline)
session.flush()
dataset = Dataset(
tenant_id=tenant_id,
name=rag_pipeline_dataset_create_entity.name,
description=rag_pipeline_dataset_create_entity.description,
permission=rag_pipeline_dataset_create_entity.permission,
provider="vendor",
runtime_mode=DatasetRuntimeMode.RAG_PIPELINE,
icon_info=rag_pipeline_dataset_create_entity.icon_info.model_dump(),
created_by=current_user.id,
maintainer=current_user.id,
pipeline_id=pipeline.id,
)
session.add(dataset)
session.commit()
return dataset
@staticmethod
def get_dataset(dataset_id, session: Session) -> Dataset | None:
dataset: Dataset | None = session.get(Dataset, dataset_id)
return dataset
@staticmethod
def get_dataset_for_tenant(dataset_id: str, tenant_id: str, *, session: Session) -> Dataset | None:
"""Fetch a dataset only when it belongs to the provided tenant."""
return session.scalar(select(Dataset).where(Dataset.id == dataset_id, Dataset.tenant_id == tenant_id).limit(1))
@staticmethod
def check_doc_form(dataset: Dataset, doc_form: str, *, session: Session):
dataset_doc_form = get_dataset_doc_form(dataset, session=session)
if dataset_doc_form and doc_form != dataset_doc_form:
raise ValueError("doc_form is different from the dataset doc_form.")
@staticmethod
def check_dataset_model_setting(dataset):
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
try:
model_manager = ModelManager.for_tenant(tenant_id=dataset.tenant_id)
model_manager.get_model_instance(
tenant_id=dataset.tenant_id,
provider=dataset.embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=dataset.embedding_model,
)
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(f"The dataset is unavailable, due to: {ex.description}")
@staticmethod
def check_embedding_model_setting(tenant_id: str, embedding_model_provider: str, embedding_model: str):
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_manager.get_model_instance(
tenant_id=tenant_id,
provider=embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=embedding_model,
)
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
@staticmethod
def check_is_multimodal_model(tenant_id: str, model_provider: str, model: str):
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
provider=model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=model,
)
text_embedding_model = cast(TextEmbeddingModel, model_instance.model_type_instance)
model_schema = text_embedding_model.get_model_schema(model_instance.model_name, model_instance.credentials)
if not model_schema:
raise ValueError("Model schema not found")
if model_schema.features and ModelFeature.VISION in model_schema.features:
return True
else:
return False
except LLMBadRequestError:
raise ValueError("No Model available. Please configure a valid provider in the Settings -> Model Provider.")
@staticmethod
def check_reranking_model_setting(tenant_id: str, reranking_model_provider: str, reranking_model: str):
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_manager.get_model_instance(
tenant_id=tenant_id,
provider=reranking_model_provider,
model_type=ModelType.RERANK,
model=reranking_model,
)
except LLMBadRequestError:
raise ValueError(
"No Rerank Model available. Please configure a valid provider in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
@staticmethod
def update_dataset(dataset_id, data, user, *, session: Session):
"""
Update dataset configuration and settings.
Args:
dataset_id: The unique identifier of the dataset to update
data: Dictionary containing the update data
user: The user performing the update operation
Returns:
Dataset: The updated dataset object
Raises:
ValueError: If dataset not found or validation fails
NoPermissionError: If user lacks permission to update the dataset
"""
# Retrieve and validate dataset existence
dataset = DatasetService.get_dataset(dataset_id, session)
if not dataset:
raise ValueError("Dataset not found")
# check if dataset name is exists
if data.get("name") and data.get("name") != dataset.name:
if DatasetService._has_dataset_same_name(
tenant_id=dataset.tenant_id,
dataset_id=dataset_id,
name=data.get("name", dataset.name),
session=session,
):
raise ValueError("Dataset name already exists")
# Verify user has permission to update this dataset
DatasetService.check_dataset_permission(dataset, user, session)
# Handle external dataset updates
if dataset.provider == "external":
return DatasetService._update_external_dataset(dataset, data, user, session)
else:
return DatasetService._update_internal_dataset(dataset, data, user, session)
@staticmethod
def _has_dataset_same_name(tenant_id: str, dataset_id: str, name: str, session: Session):
dataset = session.scalar(
select(Dataset)
.where(
Dataset.id != dataset_id,
Dataset.name == name,
Dataset.tenant_id == tenant_id,
)
.limit(1)
)
return dataset is not None
@staticmethod
def _update_external_dataset(dataset, data, user, session: Session):
"""
Update external dataset configuration.
Args:
dataset: The dataset object to update
data: Update data dictionary
user: User performing the update
Returns:
Dataset: Updated dataset object
"""
# Update retrieval model if provided
external_retrieval_model = data.get("external_retrieval_model", None)
if external_retrieval_model:
dataset.retrieval_model = external_retrieval_model
# Update summary index setting if provided
summary_index_setting = data.get("summary_index_setting", None)
if summary_index_setting is not None:
dataset.summary_index_setting = summary_index_setting
# Update basic dataset properties
dataset.name = data.get("name", dataset.name)
dataset.description = data.get("description", dataset.description)
# Update permission if provided
permission = data.get("permission")
if permission:
dataset.permission = permission
# Validate and update external knowledge configuration
external_knowledge_id = data.get("external_knowledge_id", None)
external_knowledge_api_id = data.get("external_knowledge_api_id", None)
if not external_knowledge_id:
raise ValueError("External knowledge id is required.")
if not external_knowledge_api_id:
raise ValueError("External knowledge api id is required.")
# Ensure the referenced external API template exists and belongs to the dataset tenant.
ExternalDatasetService.get_external_knowledge_api(external_knowledge_api_id, dataset.tenant_id, session=session)
# Update metadata fields
dataset.updated_by = user.id if user else None
dataset.updated_at = naive_utc_now()
session.add(dataset)
# Update external knowledge binding
DatasetService._update_external_knowledge_binding(
dataset.id, external_knowledge_id, external_knowledge_api_id, session
)
# Flush changes to the database without closing the caller-managed
# transaction. This helper receives a session opened by the caller
# (`with Session(...) as session`); calling commit() here closed that
# context manager early and raised
# sqlalchemy.exc.InvalidRequestError: Can't operate on closed transaction
# (#39191).
session.flush()
return dataset
@staticmethod
def _update_external_knowledge_binding(
dataset_id, external_knowledge_id, external_knowledge_api_id, session: Session
):
"""
Update external knowledge binding configuration.
Args:
dataset_id: Dataset identifier
external_knowledge_id: External knowledge identifier
external_knowledge_api_id: External knowledge API identifier
"""
external_knowledge_binding = session.scalar(
select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.dataset_id == dataset_id).limit(1)
)
if not external_knowledge_binding:
raise ValueError("External knowledge binding not found.")
# Update binding if values have changed
if (
external_knowledge_binding.external_knowledge_id != external_knowledge_id
or external_knowledge_binding.external_knowledge_api_id != external_knowledge_api_id
):
external_knowledge_binding.external_knowledge_id = external_knowledge_id
external_knowledge_binding.external_knowledge_api_id = external_knowledge_api_id
session.add(external_knowledge_binding)
@staticmethod
def _update_internal_dataset(dataset, data, user, session: Session):
"""
Update internal dataset configuration.
Args:
dataset: The dataset object to update
data: Update data dictionary
user: User performing the update
Returns:
Dataset: Updated dataset object
"""
# Remove external-specific fields from update data
data.pop("partial_member_list", None)
data.pop("external_knowledge_api_id", None)
data.pop("external_knowledge_id", None)
data.pop("external_retrieval_model", None)
# Filter out None values except for description field
filtered_data = {k: v for k, v in data.items() if v is not None or k == "description"}
# Handle indexing technique changes and embedding model updates
action = DatasetService._handle_indexing_technique_change(dataset, data, filtered_data, session)
# Add metadata fields
filtered_data["updated_by"] = user.id
filtered_data["updated_at"] = naive_utc_now()
# update Retrieval model
if data.get("retrieval_model"):
filtered_data["retrieval_model"] = data["retrieval_model"]
# update summary index setting
if data.get("summary_index_setting"):
filtered_data["summary_index_setting"] = data.get("summary_index_setting")
# update icon info
if data.get("icon_info"):
filtered_data["icon_info"] = data.get("icon_info")
# Update dataset in database. Use flush() rather than commit() so the
# caller-managed transaction (opened with `with Session(...) as session`)
# stays open for subsequent operations — _update_pipeline_knowledge_base
# node data and any caller follow-ups run on the same session. Calling
# commit() here closed the context manager early and raised
# sqlalchemy.exc.InvalidRequestError: Can't operate on closed transaction
# (#39191).
session.execute(update(Dataset).where(Dataset.id == dataset.id).values(**filtered_data))
session.flush()
# Reload dataset to get updated values
session.refresh(dataset)
# update pipeline knowledge base node data
DatasetService._update_pipeline_knowledge_base_node_data(dataset, user.id, session)
# Trigger vector index task if indexing technique changed
if action:
deal_dataset_vector_index_task.delay(dataset.id, action)
# If embedding_model changed, also regenerate summary vectors
if action == "update":
regenerate_summary_index_task.delay(
dataset.id,
regenerate_reason="embedding_model_changed",
regenerate_vectors_only=True,
)
# Note: summary_index_setting changes do not trigger automatic regeneration of existing summaries.
# The new setting will only apply to:
# 1. New documents added after the setting change
# 2. Manual summary generation requests
return dataset
@staticmethod
def _update_pipeline_knowledge_base_node_data(dataset: Dataset, updata_user_id: str, session: Session):
"""
Update pipeline knowledge base node data.
"""
if dataset.runtime_mode != DatasetRuntimeMode.RAG_PIPELINE:
return
pipeline = session.get(Pipeline, dataset.pipeline_id)
if not pipeline:
return
try:
rag_pipeline_service = RagPipelineService(session)
published_workflow = rag_pipeline_service.get_published_workflow(pipeline)
draft_workflow = rag_pipeline_service.get_draft_workflow(pipeline)
# update knowledge nodes
def update_knowledge_nodes(workflow_graph: str) -> str:
"""Update knowledge-index nodes in workflow graph."""
data: dict[str, Any] = json.loads(workflow_graph)
nodes = data.get("nodes", [])
updated = False
for node in nodes:
if node.get("data", {}).get("type") == "knowledge-index":
try:
knowledge_index_node_data = node.get("data", {})
knowledge_index_node_data["embedding_model"] = dataset.embedding_model
knowledge_index_node_data["embedding_model_provider"] = dataset.embedding_model_provider
knowledge_index_node_data["retrieval_model"] = dataset.retrieval_model
knowledge_index_node_data["chunk_structure"] = dataset.chunk_structure
knowledge_index_node_data["indexing_technique"] = dataset.indexing_technique
knowledge_index_node_data["keyword_number"] = dataset.keyword_number
knowledge_index_node_data["summary_index_setting"] = dataset.summary_index_setting
node["data"] = knowledge_index_node_data
updated = True
except Exception:
logging.exception("Failed to update knowledge node")
continue
if updated:
data["nodes"] = nodes
return json.dumps(data)
return workflow_graph
# Update published workflow
if published_workflow:
updated_graph = update_knowledge_nodes(published_workflow.graph)
if updated_graph != published_workflow.graph:
# Create new workflow version
workflow = Workflow.new(
tenant_id=pipeline.tenant_id,
app_id=pipeline.id,
type=published_workflow.type,
version=str(datetime.datetime.now(datetime.UTC).replace(tzinfo=None)),
graph=updated_graph,
features=published_workflow.features,
created_by=updata_user_id,
environment_variables=published_workflow.environment_variables,
conversation_variables=published_workflow.conversation_variables,
rag_pipeline_variables=published_workflow.rag_pipeline_variables,
marked_name="",
marked_comment="",
)
session.add(workflow)
# Update draft workflow
if draft_workflow:
updated_graph = update_knowledge_nodes(draft_workflow.graph)
if updated_graph != draft_workflow.graph:
draft_workflow.graph = updated_graph
session.add(draft_workflow)
# Commit all changes in one transaction
session.commit()
except Exception:
logging.exception("Failed to update pipeline knowledge base node data")
session.rollback()
raise
@staticmethod
def _handle_indexing_technique_change(dataset, data, filtered_data, session: Session):
"""
Handle changes in indexing technique and configure embedding models accordingly.
Args:
dataset: Current dataset object
data: Update data dictionary
filtered_data: Filtered update data
session: SQLAlchemy session used for embedding collection binding lookups
Returns:
str: Action to perform ('add', 'remove', 'update', or None)
"""
if "indexing_technique" not in data:
return None
if dataset.indexing_technique != data["indexing_technique"]:
if data["indexing_technique"] == IndexTechniqueType.ECONOMY:
# Remove embedding model configuration for economy mode
filtered_data["embedding_model"] = None
filtered_data["embedding_model_provider"] = None
filtered_data["collection_binding_id"] = None
return "remove"
elif data["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY:
# Configure embedding model for high quality mode
DatasetService._configure_embedding_model_for_high_quality(data, filtered_data, session)
return "add"
else:
# Handle embedding model updates when indexing technique remains the same
return DatasetService._handle_embedding_model_update_when_technique_unchanged(
dataset, data, filtered_data, session
)
return None
@staticmethod
def _configure_embedding_model_for_high_quality(data, filtered_data, session: Session):
"""
Configure embedding model settings for high quality indexing.
Args:
data: Update data dictionary
filtered_data: Filtered update data to modify
session: SQLAlchemy session used for embedding collection binding lookups
"""
# assert isinstance(current_user, Account) and current_user.current_tenant_id is not None
try:
model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
embedding_model = model_manager.get_model_instance(
tenant_id=current_user.current_tenant_id,
provider=data["embedding_model_provider"],
model_type=ModelType.TEXT_EMBEDDING,
model=data["embedding_model"],
)
embedding_model_name = embedding_model.model_name
filtered_data["embedding_model"] = embedding_model_name
filtered_data["embedding_model_provider"] = embedding_model.provider
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
embedding_model.provider,
embedding_model_name,
session,
)
filtered_data["collection_binding_id"] = dataset_collection_binding.id
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
@staticmethod
def _handle_embedding_model_update_when_technique_unchanged(dataset, data, filtered_data, session: Session):
"""
Handle embedding model updates when indexing technique remains the same.
Args:
dataset: Current dataset object
data: Update data dictionary
filtered_data: Filtered update data to modify
session: SQLAlchemy session used for embedding collection binding lookups
Returns:
str: Action to perform ('update' or None)
"""
# Skip embedding model checks if not provided in the update request
if (
"embedding_model_provider" not in data
or "embedding_model" not in data
or not data.get("embedding_model_provider")
or not data.get("embedding_model")
):
DatasetService._preserve_existing_embedding_settings(dataset, filtered_data)
return None
else:
return DatasetService._update_embedding_model_settings(dataset, data, filtered_data, session)
@staticmethod
def _preserve_existing_embedding_settings(dataset, filtered_data):
"""
Preserve existing embedding model settings when not provided in update.
Args:
dataset: Current dataset object
filtered_data: Filtered update data to modify
"""
# If the dataset already has embedding model settings, use those
if dataset.embedding_model_provider and dataset.embedding_model:
filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
filtered_data["embedding_model"] = dataset.embedding_model
# If collection_binding_id exists, keep it too
if dataset.collection_binding_id:
filtered_data["collection_binding_id"] = dataset.collection_binding_id
# Otherwise, don't try to update embedding model settings at all
# Remove these fields from filtered_data if they exist but are None/empty
if "embedding_model_provider" in filtered_data and not filtered_data["embedding_model_provider"]:
del filtered_data["embedding_model_provider"]
if "embedding_model" in filtered_data and not filtered_data["embedding_model"]:
del filtered_data["embedding_model"]
@staticmethod
def _update_embedding_model_settings(dataset, data, filtered_data, session: Session):
"""
Update embedding model settings with new values.
Args:
dataset: Current dataset object
data: Update data dictionary
filtered_data: Filtered update data to modify
session: SQLAlchemy session used for embedding collection binding lookups
Returns:
str: Action to perform ('update' or None)
"""
try:
# Compare current and new model provider settings
current_provider_str = (
str(ModelProviderID(dataset.embedding_model_provider)) if dataset.embedding_model_provider else None
)
new_provider_str = (
str(ModelProviderID(data["embedding_model_provider"])) if data["embedding_model_provider"] else None
)
# Only update if values are different
if current_provider_str != new_provider_str or data["embedding_model"] != dataset.embedding_model:
DatasetService._apply_new_embedding_settings(dataset, data, filtered_data, session)
return "update"
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
return None
@staticmethod
def _apply_new_embedding_settings(dataset, data, filtered_data, session: Session):
"""
Apply new embedding model settings to the dataset.
Args:
dataset: Current dataset object
data: Update data dictionary
filtered_data: Filtered update data to modify
session: SQLAlchemy session used for embedding collection binding lookups
"""
# assert isinstance(current_user, Account) and current_user.current_tenant_id is not None
model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
try:
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
embedding_model = model_manager.get_model_instance(
tenant_id=current_user.current_tenant_id,
provider=data["embedding_model_provider"],
model_type=ModelType.TEXT_EMBEDDING,
model=data["embedding_model"],
)
except ProviderTokenNotInitError:
# If we can't get the embedding model, preserve existing settings
logger.warning(
"Failed to initialize embedding model %s/%s, preserving existing settings",
data["embedding_model_provider"],
data["embedding_model"],
)
if dataset.embedding_model_provider and dataset.embedding_model:
filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
filtered_data["embedding_model"] = dataset.embedding_model
if dataset.collection_binding_id:
filtered_data["collection_binding_id"] = dataset.collection_binding_id
# Skip the rest of the embedding model update
return
# Apply new embedding model settings
embedding_model_name = embedding_model.model_name
filtered_data["embedding_model"] = embedding_model_name
filtered_data["embedding_model_provider"] = embedding_model.provider
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
embedding_model.provider,
embedding_model_name,
session,
)
filtered_data["collection_binding_id"] = dataset_collection_binding.id
@staticmethod
def _check_summary_index_setting_model_changed(dataset: Dataset, data: dict[str, Any]) -> bool:
"""
Check if summary_index_setting model (model_name or model_provider_name) has changed.
Args:
dataset: Current dataset object
data: Update data dictionary
Returns:
bool: True if summary model changed, False otherwise
"""
# Check if summary_index_setting is being updated
if "summary_index_setting" not in data or data.get("summary_index_setting") is None:
return False
new_summary_setting = data.get("summary_index_setting")
old_summary_setting = dataset.summary_index_setting
# If new setting is disabled, no need to regenerate
if not new_summary_setting or not new_summary_setting.get("enable"):
return False
# If old setting doesn't exist, no need to regenerate (no existing summaries to regenerate)
# Note: This task only regenerates existing summaries, not generates new ones
if not old_summary_setting:
return False
# Compare model_name and model_provider_name
old_model_name = old_summary_setting.get("model_name")
old_model_provider = old_summary_setting.get("model_provider_name")
new_model_name = new_summary_setting.get("model_name")
new_model_provider = new_summary_setting.get("model_provider_name")
# Check if model changed
if old_model_name != new_model_name or old_model_provider != new_model_provider:
logger.info(
"Summary index setting model changed for dataset %s: old=%s/%s, new=%s/%s",
dataset.id,
old_model_provider,
old_model_name,
new_model_provider,
new_model_name,
)
return True
return False
@staticmethod
def update_rag_pipeline_dataset_settings(
dataset: Dataset,
knowledge_configuration: KnowledgeConfiguration,
has_published: bool = False,
*,
session: Session,
):
if not current_user or not current_user.current_tenant_id:
raise ValueError("Current user or current tenant not found")
dataset = session.merge(dataset)
if not has_published:
dataset.chunk_structure = knowledge_configuration.chunk_structure
dataset.indexing_technique = IndexTechniqueType(knowledge_configuration.indexing_technique)
if knowledge_configuration.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
embedding_model = model_manager.get_model_instance(
tenant_id=current_user.current_tenant_id, # ignore type error
provider=knowledge_configuration.embedding_model_provider or "",
model_type=ModelType.TEXT_EMBEDDING,
model=knowledge_configuration.embedding_model or "",
)
is_multimodal = DatasetService.check_is_multimodal_model(
current_user.current_tenant_id,
knowledge_configuration.embedding_model_provider,
knowledge_configuration.embedding_model,
)
dataset.is_multimodal = is_multimodal
embedding_model_name = embedding_model.model_name
dataset.embedding_model = embedding_model_name
dataset.embedding_model_provider = embedding_model.provider
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
embedding_model.provider,
embedding_model_name,
session,
)
dataset.collection_binding_id = dataset_collection_binding.id
elif knowledge_configuration.indexing_technique == IndexTechniqueType.ECONOMY:
dataset.keyword_number = knowledge_configuration.keyword_number
else:
raise ValueError("Invalid index method")
dataset.retrieval_model = knowledge_configuration.retrieval_model.model_dump()
# Update summary_index_setting if provided
if knowledge_configuration.summary_index_setting is not None:
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
session.add(dataset)
else:
if dataset.chunk_structure and dataset.chunk_structure != knowledge_configuration.chunk_structure:
raise ValueError("Chunk structure is not allowed to be updated.")
action = None
if dataset.indexing_technique != knowledge_configuration.indexing_technique:
# if update indexing_technique
if knowledge_configuration.indexing_technique == IndexTechniqueType.ECONOMY:
raise ValueError("Knowledge base indexing technique is not allowed to be updated to economy.")
elif knowledge_configuration.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
action = "add"
# get embedding model setting
try:
model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
embedding_model = model_manager.get_model_instance(
tenant_id=current_user.current_tenant_id,
provider=knowledge_configuration.embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=knowledge_configuration.embedding_model,
)
embedding_model_name = embedding_model.model_name
dataset.embedding_model = embedding_model_name
dataset.embedding_model_provider = embedding_model.provider
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
embedding_model.provider,
embedding_model_name,
session,
)
is_multimodal = DatasetService.check_is_multimodal_model(
current_user.current_tenant_id,
knowledge_configuration.embedding_model_provider,
knowledge_configuration.embedding_model,
)
dataset.is_multimodal = is_multimodal
dataset.collection_binding_id = dataset_collection_binding.id
dataset.indexing_technique = IndexTechniqueType(knowledge_configuration.indexing_technique)
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider "
"in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
else:
# add default plugin id to both setting sets, to make sure the plugin model provider is consistent
# Skip embedding model checks if not provided in the update request
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
skip_embedding_update = False
try:
# Handle existing model provider
plugin_model_provider = dataset.embedding_model_provider
plugin_model_provider_str = None
if plugin_model_provider:
plugin_model_provider_str = str(ModelProviderID(plugin_model_provider))
# Handle new model provider from request
new_plugin_model_provider = knowledge_configuration.embedding_model_provider
new_plugin_model_provider_str = None
if new_plugin_model_provider:
new_plugin_model_provider_str = str(ModelProviderID(new_plugin_model_provider))
# Only update embedding model if both values are provided and different from current
if (
plugin_model_provider_str != new_plugin_model_provider_str
or knowledge_configuration.embedding_model != dataset.embedding_model
):
action = "update"
model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
embedding_model = None
try:
embedding_model = model_manager.get_model_instance(
tenant_id=current_user.current_tenant_id,
provider=knowledge_configuration.embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=knowledge_configuration.embedding_model,
)
except ProviderTokenNotInitError:
# If we can't get the embedding model, skip updating it
# and keep the existing settings if available
# Skip the rest of the embedding model update
skip_embedding_update = True
if not skip_embedding_update:
if embedding_model:
embedding_model_name = embedding_model.model_name
dataset.embedding_model = embedding_model_name
dataset.embedding_model_provider = embedding_model.provider
dataset_collection_binding = (
DatasetCollectionBindingService.get_dataset_collection_binding(
embedding_model.provider,
embedding_model_name,
session,
)
)
dataset.collection_binding_id = dataset_collection_binding.id
is_multimodal = DatasetService.check_is_multimodal_model(
current_user.current_tenant_id,
knowledge_configuration.embedding_model_provider,
knowledge_configuration.embedding_model,
)
dataset.is_multimodal = is_multimodal
except LLMBadRequestError:
raise ValueError(
"No Embedding Model available. Please configure a valid provider "
"in the Settings -> Model Provider."
)
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
elif dataset.indexing_technique == IndexTechniqueType.ECONOMY:
if dataset.keyword_number != knowledge_configuration.keyword_number:
dataset.keyword_number = knowledge_configuration.keyword_number
dataset.retrieval_model = knowledge_configuration.retrieval_model.model_dump()
# Update summary_index_setting if provided
if knowledge_configuration.summary_index_setting is not None:
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
session.add(dataset)
session.commit()
if action:
deal_dataset_index_update_task.delay(dataset.id, action)
@staticmethod
def delete_dataset(dataset_id, user, session: Session):
dataset = DatasetService.get_dataset(dataset_id, session)
if dataset is None:
return False
DatasetService.check_dataset_permission(dataset, user, session)
dataset_was_deleted.send(dataset)
# Remove any dataset API key scoped only to this knowledge base, so it cannot
# silently degrade to unrestricted (access-all) once its last binding is gone.
dataset_api_key_bindings.delete_keys_scoped_only_to(session, str(dataset.id))
session.delete(dataset)
session.commit()
return True
@staticmethod
def dataset_use_check(dataset_ref: DatasetRef, session: Session) -> bool:
stmt = select(exists().where(AppDatasetJoin.dataset_id == dataset_ref.dataset_id))
return session.execute(stmt).scalar_one()
@staticmethod
def check_dataset_permission(dataset, user, session: Session):
"""Validate dataset access for a user, using the injected session for partial-member lookups."""
if dataset.tenant_id != user.current_tenant_id:
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
raise NoPermissionError("You do not have permission to access this dataset.")
if dify_config.RBAC_ENABLED:
return
has_permission = False
if (
user.current_role != TenantAccountRole.OWNER
and dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM
and dataset.maintainer != user.id
):
has_permission = (
session.scalar(
select(DatasetPermission.id)
.where(
DatasetPermission.dataset_id == dataset.id,
DatasetPermission.account_id == user.id,
DatasetPermission.tenant_id == dataset.tenant_id,
DatasetPermission.has_permission.is_(True),
)
.limit(1)
)
is not None
)
snapshot = DatasetAccessSnapshot(
dataset=DatasetAccessRecord(
id=dataset.id,
workspace_id=dataset.tenant_id,
maintainer_id=dataset.maintainer,
permission=dataset.permission,
),
actor_has_partial_access=has_permission,
)
if not can_access_dataset(snapshot, actor_id=user.id, workspace_role=user.current_role):
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
raise NoPermissionError("You do not have permission to access this dataset.")
@staticmethod
def check_dataset_operator_permission(
user: Account | None = None, dataset: Dataset | None = None, *, session: Session
):
if not dataset:
raise ValueError("Dataset not found")
if not user:
raise ValueError("User not found")
if user.current_role != TenantAccountRole.OWNER:
if dataset.permission == DatasetPermissionEnum.ONLY_ME:
if dataset.maintainer != user.id:
raise NoPermissionError("You do not have permission to access this dataset.")
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
user_permission = session.scalar(
select(DatasetPermission.id)
.where(
DatasetPermission.dataset_id == dataset.id,
DatasetPermission.account_id == user.id,
DatasetPermission.tenant_id == dataset.tenant_id,
)
.limit(1)
)
if user_permission is None:
raise NoPermissionError("You do not have permission to access this dataset.")
@staticmethod
def get_dataset_queries(dataset_id: str, page: int, per_page: int, session: Session):
stmt = select(DatasetQuery).filter_by(dataset_id=dataset_id).order_by(DatasetQuery.created_at.desc())
dataset_queries = paginate_query(stmt, page=page, per_page=per_page, max_per_page=100, session=session)
return dataset_queries.items, dataset_queries.total
@staticmethod
def get_related_apps(dataset_id: str, session: Session):
return session.scalars(
select(AppDatasetJoin)
.where(AppDatasetJoin.dataset_id == dataset_id)
.order_by(AppDatasetJoin.created_at.desc())
).all()
@staticmethod
def update_dataset_api_status(dataset: Dataset, status: bool, actor: Account, session: Session):
if not actor.id:
raise ValueError("Current user or current user id not found")
dataset.enable_api = status
dataset.updated_by = actor.id
dataset.updated_at = naive_utc_now()
session.flush()
@staticmethod
def get_dataset_auto_disable_logs(dataset_ref: DatasetRef, session: Session) -> AutoDisableLogsDict:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return {"document_ids": [], "count": 0}
features = FeatureService.get_features(dataset_ref.tenant_id, exclude_vector_space=True)
if features.billing.subscription.plan == CloudPlan.SANDBOX:
return {
"document_ids": [],
"count": 0,
}
# get recent 30 days auto disable logs
start_date = datetime.datetime.now() - datetime.timedelta(days=30)
dataset_auto_disable_logs = session.scalars(
select(DatasetAutoDisableLog).where(
DatasetAutoDisableLog.tenant_id == dataset_ref.tenant_id,
DatasetAutoDisableLog.dataset_id == dataset_ref.dataset_id,
DatasetAutoDisableLog.created_at >= start_date,
)
).all()
if dataset_auto_disable_logs:
return {
"document_ids": [log.document_id for log in dataset_auto_disable_logs],
"count": len(dataset_auto_disable_logs),
}
return {
"document_ids": [],
"count": 0,
}
class DocumentService:
DEFAULT_RULES: dict[str, Any] = {
"mode": "custom",
"rules": {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": False},
],
"segmentation": {"delimiter": "\n", "max_tokens": 1024, "chunk_overlap": 50},
},
"limits": {
"indexing_max_segmentation_tokens_length": dify_config.INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH,
},
}
DISPLAY_STATUS_ALIASES: dict[str, str] = {
"active": "available",
"enabled": "available",
}
_INDEXING_STATUSES: tuple[IndexingStatus, ...] = (
IndexingStatus.PARSING,
IndexingStatus.CLEANING,
IndexingStatus.SPLITTING,
IndexingStatus.INDEXING,
)
DISPLAY_STATUS_FILTERS: dict[str, tuple[Any, ...]] = {
"queuing": (Document.indexing_status == IndexingStatus.WAITING,),
"indexing": (
Document.indexing_status.in_(_INDEXING_STATUSES),
Document.is_paused.is_not(True),
),
"paused": (
Document.indexing_status.in_(_INDEXING_STATUSES),
Document.is_paused.is_(True),
),
"error": (Document.indexing_status == IndexingStatus.ERROR,),
"available": (
Document.indexing_status == IndexingStatus.COMPLETED,
Document.archived.is_(False),
Document.enabled.is_(True),
),
"disabled": (
Document.indexing_status == IndexingStatus.COMPLETED,
Document.archived.is_(False),
Document.enabled.is_(False),
),
"archived": (
Document.indexing_status == IndexingStatus.COMPLETED,
Document.archived.is_(True),
),
}
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
@classmethod
def normalize_display_status(cls, status: str | None) -> str | None:
if not status:
return None
normalized = status.lower()
normalized = cls.DISPLAY_STATUS_ALIASES.get(normalized, normalized)
return normalized if normalized in cls.DISPLAY_STATUS_FILTERS else None
@classmethod
def build_display_status_filters(cls, status: str | None) -> tuple[Any, ...]:
normalized = cls.normalize_display_status(status)
if not normalized:
return ()
return cls.DISPLAY_STATUS_FILTERS[normalized]
@classmethod
def apply_display_status_filter(cls, query, status: str | None):
filters = cls.build_display_status_filters(status)
if not filters:
return query
return query.where(*filters)
DOCUMENT_METADATA_SCHEMA: dict[str, Any] = {
"book": {
"title": str,
"language": str,
"author": str,
"publisher": str,
"publication_date": str,
"isbn": str,
"category": str,
},
"web_page": {
"title": str,
"url": str,
"language": str,
"publish_date": str,
"author/publisher": str,
"topic/keywords": str,
"description": str,
},
"paper": {
"title": str,
"language": str,
"author": str,
"publish_date": str,
"journal/conference_name": str,
"volume/issue/page_numbers": str,
"doi": str,
"topic/keywords": str,
"abstract": str,
},
"social_media_post": {
"platform": str,
"author/username": str,
"publish_date": str,
"post_url": str,
"topic/tags": str,
},
"wikipedia_entry": {
"title": str,
"language": str,
"web_page_url": str,
"last_edit_date": str,
"editor/contributor": str,
"summary/introduction": str,
},
"personal_document": {
"title": str,
"author": str,
"creation_date": str,
"last_modified_date": str,
"document_type": str,
"tags/category": str,
},
"business_document": {
"title": str,
"author": str,
"creation_date": str,
"last_modified_date": str,
"document_type": str,
"department/team": str,
},
"im_chat_log": {
"chat_platform": str,
"chat_participants/group_name": str,
"start_date": str,
"end_date": str,
"summary": str,
},
"synced_from_notion": {
"title": str,
"language": str,
"author/creator": str,
"creation_date": str,
"last_modified_date": str,
"notion_page_link": str,
"category/tags": str,
"description": str,
},
"synced_from_github": {
"repository_name": str,
"repository_description": str,
"repository_owner/organization": str,
"code_filename": str,
"code_file_path": str,
"programming_language": str,
"github_link": str,
"open_source_license": str,
"commit_date": str,
"commit_author": str,
},
"others": dict,
}
@staticmethod
def get_document(dataset_id: str, document_id: str | None = None, *, session: Session) -> Document | None:
"""Fetch a document by id within a dataset using the caller-provided session."""
if document_id:
document = session.scalar(
select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1)
)
return document
else:
return None
@staticmethod
def get_documents_by_ids(
dataset_ref: DatasetRef, document_ids: Sequence[str], session: Session
) -> Sequence[Document]:
"""Fetch documents for a dataset in a single batch query."""
if not document_ids:
return []
document_id_list: list[str] = list(document_ids)
# Fetch all requested documents in one query to avoid N+1 lookups.
documents: Sequence[Document] = session.scalars(
select(Document).where(
Document.tenant_id == dataset_ref.tenant_id,
Document.dataset_id == dataset_ref.dataset_id,
Document.id.in_(document_id_list),
)
).all()
return documents
@staticmethod
def update_documents_need_summary(
dataset_id: str,
document_ids: Sequence[str],
session: Session,
need_summary: bool = True,
) -> int:
"""
Update need_summary field for multiple documents.
This method handles the case where documents were created when summary_index_setting was disabled,
and need to be updated when summary_index_setting is later enabled.
Args:
dataset_id: Dataset ID
document_ids: List of document IDs to update
session: SQLAlchemy session used for the update
need_summary: Value to set for need_summary field (default: True)
Returns:
Number of documents updated
"""
if not document_ids:
return 0
document_id_list: list[str] = list(document_ids)
result = session.execute(
update(Document)
.where(
Document.id.in_(document_id_list),
Document.dataset_id == dataset_id,
Document.doc_form != IndexStructureType.QA_INDEX, # Skip qa_model documents
)
.values(need_summary=need_summary)
.execution_options(synchronize_session=False)
)
updated_count = result.rowcount # type: ignore[union-attr,attr-defined]
session.commit()
logger.info(
"Updated need_summary to %s for %d documents in dataset %s",
need_summary,
updated_count,
dataset_id,
)
return updated_count
@staticmethod
def get_document_download_url(document: Document, session: Session) -> str:
"""
Return a signed download URL for an upload-file document.
"""
upload_file = DocumentService._get_upload_file_for_upload_file_document(document, session)
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
@staticmethod
def enrich_documents_with_summary_index_status(
documents: Sequence[Document],
dataset: Dataset,
tenant_id: str,
session: Session,
) -> None:
"""
Enrich documents with summary_index_status based on dataset summary index settings.
This method calculates and sets the summary_index_status for each document that needs summary.
Documents that don't need summary or when summary index is disabled will have status set to None.
Args:
documents: List of Document instances to enrich
dataset: Dataset instance containing summary_index_setting
tenant_id: Tenant ID for summary status lookup
session: SQLAlchemy session used to read summary status records
"""
# Check if dataset has summary index enabled
has_summary_index = dataset.summary_index_setting and dataset.summary_index_setting.get("enable") is True
# Filter documents that need summary calculation
documents_need_summary = [doc for doc in documents if doc.need_summary is True]
document_ids_need_summary = [str(doc.id) for doc in documents_need_summary]
# Calculate summary_index_status for documents that need summary (only if dataset summary index is enabled)
summary_status_map: dict[str, str | None] = {}
if has_summary_index and document_ids_need_summary:
from services.knowledge.summaries.adapters import SummaryIndexAdapter
summary_status_map = SummaryIndexAdapter.get_documents_summary_index_status(
document_ids=document_ids_need_summary,
dataset_id=dataset.id,
tenant_id=tenant_id,
session=session,
)
# Add summary_index_status to each document
for document in documents:
if has_summary_index and document.need_summary is True:
# Get status from map, default to None (not queued yet)
document.summary_index_status = summary_status_map.get(str(document.id)) # type: ignore[attr-defined]
else:
# Return null if summary index is not enabled or document doesn't need summary
document.summary_index_status = None # type: ignore[attr-defined]
@staticmethod
def prepare_document_batch_download_zip(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
current_user: Account,
session: Session,
) -> tuple[list[UploadFile], str]:
"""
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
"""
dataset = DatasetService.get_dataset(dataset_id, session)
if not dataset:
raise NotFound("Dataset not found.")
try:
DatasetService.check_dataset_permission(dataset, current_user, session)
except NoPermissionError as e:
raise Forbidden(str(e))
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=dataset_id,
document_ids=document_ids,
tenant_id=tenant_id,
session=session,
)
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
download_name = DocumentService._generate_document_batch_download_zip_filename()
return upload_files, download_name
@staticmethod
def _generate_document_batch_download_zip_filename() -> str:
"""
Generate a random attachment filename for the batch download ZIP.
"""
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
@staticmethod
def _get_upload_file_id_for_upload_file_document(
document: Document,
*,
invalid_source_message: str,
missing_file_message: str,
) -> str:
"""
Normalize and validate `Document -> UploadFile` linkage for download flows.
"""
if document.data_source_type != DataSourceType.UPLOAD_FILE:
raise NotFound(invalid_source_message)
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
upload_file_id: str | None = data_source_info.get("upload_file_id")
if not upload_file_id:
raise NotFound(missing_file_message)
return str(upload_file_id)
@staticmethod
def _get_upload_file_for_upload_file_document(document: Document, session: Session) -> UploadFile:
"""
Load the `UploadFile` row for an upload-file document.
"""
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Document does not have an uploaded file to download.",
missing_file_message="Uploaded file not found.",
)
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id], session=session)
upload_file = upload_files_by_id.get(upload_file_id)
if not upload_file:
raise NotFound("Uploaded file not found.")
return upload_file
@staticmethod
def _get_upload_files_by_document_id_for_zip_download(
*,
dataset_id: str,
document_ids: Sequence[str],
tenant_id: str,
session: Session,
) -> dict[str, UploadFile]:
"""
Batch load upload files keyed by document id for ZIP downloads.
"""
document_id_list: list[str] = list(document_ids)
documents = DocumentService.get_documents_by_ids(
DatasetRef(tenant_id=tenant_id, dataset_id=dataset_id), document_id_list, session
)
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
if missing_document_ids:
raise NotFound("Document not found.")
upload_file_ids: list[str] = []
upload_file_ids_by_document_id: dict[str, str] = {}
for document_id, document in documents_by_id.items():
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
document,
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
)
upload_file_ids.append(upload_file_id)
upload_file_ids_by_document_id[document_id] = upload_file_id
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids, session=session)
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
if missing_upload_file_ids:
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
return {
document_id: upload_files_by_id[upload_file_id]
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
}
@staticmethod
def get_document_by_id(document_id: str, session: Session) -> Document | None:
"""Fetch a document by primary key; callers must authorize its dataset before exposing it."""
document = session.get(Document, document_id)
return document
@staticmethod
def get_document_by_ids(
dataset_ref: DatasetRef, document_ids: Sequence[str], session: Session
) -> Sequence[Document]:
documents = session.scalars(
select(Document).where(
Document.tenant_id == dataset_ref.tenant_id,
Document.dataset_id == dataset_ref.dataset_id,
Document.id.in_(document_ids),
Document.enabled == True,
Document.indexing_status == IndexingStatus.COMPLETED,
Document.archived == False,
)
).all()
return documents
@staticmethod
def get_document_by_dataset_id(dataset_id: str, session: Session) -> Sequence[Document]:
documents = session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.enabled == True,
)
).all()
return documents
@staticmethod
def get_working_documents_by_dataset_id(dataset_id: str, session: Session) -> Sequence[Document]:
documents = session.scalars(
select(Document).where(
Document.dataset_id == dataset_id,
Document.enabled == True,
Document.indexing_status == IndexingStatus.COMPLETED,
Document.archived == False,
)
).all()
return documents
@staticmethod
def get_error_documents_by_dataset_ref(dataset_ref: DatasetRef, session: Session) -> Sequence[Document]:
documents = session.scalars(
select(Document).where(
Document.tenant_id == dataset_ref.tenant_id,
Document.dataset_id == dataset_ref.dataset_id,
Document.indexing_status.in_([IndexingStatus.ERROR, IndexingStatus.PAUSED]),
)
).all()
return documents
@staticmethod
def get_batch_documents(dataset_id: str, batch: str, session: Session) -> Sequence[Document]:
assert isinstance(current_user, Account)
documents = session.scalars(
select(Document).where(
Document.batch == batch,
Document.dataset_id == dataset_id,
Document.tenant_id == current_user.current_tenant_id,
)
).all()
return documents
@staticmethod
def get_document_segment_counts(
documents: Sequence[Document],
session: Session,
) -> dict[str, tuple[int, int]]:
"""Get completed and total segment counts for multiple documents in one query."""
if not documents:
return {}
document_owner_keys = {
(str(document.tenant_id), str(document.dataset_id), str(document.id)) for document in documents
}
rows = session.execute(
select(
DocumentSegment.document_id,
func.count(DocumentSegment.id).label("total_segments"),
func.coalesce(func.sum(case((DocumentSegment.completed_at.isnot(None), 1), else_=0)), 0).label(
"completed_segments"
),
)
.where(
tuple_(DocumentSegment.tenant_id, DocumentSegment.dataset_id, DocumentSegment.document_id).in_(
document_owner_keys
),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
)
.group_by(DocumentSegment.document_id)
)
return {
str(document_id): (int(completed_segments or 0), int(total_segments or 0))
for document_id, total_segments, completed_segments in rows
}
@staticmethod
def get_document_file_detail(file_id: str, session: Session):
file_detail = session.get(UploadFile, file_id)
return file_detail
@staticmethod
def check_archived(document):
if document.archived:
return True
else:
return False
@staticmethod
def delete_document(document, session: Session):
# trigger document_was_deleted signal
file_id = None
if document.data_source_type == DataSourceType.UPLOAD_FILE:
if document.data_source_info:
data_source_info = document.data_source_info_dict
if data_source_info and "upload_file_id" in data_source_info:
file_id = data_source_info["upload_file_id"]
document_was_deleted.send(
document.id,
dataset_id=document.dataset_id,
doc_form=document.doc_form,
file_id=file_id,
)
session.delete(document)
session.commit()
@staticmethod
def delete_documents(
dataset_ref: DatasetRef,
document_ids: list[str],
doc_form: str | None,
session: Session,
):
# Check if document_ids is not empty to avoid WHERE false condition
if not document_ids or len(document_ids) == 0:
return
documents = session.scalars(
select(Document).where(
Document.id.in_(document_ids),
Document.tenant_id == dataset_ref.tenant_id,
Document.dataset_id == dataset_ref.dataset_id,
)
).all()
deleted_document_ids = [document.id for document in documents]
file_ids = [
document.data_source_info_dict.get("upload_file_id", "")
for document in documents
if document.data_source_type == DataSourceType.UPLOAD_FILE and document.data_source_info_dict
]
# Delete documents first, then dispatch cleanup task after commit
# to avoid deadlock between main transaction and async task
for document in documents:
session.delete(document)
session.commit()
# Dispatch cleanup task after commit to avoid lock contention
# Task cleans up segments, files, and vector indexes
if deleted_document_ids and doc_form is not None:
batch_clean_document_task.delay(
deleted_document_ids,
dataset_ref.dataset_id,
doc_form,
file_ids,
)
@staticmethod
def rename_document(dataset_id: str, document_id: str, name: str, session: Session) -> Document:
assert isinstance(current_user, Account)
dataset = DatasetService.get_dataset(dataset_id, session)
if not dataset:
raise ValueError("Dataset not found.")
document = DocumentService.get_document(dataset_id, document_id, session=session)
if not document:
raise ValueError("Document not found.")
if document.tenant_id != current_user.current_tenant_id:
raise ValueError("No permission.")
if dataset.built_in_field_enabled:
if document.doc_metadata:
doc_metadata = copy.deepcopy(document.doc_metadata)
doc_metadata[BuiltInField.document_name] = name
document.doc_metadata = doc_metadata
document.name = name
session.add(document)
if document.data_source_info_dict and "upload_file_id" in document.data_source_info_dict:
session.execute(
update(UploadFile)
.where(UploadFile.id == document.data_source_info_dict["upload_file_id"])
.values(name=name)
)
session.flush()
return document
@staticmethod
def pause_document(document, session: Session, *, actor_id: str):
if document.indexing_status not in {
IndexingStatus.WAITING,
IndexingStatus.PARSING,
IndexingStatus.CLEANING,
IndexingStatus.SPLITTING,
IndexingStatus.INDEXING,
}:
raise DocumentIndexingError()
# update document to be paused
document.is_paused = True
document.paused_by = actor_id
document.paused_at = naive_utc_now()
session.add(document)
session.commit()
# set document paused flag
indexing_cache_key = f"document_{document.id}_is_paused"
redis_client.setnx(indexing_cache_key, "True")
@staticmethod
def recover_document(document, session: Session):
if not document.is_paused:
raise DocumentIndexingError()
# update document to be recover
document.is_paused = False
document.paused_by = None
document.paused_at = None
session.add(document)
session.commit()
# delete paused flag
indexing_cache_key = f"document_{document.id}_is_paused"
redis_client.delete(indexing_cache_key)
# trigger async task
recover_document_indexing_task.delay(document.dataset_id, document.id)
@staticmethod
def retry_document(dataset_id: str, documents: list[Document], session: Session, *, actor_id: str):
"""Reserve the whole retry batch before changing any document state.
Redis lock acquisition is intentionally coupled to this bounded status
transaction so a concurrent request cannot partially admit the batch.
"""
if not actor_id:
raise ValueError("Current user or current user id not found")
unique_documents = list({document.id: document for document in documents}.values())
retry_indexing_cache_keys = [f"document_{document.id}_is_retried" for document in unique_documents]
acquired_locks: list[Any] = []
def release_acquired_locks() -> None:
for retry_lock in acquired_locks:
try:
retry_lock.release()
except Exception:
logger.warning("Failed to release document retry lock", exc_info=True)
try:
for retry_indexing_cache_key in retry_indexing_cache_keys:
retry_lock = redis_client.lock(retry_indexing_cache_key, timeout=600, thread_local=False)
if not retry_lock.acquire(blocking=False):
raise ValueError("Document is being retried, please try again later")
acquired_locks.append(retry_lock)
except Exception:
release_acquired_locks()
raise
try:
for document in unique_documents:
document.indexing_status = IndexingStatus.WAITING
session.add(document)
session.commit()
except Exception:
session.rollback()
release_acquired_locks()
raise
document_ids = [document.id for document in unique_documents]
retry_document_indexing_task.delay(dataset_id, document_ids, actor_id)
@staticmethod
def sync_website_document(dataset: Dataset, document: Document, session: Session):
if document.tenant_id != dataset.tenant_id or document.dataset_id != dataset.id:
raise ValueError("Document not found.")
# add sync flag
sync_indexing_cache_key = f"document_{document.id}_is_sync"
cache_result = redis_client.get(sync_indexing_cache_key)
if cache_result is not None:
raise ValueError("Document is being synced, please try again later")
# sync document indexing
document.indexing_status = IndexingStatus.WAITING
data_source_info = document.data_source_info_dict
if data_source_info:
data_source_info["mode"] = "scrape"
document.data_source_info = json.dumps(data_source_info, ensure_ascii=False)
session.add(document)
session.commit()
redis_client.setex(sync_indexing_cache_key, 600, 1)
sync_website_document_indexing_task.delay(dataset.id, document.id)
@staticmethod
def get_documents_position(dataset_id, session: Session):
document = session.scalar(
select(Document).where(Document.dataset_id == dataset_id).order_by(Document.position.desc()).limit(1)
)
if document:
return document.position + 1
else:
return 1
@staticmethod
def save_document_with_dataset_id(
dataset: Dataset,
knowledge_config: KnowledgeConfig,
account: Account | Any,
dataset_process_rule: DatasetProcessRule | None = None,
created_from: str = DocumentCreatedFrom.WEB,
*,
session: Session,
) -> tuple[list[Document], str]:
# Compatibility entry point for existing callers that own their session.
DatasetService.check_doc_form(dataset, knowledge_config.doc_form, session=session)
assert isinstance(account, Account)
assert account.current_tenant_id is not None
config = DocumentService.prepare_document_creation(
DocumentCreationDataset(
dataset.tenant_id, dataset.indexing_technique, dataset.embedding_model, dataset.embedding_model_provider
),
knowledge_config,
)
documents: list[Document] = []
batch = ""
lock = (
nullcontext()
if config.original_document_id
else redis_client.lock(f"add_document_lock_dataset_id_{dataset.id}", timeout=600)
)
try:
with lock:
documents, batch, jobs = DocumentService.save_prepared_documents(
dataset, config, account, dataset_process_rule, created_from, session=session
)
DocumentService.dispatch_document_indexing(jobs)
except LockNotOwnedError:
if config.original_document_id:
raise
if not batch:
batch = DocumentService.generate_document_batch()
return documents, batch
@staticmethod
def generate_document_batch() -> str:
return time.strftime("%Y%m%d%H%M%S") + str(100000 + secrets.randbelow(exclusive_upper_bound=900000))
@staticmethod
def prepare_document_creation(
dataset: DocumentCreationDataset, knowledge_config: KnowledgeConfig
) -> KnowledgeConfig:
"""Resolve remote quotas and models from values; new callers close their read transaction first."""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
features = FeatureService.get_features(dataset.tenant_id, exclude_vector_space=True)
if not knowledge_config.original_document_id:
count = 0
if knowledge_config.data_source:
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
if not knowledge_config.data_source.info_list.file_info_list:
raise ValueError("File source info is required")
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
count = len(upload_file_list)
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
notion_info_list = knowledge_config.data_source.info_list.notion_info_list or []
for notion_info in notion_info_list:
count = count + len(notion_info.pages)
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
website_info = knowledge_config.data_source.info_list.website_info_list
assert website_info
count = len(website_info.urls)
DocumentService.check_document_creation_limits(count, features)
config = knowledge_config
effective_dataset = dataset
if not dataset.indexing_technique:
if config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
raise ValueError("Indexing technique is invalid")
if config.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
if not config.embedding_model or not config.embedding_model_provider:
model = ModelManager.for_tenant(tenant_id=dataset.tenant_id).get_default_model_instance(
tenant_id=dataset.tenant_id, model_type=ModelType.TEXT_EMBEDDING
)
config = config.model_copy(
update={"embedding_model": model.model_name, "embedding_model_provider": model.provider}
)
effective_dataset = replace(
dataset,
indexing_technique=config.indexing_technique,
embedding_model=config.embedding_model,
embedding_model_provider=config.embedding_model_provider,
)
if config.original_document_id:
DatasetService.check_dataset_model_setting(effective_dataset)
return config
@staticmethod
def dispatch_document_indexing(jobs: DocumentIndexingJobs) -> None:
"""Publish only identifiers after the corresponding database writes commit."""
ref = jobs.dataset
if jobs.removed_notion:
clean_notion_document_task.delay(list(jobs.removed_notion), ref.dataset_id)
if jobs.created:
DocumentIndexingTaskProxy(ref.tenant_id, ref.dataset_id, list(jobs.created)).delay()
if jobs.duplicated:
DuplicateDocumentIndexingTaskProxy(ref.tenant_id, ref.dataset_id, list(jobs.duplicated)).delay()
if jobs.updated:
document_indexing_update_task.delay(ref.dataset_id, jobs.updated)
@staticmethod
def save_prepared_documents(
dataset: Dataset,
knowledge_config: KnowledgeConfig,
account: Account | Any,
dataset_process_rule: DatasetProcessRule | None = None,
created_from: str = DocumentCreatedFrom.WEB,
*,
session: Session,
) -> tuple[list[Document], str, DocumentIndexingJobs]:
"""Persist prepared input without remote calls, Redis locks, or task dispatch.
The caller must resolve quotas/models with prepare_document_creation and
hold the dataset's creation lock for new documents. ORM values stay within
this persistence adapter; publish the returned jobs after closing its session.
"""
DatasetService.check_doc_form(dataset, knowledge_config.doc_form, session=session)
# if dataset is empty, update dataset data_source_type
if not dataset.data_source_type and knowledge_config.data_source:
dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
if not dataset.indexing_technique:
if knowledge_config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
raise ValueError("Indexing technique is invalid")
dataset.indexing_technique = IndexTechniqueType(knowledge_config.indexing_technique)
if knowledge_config.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
assert knowledge_config.embedding_model
assert knowledge_config.embedding_model_provider
dataset_embedding_model = knowledge_config.embedding_model
dataset_embedding_model_provider = knowledge_config.embedding_model_provider
dataset.embedding_model = dataset_embedding_model
dataset.embedding_model_provider = dataset_embedding_model_provider
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
dataset_embedding_model_provider, dataset_embedding_model, session
)
dataset.collection_binding_id = dataset_collection_binding.id
if not dataset.retrieval_model:
default_retrieval_model = {
"search_method": RetrievalMethod.SEMANTIC_SEARCH,
"reranking_enable": False,
"reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
"top_k": 4,
"score_threshold_enabled": False,
}
dataset.retrieval_model = (
knowledge_config.retrieval_model.model_dump()
if knowledge_config.retrieval_model
else default_retrieval_model
)
documents: list[Document] = []
jobs = DocumentIndexingJobs(DatasetRef(dataset.tenant_id, dataset.id))
if knowledge_config.original_document_id:
document = DocumentService.update_prepared_document(dataset, knowledge_config, account, session=session)
documents.append(document)
batch = document.batch
jobs = replace(jobs, updated=document.id)
else:
# When creating new documents, data_source must be provided
if not knowledge_config.data_source:
raise ValueError("Data source is required when creating new documents")
batch = DocumentService.generate_document_batch()
# save process rule
if not dataset_process_rule:
process_rule = knowledge_config.process_rule
if process_rule:
if process_rule.mode in (ProcessRuleMode.CUSTOM, ProcessRuleMode.HIERARCHICAL):
if process_rule.rules:
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
mode=ProcessRuleMode(process_rule.mode),
rules=process_rule.rules.model_dump_json() if process_rule.rules else None,
created_by=account.id,
)
else:
dataset_process_rule = get_latest_dataset_process_rule(dataset, session=session)
if not dataset_process_rule:
raise ValueError("No process rule found.")
elif process_rule.mode == ProcessRuleMode.AUTOMATIC:
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
mode=ProcessRuleMode.AUTOMATIC,
rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES),
created_by=account.id,
)
else:
logger.warning(
"Invalid process rule mode: %s, can not find dataset process rule",
process_rule.mode,
)
return [], "", jobs
session.add(dataset_process_rule)
session.flush()
else:
# Fallback when no process_rule provided in knowledge_config:
# 1) reuse the dataset's latest process rule if present
# 2) otherwise create an automatic rule
dataset_process_rule = get_latest_dataset_process_rule(dataset, session=session)
if not dataset_process_rule:
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
mode=ProcessRuleMode.AUTOMATIC,
rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES),
created_by=account.id,
)
session.add(dataset_process_rule)
session.flush()
assert dataset_process_rule
position = DocumentService.get_documents_position(dataset.id, session)
document_ids = []
duplicate_document_ids = []
removed_notion_ids: list[str] = []
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
if not knowledge_config.data_source.info_list.file_info_list:
raise ValueError("File source info is required")
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
# Issue #41735: under MySQL's default
# ``REPEATABLE READ`` isolation, the SELECT snapshot
# for this transaction is pinned by the first
# read done earlier in ``save_document_with_dataset_id``
# (e.g. ``check_doc_form``). The caller has just
# committed the upload in a different session
# (``FileService.upload_text`` /
# ``FileService.upload_file``), so the rows exist
# but are invisible to *this* session until its
# snapshot is refreshed. End the current transaction
# so the next SELECT starts with a fresh snapshot.
# The pending changes on ``dataset`` are also
# flushed, which is what we want — they must be
# persisted by this point anyway. PostgreSQL's
# default ``READ COMMITTED`` rebuilds the read view
# per statement so it isn't affected.
session.commit()
files = list(
session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == dataset.tenant_id,
UploadFile.id.in_(upload_file_list),
)
).all()
)
if len(files) != len(set(upload_file_list)):
raise FileNotExistsError("One or more files not found.")
file_names = [file.name for file in files]
db_documents = list(
session.scalars(
select(Document).where(
Document.dataset_id == dataset.id,
Document.tenant_id == account.current_tenant_id,
Document.data_source_type == DataSourceType.UPLOAD_FILE,
Document.enabled == True,
Document.name.in_(file_names),
)
).all()
)
documents_map = {document.name: document for document in db_documents}
for file in files:
data_source_info: dict[str, object] = {
"upload_file_id": file.id,
}
document = documents_map.get(file.name)
if knowledge_config.duplicate and document:
document.dataset_process_rule_id = dataset_process_rule.id
document.updated_at = naive_utc_now()
document.created_from = created_from
document.doc_form = IndexStructureType(knowledge_config.doc_form)
document.doc_language = knowledge_config.doc_language
document.data_source_info = json.dumps(data_source_info)
document.batch = batch
document.indexing_status = IndexingStatus.WAITING
session.add(document)
documents.append(document)
duplicate_document_ids.append(document.id)
continue
else:
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
file.name,
batch,
)
session.add(document)
session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
if not notion_info_list:
raise ValueError("No notion info list found.")
exist_page_ids = []
exist_document = {}
documents = list(
session.scalars(
select(Document).where(
Document.dataset_id == dataset.id,
Document.tenant_id == account.current_tenant_id,
Document.data_source_type == DataSourceType.NOTION_IMPORT,
Document.enabled == True,
)
).all()
)
if documents:
for document in documents:
data_source_info = json.loads(document.data_source_info)
exist_page_ids.append(data_source_info["notion_page_id"])
exist_document[data_source_info["notion_page_id"]] = document.id
for notion_info in notion_info_list:
workspace_id = notion_info.workspace_id
for page in notion_info.pages:
if page.page_id not in exist_page_ids:
data_source_info = {
"credential_id": notion_info.credential_id,
"notion_workspace_id": workspace_id,
"notion_page_id": page.page_id,
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
"type": page.type,
}
# Truncate page name to 255 characters to prevent DB field length errors
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
truncated_page_name,
batch,
)
document.id = str(uuid.uuid4())
session.add(document)
document_ids.append(document.id)
documents.append(document)
position += 1
else:
exist_document.pop(page.page_id)
session.flush()
# delete not selected documents
if len(exist_document) > 0:
removed_notion_ids = list(exist_document.values())
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
website_info = knowledge_config.data_source.info_list.website_info_list
if not website_info:
raise ValueError("No website info list found.")
urls = website_info.urls
for url in urls:
data_source_info = {
"url": url,
"provider": website_info.provider,
"job_id": website_info.job_id,
"only_main_content": website_info.only_main_content,
"mode": "crawl",
}
if len(url) > 255:
document_name = url[:200] + "..."
else:
document_name = url
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
document_name,
batch,
)
session.add(document)
session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
session.commit()
jobs = replace(
jobs,
created=tuple(document_ids),
duplicated=tuple(duplicate_document_ids),
removed_notion=tuple(removed_notion_ids),
)
return documents, batch, jobs
# @staticmethod
# def save_document_with_dataset_id(
# dataset: Dataset,
# knowledge_config: KnowledgeConfig,
# account: Account | Any,
# dataset_process_rule: Optional[DatasetProcessRule] = None,
# created_from: str = "web",
# ):
# # check document limit
# features = FeatureService.get_features(current_user.current_tenant_id)
# if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
# if not knowledge_config.original_document_id:
# count = 0
# if knowledge_config.data_source:
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
#
# count = len(upload_file_list)
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# for notion_info in notion_info_list:
# count = count + len(notion_info.pages)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# count = len(website_info.urls)
# batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT)
# if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1:
# raise ValueError("Your current plan does not support batch upload, please upgrade your plan.")
# if count > batch_upload_limit:
# raise ValueError(f"You have reached the batch upload limit of {batch_upload_limit}.")
# DocumentService.check_documents_upload_quota(count, features)
# # if dataset is empty, update dataset data_source_type
# if not dataset.data_source_type:
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
# if not dataset.indexing_technique:
# if knowledge_config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
# raise ValueError("Indexing technique is invalid")
# dataset.indexing_technique = knowledge_config.indexing_technique
# if knowledge_config.indexing_technique == "high_quality":
# model_manager = ModelManager.for_tenant(tenant_id=current_user.current_tenant_id)
# if knowledge_config.embedding_model and knowledge_config.embedding_model_provider:
# dataset_embedding_model = knowledge_config.embedding_model
# dataset_embedding_model_provider = knowledge_config.embedding_model_provider
# else:
# embedding_model = model_manager.get_default_model_instance(
# tenant_id=current_user.current_tenant_id, model_type=ModelType.TEXT_EMBEDDING
# )
# dataset_embedding_model = embedding_model.model
# dataset_embedding_model_provider = embedding_model.provider
# dataset.embedding_model = dataset_embedding_model
# dataset.embedding_model_provider = dataset_embedding_model_provider
# dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
# dataset_embedding_model_provider, dataset_embedding_model
# )
# dataset.collection_binding_id = dataset_collection_binding.id
# if not dataset.retrieval_model:
# default_retrieval_model = {
# "search_method": RetrievalMethod.SEMANTIC_SEARCH,
# "reranking_enable": False,
# "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
# "top_k": 2,
# "score_threshold_enabled": False,
# }
# dataset.retrieval_model = (
# knowledge_config.retrieval_model.model_dump()
# if knowledge_config.retrieval_model
# else default_retrieval_model
# )
# documents = []
# if knowledge_config.original_document_id:
# document = DocumentService.update_document_with_dataset_id(dataset, knowledge_config, account)
# documents.append(document)
# batch = document.batch
# else:
# batch = time.strftime("%Y%m%d%H%M%S") + str(random.randint(100000, 999999))
# # save process rule
# if not dataset_process_rule:
# process_rule = knowledge_config.process_rule
# if process_rule:
# if process_rule.mode in ("custom", "hierarchical"):
# dataset_process_rule = DatasetProcessRule(
# dataset_id=dataset.id,
# mode=process_rule.mode,
# rules=process_rule.rules.model_dump_json() if process_rule.rules else None,
# created_by=account.id,
# )
# elif process_rule.mode == "automatic":
# dataset_process_rule = DatasetProcessRule(
# dataset_id=dataset.id,
# mode=process_rule.mode,
# rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES),
# created_by=account.id,
# )
# else:
# logging.warn(
# f"Invalid process rule mode: {process_rule.mode}, can not find dataset process rule"
# )
# return
# session.add(dataset_process_rule)
# session.commit()
# lock_name = "add_document_lock_dataset_id_{}".format(dataset.id)
# with redis_client.lock(lock_name, timeout=600):
# position = DocumentService.get_documents_position(dataset.id)
# document_ids = []
# duplicate_document_ids = []
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
# for file_id in upload_file_list:
# file = (
# session.query(UploadFile)
# .filter(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
# .first()
# )
# # raise error if file not found
# if not file:
# raise FileNotExistsError()
# file_name = file.name
# data_source_info = {
# "upload_file_id": file_id,
# }
# # check duplicate
# if knowledge_config.duplicate:
# document = Document.query.filter_by(
# dataset_id=dataset.id,
# tenant_id=current_user.current_tenant_id,
# data_source_type="upload_file",
# enabled=True,
# name=file_name,
# ).first()
# if document:
# document.dataset_process_rule_id = dataset_process_rule.id
# document.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
# document.created_from = created_from
# document.doc_form = knowledge_config.doc_form
# document.doc_language = knowledge_config.doc_language
# document.data_source_info = json.dumps(data_source_info)
# document.batch = batch
# document.indexing_status = "waiting"
# session.add(document)
# documents.append(document)
# duplicate_document_ids.append(document.id)
# continue
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
# created_from,
# position,
# account,
# file_name,
# batch,
# )
# session.add(document)
# session.flush()
# document_ids.append(document.id)
# documents.append(document)
# position += 1
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# if not notion_info_list:
# raise ValueError("No notion info list found.")
# exist_page_ids = []
# exist_document = {}
# documents = Document.query.filter_by(
# dataset_id=dataset.id,
# tenant_id=current_user.current_tenant_id,
# data_source_type="notion_import",
# enabled=True,
# ).all()
# if documents:
# for document in documents:
# data_source_info = json.loads(document.data_source_info)
# exist_page_ids.append(data_source_info["notion_page_id"])
# exist_document[data_source_info["notion_page_id"]] = document.id
# for notion_info in notion_info_list:
# workspace_id = notion_info.workspace_id
# data_source_binding = DataSourceOauthBinding.query.filter(
# sa.and_(
# DataSourceOauthBinding.tenant_id == current_user.current_tenant_id,
# DataSourceOauthBinding.provider == "notion",
# DataSourceOauthBinding.disabled == False,
# DataSourceOauthBinding.source_info["workspace_id"] == f'"{workspace_id}"',
# )
# ).first()
# if not data_source_binding:
# raise ValueError("Data source binding not found.")
# for page in notion_info.pages:
# if page.page_id not in exist_page_ids:
# data_source_info = {
# "notion_workspace_id": workspace_id,
# "notion_page_id": page.page_id,
# "notion_page_icon": page.page_icon.model_dump() if page.page_icon else None,
# "type": page.type,
# }
# # Truncate page name to 255 characters to prevent DB field length errors
# truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
# created_from,
# position,
# account,
# truncated_page_name,
# batch,
# )
# session.add(document)
# session.flush()
# document_ids.append(document.id)
# documents.append(document)
# position += 1
# else:
# exist_document.pop(page.page_id)
# # delete not selected documents
# if len(exist_document) > 0:
# clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# if not website_info:
# raise ValueError("No website info list found.")
# urls = website_info.urls
# for url in urls:
# data_source_info = {
# "url": url,
# "provider": website_info.provider,
# "job_id": website_info.job_id,
# "only_main_content": website_info.only_main_content,
# "mode": "crawl",
# }
# if len(url) > 255:
# document_name = url[:200] + "..."
# else:
# document_name = url
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
# created_from,
# position,
# account,
# document_name,
# batch,
# )
# session.add(document)
# session.flush()
# document_ids.append(document.id)
# documents.append(document)
# position += 1
# session.commit()
# # trigger async task
# if document_ids:
# document_indexing_task.delay(dataset.id, document_ids)
# if duplicate_document_ids:
# duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
# return documents, batch
@staticmethod
def check_documents_upload_quota(count: int, features: FeatureModel):
can_upload_size = features.documents_upload_quota.limit - features.documents_upload_quota.size
if count > can_upload_size:
raise ValueError(
f"You have reached the limit of your subscription. Only {can_upload_size} documents can be uploaded."
)
@staticmethod
def check_document_creation_limits(count: int, features: FeatureModel):
"""Validate billing-backed document creation limits before document rows are created."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1:
raise ValueError("Your current plan does not support batch upload, please upgrade your plan.")
batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT)
if count > batch_upload_limit:
raise ValueError(f"You have reached the batch upload limit of {batch_upload_limit}.")
DocumentService.check_documents_upload_quota(count, features)
@staticmethod
def build_document(
dataset: Dataset,
process_rule_id: str | None,
data_source_type: str,
document_form: str,
document_language: str,
data_source_info: dict[str, Any],
created_from: str,
position: int,
account: Account,
name: str,
batch: str,
):
# Set need_summary based on dataset's summary_index_setting
need_summary = False
if dataset.summary_index_setting and dataset.summary_index_setting.get("enable") is True:
need_summary = True
document = Document(
tenant_id=dataset.tenant_id,
dataset_id=dataset.id,
position=position,
data_source_type=data_source_type,
data_source_info=json.dumps(data_source_info),
dataset_process_rule_id=process_rule_id,
batch=batch,
name=name,
created_from=created_from,
created_by=account.id,
doc_form=document_form,
doc_language=document_language,
need_summary=need_summary,
)
doc_metadata = {}
if dataset.built_in_field_enabled:
doc_metadata = {
BuiltInField.document_name: name,
BuiltInField.uploader: account.name,
BuiltInField.upload_date: datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M:%S"),
BuiltInField.last_update_date: datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M:%S"),
BuiltInField.source: data_source_type,
}
if doc_metadata:
document.doc_metadata = doc_metadata
return document
@staticmethod
def get_tenant_documents_count(session: Session):
assert isinstance(current_user, Account)
documents_count = (
session.scalar(
select(func.count(Document.id)).where(
Document.completed_at.isnot(None),
Document.enabled == True,
Document.archived == False,
Document.tenant_id == current_user.current_tenant_id,
)
)
or 0
)
return documents_count
@staticmethod
def update_document_with_dataset_id(
dataset: Dataset,
document_data: KnowledgeConfig,
account: Account,
dataset_process_rule: DatasetProcessRule | None = None,
created_from: str = DocumentCreatedFrom.WEB,
*,
session: Session,
):
DatasetService.check_dataset_model_setting(dataset)
document = DocumentService.update_prepared_document(
dataset, document_data, account, dataset_process_rule, created_from, session=session
)
DocumentService.dispatch_document_indexing(
DocumentIndexingJobs(DatasetRef(dataset.tenant_id, dataset.id), updated=document.id)
)
return document
@staticmethod
def update_prepared_document(
dataset: Dataset,
document_data: KnowledgeConfig,
account: Account,
dataset_process_rule: DatasetProcessRule | None = None,
created_from: str = DocumentCreatedFrom.WEB,
*,
session: Session,
):
assert isinstance(account, Account)
document = DocumentService.get_document(dataset.id, document_data.original_document_id, session=session)
if document is None:
raise NotFound("Document not found")
if document.display_status != "available":
raise ValueError("Document is not available")
# save process rule
if document_data.process_rule:
process_rule = document_data.process_rule
if process_rule.mode in {ProcessRuleMode.CUSTOM, ProcessRuleMode.HIERARCHICAL}:
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
mode=ProcessRuleMode(process_rule.mode),
rules=process_rule.rules.model_dump_json() if process_rule.rules else None,
created_by=account.id,
)
elif process_rule.mode == ProcessRuleMode.AUTOMATIC:
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
mode=ProcessRuleMode.AUTOMATIC,
rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES),
created_by=account.id,
)
if dataset_process_rule is not None:
session.add(dataset_process_rule)
session.commit()
document.dataset_process_rule_id = dataset_process_rule.id
# update document data source
if document_data.data_source:
file_name = ""
data_source_info: dict[str, object] = {}
if document_data.data_source.info_list.data_source_type == "upload_file":
if not document_data.data_source.info_list.file_info_list:
raise ValueError("No file info list found.")
upload_file_list = document_data.data_source.info_list.file_info_list.file_ids
for file_id in upload_file_list:
file = session.scalar(
select(UploadFile)
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
.limit(1)
)
# raise error if file not found
if not file:
raise FileNotExistsError()
file_name = file.name
data_source_info = {
"upload_file_id": file_id,
}
elif document_data.data_source.info_list.data_source_type == "notion_import":
if not document_data.data_source.info_list.notion_info_list:
raise ValueError("No notion info list found.")
notion_info_list = document_data.data_source.info_list.notion_info_list
for notion_info in notion_info_list:
workspace_id = notion_info.workspace_id
data_source_binding = session.scalar(
select(DataSourceOauthBinding)
.where(
sa.and_(
DataSourceOauthBinding.tenant_id == account.current_tenant_id,
DataSourceOauthBinding.provider == "notion",
DataSourceOauthBinding.disabled == False,
DataSourceOauthBinding.source_info["workspace_id"] == f'"{workspace_id}"',
)
)
.limit(1)
)
if not data_source_binding:
raise ValueError("Data source binding not found.")
for page in notion_info.pages:
data_source_info = {
"credential_id": notion_info.credential_id,
"notion_workspace_id": workspace_id,
"notion_page_id": page.page_id,
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
"type": page.type,
}
elif document_data.data_source.info_list.data_source_type == "website_crawl":
website_info = document_data.data_source.info_list.website_info_list
if website_info:
urls = website_info.urls
for url in urls:
data_source_info = {
"url": url,
"provider": website_info.provider,
"job_id": website_info.job_id,
"only_main_content": website_info.only_main_content,
"mode": "crawl",
}
document.data_source_type = document_data.data_source.info_list.data_source_type
document.data_source_info = json.dumps(data_source_info)
document.name = file_name
# update document name
if document_data.name:
document.name = document_data.name
# update document to be waiting
document.indexing_status = IndexingStatus.WAITING
document.completed_at = None
document.processing_started_at = None
document.parsing_completed_at = None
document.cleaning_completed_at = None
document.splitting_completed_at = None
document.updated_at = naive_utc_now()
document.created_from = created_from
document.doc_form = IndexStructureType(document_data.doc_form)
session.add(document)
session.commit()
# update document segment
session.execute(
update(DocumentSegment)
.where(DocumentSegment.document_id == document.id)
.values(status=SegmentStatus.RE_SEGMENT)
)
session.commit()
return document
@staticmethod
def save_document_without_dataset_id(
tenant_id: str, knowledge_config: KnowledgeConfig, account: Account, session: Session
):
assert isinstance(account, Account)
assert account.current_tenant_id is not None
assert knowledge_config.data_source
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
features = FeatureService.get_features(account.current_tenant_id, exclude_vector_space=True)
count = 0
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
upload_file_list = (
knowledge_config.data_source.info_list.file_info_list.file_ids
if knowledge_config.data_source.info_list.file_info_list
else []
)
count = len(upload_file_list)
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
notion_info_list = knowledge_config.data_source.info_list.notion_info_list
if notion_info_list:
for notion_info in notion_info_list:
count = count + len(notion_info.pages)
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
website_info = knowledge_config.data_source.info_list.website_info_list
if website_info:
count = len(website_info.urls)
DocumentService.check_document_creation_limits(count, features)
dataset = DocumentService.build_dataset_for_documents(
DatasetRef(tenant_id, str(uuid.uuid4())), knowledge_config, account, session
)
documents, batch = DocumentService.save_document_with_dataset_id(
dataset, knowledge_config, account, session=session
)
cut_length = 18
cut_name = documents[0].name[:cut_length]
dataset.name = cut_name + "..."
dataset.description = "useful for when you want to answer queries about the " + documents[0].name
session.flush()
return dataset, documents, batch
@staticmethod
def build_dataset_for_documents(
ref: DatasetRef, knowledge_config: KnowledgeConfig, account: Account, session: Session
) -> Dataset:
"""Create a dataset inside the caller's SQL-only persistence phase."""
assert knowledge_config.data_source
dataset_collection_binding_id = None
retrieval_model = None
if knowledge_config.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
assert knowledge_config.embedding_model_provider
assert knowledge_config.embedding_model
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
knowledge_config.embedding_model_provider,
knowledge_config.embedding_model,
session,
)
dataset_collection_binding_id = dataset_collection_binding.id
if knowledge_config.retrieval_model:
retrieval_model = knowledge_config.retrieval_model
else:
retrieval_model = RetrievalModel(
search_method=RetrievalMethod.SEMANTIC_SEARCH,
reranking_enable=False,
reranking_model=RerankingModel(reranking_provider_name="", reranking_model_name=""),
top_k=4,
score_threshold_enabled=False,
)
# save dataset
dataset = Dataset(
id=ref.dataset_id,
tenant_id=ref.tenant_id,
name="",
data_source_type=knowledge_config.data_source.info_list.data_source_type,
indexing_technique=IndexTechniqueType(knowledge_config.indexing_technique),
created_by=account.id,
maintainer=account.id,
embedding_model=knowledge_config.embedding_model,
embedding_model_provider=knowledge_config.embedding_model_provider,
collection_binding_id=dataset_collection_binding_id,
retrieval_model=retrieval_model.model_dump() if retrieval_model else None,
summary_index_setting=knowledge_config.summary_index_setting,
is_multimodal=knowledge_config.is_multimodal,
)
session.add(dataset)
session.flush()
return dataset
@classmethod
def document_create_args_validate(cls, knowledge_config: KnowledgeConfig):
if not knowledge_config.data_source and not knowledge_config.process_rule:
raise ValueError("Data source or Process rule is required")
else:
if knowledge_config.data_source:
DocumentService.data_source_args_validate(knowledge_config)
if knowledge_config.process_rule:
DocumentService.process_rule_args_validate(knowledge_config)
@classmethod
def data_source_args_validate(cls, knowledge_config: KnowledgeConfig):
if not knowledge_config.data_source:
raise ValueError("Data source is required")
if knowledge_config.data_source.info_list.data_source_type not in Document.DATA_SOURCES:
raise ValueError("Data source type is invalid")
if not knowledge_config.data_source.info_list:
raise ValueError("Data source info is required")
if knowledge_config.data_source.info_list.data_source_type == "upload_file":
if not knowledge_config.data_source.info_list.file_info_list:
raise ValueError("File source info is required")
if knowledge_config.data_source.info_list.data_source_type == "notion_import":
if not knowledge_config.data_source.info_list.notion_info_list:
raise ValueError("Notion source info is required")
if knowledge_config.data_source.info_list.data_source_type == "website_crawl":
if not knowledge_config.data_source.info_list.website_info_list:
raise ValueError("Website source info is required")
@classmethod
def process_rule_args_validate(cls, knowledge_config: KnowledgeConfig):
if not knowledge_config.process_rule:
raise ValueError("Process rule is required")
if not knowledge_config.process_rule.mode:
raise ValueError("Process rule mode is required")
if knowledge_config.process_rule.mode not in DatasetProcessRule.MODES:
raise ValueError("Process rule mode is invalid")
if knowledge_config.process_rule.mode == ProcessRuleMode.AUTOMATIC:
knowledge_config.process_rule.rules = None
else:
if not knowledge_config.process_rule.rules:
raise ValueError("Process rule rules is required")
if knowledge_config.process_rule.rules.pre_processing_rules is None:
raise ValueError("Process rule pre_processing_rules is required")
unique_pre_processing_rule_dicts = {}
for pre_processing_rule in knowledge_config.process_rule.rules.pre_processing_rules:
if not pre_processing_rule.id:
raise ValueError("Process rule pre_processing_rules id is required")
if not isinstance(pre_processing_rule.enabled, bool):
raise ValueError("Process rule pre_processing_rules enabled is invalid")
unique_pre_processing_rule_dicts[pre_processing_rule.id] = pre_processing_rule
knowledge_config.process_rule.rules.pre_processing_rules = list(unique_pre_processing_rule_dicts.values())
if knowledge_config.process_rule.mode == ProcessRuleMode.HIERARCHICAL:
if not knowledge_config.process_rule.rules.parent_mode:
knowledge_config.process_rule.rules.parent_mode = "paragraph"
if not knowledge_config.process_rule.rules.segmentation:
raise ValueError("Process rule segmentation is required")
if not knowledge_config.process_rule.rules.segmentation.separator:
raise ValueError("Process rule segmentation separator is required")
if not isinstance(knowledge_config.process_rule.rules.segmentation.separator, str):
raise ValueError("Process rule segmentation separator is invalid")
if not (
knowledge_config.process_rule.mode == ProcessRuleMode.HIERARCHICAL
and knowledge_config.process_rule.rules.parent_mode == "full-doc"
):
if not knowledge_config.process_rule.rules.segmentation.max_tokens:
raise ValueError("Process rule segmentation max_tokens is required")
if not isinstance(knowledge_config.process_rule.rules.segmentation.max_tokens, int):
raise ValueError("Process rule segmentation max_tokens is invalid")
@staticmethod
def batch_update_document_status(
dataset: Dataset,
document_ids: list[str],
action: Literal["enable", "disable", "archive", "un_archive"],
user,
session: Session,
):
"""
Batch update document status.
Args:
dataset (Dataset): The dataset object
document_ids (list[str]): List of document IDs to update
action (Literal["enable", "disable", "archive", "un_archive"]): Action to perform
user: Current user performing the action
Raises:
DocumentIndexingError: If document is being indexed or not in correct state
ValueError: If action is invalid
"""
if not document_ids:
return
# Early validation of action parameter
valid_actions = ["enable", "disable", "archive", "un_archive"]
if action not in valid_actions:
raise ValueError(f"Invalid action: {action}. Must be one of {valid_actions}")
documents_to_update = []
# First pass: validate all documents and prepare updates
for document_id in document_ids:
document = DocumentService.get_document(dataset.id, document_id, session=session)
if not document:
continue
# Check if document is being indexed
indexing_cache_key = f"document_{document.id}_indexing"
cache_result = redis_client.get(indexing_cache_key)
if cache_result is not None:
raise DocumentIndexingError(f"Document:{document.name} is being indexed, please try again later")
# Prepare update based on action
update_info = DocumentService._prepare_document_status_update(document, action, user)
if update_info:
documents_to_update.append(update_info)
# Second pass: apply all updates in a single transaction
if documents_to_update:
try:
for update_info in documents_to_update:
document = update_info["document"]
updates = update_info["updates"]
# Apply updates to the document
for field, value in updates.items():
setattr(document, field, value)
session.add(document)
# Batch commit all changes
session.commit()
except Exception as e:
# Rollback on any error
session.rollback()
raise e
# Execute async tasks and set Redis cache after successful commit
# propagation_error is used to capture any errors for submitting async task execution
propagation_error = None
for update_info in documents_to_update:
try:
# Execute async tasks after successful commit
if update_info["async_task"]:
task_info = update_info["async_task"]
task_func = task_info["function"]
task_args = task_info["args"]
task_func.delay(*task_args)
except Exception as e:
# Log the error but do not rollback the transaction
logger.exception("Error executing async task for document %s", update_info["document"].id)
# don't raise the error immediately, but capture it for later
propagation_error = e
try:
# Set Redis cache if needed after successful commit
if update_info["set_cache"]:
document = update_info["document"]
indexing_cache_key = f"document_{document.id}_indexing"
redis_client.setex(indexing_cache_key, 600, 1)
except Exception:
# Log the error but do not rollback the transaction
logger.exception("Error setting cache for document %s", update_info["document"].id)
# Raise any propagation error after all updates
if propagation_error:
raise propagation_error
@staticmethod
def _prepare_document_status_update(
document: Document, action: Literal["enable", "disable", "archive", "un_archive"], user
):
"""Prepare document status update information.
Args:
document: Document object to update
action: Action to perform
user: Current user
Returns:
dict: Update information or None if no update needed
"""
now = naive_utc_now()
match action:
case "enable":
return DocumentService._prepare_enable_update(document, now)
case "disable":
return DocumentService._prepare_disable_update(document, user, now)
case "archive":
return DocumentService._prepare_archive_update(document, user, now)
case "un_archive":
return DocumentService._prepare_unarchive_update(document, now)
return None
@staticmethod
def _prepare_enable_update(document, now):
"""Prepare updates for enabling a document."""
if document.enabled:
return None
return {
"document": document,
"updates": {"enabled": True, "disabled_at": None, "disabled_by": None, "updated_at": now},
"async_task": {"function": add_document_to_index_task, "args": [document.id]},
"set_cache": True,
}
@staticmethod
def _prepare_disable_update(document, user, now):
"""Prepare updates for disabling a document."""
if not document.completed_at or document.indexing_status != IndexingStatus.COMPLETED:
raise DocumentIndexingError(f"Document: {document.name} is not completed.")
if not document.enabled:
return None
return {
"document": document,
"updates": {"enabled": False, "disabled_at": now, "disabled_by": user.id, "updated_at": now},
"async_task": {"function": remove_document_from_index_task, "args": [document.id]},
"set_cache": True,
}
@staticmethod
def _prepare_archive_update(document, user, now):
"""Prepare updates for archiving a document."""
if document.archived:
return None
update_info = {
"document": document,
"updates": {"archived": True, "archived_at": now, "archived_by": user.id, "updated_at": now},
"async_task": None,
"set_cache": False,
}
# Only set async task and cache if document is currently enabled
if document.enabled:
# pyrefly: ignore [bad-assignment]
update_info["async_task"] = {"function": remove_document_from_index_task, "args": [document.id]}
update_info["set_cache"] = True
return update_info
@staticmethod
def _prepare_unarchive_update(document, now):
"""Prepare updates for unarchiving a document."""
if not document.archived:
return None
update_info = {
"document": document,
"updates": {"archived": False, "archived_at": None, "archived_by": None, "updated_at": now},
"async_task": None,
"set_cache": False,
}
# Only re-index if the document is currently enabled
if document.enabled:
# pyrefly: ignore [bad-assignment]
update_info["async_task"] = {"function": add_document_to_index_task, "args": [document.id]}
update_info["set_cache"] = True
return update_info
def _segment_mutation_scope(dataset: Dataset, document: Document) -> SegmentMutationScope:
"""Materialize already-authorized rows before ending their read transaction."""
if document.tenant_id != dataset.tenant_id or document.dataset_id != dataset.id:
raise ValueError("Document does not belong to the dataset")
return SegmentMutationScope(
dataset=SegmentDatasetRecord(
id=dataset.id,
workspace_id=dataset.tenant_id,
indexing_technique=dataset.indexing_technique or "economy",
embedding_model_provider=dataset.embedding_model_provider,
embedding_model=dataset.embedding_model,
),
document=SegmentDocumentRecord(
id=document.id,
dataset_id=document.dataset_id,
workspace_id=document.tenant_id,
doc_form=document.doc_form,
),
)
def _segment_ref_for_mutation(scope: SegmentMutationScope, segment: DocumentSegment) -> SegmentRef:
document_ref = scope.document.ref
if (
segment.tenant_id != document_ref.dataset.tenant_id
or segment.dataset_id != document_ref.dataset.dataset_id
or segment.document_id != document_ref.document_id
):
raise ValueError("Segment does not belong to the document")
return document_ref.segment(segment.id)
def _check_child_chunk_scope(child: ChildChunk, segment_ref: SegmentRef) -> None:
if (
child.tenant_id != segment_ref.document.dataset.tenant_id
or child.dataset_id != segment_ref.document.dataset.dataset_id
or child.document_id != segment_ref.document.document_id
or child.segment_id != segment_ref.segment_id
):
raise ValueError("Child chunk does not belong to the segment")
class SegmentService:
@classmethod
def segment_create_args_validate(cls, args: dict[str, Any], document: Document):
validate_segment_values(
args, doc_form=document.doc_form, attachment_limit=dify_config.SINGLE_CHUNK_ATTACHMENT_LIMIT
)
@classmethod
def multi_create_segment(
cls,
segments: list,
document: Document,
dataset: Dataset,
session: Session,
*,
mutations: SegmentMutationService,
):
"""Commit a Service API batch before dispatching its scoped index update."""
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
scope = _segment_mutation_scope(dataset, document)
if current_user.current_tenant_id != scope.dataset.workspace_id:
raise ValueError("Dataset does not belong to the current workspace")
actor_id = current_user.id
document_ref = scope.document.ref
session.commit()
lock_name = f"multi_add_segment_lock_document_id_{document_ref.document_id}"
increment_word_count = 0
try:
with redis_client.lock(lock_name, timeout=600):
embedding_model = None
if scope.dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
model_manager = ModelManager.for_tenant(tenant_id=scope.dataset.workspace_id)
embedding_model = model_manager.get_model_instance(
tenant_id=scope.dataset.workspace_id,
provider=scope.dataset.embedding_model_provider or "",
model_type=ModelType.TEXT_EMBEDDING,
model=scope.dataset.embedding_model or "",
)
token_counts = [
embedding_model.get_text_embedding_num_tokens(
texts=[
item["content"] + item["answer"]
if scope.document.doc_form == IndexStructureType.QA_INDEX
else item["content"]
]
)[0]
if embedding_model
else 0
for item in segments
]
persisted_document = session.scalar(
select(Document).where(
Document.id == document_ref.document_id,
Document.dataset_id == document_ref.dataset.dataset_id,
Document.tenant_id == document_ref.dataset.tenant_id,
)
)
if persisted_document is None:
raise ValueError("Document no longer exists")
document = persisted_document
max_position = session.scalar(
select(func.max(DocumentSegment.position)).where(
DocumentSegment.tenant_id == document_ref.dataset.tenant_id,
DocumentSegment.dataset_id == document_ref.dataset.dataset_id,
DocumentSegment.document_id == document_ref.document_id,
)
)
segment_data_list = []
keywords_list = []
position = max_position + 1 if max_position else 1
for segment_item, tokens in zip(segments, token_counts, strict=True):
content = segment_item["content"]
doc_id = str(uuid.uuid4())
segment_hash = helper.generate_text_hash(content)
segment_document = DocumentSegment(
tenant_id=document_ref.dataset.tenant_id,
dataset_id=document.dataset_id,
document_id=document.id,
index_node_id=doc_id,
index_node_hash=segment_hash,
position=position,
content=content,
word_count=len(content),
tokens=tokens,
keywords=segment_item.get("keywords", []),
status=SegmentStatus.COMPLETED,
indexing_at=naive_utc_now(),
completed_at=naive_utc_now(),
created_by=actor_id,
)
if document.doc_form == IndexStructureType.QA_INDEX:
segment_document.answer = segment_item["answer"]
segment_document.word_count += len(segment_item["answer"])
increment_word_count += segment_document.word_count
session.add(segment_document)
segment_data_list.append(segment_document)
position += 1
if "keywords" in segment_item:
keywords_list.append(segment_item["keywords"])
else:
keywords_list.append(None)
# update document word count
assert document.word_count is not None
document.word_count += increment_word_count
session.add(document)
session.flush()
segment_ids = [segment.id for segment in segment_data_list]
session.commit()
try:
mutations.index_segments(document_ref, segment_ids=segment_ids, keywords_list=keywords_list)
except Exception:
logger.exception("create segment index failed")
session.expire_all()
for segment in segment_data_list:
session.refresh(segment)
return segment_data_list
except LockNotOwnedError:
pass
@classmethod
def update_segment(
cls,
args: SegmentUpdateArgs,
segment: DocumentSegment,
document: Document,
dataset: Dataset,
session: Session,
*,
actor_id: str,
mutations: SegmentMutationService,
) -> DocumentSegment:
"""Delegate an authorized Service API patch to the shared mutation owner."""
scope = _segment_mutation_scope(dataset, document)
segment_ref = _segment_ref_for_mutation(scope, segment)
# Finish the caller's read transaction before the mutation owner performs
# external indexing I/O in between its own bounded write transactions.
session.commit()
mutations.update_segment(scope, actor_id=actor_id, segment_id=segment_ref.segment_id, args=args)
session.expire_all()
updated = cls.get_segment_by_ref(segment_ref, session=session)
if updated is None:
raise ValueError("new_segment is not found")
return updated
@classmethod
def delete_segment(
cls,
segment: DocumentSegment,
document: Document,
dataset: Dataset,
session: Session,
*,
mutations: SegmentMutationService,
) -> None:
segment_ref = _segment_ref_for_mutation(_segment_mutation_scope(dataset, document), segment)
session.commit()
mutations.delete_segment(segment_ref)
session.expire_all()
@classmethod
def create_child_chunk(
cls,
content: str,
segment: DocumentSegment,
document: Document,
dataset: Dataset,
session: Session,
*,
actor_id: str,
mutations: SegmentMutationService,
) -> ChildChunk:
segment_ref = _segment_ref_for_mutation(_segment_mutation_scope(dataset, document), segment)
session.commit()
try:
created = mutations.create_child_chunk(segment_ref, content=content, actor_id=actor_id)
except ChildChunkIndexingApplicationError as error:
raise ChildChunkIndexingError(str(error)) from error
session.expire_all()
child = cls.get_child_chunk_by_segment_ref(created.id, segment_ref, session=session)
if child is None:
raise ValueError("Child chunk no longer exists")
return child
@classmethod
def update_child_chunk(
cls,
content: str,
child_chunk: ChildChunk,
segment: DocumentSegment,
document: Document,
dataset: Dataset,
session: Session,
*,
actor_id: str,
mutations: SegmentMutationService,
) -> ChildChunk:
segment_ref = _segment_ref_for_mutation(_segment_mutation_scope(dataset, document), segment)
_check_child_chunk_scope(child_chunk, segment_ref)
child_id = child_chunk.id
session.commit()
try:
mutations.update_child_chunk(segment_ref, child_chunk_id=child_id, content=content, actor_id=actor_id)
except ChildChunkIndexingApplicationError as error:
raise ChildChunkIndexingError(str(error)) from error
session.expire_all()
updated = cls.get_child_chunk_by_segment_ref(child_id, segment_ref, session=session)
if updated is None:
raise ValueError("Child chunk no longer exists")
return updated
@classmethod
def delete_child_chunk(
cls,
child_chunk: ChildChunk,
dataset: Dataset,
session: Session,
*,
mutations: SegmentMutationService,
) -> None:
segment_ref = (
DatasetRef(tenant_id=dataset.tenant_id, dataset_id=dataset.id)
.document(child_chunk.document_id)
.segment(child_chunk.segment_id)
)
_check_child_chunk_scope(child_chunk, segment_ref)
child_id = child_chunk.id
session.commit()
try:
mutations.delete_child_chunk(segment_ref, child_chunk_id=child_id)
except ChildChunkDeleteIndexApplicationError as error:
raise ChildChunkDeleteIndexError(str(error)) from error
session.expire_all()
@classmethod
def get_child_chunks(
cls,
segment_id: str,
document_id: str,
dataset_id: str,
page: int,
limit: int,
keyword: str | None = None,
*,
session: Session,
):
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
return query_child_chunks(
session,
DatasetRef(current_user.current_tenant_id, dataset_id).document(document_id).segment(segment_id),
page=page,
limit=limit,
keyword=keyword,
)
@classmethod
def get_child_chunk_by_id(cls, child_chunk_id: str, tenant_id: str, session: Session) -> ChildChunk | None:
"""Get a child chunk by its ID."""
result = session.scalar(
select(ChildChunk).where(ChildChunk.id == child_chunk_id, ChildChunk.tenant_id == tenant_id).limit(1)
)
return result if isinstance(result, ChildChunk) else None
@classmethod
def get_child_chunk_by_segment_ref(
cls, child_chunk_id: str, segment_ref: SegmentRef, session: Session
) -> ChildChunk | None:
"""Get a child chunk through the full tenant/dataset/document/segment chain."""
result = session.scalar(
select(ChildChunk)
.where(
ChildChunk.id == child_chunk_id,
ChildChunk.tenant_id == segment_ref.document.dataset.tenant_id,
ChildChunk.dataset_id == segment_ref.document.dataset.dataset_id,
ChildChunk.document_id == segment_ref.document.document_id,
ChildChunk.segment_id == segment_ref.segment_id,
)
.limit(1)
)
return result if isinstance(result, ChildChunk) else None
@classmethod
def get_segments(
cls,
document_id: str,
tenant_id: str,
status_list: list[str] | None = None,
keyword: str | None = None,
page: int = 1,
limit: int = 20,
*,
session: Session,
):
"""Get segments for a document with optional filtering."""
query = select(DocumentSegment).where(
DocumentSegment.document_id == document_id, DocumentSegment.tenant_id == tenant_id
)
# Check if status_list is not empty to avoid WHERE false condition
if status_list and len(status_list) > 0:
query = query.where(DocumentSegment.status.in_(status_list))
if keyword:
escaped_keyword = helper.escape_like_pattern(keyword)
query = query.where(DocumentSegment.content.ilike(f"%{escaped_keyword}%", escape="\\"))
query = query.order_by(DocumentSegment.position.asc(), DocumentSegment.id.asc())
paginated_segments = paginate_query(query, session=session, page=page, per_page=limit, max_per_page=100)
return paginated_segments.items, paginated_segments.total
@classmethod
def get_segment_by_id(cls, segment_id: str, tenant_id: str, session: Session) -> DocumentSegment | None:
"""Get a segment by its ID."""
result = session.scalar(
select(DocumentSegment)
.where(DocumentSegment.id == segment_id, DocumentSegment.tenant_id == tenant_id)
.limit(1)
)
return result if isinstance(result, DocumentSegment) else None
@classmethod
def get_segment_by_ref(cls, segment_ref: SegmentRef, session: Session) -> DocumentSegment | None:
"""Get a segment through the full tenant/dataset/document ownership chain."""
result = session.scalar(
select(DocumentSegment)
.where(
DocumentSegment.id == segment_ref.segment_id,
DocumentSegment.tenant_id == segment_ref.document.dataset.tenant_id,
DocumentSegment.dataset_id == segment_ref.document.dataset.dataset_id,
DocumentSegment.document_id == segment_ref.document.document_id,
)
.limit(1)
)
return result if isinstance(result, DocumentSegment) else None
@classmethod
def get_segments_by_document_and_dataset(
cls,
document_id: str,
dataset_id: str,
session: Session,
status: str | None = None,
enabled: bool | None = None,
*,
tenant_id: str,
) -> Sequence[DocumentSegment]:
"""
Get segments through their tenant, dataset, and document owner chain.
Args:
document_id: Document ID
dataset_id: Dataset ID
tenant_id: Owning tenant ID
status: Optional status filter (e.g., "completed")
enabled: Optional enabled filter (True/False)
Returns:
Sequence of DocumentSegment instances
"""
query = select(DocumentSegment).where(
DocumentSegment.tenant_id == tenant_id,
DocumentSegment.document_id == document_id,
DocumentSegment.dataset_id == dataset_id,
)
if status is not None:
query = query.where(DocumentSegment.status == status)
if enabled is not None:
query = query.where(DocumentSegment.enabled == enabled)
return session.scalars(query).all()
class DatasetCollectionBindingService:
@classmethod
def get_dataset_collection_binding(
cls, provider_name: str, model_name: str, session: Session, collection_type: str = "dataset"
) -> DatasetCollectionBinding:
dataset_collection_binding = session.scalar(
select(DatasetCollectionBinding)
.where(
DatasetCollectionBinding.provider_name == provider_name,
DatasetCollectionBinding.model_name == model_name,
DatasetCollectionBinding.type == collection_type,
)
.order_by(DatasetCollectionBinding.created_at)
.limit(1)
)
if not dataset_collection_binding:
dataset_collection_binding = DatasetCollectionBinding(
provider_name=provider_name,
model_name=model_name,
collection_name=Dataset.gen_collection_name_by_id(str(uuid.uuid4())),
type=collection_type,
)
session.add(dataset_collection_binding)
session.flush()
return dataset_collection_binding
@classmethod
def get_dataset_collection_binding_by_id_and_type(
cls, collection_binding_id: str, session: Session, collection_type: str = "dataset"
) -> DatasetCollectionBinding:
dataset_collection_binding = session.scalar(
select(DatasetCollectionBinding)
.where(
DatasetCollectionBinding.id == collection_binding_id, DatasetCollectionBinding.type == collection_type
)
.order_by(DatasetCollectionBinding.created_at)
.limit(1)
)
if not dataset_collection_binding:
raise ValueError("Dataset collection binding not found")
return dataset_collection_binding
class DatasetPermissionService:
@classmethod
def get_dataset_partial_member_list(cls, dataset_id, session: Session):
user_list_query = session.scalars(
select(
DatasetPermission.account_id,
).where(DatasetPermission.dataset_id == dataset_id)
).all()
return user_list_query
@classmethod
def update_partial_member_list(cls, tenant_id, dataset_id, user_list, session: Session):
session.execute(delete(DatasetPermission).where(DatasetPermission.dataset_id == dataset_id))
permissions = []
for user in user_list:
permission = DatasetPermission(
tenant_id=tenant_id,
dataset_id=dataset_id,
account_id=user["user_id"],
)
permissions.append(permission)
session.add_all(permissions)
session.flush()
@classmethod
def check_permission(cls, user, dataset, requested_permission, requested_partial_member_list, *, session: Session):
if not user.is_dataset_editor:
raise NoPermissionError("User does not have permission to edit this dataset.")
if user.is_dataset_operator and dataset.permission != requested_permission:
raise NoPermissionError("Dataset operators cannot change the dataset permissions.")
if user.is_dataset_operator and requested_permission == "partial_members":
if not requested_partial_member_list:
raise ValueError("Partial member list is required when setting to partial members.")
local_member_list = cls.get_dataset_partial_member_list(dataset.id, session)
request_member_list = [user["user_id"] for user in requested_partial_member_list]
if set(local_member_list) != set(request_member_list):
raise ValueError("Dataset operators cannot change the dataset permissions.")
@classmethod
def clear_partial_member_list(cls, dataset_id, session: Session):
session.execute(delete(DatasetPermission).where(DatasetPermission.dataset_id == dataset_id))
session.flush()