Files
dify/api/services/knowledge/documents/adapters.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

581 lines
28 KiB
Python

"""Session-owning adapters for document queries and existing document operations."""
import copy
import json
from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager, nullcontext
from typing import Any, Literal
from uuid import uuid4
from redis.exceptions import LockNotOwnedError
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import NotFound
from configs import dify_config
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
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 IndexTechniqueType
from extensions.ext_redis import redis_client
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
from libs.pagination import paginate_query
from machinery.context import RequestContext
from models import Account, Dataset, Document, DocumentSegment, UploadFile
from models.dataset import DatasetPermissionEnum, DocumentPipelineExecutionLog
from models.enums import IndexingStatus
from repositories.knowledge.dataset_read_repository import (
get_dataset_doc_form,
get_document_hit_count,
get_document_process_rule,
get_document_segment_count,
get_latest_dataset_process_rule,
)
from repositories.knowledge.dataset_repository import _get_dataset
from repositories.knowledge.document_repository import _get_document
from services.enterprise import rbac_service
from services.errors.base import NoPermissionError
from services.errors.document import DocumentIndexingError
from services.file_service import FileService
from services.knowledge.dataset_access import DatasetAccessDeniedError, DatasetNotFoundError
from services.knowledge.dataset_read_service import (
get_document_metadata_details,
get_document_source_detail,
load_document_detail,
load_document_details,
)
from services.knowledge.dataset_service import DatasetService, DocumentService
from services.knowledge.documents.application import (
DocumentIndexingStateError,
DocumentInvalidActionError,
DocumentListFilter,
DocumentNotFoundError,
DocumentProviderError,
DocumentState,
DocumentZip,
)
from services.knowledge.entities.document_creation import DocumentCreationDataset, DocumentIndexingJobs
from services.knowledge.entities.knowledge_entities import KnowledgeConfig
from services.knowledge.resource_scope import DatasetRef, DocumentRef
from services.knowledge.summaries.adapters import SummaryIndexAdapter
from services.vector_space_admission_service import get_vector_space_admission_error_fields
from tasks.generate_summary_index_task import generate_summary_index_task
@contextmanager
def _translate_errors() -> Generator[None, None, None]:
try:
yield
except NotFound as error:
raise DocumentNotFoundError(error.description) from error
except NoPermissionError as error:
raise DatasetAccessDeniedError() from error
except DocumentIndexingError as error:
raise DocumentIndexingStateError(str(error)) from error
except (ProviderTokenNotInitError, InvokeAuthorizationError) as error:
raise DocumentProviderError(str(error), kind="uninitialized") from error
except QuotaExceededError as error:
raise DocumentProviderError(str(error), kind="quota") from error
except ModelCurrentlyNotSupportError as error:
raise DocumentProviderError(str(error), kind="unsupported") from error
def _require_dataset(session: Session, ref: DatasetRef) -> Dataset:
dataset = _get_dataset(session, ref)
if dataset is None:
raise DatasetNotFoundError()
return dataset
def _require_document(session: Session, ref: DocumentRef) -> Document:
document = _get_document(session, ref)
if document is None:
raise DocumentNotFoundError("Document not found.")
return document
def _state(document: Document) -> DocumentState:
return DocumentState(
id=document.id,
archived=bool(document.archived),
indexing_status=str(document.indexing_status),
is_paused=bool(document.is_paused),
data_source_type=str(document.data_source_type),
doc_form=str(document.doc_form),
need_summary=bool(document.need_summary),
)
def _created_response(dataset: Dataset, documents: Sequence[Document], batch: str, session: Session) -> dict[str, Any]:
return {
"dataset": {
"id": dataset.id,
"name": dataset.name,
"description": dataset.description,
"permission": dataset.permission,
"data_source_type": dataset.data_source_type,
"indexing_technique": dataset.indexing_technique,
"created_by": dataset.created_by,
"created_at": dataset.created_at,
},
"documents": load_document_details(documents, session=session),
"batch": batch,
}
def _indexing_status(document: Document, counts: tuple[int, int]) -> dict[str, Any]:
return {
"id": document.id,
"processing_started_at": document.processing_started_at,
"parsing_completed_at": document.parsing_completed_at,
"cleaning_completed_at": document.cleaning_completed_at,
"splitting_completed_at": document.splitting_completed_at,
"completed_at": document.completed_at,
"paused_at": document.paused_at,
"error": document.error,
"stopped_at": document.stopped_at,
"indexing_status": IndexingStatus.PAUSED if document.is_paused else document.indexing_status,
**get_vector_space_admission_error_fields(document.error),
"completed_segments": counts[0],
"total_segments": counts[1],
}
class SQLAlchemyDocumentOperations:
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._sessions = session_factory
def find_document(self, *, workspace_id: str, document_id: str) -> DocumentRef | None:
with self._sessions() as session:
document = session.scalar(
select(Document).where(Document.id == document_id, Document.tenant_id == workspace_id)
)
return DatasetRef(workspace_id, document.dataset_id).document(document.id) if document else None
def get_state(self, ref: DocumentRef) -> DocumentState | None:
with self._sessions() as session:
document = _get_document(session, ref)
return _state(document) if document else None
def get_states(self, ref: DatasetRef, document_ids: Sequence[str]) -> Sequence[DocumentState]:
with self._sessions() as session:
return tuple(
_state(document) for document in DocumentService.get_documents_by_ids(ref, document_ids, session)
)
def get_process_rule(self, ref: DatasetRef | None) -> dict[str, Any]:
result = copy.deepcopy(DocumentService.DEFAULT_RULES)
if ref is not None:
with self._sessions() as session:
rule = get_latest_dataset_process_rule(_require_dataset(session, ref), session=session)
if rule is not None:
result.update(mode=rule.mode, rules=rule.rules_dict)
return result
def list_documents(self, ref: DatasetRef, query: DocumentListFilter) -> dict[str, Any]:
with self._sessions() as session:
dataset = _require_dataset(session, ref)
statement = select(Document).where(
Document.dataset_id == ref.dataset_id, Document.tenant_id == ref.tenant_id
)
statement = DocumentService.apply_display_status_filter(statement, query.status)
if query.search:
statement = statement.where(Document.name.like(f"%{query.search}%"))
direction = desc if query.sort.startswith("-") else asc
sort = query.sort.removeprefix("-")
if sort == "hit_count":
hits = (
select(DocumentSegment.document_id, func.sum(DocumentSegment.hit_count).label("total_hit_count"))
.where(DocumentSegment.dataset_id == ref.dataset_id, DocumentSegment.tenant_id == ref.tenant_id)
.group_by(DocumentSegment.document_id)
.subquery()
)
statement = statement.outerjoin(hits, hits.c.document_id == Document.id).order_by(
direction(func.coalesce(hits.c.total_hit_count, 0)), direction(Document.position)
)
elif sort == "created_at":
statement = statement.order_by(direction(Document.created_at), direction(Document.position))
else:
statement = statement.order_by(desc(Document.created_at), desc(Document.position))
page = paginate_query(statement, session=session, page=query.page, per_page=query.limit, max_per_page=100)
DocumentService.enrich_documents_with_summary_index_status(
documents=page.items, dataset=dataset, tenant_id=ref.tenant_id, session=session
)
if query.fetch:
counts = DocumentService.get_document_segment_counts(page.items, session=session)
for document in page.items:
document.completed_segments, document.total_segments = counts.get(document.id, (0, 0))
return {
"data": load_document_details(page.items, session=session),
"has_more": page.has_next,
"limit": page.per_page,
"total": page.total,
"page": page.page,
}
def get_detail(self, ref: DocumentRef, *, metadata_only: bool) -> dict[str, Any]:
with self._sessions() as session:
document = _require_document(session, ref)
result = {
"id": document.id,
"doc_type": document.doc_type,
"doc_metadata": get_document_metadata_details(document, session=session),
}
if metadata_only:
return result
rule = get_document_process_rule(document, session=session)
segment_count = get_document_segment_count(document, session=session)
result.update(
{
"position": document.position,
"data_source_type": document.data_source_type,
"dataset_process_rule_id": document.dataset_process_rule_id,
"name": document.name,
"created_from": document.created_from,
"created_by": document.created_by,
"tokens": document.tokens,
"indexing_status": document.indexing_status,
"indexing_latency": document.indexing_latency,
"error": document.error,
"enabled": document.enabled,
"disabled_by": document.disabled_by,
"archived": document.archived,
"display_status": document.display_status,
"doc_form": document.doc_form,
"doc_language": document.doc_language,
}
)
result.update(
{
"created_at": int(document.created_at.timestamp()) if document.created_at else None,
"completed_at": int(document.completed_at.timestamp()) if document.completed_at else None,
"updated_at": int(document.updated_at.timestamp()) if document.updated_at else None,
"disabled_at": int(document.disabled_at.timestamp()) if document.disabled_at else None,
}
)
result.update(
data_source_info=document.data_source_info_dict,
data_source_detail_dict=get_document_source_detail(document, session=session),
dataset_process_rule=DatasetService.get_process_rules(ref.dataset.dataset_id, session),
document_process_rule=dict(rule.to_dict()) if rule else {},
segment_count=segment_count,
average_segment_length=(document.word_count or 0) // segment_count if segment_count else 0,
hit_count=get_document_hit_count(document, session=session),
need_summary=bool(document.need_summary),
)
return result
def get_indexing_status(self, ref: DocumentRef) -> dict[str, Any]:
with self._sessions() as session:
document = _require_document(session, ref)
counts = DocumentService.get_document_segment_counts([document], session=session)
return _indexing_status(document, counts.get(document.id, (0, 0)))
def get_batch_indexing_status(self, ref: DatasetRef, batch: str) -> dict[str, Any]:
with self._sessions() as session:
documents = session.scalars(
select(Document).where(
Document.tenant_id == ref.tenant_id, Document.dataset_id == ref.dataset_id, Document.batch == batch
)
).all()
if not documents:
raise DocumentNotFoundError("Documents not found.")
counts = DocumentService.get_document_segment_counts(documents, session=session)
return {"data": [_indexing_status(document, counts.get(document.id, (0, 0))) for document in documents]}
def get_execution_log(self, ref: DocumentRef) -> dict[str, Any]:
with self._sessions() as session:
_require_document(session, ref)
log = session.scalar(
select(DocumentPipelineExecutionLog)
.where(DocumentPipelineExecutionLog.document_id == ref.document_id)
.order_by(DocumentPipelineExecutionLog.created_at.desc())
.limit(1)
)
if log is None:
return {}
return {
"datasource_info": json.loads(log.datasource_info),
"datasource_type": log.datasource_type,
"input_data": log.input_data,
"datasource_node_id": log.datasource_node_id,
}
def get_summary_status(self, ref: DocumentRef) -> dict[str, Any]:
with self._sessions() as session:
_require_document(session, ref)
return dict(
SummaryIndexAdapter.get_document_summary_status_detail(
tenant_id=ref.dataset.tenant_id,
document_id=ref.document_id,
dataset_id=ref.dataset.dataset_id,
session=session,
)
)
def get_download_url(self, ref: DocumentRef) -> str:
with _translate_errors(), self._sessions() as session:
return DocumentService.get_document_download_url(_require_document(session, ref), session)
@contextmanager
def build_download_zip(self, ref: DatasetRef, document_ids: Sequence[str]) -> Generator[DocumentZip, None, None]:
with _translate_errors(), self._sessions() as session:
files_by_document = DocumentService._get_upload_files_by_document_id_for_zip_download(
dataset_id=ref.dataset_id, document_ids=document_ids, tenant_id=ref.tenant_id, session=session
)
files = [files_by_document[document_id] for document_id in document_ids]
filename = DocumentService._generate_document_batch_download_zip_filename()
# The read transaction is closed before reading storage or streaming the response.
with FileService.build_upload_files_zip_tempfile(upload_files=files) as path:
yield DocumentZip(path=path, filename=filename)
@staticmethod
def _actor(session: Session, context: RequestContext) -> Account:
account = session.get(Account, context.account_id)
if account is None:
raise DatasetAccessDeniedError()
account.set_tenant_id_with_session(context.active_workspace_id, session=session)
return account
def _creation_dataset(
self, context: RequestContext, ref: DatasetRef, config: KnowledgeConfig
) -> DocumentCreationDataset:
with self._sessions() as session:
dataset = _require_dataset(session, ref)
if not dataset.indexing_technique and not config.indexing_technique:
raise ValueError("indexing_technique is required.")
self._actor(session, context)
DatasetService.check_doc_form(dataset, config.doc_form, session=session)
return DocumentCreationDataset(
dataset.tenant_id, dataset.indexing_technique, dataset.embedding_model, dataset.embedding_model_provider
)
def _persist_creation(
self, context: RequestContext, ref: DatasetRef, config: KnowledgeConfig, *, initialize: bool
) -> tuple[dict[str, Any], DocumentIndexingJobs]:
"""Keep ORM values local and materialize the response before closing the session."""
jobs: DocumentIndexingJobs | None = None
try:
with self._sessions() as session:
account = self._actor(session, context)
dataset = (
DocumentService.build_dataset_for_documents(ref, config, account, session)
if initialize
else _require_dataset(session, ref)
)
documents, batch, jobs = DocumentService.save_prepared_documents(
dataset, config, account, session=session
)
if initialize:
dataset.name = documents[0].name[:18] + "..."
dataset.description = "useful for when you want to answer queries about the " + documents[0].name
dataset.permission = (
DatasetPermissionEnum.ALL_TEAM if dify_config.RBAC_ENABLED else DatasetPermissionEnum.ONLY_ME
)
session.flush()
result = _created_response(dataset, documents, batch, session)
session.commit()
return result, jobs
except Exception:
# The persistence helper commits documents before loading response
# details. Preserve their indexing even if that later read fails.
if jobs is not None:
DocumentService.dispatch_document_indexing(jobs)
raise
def _run_creation(
self, context: RequestContext, ref: DatasetRef, config: KnowledgeConfig, *, initialize: bool
) -> dict[str, Any]:
lock = (
nullcontext()
if config.original_document_id
else redis_client.lock(f"add_document_lock_dataset_id_{ref.dataset_id}", timeout=600)
)
result = None
try:
with lock:
result, jobs = self._persist_creation(context, ref, config, initialize=initialize)
# No session or ORM entity crosses the task boundary. Keep the
# creation lock until committed documents have been dispatched.
DocumentService.dispatch_document_indexing(jobs)
except LockNotOwnedError:
if config.original_document_id:
raise
# Preserve successful creation when the Redis lease expires on release.
if result is None:
if initialize:
raise
with self._sessions() as session:
result = _created_response(
_require_dataset(session, ref), [], DocumentService.generate_document_batch(), session
)
assert result is not None
return result
def create_documents(self, context: RequestContext, ref: DatasetRef, settings: Mapping[str, Any]) -> dict[str, Any]:
config = KnowledgeConfig.model_validate(settings)
DocumentService.document_create_args_validate(config)
with _translate_errors():
snapshot = self._creation_dataset(context, ref, config)
config = DocumentService.prepare_document_creation(snapshot, config)
return self._run_creation(context, ref, config, initialize=False)
def initialize_dataset(self, context: RequestContext, settings: Mapping[str, Any]) -> dict[str, Any]:
config = KnowledgeConfig.model_validate(settings)
with _translate_errors():
if config.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
if config.embedding_model is None or config.embedding_model_provider is None:
raise ValueError(
"embedding model and embedding model provider are required for high quality indexing."
)
ModelManager.for_tenant(tenant_id=context.active_workspace_id).get_model_instance(
tenant_id=context.active_workspace_id,
provider=config.embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
model=config.embedding_model,
)
config = config.model_copy(
update={
"is_multimodal": DatasetService.check_is_multimodal_model(
context.active_workspace_id, config.embedding_model_provider, config.embedding_model
)
}
)
DocumentService.document_create_args_validate(config)
with self._sessions() as session:
self._actor(session, context)
config = DocumentService.prepare_document_creation(
DocumentCreationDataset(
context.active_workspace_id,
config.indexing_technique,
config.embedding_model,
config.embedding_model_provider,
),
config,
)
dataset_id = str(uuid4())
result = self._run_creation(
context, DatasetRef(context.active_workspace_id, dataset_id), config, initialize=True
)
if dify_config.RBAC_ENABLED:
rbac_service.RBACService.DatasetAccess.replace_whitelist(
context.active_workspace_id,
context.account_id,
dataset_id,
rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=False),
)
rbac_service.try_sync_creator_access_policy_member_bindings(
context.active_workspace_id,
context.account_id,
rbac_service.RBACResourceType.DATASET,
dataset_id,
)
return result
def _check_model(self, ref: DatasetRef) -> None:
with self._sessions() as session:
dataset = _require_dataset(session, ref)
DatasetService.check_dataset_model_setting(dataset)
def delete_document(self, ref: DocumentRef) -> None:
self._check_model(ref.dataset)
with _translate_errors(), self._sessions() as session:
DocumentService.delete_document(_require_document(session, ref), session)
def delete_documents(self, ref: DatasetRef, document_ids: Sequence[str]) -> None:
with _translate_errors(), self._sessions() as session:
dataset = _require_dataset(session, ref)
DocumentService.delete_documents(
ref, list(document_ids), get_dataset_doc_form(dataset, session=session), session
)
def update_document(self, ref: DocumentRef, values: Mapping[str, object]) -> None:
with self._sessions.begin() as session:
document = _require_document(session, ref)
for name, value in values.items():
setattr(document, name, value)
def rename_document(self, ref: DocumentRef, name: str) -> dict[str, Any]:
with self._sessions.begin() as session:
dataset = _require_dataset(session, ref.dataset)
document = _require_document(session, ref)
if dataset.built_in_field_enabled and document.doc_metadata:
document.doc_metadata = {**document.doc_metadata, BuiltInField.document_name: name}
document.name = name
if document.data_source_info_dict and (file_id := document.data_source_info_dict.get("upload_file_id")):
session.execute(
update(UploadFile)
.where(UploadFile.id == file_id, UploadFile.tenant_id == ref.dataset.tenant_id)
.values(name=name)
)
session.flush()
return load_document_detail(document, session=session)
def pause_document(self, ref: DocumentRef, *, actor_id: str) -> None:
with _translate_errors(), self._sessions() as session:
DocumentService.pause_document(_require_document(session, ref), session, actor_id=actor_id)
def recover_document(self, ref: DocumentRef) -> None:
with _translate_errors(), self._sessions() as session:
DocumentService.recover_document(_require_document(session, ref), session)
def retry_documents(self, ref: DatasetRef, document_ids: Sequence[str], *, actor_id: str) -> None:
with _translate_errors(), self._sessions() as session:
documents = DocumentService.get_documents_by_ids(ref, document_ids, session)
DocumentService.retry_document(ref.dataset_id, list(documents), session, actor_id=actor_id)
def change_status(
self,
context: RequestContext,
ref: DatasetRef,
document_ids: Sequence[str],
action: Literal["enable", "disable", "archive", "un_archive"],
) -> None:
self._check_model(ref)
with _translate_errors(), self._sessions() as session:
dataset = _require_dataset(session, ref)
account = self._actor(session, context)
documents = DocumentService.get_documents_by_ids(ref, document_ids, session)
try:
DocumentService.batch_update_document_status(
dataset, [doc.id for doc in documents], action, account, session
)
except (ValueError, DocumentIndexingError) as error:
raise DocumentInvalidActionError(str(error)) from error
def sync_website(self, ref: DocumentRef) -> None:
with _translate_errors(), self._sessions() as session:
DocumentService.sync_website_document(
_require_dataset(session, ref.dataset), _require_document(session, ref), session
)
def require_summary_enabled(self, ref: DatasetRef) -> None:
with self._sessions() as session:
dataset = _require_dataset(session, ref)
if dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY:
raise ValueError(
"Summary generation is only available for 'high_quality' indexing technique. "
f"Current indexing technique: {dataset.indexing_technique}"
)
if not dataset.summary_index_setting or not dataset.summary_index_setting.get("enable"):
raise ValueError(
"Summary index is not enabled for this dataset. Please enable it in the dataset settings."
)
def enable_summary(self, ref: DatasetRef, document_ids: Sequence[str]) -> None:
if document_ids:
with self._sessions.begin() as session:
session.execute(
update(Document)
.where(
Document.tenant_id == ref.tenant_id,
Document.dataset_id == ref.dataset_id,
Document.id.in_(document_ids),
)
.values(need_summary=True)
)
def dispatch_summary(self, ref: DocumentRef) -> None:
generate_summary_index_task.delay(ref.dataset.dataset_id, ref.document_id)