mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
1730 lines
69 KiB
Python
1730 lines
69 KiB
Python
from collections.abc import Iterator, Mapping, Sequence
|
|
from contextlib import nullcontext
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Protocol
|
|
from unittest.mock import create_autospec, patch
|
|
|
|
import pytest
|
|
from sqlalchemy import event, select
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.engine import Connection
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.credit_usage import CreditUsageCreatedBy
|
|
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
|
from core.model_context import get_credit_usage_metadata
|
|
from core.model_manager import ModelManager
|
|
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
|
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
|
|
from core.rag.index_processor.processor.paragraph_index_processor import ParagraphIndexProcessor
|
|
from core.rag.models.document import ChildDocument
|
|
from core.rag.models.document import Document as IndexDocument
|
|
from extensions.storage.storage_type import StorageType
|
|
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
|
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
|
from libs.helper import generate_text_hash
|
|
from libs.pagination import PaginatedResult
|
|
from machinery.context import RequestContext
|
|
from models.dataset import ChildChunk, Dataset, Document, DocumentSegment, SegmentAttachmentBinding
|
|
from models.enums import CreatorUserRole, DocumentCreatedFrom, SegmentStatus, SummaryStatus
|
|
from models.model import UploadFile
|
|
from repositories.knowledge.dataset_repository import SQLAlchemyDatasetRepository
|
|
from repositories.knowledge.segment_repository import SQLAlchemySegmentRepository
|
|
from repositories.knowledge.upload_file_repository import SQLAlchemyKnowledgeUploadRepository
|
|
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
|
|
from services.knowledge.dataset_access import DatasetAccessService
|
|
from services.knowledge.dataset_service import SegmentService
|
|
from services.knowledge.entities.segments import ChildChunkUpdateArgs, SegmentUpdateArgs
|
|
from services.knowledge.resource_scope import DatasetRef
|
|
from services.knowledge.segments.adapters import (
|
|
CelerySegmentBatchImportDispatcher,
|
|
ModelManagerSegmentGuard,
|
|
RedisSegmentIndexingState,
|
|
)
|
|
from services.knowledge.segments.application import (
|
|
DatasetSegmentApplicationService,
|
|
SegmentBatchImportDispatcher,
|
|
SegmentDatasetRecord,
|
|
SegmentDetail,
|
|
SegmentIndex,
|
|
SegmentIndexingState,
|
|
SegmentListFilter,
|
|
SegmentModelGuard,
|
|
SegmentModelProviderError,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SegmentLimitsStub:
|
|
SINGLE_CHUNK_ATTACHMENT_LIMIT: int = 10
|
|
|
|
|
|
def test_celery_dispatcher_preserves_task_argument_order() -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
|
|
def delay(*args: str) -> None:
|
|
calls.append(args)
|
|
|
|
dispatcher = CelerySegmentBatchImportDispatcher(delay=delay)
|
|
dispatcher.dispatch(
|
|
job_id="job-1",
|
|
upload_file_id="file-1",
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
workspace_id="workspace-1",
|
|
actor_id="account-1",
|
|
)
|
|
|
|
assert calls == [("job-1", "file-1", "dataset-1", "document-1", "workspace-1", "account-1")]
|
|
|
|
|
|
def _dataset(dataset_id: str, workspace_id: str, *, permission: str = "partial_members") -> Dataset:
|
|
return Dataset(
|
|
id=dataset_id,
|
|
tenant_id=workspace_id,
|
|
name="Dataset",
|
|
description="",
|
|
provider="vendor",
|
|
permission=permission,
|
|
indexing_technique="economy",
|
|
created_by="account-1",
|
|
maintainer="maintainer-1",
|
|
)
|
|
|
|
|
|
def _document(document_id: str, workspace_id: str, dataset_id: str) -> Document:
|
|
return Document(
|
|
id=document_id,
|
|
tenant_id=workspace_id,
|
|
dataset_id=dataset_id,
|
|
position=1,
|
|
data_source_type="upload_file",
|
|
batch="batch-1",
|
|
name="Document",
|
|
created_from=DocumentCreatedFrom.API,
|
|
created_by="account-1",
|
|
doc_form="text_model",
|
|
word_count=0,
|
|
)
|
|
|
|
|
|
def _segment(segment_id: str, workspace_id: str, dataset_id: str, document_id: str) -> DocumentSegment:
|
|
segment = DocumentSegment(
|
|
tenant_id=workspace_id,
|
|
dataset_id=dataset_id,
|
|
document_id=document_id,
|
|
position=1,
|
|
content="content",
|
|
word_count=7,
|
|
tokens=2,
|
|
created_by="account-1",
|
|
status=SegmentStatus.COMPLETED,
|
|
)
|
|
segment.id = segment_id
|
|
return segment
|
|
|
|
|
|
def _upload(file_id: str, workspace_id: str, name: str) -> UploadFile:
|
|
upload = UploadFile(
|
|
tenant_id=workspace_id,
|
|
storage_type=StorageType.OPENDAL,
|
|
key=f"key-{file_id}",
|
|
name=name,
|
|
size=1,
|
|
extension="csv",
|
|
mime_type="text/csv",
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by="account-1",
|
|
created_at=datetime(2026, 1, 1),
|
|
used=False,
|
|
)
|
|
upload.id = file_id
|
|
return upload
|
|
|
|
|
|
def test_scope_lookup_enforces_owner_chain(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
|
|
store = SQLAlchemyDatasetRepository(session_factory=sqlite_session_factory)
|
|
scope = store.get_segment_scope(
|
|
dataset_ref=DatasetRef("workspace-1", "dataset-1"),
|
|
document_id="document-1",
|
|
actor_id="account-1",
|
|
)
|
|
|
|
assert scope.dataset is not None
|
|
assert scope.document is not None
|
|
assert (
|
|
store.get_segment_scope(
|
|
dataset_ref=DatasetRef("workspace-2", "dataset-1"),
|
|
document_id="document-1",
|
|
actor_id="account-1",
|
|
).dataset
|
|
is None
|
|
)
|
|
|
|
|
|
def test_upload_lookup_is_tenant_scoped(sqlite_session_factory: sessionmaker[Session]) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
_upload("file-1", "workspace-1", "one.csv"),
|
|
_upload("file-2", "workspace-2", "two.csv"),
|
|
]
|
|
)
|
|
|
|
store = SQLAlchemyKnowledgeUploadRepository(session_factory=sqlite_session_factory)
|
|
|
|
assert store.get_file_name(workspace_id="workspace-1", upload_file_id="file-1") == "one.csv"
|
|
assert store.get_file_name(workspace_id="workspace-1", upload_file_id="file-2") is None
|
|
|
|
|
|
def test_segment_lookup_rejects_segment_from_another_owner_chain(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
_dataset("dataset-1", "workspace-1"),
|
|
_dataset("dataset-2", "workspace-2"),
|
|
_document("document-1", "workspace-1", "dataset-1"),
|
|
_document("document-2", "workspace-2", "dataset-2"),
|
|
_segment("segment-1", "workspace-2", "dataset-2", "document-2"),
|
|
]
|
|
)
|
|
|
|
store = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
assert store.get_segment(DatasetRef("workspace-1", "dataset-1").document("document-1").segment("segment-2")) is None
|
|
|
|
|
|
@pytest.mark.parametrize("owner_field", ["tenant_id", "dataset_id", "document_id"])
|
|
def test_segment_update_state_enforces_complete_owner_chain(
|
|
sqlite_session_factory: sessionmaker[Session], owner_field: str
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
segment = _segment("segment-1", "workspace-1", "dataset-1", "document-1")
|
|
setattr(segment, owner_field, "foreign-owner")
|
|
session.add(segment)
|
|
store = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
ref = DatasetRef("workspace-1", "dataset-1").document("document-1").segment("segment-1")
|
|
|
|
assert store.get_segment_update_state(ref) is None
|
|
|
|
|
|
def test_empty_segment_list_returns_materialized_page(sqlite_session_factory: sessionmaker[Session]) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
|
|
store = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
with patch("repositories.knowledge.segment_repository.dify_config") as config:
|
|
config.SQLALCHEMY_DATABASE_URI_SCHEME = "mysql"
|
|
result = store.list_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"),
|
|
SegmentListFilter(page=1, limit=20, keyword="missing"),
|
|
)
|
|
|
|
assert result.items == ()
|
|
assert result.total == 0
|
|
assert result.limit == 20
|
|
|
|
|
|
def test_postgresql_keyword_filter_keeps_json_array_guard(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
|
|
page = PaginatedResult[DocumentSegment](items=[], total=0, page=1, per_page=20)
|
|
store = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
with (
|
|
patch("repositories.knowledge.segment_repository.dify_config") as config,
|
|
patch("repositories.knowledge.segment_repository.paginate_query", return_value=page) as paginate,
|
|
):
|
|
config.SQLALCHEMY_DATABASE_URI_SCHEME = "postgresql"
|
|
store.list_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"),
|
|
SegmentListFilter(keyword="知识"),
|
|
)
|
|
|
|
statement = paginate.call_args.args[0]
|
|
sql = str(statement.compile(dialect=postgresql.dialect()))
|
|
assert "jsonb_typeof" in sql
|
|
assert "jsonb_array_elements_text" in sql
|
|
assert "CASE WHEN" in sql
|
|
|
|
|
|
def _application(
|
|
factory: sessionmaker[Session], *, indexing: SegmentIndex | None = None
|
|
) -> DatasetSegmentApplicationService:
|
|
datasets = SQLAlchemyDatasetRepository(session_factory=factory)
|
|
index = create_autospec(SegmentIndex, instance=True, spec_set=True)
|
|
index.count_tokens.return_value = 0
|
|
state = create_autospec(SegmentIndexingState, instance=True, spec_set=True)
|
|
state.is_segment_indexing.return_value = False
|
|
state.is_document_indexing.return_value = False
|
|
return DatasetSegmentApplicationService(
|
|
dataset_access=DatasetAccessService(
|
|
datasets=datasets,
|
|
workspace_roles=WorkspaceMemberQueryRepository(session_factory=factory),
|
|
legacy_permissions_enabled=False,
|
|
),
|
|
scopes=datasets,
|
|
store=SQLAlchemySegmentRepository(session_factory=factory),
|
|
limits=SegmentLimitsStub(),
|
|
text_hash=generate_text_hash,
|
|
index=indexing or index,
|
|
uploads=SQLAlchemyKnowledgeUploadRepository(session_factory=factory),
|
|
model_guard=create_autospec(SegmentModelGuard, instance=True, spec_set=True),
|
|
indexing_state=state,
|
|
batch_dispatcher=create_autospec(SegmentBatchImportDispatcher, instance=True, spec_set=True),
|
|
job_id_factory=lambda: "job-1",
|
|
)
|
|
|
|
|
|
def test_mutations_use_request_actor_without_a_flask_request(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
app = _application(sqlite_session_factory)
|
|
context = RequestContext("request-1", None, "author-1", "workspace-1")
|
|
created = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "original"}
|
|
)
|
|
assert created.data.created_by == "author-1"
|
|
updated = app.update_segment(
|
|
RequestContext("request-2", None, "editor-2", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "updated content"},
|
|
)
|
|
assert updated.data.updated_by == "editor-2"
|
|
assert updated.data.content == "updated content"
|
|
assert isinstance(updated.data.created_at, datetime)
|
|
with sqlite_session_factory() as session:
|
|
segment = session.get(DocumentSegment, created.data.id)
|
|
assert segment is not None
|
|
assert segment.created_by == "author-1"
|
|
assert segment.updated_by == "editor-2"
|
|
assert segment.status == SegmentStatus.COMPLETED
|
|
|
|
|
|
def test_list_reads_dataset_and_document_once(sqlite_session_factory: sessionmaker[Session]) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
statements: list[str] = []
|
|
engine = sqlite_session_factory.kw["bind"]
|
|
|
|
def record(_conn, _cursor, statement: str, _parameters, _context, _executemany) -> None:
|
|
statements.append(statement)
|
|
|
|
event.listen(engine, "before_cursor_execute", record)
|
|
try:
|
|
page = _application(sqlite_session_factory).list_segments(
|
|
RequestContext("request-1", None, "account-1", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
query=SegmentListFilter(),
|
|
)
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", record)
|
|
assert page.items == ()
|
|
assert sum("FROM datasets" in statement for statement in statements) == 1
|
|
assert sum("JOIN documents" in statement or "FROM documents" in statement for statement in statements) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "kind"),
|
|
[
|
|
(LLMBadRequestError(), "bad_request"),
|
|
(ProviderTokenNotInitError("token missing"), "token"),
|
|
],
|
|
)
|
|
def test_model_guard_translates_provider_errors(error: Exception, kind: str) -> None:
|
|
from core.model_manager import ModelManager
|
|
|
|
model = create_autospec(ModelManager, instance=True, spec_set=True)
|
|
model.get_model_instance.side_effect = error
|
|
dataset = SegmentDatasetRecord(
|
|
id="dataset-1",
|
|
workspace_id="workspace-1",
|
|
indexing_technique="high_quality",
|
|
embedding_model_provider="provider",
|
|
embedding_model="model",
|
|
)
|
|
|
|
with patch("services.knowledge.segments.adapters.ModelManager.for_tenant", return_value=model):
|
|
with pytest.raises(SegmentModelProviderError) as raised:
|
|
ModelManagerSegmentGuard().check(dataset)
|
|
|
|
assert raised.value.kind == kind
|
|
|
|
|
|
def test_redis_state_uses_existing_keys_and_decodes_status() -> None:
|
|
from services.knowledge.segments.adapters import RedisSegmentClient
|
|
|
|
redis = create_autospec(RedisSegmentClient, instance=True, spec_set=True)
|
|
redis.get.side_effect = [b"running", b"completed"]
|
|
state = RedisSegmentIndexingState(redis)
|
|
|
|
assert state.is_document_indexing("document-1") is True
|
|
state.set_batch_waiting("job-1")
|
|
assert state.get_batch_status("job-1") == "completed"
|
|
redis.get.assert_any_call("document_document-1_indexing")
|
|
redis.setnx.assert_called_once_with("segment_batch_import_job-1", "waiting")
|
|
|
|
|
|
def test_child_chunk_mutations_preserve_explicit_actors(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
document = _document("document-1", "workspace-1", "dataset-1")
|
|
document.doc_form = IndexStructureType.PARENT_CHILD_INDEX
|
|
session.add(document)
|
|
session.add(_segment("segment-1", "workspace-1", "dataset-1", "document-1"))
|
|
app = _application(sqlite_session_factory)
|
|
created = app.create_child_chunk(
|
|
RequestContext("create", None, "creator", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
content="first",
|
|
)
|
|
app.update_child_chunk(
|
|
RequestContext("update", None, "editor", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
child_chunk_id=created.id,
|
|
content="second",
|
|
)
|
|
with sqlite_session_factory() as session:
|
|
chunk = session.get(ChildChunk, created.id)
|
|
assert chunk is not None
|
|
assert chunk.created_by == "creator"
|
|
assert chunk.updated_by == "editor"
|
|
results = app.update_child_chunks(
|
|
RequestContext("batch", None, "batch-editor", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
chunks=[ChildChunkUpdateArgs(id=created.id, content="third"), ChildChunkUpdateArgs(content="new child")],
|
|
)
|
|
with sqlite_session_factory() as session:
|
|
chunks = [session.get(ChildChunk, result.id) for result in results]
|
|
assert len(chunks) == 2
|
|
existing, added = chunks
|
|
assert existing is not None
|
|
assert added is not None
|
|
assert existing.updated_by == "batch-editor"
|
|
assert added.created_by == "batch-editor"
|
|
|
|
|
|
@dataclass
|
|
class IndexRedisStub:
|
|
def get(self, name: str | bytes) -> None:
|
|
del name
|
|
|
|
def setnx(self, name: str | bytes, value: object) -> bool:
|
|
del name, value
|
|
return True
|
|
|
|
def setex(self, name: str, time: int, value: object) -> bool:
|
|
del name, time, value
|
|
return True
|
|
|
|
def lock(self, name: str, timeout: int) -> nullcontext[None]:
|
|
del name, timeout
|
|
return nullcontext()
|
|
|
|
|
|
@dataclass
|
|
class VectorProbe:
|
|
active_transactions: set[Connection]
|
|
fail: bool = False
|
|
writes: int = 0
|
|
deleted_ids: list[str] = field(default_factory=list)
|
|
tasks: list[tuple[str, tuple[object, ...]]] = field(default_factory=list)
|
|
batches: list[tuple[str, ...]] = field(default_factory=list)
|
|
|
|
def add_texts(self, documents: Sequence[IndexDocument], **_kwargs: object) -> None:
|
|
assert not self.active_transactions, "Index I/O must not hold a DB transaction"
|
|
self.writes += len(documents)
|
|
if self.fail:
|
|
raise RuntimeError("index unavailable")
|
|
|
|
def create(self, documents: Sequence[IndexDocument], **kwargs: object) -> None:
|
|
self.batches.append(tuple(document.page_content for document in documents))
|
|
self.add_texts(documents, **kwargs)
|
|
|
|
def delete_by_ids(self, ids: Sequence[str]) -> None:
|
|
assert ids
|
|
assert not self.active_transactions
|
|
if self.fail:
|
|
raise RuntimeError("index unavailable")
|
|
self.deleted_ids.extend(ids)
|
|
|
|
def create_multimodal(self, documents: Sequence[IndexDocument], **kwargs: object) -> None:
|
|
assert not self.active_transactions
|
|
assert kwargs["upload_files"]
|
|
self.add_texts(documents)
|
|
|
|
|
|
type IndexingProbe = tuple[DatasetSegmentApplicationService, SQLAlchemySegmentRepository, VectorProbe]
|
|
|
|
|
|
@pytest.fixture
|
|
def indexing_probe(
|
|
sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch
|
|
) -> Iterator[IndexingProbe]:
|
|
from services.knowledge.segments import indexing as indexing_module
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = _dataset("dataset-1", "workspace-1")
|
|
dataset.indexing_technique = IndexTechniqueType.HIGH_QUALITY
|
|
dataset.index_struct = '{"type": "qdrant"}'
|
|
session.add(dataset)
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
engine = sqlite_session_factory.kw["bind"]
|
|
active: set[Connection] = set()
|
|
begin = active.add
|
|
finish = active.discard
|
|
event.listen(engine, "begin", begin)
|
|
event.listen(engine, "commit", finish)
|
|
event.listen(engine, "rollback", finish)
|
|
probe = VectorProbe(active)
|
|
|
|
def vector(dataset: Dataset, *, session: Session | None, vector_type: str) -> VectorProbe:
|
|
assert dataset.tenant_id == "workspace-1"
|
|
assert session is None
|
|
assert vector_type == "qdrant"
|
|
assert not active
|
|
return probe
|
|
|
|
class TokenModel:
|
|
def get_text_embedding_num_tokens(self, texts: Sequence[str]) -> list[int]:
|
|
assert not active
|
|
return [len(text) for text in texts]
|
|
|
|
class ModelLookup:
|
|
def get_model_instance(self, **kwargs: object) -> TokenModel:
|
|
assert kwargs["tenant_id"] == "workspace-1"
|
|
assert not active
|
|
return TokenModel()
|
|
|
|
monkeypatch.setattr(indexing_module, "Vector", vector)
|
|
monkeypatch.setattr(indexing_module.ModelManager, "for_tenant", lambda **_kwargs: ModelLookup())
|
|
repository = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
|
|
def dispatch(kind: str, *args: object) -> None:
|
|
assert not active
|
|
probe.tasks.append((kind, args))
|
|
|
|
gateway = indexing_module.SegmentIndexingGateway(
|
|
segments=repository,
|
|
uploads=SQLAlchemyKnowledgeUploadRepository(session_factory=sqlite_session_factory),
|
|
redis=IndexRedisStub(),
|
|
new_session=sqlite_session_factory,
|
|
delete_task=lambda *args: dispatch("delete", *args),
|
|
enable_task=lambda *args: dispatch("enable", *args),
|
|
disable_task=lambda *args: dispatch("disable", *args),
|
|
)
|
|
yield _application(sqlite_session_factory, indexing=gateway), repository, probe
|
|
event.remove(engine, "begin", begin)
|
|
event.remove(engine, "commit", finish)
|
|
event.remove(engine, "rollback", finish)
|
|
|
|
|
|
class SegmentUpdateEntry(Protocol):
|
|
def __call__(
|
|
self,
|
|
context: RequestContext,
|
|
*,
|
|
dataset_id: str,
|
|
document_id: str,
|
|
segment_id: str,
|
|
values: Mapping[str, object],
|
|
) -> SegmentDetail: ...
|
|
|
|
|
|
@pytest.fixture(params=["console", "service_api"])
|
|
def segment_update(
|
|
request: pytest.FixtureRequest, indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session]
|
|
) -> SegmentUpdateEntry:
|
|
app, repository, _ = indexing_probe
|
|
if request.param == "console":
|
|
return app.update_segment
|
|
|
|
def update(
|
|
context: RequestContext,
|
|
*,
|
|
dataset_id: str,
|
|
document_id: str,
|
|
segment_id: str,
|
|
values: Mapping[str, object],
|
|
) -> SegmentDetail:
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, dataset_id)
|
|
document = session.get(Document, document_id)
|
|
segment = session.get(DocumentSegment, segment_id)
|
|
assert dataset is not None
|
|
assert document is not None
|
|
assert segment is not None
|
|
result = SegmentService.update_segment(
|
|
SegmentUpdateArgs.model_validate(values),
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id=context.account_id,
|
|
mutations=app.mutations,
|
|
)
|
|
assert result.content == (values.get("content") or segment.content)
|
|
ref = DatasetRef(context.active_workspace_id, dataset_id).document(document_id).segment(segment_id)
|
|
detail = repository.get_segment(ref)
|
|
assert detail is not None
|
|
return detail
|
|
|
|
return update
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["console", "service_api"])
|
|
@pytest.mark.parametrize("enabled", [True, False])
|
|
def test_delete_segment_with_legacy_scalar_keywords(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
entry: str,
|
|
enabled: bool,
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
with sqlite_session_factory.begin() as session:
|
|
segment = _segment("segment-1", "workspace-1", "dataset-1", "document-1")
|
|
segment.keywords = "legacy-scalar"
|
|
segment.enabled = enabled
|
|
segment.index_node_id = "index-1"
|
|
session.add(segment)
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
document.word_count = segment.word_count
|
|
|
|
if entry == "console":
|
|
app.delete_segment(
|
|
RequestContext("request", None, "author", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
)
|
|
else:
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
document = session.get(Document, "document-1")
|
|
segment = session.get(DocumentSegment, "segment-1")
|
|
assert dataset is not None
|
|
assert document is not None
|
|
assert segment is not None
|
|
SegmentService.delete_segment(segment, document, dataset, session, mutations=app.mutations)
|
|
|
|
with sqlite_session_factory() as session:
|
|
assert session.get(DocumentSegment, "segment-1") is None
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
assert document.word_count == 0
|
|
if enabled:
|
|
assert probe.tasks == [("delete", (["index-1"], "dataset-1", "document-1", ["segment-1"], []))]
|
|
else:
|
|
assert probe.tasks == []
|
|
|
|
|
|
@pytest.mark.parametrize("keywords", ["legacy-scalar", 42, {"invalid": "object"}, [{"invalid": "item"}]])
|
|
def test_update_repairs_legacy_keywords_without_validating_presentation_first(
|
|
segment_update: SegmentUpdateEntry,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
keywords: object,
|
|
) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
segment = _segment("segment-1", "workspace-1", "dataset-1", "document-1")
|
|
segment.keywords = keywords
|
|
segment.index_node_id = "index-1"
|
|
segment.index_node_hash = generate_text_hash(segment.content)
|
|
session.add(segment)
|
|
|
|
result = segment_update(
|
|
RequestContext("request", None, "editor", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
values={"content": "content", "keywords": ["replacement"]},
|
|
)
|
|
|
|
assert result.data.status == "completed"
|
|
assert result.data.keywords == ("replacement",)
|
|
with sqlite_session_factory() as session:
|
|
segment = session.get(DocumentSegment, "segment-1")
|
|
assert segment is not None
|
|
assert segment.keywords == ["replacement"]
|
|
|
|
|
|
@pytest.mark.parametrize("content", ["original", "changed text"])
|
|
@pytest.mark.parametrize(
|
|
("attachment_values", "expected_ids", "removed_original"),
|
|
[
|
|
({}, ["image-1"], False),
|
|
({"attachment_ids": None}, ["image-1"], False),
|
|
({"attachment_ids": []}, [], True),
|
|
({"attachment_ids": ["image-2"]}, ["image-2"], True),
|
|
],
|
|
ids=["omitted", "null", "clear", "replace"],
|
|
)
|
|
def test_segment_update_preserves_or_replaces_attachments(
|
|
segment_update: SegmentUpdateEntry,
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
content: str,
|
|
attachment_values: dict[str, object],
|
|
expected_ids: list[str],
|
|
removed_original: bool,
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[_upload("image-1", "workspace-1", "chart.png"), _upload("image-2", "workspace-1", "replacement.png")]
|
|
)
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.is_multimodal = True
|
|
created = app.create_segment(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
values={"content": "original", "attachment_ids": ["image-1"]},
|
|
)
|
|
assert [attachment.id for attachment in created.data.attachments] == ["image-1"]
|
|
|
|
updated = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": content, **attachment_values},
|
|
)
|
|
|
|
assert updated.data.status == "completed"
|
|
assert [attachment.id for attachment in updated.data.attachments] == expected_ids
|
|
with sqlite_session_factory() as session:
|
|
attachment_ids = list(
|
|
session.scalars(
|
|
select(SegmentAttachmentBinding.attachment_id).where(
|
|
SegmentAttachmentBinding.segment_id == created.data.id
|
|
)
|
|
)
|
|
)
|
|
assert attachment_ids == expected_ids
|
|
assert ("image-1" in probe.deleted_ids) is removed_original
|
|
|
|
|
|
def test_segment_indexing_and_summary_use_closed_read_sessions(
|
|
segment_update: SegmentUpdateEntry, indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
from models.dataset import DocumentSegmentSummary
|
|
|
|
app, repository, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "first"})
|
|
updated = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "second", "summary": "manual summary"},
|
|
)
|
|
assert updated.data.status == "completed"
|
|
assert updated.data.summary == "manual summary"
|
|
with sqlite_session_factory() as session:
|
|
summary = session.scalar(select(DocumentSegmentSummary))
|
|
assert summary is not None
|
|
assert summary.status == "completed"
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
assert document.word_count == len("second")
|
|
assert probe.writes >= 3
|
|
probe.fail = True
|
|
failed = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "failed change"},
|
|
)
|
|
assert failed.data.status == "error"
|
|
assert failed.data.enabled is False
|
|
assert failed.data.error == "index unavailable"
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["create", "update", "replace", "delete"])
|
|
def test_child_index_failure_leaves_database_unchanged(indexing_probe: IndexingProbe, operation: str) -> None:
|
|
from services.knowledge.segments.application import (
|
|
ChildChunkDeleteIndexApplicationError,
|
|
ChildChunkIndexingApplicationError,
|
|
)
|
|
|
|
app, repository, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
parent = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "parent"})
|
|
child = app.create_child_chunk(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
content="original",
|
|
)
|
|
ref = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(parent.data.id)
|
|
before = repository.get_children(ref)
|
|
probe.fail = True
|
|
|
|
def mutate() -> None:
|
|
match operation:
|
|
case "create":
|
|
app.create_child_chunk(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
content="new",
|
|
)
|
|
case "update":
|
|
app.update_child_chunk(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
child_chunk_id=child.id,
|
|
content="changed",
|
|
)
|
|
case "replace":
|
|
app.update_child_chunks(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
chunks=[ChildChunkUpdateArgs(content="replacement")],
|
|
)
|
|
case "delete":
|
|
app.delete_child_chunk(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
child_chunk_id=child.id,
|
|
)
|
|
|
|
with pytest.raises((ChildChunkIndexingApplicationError, ChildChunkDeleteIndexApplicationError)):
|
|
mutate()
|
|
assert repository.get_children(ref) == before
|
|
|
|
|
|
def test_upload_batch_uses_one_scoped_query(sqlite_session_factory: sessionmaker[Session]) -> None:
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(
|
|
[
|
|
_upload("file-1", "workspace-1", "one.csv"),
|
|
_upload("file-2", "workspace-1", "two.csv"),
|
|
_upload("foreign", "workspace-2", "foreign.csv"),
|
|
]
|
|
)
|
|
engine = sqlite_session_factory.kw["bind"]
|
|
statements = []
|
|
|
|
def record_sql(_conn, _cursor, statement: str, _parameters, _context, _executemany) -> None:
|
|
statements.append(statement)
|
|
|
|
event.listen(engine, "before_cursor_execute", record_sql)
|
|
try:
|
|
uploads = SQLAlchemyKnowledgeUploadRepository(session_factory=sqlite_session_factory).get_by_ids(
|
|
workspace_id="workspace-1", file_ids=("file-2", "file-1", "foreign", "missing")
|
|
)
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", record_sql)
|
|
assert set(uploads) == {"file-1", "file-2"}
|
|
assert len(statements) == 1
|
|
|
|
|
|
def test_child_query_escapes_wildcards_and_enforces_complete_scope(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
from services.knowledge.segments.application import ChildChunkListFilter
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_dataset("dataset-1", "workspace-1"))
|
|
session.add(_document("document-1", "workspace-1", "dataset-1"))
|
|
session.add(_segment("segment-1", "workspace-1", "dataset-1", "document-1"))
|
|
for position, (workspace, text) in enumerate(
|
|
[("workspace-1", "50%_literal"), ("workspace-1", "5000literal"), ("workspace-2", "50%_literal")], start=1
|
|
):
|
|
session.add(
|
|
ChildChunk(
|
|
tenant_id=workspace,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="segment-1",
|
|
position=position,
|
|
content=text,
|
|
word_count=len(text),
|
|
created_by="author",
|
|
)
|
|
)
|
|
repo = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
page = repo.list_child_chunks(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1").segment("segment-1"),
|
|
ChildChunkListFilter(keyword="%_", limit=1),
|
|
)
|
|
assert page is not None
|
|
assert page.total == 1
|
|
assert [child.content for child in page.items] == ["50%_literal"]
|
|
|
|
|
|
def test_batch_status_and_delete_only_modify_scoped_segments(
|
|
indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
app, repository, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
first = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "first"})
|
|
second = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "second"})
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add(_segment("foreign", "workspace-2", "dataset-1", "document-1"))
|
|
segment_ids = [first.data.id, second.data.id, "foreign"]
|
|
app.change_segment_status(
|
|
context, dataset_id="dataset-1", document_id="document-1", segment_ids=segment_ids, action="disable"
|
|
)
|
|
kind, task_args = probe.tasks[-1]
|
|
assert kind == "disable"
|
|
assert isinstance(task_args[0], list)
|
|
assert set(task_args[0]) == {first.data.id, second.data.id}
|
|
assert task_args[1:] == ("dataset-1", "document-1")
|
|
with sqlite_session_factory() as session:
|
|
first_row = session.get(DocumentSegment, first.data.id)
|
|
assert first_row is not None
|
|
assert first_row.enabled is False
|
|
foreign_row = session.get(DocumentSegment, "foreign")
|
|
assert foreign_row is not None
|
|
assert foreign_row.enabled is True
|
|
app.delete_segments(context, dataset_id="dataset-1", document_id="document-1", segment_ids=segment_ids)
|
|
with sqlite_session_factory() as session:
|
|
assert session.get(DocumentSegment, first.data.id) is None
|
|
assert session.get(DocumentSegment, second.data.id) is None
|
|
assert session.get(DocumentSegment, "foreign") is not None
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
assert document.word_count == 0
|
|
|
|
|
|
def test_child_regeneration_reuses_splitter_outside_transaction(
|
|
segment_update: SegmentUpdateEntry,
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import json
|
|
|
|
from core.rag.index_processor.processor.parent_child_index_processor import ParentChildIndexProcessor
|
|
from models.dataset import DatasetProcessRule
|
|
from models.enums import ProcessRuleMode
|
|
|
|
app, repository, probe = indexing_probe
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.embedding_model_provider = "provider"
|
|
rule = DatasetProcessRule(
|
|
dataset_id="dataset-1",
|
|
mode=ProcessRuleMode.HIERARCHICAL,
|
|
rules=json.dumps({"parent_mode": "full-doc", "subchunk_segmentation": {"max_tokens": 100}}),
|
|
created_by="author",
|
|
)
|
|
session.add(rule)
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
document.doc_form = IndexStructureType.PARENT_CHILD_INDEX
|
|
document.dataset_process_rule_id = rule.id
|
|
|
|
def split(_self, document: IndexDocument, _rule, _mode, _model) -> list[ChildDocument]:
|
|
assert not probe.active_transactions
|
|
return [
|
|
ChildDocument(
|
|
page_content=document.page_content, metadata={"doc_id": document.page_content, "doc_hash": "hash"}
|
|
)
|
|
]
|
|
|
|
monkeypatch.setattr(ParentChildIndexProcessor, "split_child_nodes", split)
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "first"})
|
|
assert created.data.status == "completed"
|
|
updated = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "second", "regenerate_child_chunks": True},
|
|
)
|
|
assert updated.data.status == "completed"
|
|
ref = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(created.data.id)
|
|
children = repository.get_children(ref)
|
|
assert children is not None
|
|
assert [child.data.content for child in children] == ["second"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("content", "submitted_summary", "summary_enabled", "expected_summary", "generated"),
|
|
[
|
|
("new text", "old summary", True, "new summary", True),
|
|
("new text", None, True, "new summary", True),
|
|
("old text", "old summary", True, "old summary", False),
|
|
("new text", "manual summary", True, "manual summary", False),
|
|
("new text", "", True, None, False),
|
|
("new text", " \n\t ", True, None, False),
|
|
("new text", "old summary", False, "old summary", False),
|
|
("new text", None, False, "old summary", False),
|
|
],
|
|
)
|
|
def test_summary_updates_preserve_generation_and_manual_edit_rules(
|
|
segment_update: SegmentUpdateEntry,
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
content: str,
|
|
submitted_summary: str | None,
|
|
summary_enabled: bool,
|
|
expected_summary: str | None,
|
|
generated: bool,
|
|
) -> None:
|
|
app, repository, _ = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "old text"}
|
|
)
|
|
reference = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(created.data.id)
|
|
repository.save_summary(reference, "old summary")
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.summary_index_setting = {
|
|
"enable": summary_enabled,
|
|
"model_name": "llm-model",
|
|
"model_provider_name": "provider",
|
|
}
|
|
calls: list[str] = []
|
|
|
|
def generate(_tenant_id: str, text: str, _setting: object, **_kwargs: object) -> tuple[str, LLMUsage]:
|
|
calls.append(text)
|
|
return "new summary", LLMUsage.empty_usage()
|
|
|
|
monkeypatch.setattr(ParagraphIndexProcessor, "generate_summary_from_inputs", staticmethod(generate))
|
|
updated = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": content, "summary": submitted_summary},
|
|
)
|
|
assert calls == ([content] if generated else [])
|
|
assert updated.data.summary == expected_summary
|
|
|
|
|
|
def test_automatic_summary_keeps_knowledge_credit_attribution(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
app, repository, _ = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "old text"}
|
|
)
|
|
reference = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(created.data.id)
|
|
repository.save_summary(reference, "old summary")
|
|
setting: SummaryIndexSettingDict = {"enable": True, "model_name": "llm-model", "model_provider_name": "provider"}
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.summary_index_setting = setting
|
|
metadata: list[Mapping[str, object] | None] = []
|
|
|
|
class ModelProbe:
|
|
credentials: dict[str, str] = {}
|
|
|
|
@property
|
|
def model_type_instance(self) -> "ModelProbe":
|
|
return self
|
|
|
|
def get_model_schema(self, _name: str, _credentials: object) -> None:
|
|
return None
|
|
|
|
def get_text_embedding_num_tokens(self, texts: Sequence[str]) -> list[int]:
|
|
return [len(text) for text in texts]
|
|
|
|
def invoke_llm(self, **_kwargs: object) -> LLMResult:
|
|
metadata.append(get_credit_usage_metadata())
|
|
return LLMResult(
|
|
model="llm-model", message=AssistantPromptMessage(content="new summary"), usage=LLMUsage.empty_usage()
|
|
)
|
|
|
|
def get_model_instance(self, **_kwargs: object) -> "ModelProbe":
|
|
return self
|
|
|
|
monkeypatch.setattr(ModelManager, "for_tenant", lambda **_kwargs: ModelProbe())
|
|
# Both public summary entry points must preserve the credit-usage context.
|
|
with sqlite_session_factory() as session:
|
|
ParagraphIndexProcessor.generate_summary("workspace-1", "old text", setting, session=session)
|
|
assert metadata.pop() == {"created_by": CreditUsageCreatedBy.KNOWLEDGE_INDEXING}
|
|
updated = app.update_segment(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "new text"},
|
|
)
|
|
assert updated.data.summary == "new summary"
|
|
assert metadata == [{"created_by": CreditUsageCreatedBy.KNOWLEDGE_INDEXING}]
|
|
assert get_credit_usage_metadata() is None
|
|
|
|
|
|
@pytest.mark.parametrize("storage_type", ["database", "file"])
|
|
def test_economy_index_shares_persistence_with_existing_keyword_calls(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
storage_type: str,
|
|
) -> None:
|
|
from core.rag.datasource.keyword.jieba import jieba as jieba_module
|
|
from core.rag.datasource.keyword.keyword_factory import Keyword
|
|
from repositories.knowledge import keyword_table_repository as table_module
|
|
from tests.unit_tests.config_override import apply_config_overrides
|
|
|
|
app, repository, probe = indexing_probe
|
|
reads: list[str] = []
|
|
writes: list[str] = []
|
|
|
|
class FileStorageProbe:
|
|
def load_once(self, key: str) -> bytes:
|
|
assert not probe.active_transactions
|
|
reads.append(key)
|
|
return (tmp_path / key).read_bytes()
|
|
|
|
def exists(self, key: str) -> bool:
|
|
assert not probe.active_transactions
|
|
return (tmp_path / key).exists()
|
|
|
|
def save(self, key: str, data: bytes) -> None:
|
|
assert not probe.active_transactions
|
|
writes.append(key)
|
|
path = tmp_path / key
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
|
|
def delete(self, key: str) -> None:
|
|
assert not probe.active_transactions
|
|
(tmp_path / key).unlink()
|
|
|
|
class KeywordExtractor:
|
|
def extract_keywords(self, text: str, _keyword_number: int = 10) -> set[str]:
|
|
assert not probe.active_transactions
|
|
return set(text.split())
|
|
|
|
storage = FileStorageProbe()
|
|
monkeypatch.setattr(jieba_module, "storage", storage)
|
|
monkeypatch.setattr(jieba_module, "JiebaKeywordTableHandler", KeywordExtractor)
|
|
apply_config_overrides(monkeypatch, KEYWORD_DATA_SOURCE_TYPE=storage_type)
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.indexing_technique = IndexTechniqueType.ECONOMY
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
first = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "initial 中文"}
|
|
)
|
|
second = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "other", "keywords": ["manual"]}
|
|
)
|
|
updated = app.update_segment(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=first.data.id,
|
|
values={"content": "replacement"},
|
|
)
|
|
assert first.data.status == second.data.status == updated.data.status == "completed"
|
|
assert first.data.index_node_id is not None
|
|
assert second.data.index_node_id is not None
|
|
assert set(first.data.keywords or ()) == {"initial", "中文"}
|
|
assert second.data.keywords == ("manual",)
|
|
assert updated.data.keywords == ("replacement",)
|
|
dataset_ref = DatasetRef("workspace-1", "dataset-1")
|
|
source_type, data = repository.get_keyword_table(dataset_ref)
|
|
payload = table_module.load_keyword_table(
|
|
storage=storage, tenant_id="workspace-1", dataset_id="dataset-1", storage_type=source_type, data=data
|
|
)
|
|
assert payload is not None
|
|
assert payload["__data__"]["table"] == {
|
|
"replacement": {first.data.index_node_id},
|
|
"manual": {second.data.index_node_id},
|
|
}
|
|
if storage_type == "database":
|
|
assert reads == writes == []
|
|
else:
|
|
assert writes == ["keyword_files/workspace-1/dataset-1.txt"] * 3
|
|
|
|
# The existing session-taking API must consume the exact payload written by
|
|
# the repository-backed path. Only that existing API retains caller sessions.
|
|
class SessionFileStorage:
|
|
def load_once(self, key: str) -> bytes:
|
|
return (tmp_path / key).read_bytes()
|
|
|
|
def exists(self, key: str) -> bool:
|
|
return (tmp_path / key).exists()
|
|
|
|
def save(self, key: str, data: bytes) -> None:
|
|
(tmp_path / key).write_bytes(data)
|
|
|
|
def delete(self, key: str) -> None:
|
|
(tmp_path / key).unlink()
|
|
|
|
session_storage = SessionFileStorage()
|
|
monkeypatch.setattr(jieba_module, "storage", session_storage)
|
|
monkeypatch.setattr(jieba_module, "redis_client", IndexRedisStub())
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
keyword = Keyword(dataset)
|
|
assert keyword.text_exists(first.data.index_node_id, session=session)
|
|
assert keyword.text_exists(second.data.index_node_id, session=session)
|
|
keyword.delete_by_ids([first.data.index_node_id], session)
|
|
session.commit()
|
|
source_type, data = repository.get_keyword_table(dataset_ref)
|
|
payload = table_module.load_keyword_table(
|
|
storage=session_storage, tenant_id="workspace-1", dataset_id="dataset-1", storage_type=source_type, data=data
|
|
)
|
|
assert payload is not None
|
|
assert payload["__data__"]["table"] == {"manual": {second.data.index_node_id}}
|
|
|
|
|
|
@pytest.mark.parametrize("generated_summary", [None, "", " \n\t "], ids=["exception", "empty", "whitespace"])
|
|
def test_summary_generation_failure_preserves_the_completed_segment(
|
|
segment_update: SegmentUpdateEntry,
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
generated_summary: str | None,
|
|
) -> None:
|
|
from models.dataset import DocumentSegmentSummary
|
|
|
|
app, repository, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "original"}
|
|
)
|
|
reference = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(created.data.id)
|
|
repository.save_summary(reference, "original summary")
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.summary_index_setting = {"enable": True, "model_name": "model", "model_provider_name": "provider"}
|
|
summary = session.scalar(select(DocumentSegmentSummary))
|
|
assert summary is not None
|
|
summary.summary_index_node_id = "original-summary-node"
|
|
|
|
def generate(*_args: object, **_kwargs: object) -> tuple[str, LLMUsage]:
|
|
assert not probe.active_transactions
|
|
if generated_summary is not None:
|
|
return generated_summary, LLMUsage.empty_usage()
|
|
raise RuntimeError("LLM unavailable")
|
|
|
|
monkeypatch.setattr(ParagraphIndexProcessor, "generate_summary_from_inputs", staticmethod(generate))
|
|
updated = segment_update(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "replacement", "summary": "original summary"},
|
|
)
|
|
assert updated.data.content == "replacement"
|
|
assert updated.data.enabled is True
|
|
assert updated.data.status == "completed"
|
|
assert updated.data.summary == "original summary"
|
|
assert "original-summary-node" not in probe.deleted_ids
|
|
with sqlite_session_factory() as session:
|
|
summary = session.scalar(select(DocumentSegmentSummary))
|
|
assert summary is not None
|
|
assert summary.status == "error"
|
|
assert summary.summary_index_node_id == "original-summary-node"
|
|
assert summary.error == ("Generated summary is empty" if generated_summary is not None else "LLM unavailable")
|
|
|
|
|
|
@pytest.mark.parametrize("failed_commits", [1, 2, 3])
|
|
def test_summary_commit_retries_use_fresh_sessions_and_persist_outcome(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failed_commits: int,
|
|
) -> None:
|
|
from models.dataset import DocumentSegmentSummary
|
|
from services.knowledge.summaries import adapters as summary_module
|
|
|
|
app, _, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
created = app.create_segment(
|
|
context, dataset_id="dataset-1", document_id="document-1", values={"content": "original"}
|
|
)
|
|
attempts: list[Session] = []
|
|
delays: list[float] = []
|
|
|
|
def fail_summary_commit(session: Session) -> None:
|
|
if any(
|
|
isinstance(record, DocumentSegmentSummary) and record.status == SummaryStatus.COMPLETED
|
|
for record in session.new | session.dirty
|
|
):
|
|
attempts.append(session)
|
|
if len(attempts) <= failed_commits:
|
|
raise ConnectionError("connection lost before commit")
|
|
|
|
def wait(seconds: float) -> None:
|
|
assert not probe.active_transactions
|
|
delays.append(seconds)
|
|
|
|
monkeypatch.setattr(summary_module.time, "sleep", wait)
|
|
event.listen(sqlite_session_factory.class_, "before_commit", fail_summary_commit)
|
|
try:
|
|
updated = app.update_segment(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=created.data.id,
|
|
values={"content": "original", "summary": "manual summary"},
|
|
)
|
|
assert updated.data.status == "completed"
|
|
assert updated.data.enabled is True
|
|
assert not probe.active_transactions
|
|
finally:
|
|
event.remove(sqlite_session_factory.class_, "before_commit", fail_summary_commit)
|
|
for session in attempts:
|
|
session.close()
|
|
|
|
assert len(attempts) == min(failed_commits + 1, 3)
|
|
assert len({id(session) for session in attempts}) == len(attempts)
|
|
assert delays == [2.0, 4.0][: min(failed_commits, 2)]
|
|
with sqlite_session_factory() as session:
|
|
summary = session.scalar(select(DocumentSegmentSummary))
|
|
assert summary is not None
|
|
assert summary.summary_content == "manual summary"
|
|
if failed_commits < 3:
|
|
assert summary.status == SummaryStatus.COMPLETED
|
|
assert summary.summary_index_node_id is not None
|
|
assert summary.summary_index_node_hash is not None
|
|
assert summary.error is None
|
|
else:
|
|
assert summary.status == SummaryStatus.ERROR
|
|
assert summary.error == "Vectorization failed: connection lost before commit"
|
|
|
|
|
|
def test_summary_noop_does_not_open_a_database_transaction(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
from services.knowledge.segments.indexing import SegmentIndexingGateway
|
|
|
|
repository = SQLAlchemySegmentRepository(session_factory=sqlite_session_factory)
|
|
gateway = SegmentIndexingGateway(
|
|
segments=repository,
|
|
uploads=SQLAlchemyKnowledgeUploadRepository(session_factory=sqlite_session_factory),
|
|
redis=IndexRedisStub(),
|
|
new_session=sqlite_session_factory,
|
|
delete_task=lambda *_args: None,
|
|
enable_task=lambda *_args: None,
|
|
disable_task=lambda *_args: None,
|
|
)
|
|
engine = sqlite_session_factory.kw["bind"]
|
|
connections: list[Connection] = []
|
|
record_connection = connections.append
|
|
event.listen(engine, "begin", record_connection)
|
|
try:
|
|
gateway.update_summary(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1").segment("segment-1"),
|
|
summary=None,
|
|
content_changed=False,
|
|
)
|
|
finally:
|
|
event.remove(engine, "begin", record_connection)
|
|
assert connections == []
|
|
|
|
|
|
def test_service_api_child_mutations_refresh_rows_and_release_read_transactions(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
) -> None:
|
|
app, repository, _ = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
parent = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "parent"})
|
|
ref = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(parent.data.id)
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
document = session.get(Document, "document-1")
|
|
segment = session.get(DocumentSegment, parent.data.id)
|
|
assert dataset is not None
|
|
assert document is not None
|
|
assert segment is not None
|
|
child = SegmentService.create_child_chunk(
|
|
"first",
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="creator",
|
|
mutations=app.mutations,
|
|
)
|
|
child_id = child.id
|
|
assert child.position == 1
|
|
assert child.created_by == "creator"
|
|
updated = SegmentService.update_child_chunk(
|
|
"second",
|
|
child,
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="editor",
|
|
mutations=app.mutations,
|
|
)
|
|
assert updated.content == "second"
|
|
assert updated.updated_by == "editor"
|
|
SegmentService.delete_child_chunk(updated, dataset, session, mutations=app.mutations)
|
|
assert session.get(ChildChunk, child_id) is None
|
|
SegmentService.delete_segment(segment, document, dataset, session, mutations=app.mutations)
|
|
assert document.word_count == 0
|
|
assert repository.get_segment(ref) is None
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["create", "update", "delete"])
|
|
def test_service_api_child_index_failure_preserves_rows_and_legacy_errors(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
operation: str,
|
|
) -> None:
|
|
from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError
|
|
|
|
app, repository, probe = indexing_probe
|
|
context = RequestContext("request", None, "author", "workspace-1")
|
|
parent = app.create_segment(context, dataset_id="dataset-1", document_id="document-1", values={"content": "parent"})
|
|
created = app.create_child_chunk(
|
|
context,
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id=parent.data.id,
|
|
content="original",
|
|
)
|
|
ref = DatasetRef("workspace-1", "dataset-1").document("document-1").segment(parent.data.id)
|
|
before = repository.get_children(ref)
|
|
probe.fail = True
|
|
expected = ChildChunkDeleteIndexError if operation == "delete" else ChildChunkIndexingError
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
document = session.get(Document, "document-1")
|
|
segment = session.get(DocumentSegment, parent.data.id)
|
|
child = session.get(ChildChunk, created.id)
|
|
assert dataset is not None
|
|
assert document is not None
|
|
assert segment is not None
|
|
assert child is not None
|
|
|
|
def mutate() -> None:
|
|
if operation == "create":
|
|
SegmentService.create_child_chunk(
|
|
"new",
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="editor",
|
|
mutations=app.mutations,
|
|
)
|
|
elif operation == "update":
|
|
SegmentService.update_child_chunk(
|
|
"changed",
|
|
child,
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="editor",
|
|
mutations=app.mutations,
|
|
)
|
|
else:
|
|
SegmentService.delete_child_chunk(child, dataset, session, mutations=app.mutations)
|
|
|
|
with pytest.raises(expected, match="index unavailable"):
|
|
mutate()
|
|
assert repository.get_children(ref) == before
|
|
|
|
|
|
@pytest.mark.parametrize("values", [{"answer": "new answer"}, {"content": "new question", "answer": "new answer"}])
|
|
def test_service_api_partial_qa_update_uses_shared_word_count_and_indexing(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
values: dict[str, str],
|
|
) -> None:
|
|
app, _, _ = indexing_probe
|
|
with sqlite_session_factory.begin() as session:
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
document.doc_form = IndexStructureType.QA_INDEX
|
|
created = app.create_segment(
|
|
RequestContext("request", None, "author", "workspace-1"),
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
values={"content": "question", "answer": "answer"},
|
|
)
|
|
with sqlite_session_factory() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
document = session.get(Document, "document-1")
|
|
segment = session.get(DocumentSegment, created.data.id)
|
|
assert dataset is not None
|
|
assert document is not None
|
|
assert segment is not None
|
|
updated = SegmentService.update_segment(
|
|
SegmentUpdateArgs(**values),
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="editor",
|
|
mutations=app.mutations,
|
|
)
|
|
assert updated.content == values.get("content", "question")
|
|
assert updated.answer == "new answer"
|
|
assert updated.word_count == len(updated.content) + len(updated.answer)
|
|
assert document.word_count == updated.word_count
|
|
if "content" in values:
|
|
assert updated.tokens == updated.word_count
|
|
|
|
|
|
@pytest.mark.parametrize("owner_field", ["tenant_id", "dataset_id", "document_id"])
|
|
def test_legacy_segment_mutation_rejects_mismatched_rows_before_dispatch(owner_field: str) -> None:
|
|
from services.knowledge.segments.application import SegmentMutationService
|
|
|
|
dataset = _dataset("dataset-1", "workspace-1")
|
|
document = _document("document-1", "workspace-1", "dataset-1")
|
|
segment = _segment("segment-1", "workspace-1", "dataset-1", "document-1")
|
|
setattr(segment, owner_field, "foreign-owner")
|
|
mutations = create_autospec(SegmentMutationService, instance=True, spec_set=True)
|
|
session = create_autospec(Session, instance=True, spec_set=True)
|
|
with pytest.raises(ValueError, match="Segment does not belong"):
|
|
SegmentService.update_segment(
|
|
SegmentUpdateArgs(content="new"),
|
|
segment,
|
|
document,
|
|
dataset,
|
|
session,
|
|
actor_id="author",
|
|
mutations=mutations,
|
|
)
|
|
mutations.update_segment.assert_not_called()
|
|
session.commit.assert_not_called()
|
|
|
|
|
|
def _persist_index_batch(
|
|
session_factory: sessionmaker[Session], *, doc_form: IndexStructureType = IndexStructureType.PARAGRAPH_INDEX
|
|
) -> None:
|
|
with session_factory.begin() as session:
|
|
document = session.get(Document, "document-1")
|
|
assert document is not None
|
|
document.doc_form = doc_form
|
|
for position, content in enumerate(("first", "second"), start=1):
|
|
segment = _segment(content, "workspace-1", "dataset-1", "document-1")
|
|
segment.position = position
|
|
segment.content = content
|
|
segment.index_node_id = f"node-{content}"
|
|
segment.index_node_hash = f"hash-{content}"
|
|
session.add(segment)
|
|
|
|
|
|
@pytest.mark.parametrize("doc_form", [IndexStructureType.PARAGRAPH_INDEX, IndexStructureType.QA_INDEX])
|
|
def test_batch_index_materializes_ordered_documents_before_one_vector_write(
|
|
indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session], doc_form: IndexStructureType
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory, doc_form=doc_form)
|
|
|
|
app.mutations.index_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"), segment_ids=["second", "first"]
|
|
)
|
|
|
|
assert probe.batches == [("second", "first")]
|
|
assert probe.writes == 2
|
|
assert not probe.active_transactions
|
|
|
|
|
|
def test_batch_index_failure_persists_errors_on_every_committed_segment(
|
|
indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory)
|
|
probe.fail = True
|
|
|
|
with pytest.raises(RuntimeError, match="index unavailable"):
|
|
app.mutations.index_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"), segment_ids=["first", "second"]
|
|
)
|
|
|
|
with sqlite_session_factory() as session:
|
|
segments = session.scalars(select(DocumentSegment)).all()
|
|
assert len(segments) == 2
|
|
assert all(segment.status == "error" and not segment.enabled for segment in segments)
|
|
assert all(segment.error == "index unavailable" and segment.disabled_at is not None for segment in segments)
|
|
|
|
|
|
@pytest.mark.parametrize("owner_field", ["tenant_id", "dataset_id", "document_id"])
|
|
def test_batch_index_validates_every_segment_owner_before_external_io(
|
|
indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session], owner_field: str
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory)
|
|
with sqlite_session_factory.begin() as session:
|
|
foreign = session.get(DocumentSegment, "second")
|
|
assert foreign is not None
|
|
setattr(foreign, owner_field, "foreign-owner")
|
|
|
|
with pytest.raises(LookupError, match="Segment no longer exists"):
|
|
app.mutations.index_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"), segment_ids=["first", "second"]
|
|
)
|
|
|
|
assert not probe.batches
|
|
with sqlite_session_factory() as session:
|
|
foreign = session.get(DocumentSegment, "second")
|
|
assert foreign is not None
|
|
assert foreign.status == "completed"
|
|
|
|
|
|
def test_batch_index_preserves_owned_attachments(
|
|
indexing_probe: IndexingProbe, sqlite_session_factory: sessionmaker[Session]
|
|
) -> None:
|
|
app, _, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory)
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.is_multimodal = True
|
|
session.add_all([_upload("owned", "workspace-1", "image.png"), _upload("foreign", "workspace-2", "other.png")])
|
|
for file_id in ("owned", "foreign"):
|
|
session.add(
|
|
SegmentAttachmentBinding(
|
|
tenant_id="workspace-1",
|
|
dataset_id="dataset-1",
|
|
document_id="document-1",
|
|
segment_id="first",
|
|
attachment_id=file_id,
|
|
)
|
|
)
|
|
|
|
app.mutations.index_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"), segment_ids=["first", "second"]
|
|
)
|
|
|
|
assert probe.writes == 3
|
|
with sqlite_session_factory() as session:
|
|
assert len(session.scalars(select(SegmentAttachmentBinding)).all()) == 2
|
|
|
|
|
|
def test_batch_index_keeps_manual_keywords_aligned_with_segments(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from core.rag.datasource.keyword.jieba import jieba as jieba_module
|
|
from tests.unit_tests.config_override import apply_config_overrides
|
|
|
|
app, _, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory)
|
|
with sqlite_session_factory.begin() as session:
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert dataset is not None
|
|
dataset.indexing_technique = IndexTechniqueType.ECONOMY
|
|
|
|
class Extractor:
|
|
def extract_keywords(self, text: str, _keyword_number: int = 10) -> set[str]:
|
|
assert not probe.active_transactions
|
|
return {text}
|
|
|
|
monkeypatch.setattr(jieba_module, "JiebaKeywordTableHandler", Extractor)
|
|
apply_config_overrides(monkeypatch, KEYWORD_DATA_SOURCE_TYPE="database")
|
|
app.mutations.index_segments(
|
|
DatasetRef("workspace-1", "dataset-1").document("document-1"),
|
|
segment_ids=["second", "first"],
|
|
keywords_list=[["manual"], None],
|
|
)
|
|
|
|
with sqlite_session_factory() as session:
|
|
assert {s.id: s.keywords for s in session.scalars(select(DocumentSegment))} == {
|
|
"first": ["first"],
|
|
"second": ["manual"],
|
|
}
|
|
assert not probe.batches
|
|
|
|
|
|
def test_batch_parent_child_index_uses_shared_splitter_without_open_transactions(
|
|
indexing_probe: IndexingProbe,
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from core.rag.index_processor.processor.parent_child_index_processor import ParentChildIndexProcessor
|
|
from models.dataset import DatasetProcessRule
|
|
from models.enums import ProcessRuleMode
|
|
|
|
app, repository, probe = indexing_probe
|
|
_persist_index_batch(sqlite_session_factory, doc_form=IndexStructureType.PARENT_CHILD_INDEX)
|
|
with sqlite_session_factory.begin() as session:
|
|
rule = DatasetProcessRule(
|
|
dataset_id="dataset-1",
|
|
mode=ProcessRuleMode.HIERARCHICAL,
|
|
rules='{"parent_mode":"full-doc","subchunk_segmentation":{"max_tokens":100}}',
|
|
created_by="author",
|
|
)
|
|
session.add(rule)
|
|
document = session.get(Document, "document-1")
|
|
dataset = session.get(Dataset, "dataset-1")
|
|
assert document is not None
|
|
assert dataset is not None
|
|
dataset.embedding_model_provider = "provider"
|
|
document.dataset_process_rule_id = rule.id
|
|
|
|
def split(_self, document: IndexDocument, _rule, _mode, _model) -> list[ChildDocument]:
|
|
assert not probe.active_transactions
|
|
return [
|
|
ChildDocument(
|
|
page_content=document.page_content, metadata={"doc_id": document.page_content, "doc_hash": "hash"}
|
|
)
|
|
]
|
|
|
|
monkeypatch.setattr(ParentChildIndexProcessor, "split_child_nodes", split)
|
|
document_ref = DatasetRef("workspace-1", "dataset-1").document("document-1")
|
|
app.mutations.index_segments(document_ref, segment_ids=["first", "second"])
|
|
|
|
assert probe.batches == [("first",), ("second",)]
|
|
for segment_id in ("first", "second"):
|
|
children = repository.get_children(document_ref.segment(segment_id))
|
|
assert children is not None
|
|
assert len(children) == 1
|
|
assert children[0].data.content == segment_id
|