Files
dify/api/fields/dataset_fields.py

476 lines
16 KiB
Python

from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import sqlalchemy as sa
from pydantic import Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from fields.base import ResponseModel
from libs.helper import to_timestamp
from models.account import Account
from models.dataset import (
AppDatasetJoin,
Dataset,
DatasetMetadata,
Document,
ExternalKnowledgeApis,
ExternalKnowledgeBindings,
Pipeline,
)
from models.model import App, Tag, TagBinding
class DatasetMetadataResponse(ResponseModel):
id: str
type: str
name: str
class DatasetMetadataListItemResponse(ResponseModel):
id: str
name: str
type: str
count: int = 0
class DatasetMetadataListResponse(ResponseModel):
doc_metadata: list[DatasetMetadataListItemResponse]
built_in_field_enabled: bool
class DatasetMetadataBuiltInFieldResponse(ResponseModel):
name: str
type: str
class DatasetMetadataBuiltInFieldsResponse(ResponseModel):
fields: list[DatasetMetadataBuiltInFieldResponse]
class DatasetMetadataActionResponse(ResponseModel):
result: str = Field(description="Operation result.")
class DatasetRerankingModelResponse(ResponseModel):
reranking_provider_name: str | None = None
reranking_model_name: str | None = None
class DatasetKeywordSettingResponse(ResponseModel):
keyword_weight: float | None = None
class DatasetVectorSettingResponse(ResponseModel):
vector_weight: float | None = None
embedding_model_name: str | None = None
embedding_provider_name: str | None = None
class DatasetWeightedScoreResponse(ResponseModel):
weight_type: str | None = None
keyword_setting: DatasetKeywordSettingResponse = Field(
default_factory=DatasetKeywordSettingResponse,
description="Keyword search weight settings.",
)
vector_setting: DatasetVectorSettingResponse = Field(
default_factory=DatasetVectorSettingResponse,
description="Semantic search weight settings.",
)
@field_validator("keyword_setting", "vector_setting", mode="before")
@classmethod
def _expand_null_nested(cls, value: object) -> object:
return {} if value is None else value
class DatasetRetrievalModelResponse(ResponseModel):
search_method: str
reranking_enable: bool
reranking_mode: str | None = None
reranking_model: DatasetRerankingModelResponse = Field(
default_factory=DatasetRerankingModelResponse,
description="Reranking model configuration.",
)
weights: DatasetWeightedScoreResponse | None = None
top_k: int
score_threshold_enabled: bool
score_threshold: float | None = None
@field_validator("reranking_model", mode="before")
@classmethod
def _expand_null_nested(cls, value: object) -> object:
return {} if value is None else value
class DatasetSummaryIndexSettingResponse(ResponseModel):
enable: bool | None = None
model_name: str | None = None
model_provider_name: str | None = None
summary_prompt: str | None = None
class DatasetTagResponse(ResponseModel):
id: str
name: str
type: str
class DatasetExternalKnowledgeInfoResponse(ResponseModel):
external_knowledge_id: str | None = None
external_knowledge_api_id: str | None = None
external_knowledge_api_name: str | None = None
external_knowledge_api_endpoint: str | None = None
class DatasetExternalRetrievalModelResponse(ResponseModel):
top_k: int
score_threshold: float | None = None
score_threshold_enabled: bool | None = None
class DatasetDocMetadataResponse(ResponseModel):
id: str
name: str
type: str
class DatasetIconInfoResponse(ResponseModel):
icon_type: str | None = None
icon: str | None = None
icon_background: str | None = None
icon_url: str | None = None
class DatasetDetailResponse(ResponseModel):
id: str
name: str
description: str | None
provider: str
permission: str
data_source_type: str | None
indexing_technique: str | None
app_count: int
document_count: int
word_count: int
created_by: str
author_name: str | None
created_at: int
updated_by: str | None
updated_at: int
embedding_model: str | None
embedding_model_provider: str | None
embedding_available: bool | None = None
retrieval_model_dict: DatasetRetrievalModelResponse = Field(
description="Retrieval configuration for the knowledge base."
)
summary_index_setting: DatasetSummaryIndexSettingResponse = Field(
default_factory=DatasetSummaryIndexSettingResponse,
description="Summary index configuration.",
)
tags: list[DatasetTagResponse]
doc_form: str | None
external_knowledge_info: DatasetExternalKnowledgeInfoResponse = Field(
default_factory=DatasetExternalKnowledgeInfoResponse,
description=(
"Connection details for external knowledge bases. Populated when `provider` is `external`; otherwise "
"its properties are `null`."
),
)
external_retrieval_model: DatasetExternalRetrievalModelResponse | None
doc_metadata: list[DatasetDocMetadataResponse]
built_in_field_enabled: bool
pipeline_id: str | None
runtime_mode: str | None
chunk_structure: str | None
icon_info: DatasetIconInfoResponse = Field(
default_factory=DatasetIconInfoResponse,
description="Icon display configuration for the knowledge base.",
)
is_published: bool
total_documents: int
total_available_documents: int
enable_api: bool
is_multimodal: bool
permission_keys: list[str] = Field(default_factory=list)
maintainer: str | None = None
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
@field_validator("summary_index_setting", "external_knowledge_info", "icon_info", mode="before")
@classmethod
def _expand_null_nested(cls, value: object) -> object:
return {} if value is None else value
@dataclass(frozen=True)
class DatasetDetailPrefetch:
"""Batch-loaded values backing the session-scoped fields of a page of datasets.
Building a detail response for a single dataset issues about ten queries for
that row alone, so a list endpoint costs one query per dataset per field.
Loading the same values for every dataset on the page up front keeps the
query count bounded regardless of the page size.
"""
app_counts: dict[str, int] = field(default_factory=dict)
document_counts: dict[str, int] = field(default_factory=dict)
word_counts: dict[str, int] = field(default_factory=dict)
available_document_counts: dict[str, int] = field(default_factory=dict)
author_names: dict[str, str] = field(default_factory=dict)
tags: dict[tuple[str, str], list[Tag]] = field(default_factory=dict)
doc_forms: dict[tuple[str, str], str | None] = field(default_factory=dict)
external_knowledge_infos: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict)
doc_metadata: dict[str, list[DatasetMetadata]] = field(default_factory=dict)
pipeline_published: dict[str, bool] = field(default_factory=dict)
def build_dataset_detail_prefetch(datasets: Sequence[Dataset], *, session: Session) -> DatasetDetailPrefetch:
"""Load every session-scoped detail field for the given datasets in a fixed number of queries."""
dataset_ids = [dataset.id for dataset in datasets]
if not dataset_ids:
return DatasetDetailPrefetch()
tenant_ids = {dataset.tenant_id for dataset in datasets}
app_counts = dict(
session.execute(
select(AppDatasetJoin.dataset_id, func.count(AppDatasetJoin.id))
.where(AppDatasetJoin.dataset_id.in_(dataset_ids), App.id == AppDatasetJoin.app_id)
.group_by(AppDatasetJoin.dataset_id)
)
.tuples()
.all()
)
document_counts: dict[str, int] = {}
word_counts: dict[str, int] = {}
available_document_counts: dict[str, int] = {}
available_document_case = sa.case(
(
sa.and_(
Document.indexing_status == "completed",
Document.enabled == True,
Document.archived == False,
),
1,
),
else_=0,
)
document_rows = session.execute(
select(
Document.dataset_id,
func.count(Document.id),
func.coalesce(func.sum(Document.word_count), 0),
func.coalesce(func.sum(available_document_case), 0),
)
.where(Document.dataset_id.in_(dataset_ids))
.group_by(Document.dataset_id)
).all()
for dataset_id, document_count, word_count, available_count in document_rows:
document_counts[dataset_id] = document_count
word_counts[dataset_id] = word_count
available_document_counts[dataset_id] = available_count
# doc_form only falls back to the documents table for datasets without a
# chunk structure of their own.
doc_forms: dict[tuple[str, str], str | None] = {}
doc_form_dataset_ids = [dataset.id for dataset in datasets if not dataset.chunk_structure]
if doc_form_dataset_ids:
doc_forms = {
(dataset_id, tenant_id): doc_form
for dataset_id, tenant_id, doc_form in session.execute(
select(Document.dataset_id, Document.tenant_id, func.min(Document.doc_form))
.where(Document.dataset_id.in_(doc_form_dataset_ids), Document.tenant_id.in_(tenant_ids))
.group_by(Document.dataset_id, Document.tenant_id)
).all()
}
author_ids = {dataset.created_by for dataset in datasets if dataset.created_by}
author_names = dict(
session.execute(select(Account.id, Account.name).where(Account.id.in_(author_ids))).tuples().all()
)
tags: dict[tuple[str, str], list[Tag]] = {}
tag_rows = session.execute(
select(TagBinding.target_id, TagBinding.tenant_id, Tag)
.join(TagBinding, Tag.id == TagBinding.tag_id)
.where(
TagBinding.target_id.in_(dataset_ids),
TagBinding.tenant_id.in_(tenant_ids),
Tag.tenant_id == TagBinding.tenant_id,
Tag.type == "knowledge",
)
).all()
for target_id, tenant_id, tag in tag_rows:
tags.setdefault((target_id, tenant_id), []).append(tag)
doc_metadata: dict[str, list[DatasetMetadata]] = {}
for dataset_metadata in session.scalars(
select(DatasetMetadata).where(DatasetMetadata.dataset_id.in_(dataset_ids))
).all():
doc_metadata.setdefault(dataset_metadata.dataset_id, []).append(dataset_metadata)
pipeline_ids = {dataset.pipeline_id for dataset in datasets if dataset.pipeline_id}
pipeline_published: dict[str, bool] = {}
if pipeline_ids:
pipeline_published = dict(
session.execute(select(Pipeline.id, Pipeline.is_published).where(Pipeline.id.in_(pipeline_ids)))
.tuples()
.all()
)
return DatasetDetailPrefetch(
app_counts=app_counts,
document_counts=document_counts,
word_counts=word_counts,
available_document_counts=available_document_counts,
author_names=author_names,
tags=tags,
doc_forms=doc_forms,
external_knowledge_infos=_load_external_knowledge_infos(datasets, session=session),
doc_metadata=doc_metadata,
pipeline_published=pipeline_published,
)
def _load_external_knowledge_infos(
datasets: Sequence[Dataset], *, session: Session
) -> dict[tuple[str, str], dict[str, Any]]:
external_datasets = [dataset for dataset in datasets if dataset.provider == "external"]
if not external_datasets:
return {}
external_dataset_ids = [dataset.id for dataset in external_datasets]
external_tenant_ids = {dataset.tenant_id for dataset in external_datasets}
bindings = session.scalars(
select(ExternalKnowledgeBindings).where(
ExternalKnowledgeBindings.dataset_id.in_(external_dataset_ids),
ExternalKnowledgeBindings.tenant_id.in_(external_tenant_ids),
)
).all()
if not bindings:
return {}
apis = {
(api.id, api.tenant_id): api
for api in session.scalars(
select(ExternalKnowledgeApis).where(
ExternalKnowledgeApis.id.in_({binding.external_knowledge_api_id for binding in bindings}),
ExternalKnowledgeApis.tenant_id.in_(external_tenant_ids),
)
).all()
}
infos: dict[tuple[str, str], dict[str, Any]] = {}
for binding in bindings:
info = Dataset.build_external_knowledge_info(
binding, apis.get((binding.external_knowledge_api_id, binding.tenant_id))
)
if info is not None:
infos[(binding.dataset_id, binding.tenant_id)] = info
return infos
@dataclass(frozen=True)
class DatasetDetailResponseSource:
"""Expose session-backed dataset fields during response validation.
With a prefetch the values come from the batch load for the whole page;
without one each field is resolved against the session on access.
"""
dataset: Any
session: Session
prefetch: DatasetDetailPrefetch | None = None
@property
def _prefetch_key(self) -> tuple[str, str]:
return (self.dataset.id, self.dataset.tenant_id)
@property
def app_count(self) -> int:
if self.prefetch is not None:
return self.prefetch.app_counts.get(self.dataset.id, 0)
return self.dataset.get_app_count(session=self.session)
@property
def document_count(self) -> int:
if self.prefetch is not None:
return self.prefetch.document_counts.get(self.dataset.id, 0)
return self.dataset.get_document_count(session=self.session)
@property
def word_count(self) -> int:
if self.prefetch is not None:
return self.prefetch.word_counts.get(self.dataset.id, 0)
return self.dataset.get_word_count(session=self.session)
@property
def author_name(self) -> str | None:
if self.prefetch is not None:
return self.prefetch.author_names.get(self.dataset.created_by)
return self.dataset.get_author_name(session=self.session)
@property
def tags(self) -> Any:
if self.prefetch is not None:
return self.prefetch.tags.get(self._prefetch_key, [])
return self.dataset.get_tags(session=self.session)
@property
def doc_form(self) -> str | None:
if self.prefetch is not None:
if self.dataset.chunk_structure:
return self.dataset.chunk_structure
return self.prefetch.doc_forms.get(self._prefetch_key)
return self.dataset.get_doc_form(session=self.session)
@property
def external_knowledge_info(self) -> Any:
if self.prefetch is not None:
if self.dataset.provider != "external":
return None
return self.prefetch.external_knowledge_infos.get(self._prefetch_key)
return self.dataset.get_external_knowledge_info(session=self.session)
@property
def doc_metadata(self) -> Any:
if self.prefetch is not None:
return self.dataset.build_doc_metadata(self.prefetch.doc_metadata.get(self.dataset.id, []))
return self.dataset.get_doc_metadata(session=self.session)
@property
def is_published(self) -> bool:
if self.prefetch is not None:
if not self.dataset.pipeline_id:
return False
return self.prefetch.pipeline_published.get(self.dataset.pipeline_id, False)
return self.dataset.get_is_published(session=self.session)
@property
def total_documents(self) -> int:
if self.prefetch is not None:
return self.prefetch.document_counts.get(self.dataset.id, 0)
return self.dataset.get_total_documents(session=self.session)
@property
def total_available_documents(self) -> int:
if self.prefetch is not None:
return self.prefetch.available_document_counts.get(self.dataset.id, 0)
return self.dataset.get_total_available_documents(session=self.session)
def __getattr__(self, name: str) -> Any:
return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields
def dataset_detail_response_source(
dataset: Any, *, session: Session, prefetch: DatasetDetailPrefetch | None = None
) -> DatasetDetailResponseSource:
return DatasetDetailResponseSource(dataset=dataset, session=session, prefetch=prefetch)