mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 06:41:07 +08:00
376 lines
14 KiB
Python
376 lines
14 KiB
Python
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from unittest.mock import Mock, create_autospec
|
|
|
|
import pytest
|
|
|
|
from core.entities.knowledge_entities import IndexingEstimate
|
|
from core.rag.extractor.entity.datasource_type import DatasourceType
|
|
from core.rag.extractor.entity.extract_setting import ExtractSetting, UploadFileExtractionInput
|
|
from machinery.context import RequestContext
|
|
from services.credentials.refresh import CredentialRefreshUnavailableError
|
|
from services.data_source.credential_gateway import DatasourceCredentialNotFoundError
|
|
from services.data_source.entities.notion_import import NotionPageType
|
|
from services.knowledge.dataset_access import AccessibleDataset, DatasetAccess, DatasetAccessDeniedError
|
|
from services.knowledge.entities.indexing_estimate import (
|
|
NewSourcesEstimateCommand,
|
|
NotionEstimateSource,
|
|
UploadFileEstimateSource,
|
|
WebsiteEstimateSource,
|
|
)
|
|
from services.knowledge.indexing.errors import IndexingInputSourceError, UnsupportedStoredSourceError
|
|
from services.knowledge.indexing.estimate import (
|
|
DatasetEstimateRecord,
|
|
EstimateDatasetReader,
|
|
EstimateDocumentAlreadyFinishedError,
|
|
EstimateDocumentNotFoundError,
|
|
EstimateDocumentReader,
|
|
EstimateDocumentRecord,
|
|
EstimateSourceNotFoundError,
|
|
IndexingEstimateApplicationService,
|
|
IndexingEstimateCredentialUnavailableError,
|
|
IndexingEstimateExecutionError,
|
|
IndexingEstimateRunner,
|
|
NotionEstimateSourceResolver,
|
|
ProcessRuleReader,
|
|
StoredEstimateSourceResolver,
|
|
StoredSource,
|
|
UnsupportedEstimateSourceError,
|
|
UploadEstimateSourceResolver,
|
|
WebsiteEstimateSourceResolver,
|
|
)
|
|
from services.knowledge.resource_scope import DatasetRef
|
|
|
|
|
|
def _context() -> RequestContext:
|
|
return RequestContext("request-1", None, "account-1", "workspace-1")
|
|
|
|
|
|
def _access_dataset() -> AccessibleDataset:
|
|
return AccessibleDataset(
|
|
id="dataset-1",
|
|
workspace_id="workspace-1",
|
|
)
|
|
|
|
|
|
def _estimate_dataset() -> DatasetEstimateRecord:
|
|
return DatasetEstimateRecord(
|
|
id="dataset-1",
|
|
workspace_id="workspace-1",
|
|
indexing_technique="high_quality",
|
|
)
|
|
|
|
|
|
def _document(
|
|
document_id: str = "document-1",
|
|
*,
|
|
status: str = "waiting",
|
|
process_rule_id: str | None = "rule-1",
|
|
) -> EstimateDocumentRecord:
|
|
return EstimateDocumentRecord(
|
|
id=document_id,
|
|
workspace_id="workspace-1",
|
|
dataset_id="dataset-1",
|
|
data_source_type="upload_file",
|
|
data_source_info={"upload_file_id": f"file-{document_id}"},
|
|
indexing_status=status,
|
|
doc_form="text_model",
|
|
doc_language="English",
|
|
dataset_process_rule_id=process_rule_id,
|
|
)
|
|
|
|
|
|
def _files(*, workspace_id: str, file_ids: Sequence[str], document_model: str) -> dict[str, ExtractSetting]:
|
|
return {
|
|
file_id: ExtractSetting(
|
|
datasource_type=DatasourceType.FILE,
|
|
document_model=document_model,
|
|
upload_file=UploadFileExtractionInput(
|
|
id=file_id, tenant_id=workspace_id, key=file_id, created_by="account-1"
|
|
),
|
|
)
|
|
for file_id in file_ids
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ServiceFixture:
|
|
service: IndexingEstimateApplicationService
|
|
access: Mock
|
|
datasets: Mock
|
|
documents: Mock
|
|
files: Mock
|
|
notion: Mock
|
|
websites: Mock
|
|
stored: Mock
|
|
rules: Mock
|
|
runner: Mock
|
|
|
|
|
|
@pytest.fixture
|
|
def fixture() -> ServiceFixture:
|
|
access = create_autospec(DatasetAccess, instance=True, spec_set=True)
|
|
access.require_accessible.return_value = _access_dataset()
|
|
access.check_access.side_effect = AssertionError("Estimate access must use the selected dataset")
|
|
datasets = create_autospec(EstimateDatasetReader, instance=True, spec_set=True)
|
|
datasets.get_estimate_record.return_value = _estimate_dataset()
|
|
documents = create_autospec(EstimateDocumentReader, instance=True, spec_set=True)
|
|
documents.get_estimate_document.return_value = _document()
|
|
documents.list_estimate_documents_by_batch.return_value = ()
|
|
files = create_autospec(UploadEstimateSourceResolver, instance=True, spec_set=True)
|
|
files.resolve_selections.side_effect = _files
|
|
notion = create_autospec(NotionEstimateSourceResolver, instance=True, spec_set=True)
|
|
notion.resolve_selection.return_value = ExtractSetting(datasource_type=DatasourceType.NOTION)
|
|
websites = create_autospec(WebsiteEstimateSourceResolver, instance=True, spec_set=True)
|
|
websites.resolve_selection.return_value = ExtractSetting(datasource_type=DatasourceType.WEBSITE)
|
|
stored = create_autospec(StoredEstimateSourceResolver, instance=True, spec_set=True)
|
|
stored.resolve.side_effect = lambda source: ExtractSetting(
|
|
datasource_type=source.source_type, document_model=source.document_model
|
|
)
|
|
rules = create_autospec(ProcessRuleReader, instance=True, spec_set=True)
|
|
rules.get_by_id.return_value = {"mode": "custom", "rules": {"segmentation": dict[str, object]()}}
|
|
runner = create_autospec(IndexingEstimateRunner, instance=True, spec_set=True)
|
|
runner.run.side_effect = lambda **kwargs: IndexingEstimate(
|
|
total_segments=len(kwargs["extract_settings"]), preview=[]
|
|
)
|
|
service = IndexingEstimateApplicationService(
|
|
dataset_access=access,
|
|
datasets=datasets,
|
|
documents=documents,
|
|
files=files,
|
|
notion=notion,
|
|
websites=websites,
|
|
stored_sources=stored,
|
|
process_rules=rules,
|
|
runner=runner,
|
|
)
|
|
return ServiceFixture(service, access, datasets, documents, files, notion, websites, stored, rules, runner)
|
|
|
|
|
|
def test_estimate_new_sources_resolves_each_variant_and_normalizes_rule(fixture: ServiceFixture) -> None:
|
|
result = fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(
|
|
sources=(
|
|
UploadFileEstimateSource("file-1"),
|
|
NotionEstimateSource("notion-workspace", "page-1", NotionPageType.PAGE, "credential-1"),
|
|
WebsiteEstimateSource("firecrawl", "job-1", "https://example.com", only_main_content=True),
|
|
),
|
|
process_rule={"mode": "automatic", "summary_index_setting": {"enable": None}},
|
|
dataset_id="dataset-1",
|
|
),
|
|
)
|
|
|
|
assert result.total_segments == 3
|
|
fixture.access.require_accessible.assert_called_once_with(_context(), "dataset-1")
|
|
fixture.files.resolve_selections.assert_called_once_with(
|
|
workspace_id="workspace-1",
|
|
file_ids=("file-1",),
|
|
document_model="text_model",
|
|
)
|
|
fixture.notion.resolve_selection.assert_called_once_with(
|
|
workspace_id="workspace-1",
|
|
actor_id="account-1",
|
|
credential_id="credential-1",
|
|
notion_workspace_id="notion-workspace",
|
|
page_id="page-1",
|
|
page_type=NotionPageType.PAGE,
|
|
document_model="text_model",
|
|
)
|
|
fixture.websites.resolve_selection.assert_called_once_with(
|
|
workspace_id="workspace-1",
|
|
provider="firecrawl",
|
|
job_id="job-1",
|
|
url="https://example.com",
|
|
mode="crawl",
|
|
only_main_content=True,
|
|
document_model="text_model",
|
|
)
|
|
assert fixture.runner.run.call_args.kwargs["tmp_processing_rule"] == {"mode": "automatic", "rules": {}}
|
|
assert fixture.runner.run.call_args.kwargs["dataset_id"] == "dataset-1"
|
|
|
|
|
|
@pytest.mark.parametrize("dataset_id", [None, ""])
|
|
@pytest.mark.parametrize(
|
|
"source",
|
|
[
|
|
NotionEstimateSource("notion-workspace", "page-1", NotionPageType.PAGE, "credential-1"),
|
|
WebsiteEstimateSource("firecrawl", "job-1", "https://example.com"),
|
|
],
|
|
ids=["notion", "website"],
|
|
)
|
|
def test_estimate_new_dataset_accepts_missing_or_empty_dataset_id(
|
|
fixture: ServiceFixture,
|
|
dataset_id: str | None,
|
|
source: NotionEstimateSource | WebsiteEstimateSource,
|
|
) -> None:
|
|
result = fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(
|
|
sources=(source,),
|
|
process_rule={"mode": "automatic"},
|
|
dataset_id=dataset_id,
|
|
),
|
|
)
|
|
|
|
assert result.total_segments == 1
|
|
fixture.access.require_accessible.assert_not_called()
|
|
assert fixture.runner.run.call_args.kwargs["dataset_id"] is None
|
|
|
|
|
|
def test_estimate_new_sources_stops_when_dataset_access_is_denied(fixture: ServiceFixture) -> None:
|
|
fixture.access.require_accessible.side_effect = DatasetAccessDeniedError()
|
|
|
|
with pytest.raises(DatasetAccessDeniedError):
|
|
fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(
|
|
sources=(UploadFileEstimateSource("file-1"),),
|
|
process_rule={"mode": "automatic"},
|
|
dataset_id="dataset-1",
|
|
),
|
|
)
|
|
|
|
fixture.files.resolve_selections.assert_not_called()
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
def test_estimate_new_notion_source_maps_actor_credential_failure_without_running(fixture: ServiceFixture) -> None:
|
|
fixture.notion.resolve_selection.side_effect = DatasourceCredentialNotFoundError()
|
|
|
|
with pytest.raises(IndexingEstimateCredentialUnavailableError):
|
|
fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(
|
|
sources=(
|
|
NotionEstimateSource(
|
|
"notion-workspace",
|
|
"page-1",
|
|
NotionPageType.PAGE,
|
|
"private-credential",
|
|
),
|
|
),
|
|
process_rule={"mode": "automatic"},
|
|
),
|
|
)
|
|
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
def test_estimate_empty_new_sources_returns_zero_without_runner_call(fixture: ServiceFixture) -> None:
|
|
result = fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(sources=(), process_rule={"mode": "automatic"}),
|
|
)
|
|
|
|
assert result == IndexingEstimate(total_segments=0, preview=[])
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
def test_estimate_batch_preserves_first_document_contract_and_loads_its_rule(fixture: ServiceFixture) -> None:
|
|
documents = (_document("document-1"), _document("document-2"))
|
|
fixture.documents.list_estimate_documents_by_batch.return_value = documents
|
|
|
|
fixture.service.estimate_batch(_context(), dataset_id="dataset-1", batch="batch-1")
|
|
|
|
fixture.stored.resolve.assert_called_once_with(StoredSource.from_document(documents[0]))
|
|
fixture.rules.get_by_id.assert_called_once_with(
|
|
dataset_ref=DatasetRef("workspace-1", "dataset-1"),
|
|
process_rule_id="rule-1",
|
|
)
|
|
fixture.documents.list_estimate_documents_by_batch.assert_called_once_with(
|
|
DatasetRef("workspace-1", "dataset-1"),
|
|
"batch-1",
|
|
)
|
|
assert fixture.runner.run.call_args.kwargs["indexing_technique"] == "high_quality"
|
|
|
|
|
|
def test_estimate_uses_automatic_rule_when_document_has_no_saved_rule(fixture: ServiceFixture) -> None:
|
|
fixture.documents.get_estimate_document.return_value = _document(process_rule_id=None)
|
|
|
|
fixture.service.estimate_document(_context(), dataset_id="dataset-1", document_id="document-1")
|
|
|
|
fixture.datasets.get_estimate_record.assert_called_once_with(DatasetRef("workspace-1", "dataset-1"))
|
|
fixture.documents.get_estimate_document.assert_called_once_with(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1")
|
|
)
|
|
fixture.rules.get_by_id.assert_not_called()
|
|
assert fixture.runner.run.call_args.kwargs["tmp_processing_rule"] == {"mode": "automatic", "rules": {}}
|
|
|
|
|
|
def test_estimate_rejects_missing_saved_process_rule(fixture: ServiceFixture) -> None:
|
|
fixture.rules.get_by_id.return_value = None
|
|
|
|
with pytest.raises(EstimateSourceNotFoundError, match="rule-1"):
|
|
fixture.service.estimate_document(_context(), dataset_id="dataset-1", document_id="document-1")
|
|
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
def test_estimate_empty_batch_raises_not_found_without_runner_call(fixture: ServiceFixture) -> None:
|
|
with pytest.raises(EstimateDocumentNotFoundError):
|
|
fixture.service.estimate_batch(_context(), dataset_id="dataset-1", batch="batch-1")
|
|
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["completed", "error"])
|
|
def test_estimate_rejects_finished_document(fixture: ServiceFixture, status: str) -> None:
|
|
fixture.documents.get_estimate_document.return_value = _document(status=status)
|
|
|
|
with pytest.raises(EstimateDocumentAlreadyFinishedError):
|
|
fixture.service.estimate_document(_context(), dataset_id="dataset-1", document_id="document-1")
|
|
|
|
fixture.runner.run.assert_not_called()
|
|
|
|
|
|
def test_estimate_rejects_missing_document(fixture: ServiceFixture) -> None:
|
|
fixture.documents.get_estimate_document.return_value = None
|
|
|
|
with pytest.raises(EstimateDocumentNotFoundError):
|
|
fixture.service.estimate_document(_context(), dataset_id="dataset-1", document_id="document-1")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source_error", "expected_error"),
|
|
[
|
|
(CredentialRefreshUnavailableError(), CredentialRefreshUnavailableError),
|
|
(DatasourceCredentialNotFoundError(), IndexingEstimateCredentialUnavailableError),
|
|
(IndexingInputSourceError("missing source"), EstimateSourceNotFoundError),
|
|
(UnsupportedStoredSourceError("unknown"), UnsupportedEstimateSourceError),
|
|
(RuntimeError("failed"), IndexingEstimateExecutionError),
|
|
],
|
|
)
|
|
def test_estimate_translates_source_boundary_errors(
|
|
fixture: ServiceFixture, source_error: Exception, expected_error: type[Exception]
|
|
) -> None:
|
|
fixture.stored.resolve.side_effect = source_error
|
|
|
|
with pytest.raises(expected_error):
|
|
fixture.service.estimate_document(_context(), dataset_id="dataset-1", document_id="document-1")
|
|
|
|
|
|
def test_multi_file_estimate_reads_once_and_preserves_selection_order(fixture: ServiceFixture) -> None:
|
|
fixture.service.estimate_new_sources(
|
|
_context(),
|
|
NewSourcesEstimateCommand(
|
|
sources=(
|
|
UploadFileEstimateSource("file-2"),
|
|
UploadFileEstimateSource("file-1"),
|
|
UploadFileEstimateSource("file-2"),
|
|
),
|
|
process_rule={"mode": "automatic"},
|
|
),
|
|
)
|
|
fixture.files.resolve_selections.assert_called_once_with(
|
|
workspace_id="workspace-1",
|
|
file_ids=("file-2", "file-1"),
|
|
document_model="text_model",
|
|
)
|
|
settings = fixture.runner.run.call_args.kwargs["extract_settings"]
|
|
assert [setting.upload_file.id for setting in settings if setting.upload_file is not None] == [
|
|
"file-2",
|
|
"file-1",
|
|
"file-2",
|
|
]
|