refactor: overhaul task executor with layered architecture and comprehensive test suite (#15471)

## Summary

Decomposes the monolithic `task_executor.py` (1945 lines) into a 6-layer
architecture with clear separation of concerns. The refactored code is
functionally equivalent to the original, verified through 400 passing
tests and a production-vs-dry-run comparison framework.

## Architecture

```
entry (task_manager)
  └─ orchestration (task_handler)
       ├─ services (chunk_service, embedding_service, dataflow_service, raptor_service, post_processor)
       │    └─ utilities (chunk_builder, chunk_post_processor, embedding_utils)
       └─ infrastructure (task_context, recording_context, interceptor)
```

Key design decisions:
- **TaskContext** — typed facade over raw task dict, injects rate
limiters + callbacks via composition
- **RecordingContext + Comparator** — enables side-by-side production vs
dry-run execution for safe migration
- **NullRecordingContext** — zero-allocation no-op for production, uses
`__slots__`
- **WriteOperationInterceptor** — FIFO replay of previous runs function
returns for comparison mode

## Migration Strategy

The original `handle_task()` in `task_executor.py` uses a 3-way switch
via `TE_RUN_MODE`:
- `TE_RUN_MODE=0` (default) → runs refactored code
- `TE_RUN_MODE=1` → runs both original + refactored, compares all
intermediate results
- `TE_RUN_MODE=2` → runs original code (fallback)

The comparison mode (`TE_RUN_MODE=1`) records ~40 intermediate values
(chunks, vectors, token counts, func return values) from the production
run and replays them during dry-run, then uses `ContextComparator` to
report mismatches.

## Functional Equivalence Fixes

All divergences between original and refactored code were identified and
fixed:
- Timeout decorators (handle/build_chunks/raptor/embedding)
- NullRecordingContext leak in finally block causing RuntimeError
- MinIO None-binary check with proper FileNotFoundError
- Dataflow dispatch after embedding binding + init_kb
- Memory task missing return after processing
- RAPTOR checkpoint progress reporting
- Tag cache (get_tags_from_cache/set_tags_to_cache) restoration
- dataflow_id correction in _load_dsl
- Language default Chinese, dead code guard removal
- embed_chunks made async with proper thread_pool_exec
- Full GraphRAG default configuration (10 parameters)
- Hardcoded q_768_vec fallback removal in RAPTOR

## Test Changes

- 20 new tests covering table parser manual mode, tag cache, embedding
edge cases, RAPTOR checkpoint, dataflow_id correction, storage binary
None, cancel cleanup, metadata=None boundary
- Unified `make_task_context`/`make_task_dict` factories eliminated 10+
duplicated helpers
- DataflowService tests migrated from internal method mocks to IO
boundary mocks (real orchestration code executes)
- Parametrized duplicate build_chunks post-processor tests
- 7 raptor tests modernized to @pytest.mark.asyncio
- Mock count per test reduced through boundary-level mocking strategy

**Test count: 400 passing, 0 warnings, 0 skips**

## Files Changed

| File | Change |
|------|--------|
| `rag/svr/task_executor.py` | +1 line (NullRecordingContext fix) |
| `rag/svr/task_executor_refactor/task_handler.py` | Orchestration
layer, 8 logic fixes |
| `rag/svr/task_executor_refactor/chunk_service.py` | +timeout +
None-check |
| `rag/svr/task_executor_refactor/embedding_service.py` | sync→async
rewrite |
| `rag/svr/task_executor_refactor/dataflow_service.py` | dataflow_id fix
+ timeout |
| `rag/svr/task_executor_refactor/raptor_service.py` | checkpoint fix +
assert |
| `rag/svr/task_executor_refactor/chunk_post_processor.py` | tag cache
restore |
| `rag/svr/task_executor_refactor/task_context.py` | language default
fix |
| `test/.../conftest.py` | +294 lines shared helpers |
| `test/.../*.py` | 15 test files refactored, 20 new tests |

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jack
2026-06-03 17:18:31 +08:00
committed by GitHub
parent d736f358ba
commit b363146997
21 changed files with 1317 additions and 1222 deletions

View File

@@ -272,6 +272,7 @@ class MockChatModel:
def __init__(self):
self.llm_name = "mock_chat"
self.max_length = 4096
def __enter__(self):
return self
@@ -469,26 +470,293 @@ def mock_chunk_service_factory():
# =============================================================================
# RaptorService Fixtures
# Unified Mock TaskContext Factory
# =============================================================================
def make_task_context(**overrides):
"""Build a MagicMock TaskContext with sensible defaults for all services.
Every test file that needs a mock ``TaskContext`` should use this factory
with keyword-only overrides instead of defining its own ``_create_mock_context``.
Usage::
ctx = make_task_context(parser_id="table", kb_parser_config={"tag_kb_ids": ["kb_1"]})
"""
defaults = {
"id": "task_1",
"tenant_id": "tenant_1",
"kb_id": "kb_1",
"doc_id": "doc_1",
"name": "test.pdf",
"location": "/path/to/test.pdf",
"language": "en",
"parser_id": "naive",
"parser_config": {},
"kb_parser_config": {},
"llm_id": "llm_1",
"embd_id": "embd_1",
"from_page": 0,
"to_page": -1,
"size": 1000,
"pagerank": 0,
"task_type": "standard",
"dataflow_id": "",
"doc_ids": [],
"file": None,
"memory_id": "",
"source_id": "",
"message_dict": {},
}
ctx = MagicMock()
for k, v in defaults.items():
setattr(ctx, k, v if k not in overrides else overrides.pop(k))
# Callbacks
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.recording_context = MagicMock()
ctx.write_interceptor = None
# Raw task dict — derive from context attributes
ctx.raw_task = MagicMock()
# Limiters — all use AsyncMockLimiter so services that acquire them work
limiter = AsyncMockLimiter()
ctx.chunk_limiter = limiter
ctx.chat_limiter = limiter
ctx.embed_limiter = limiter
ctx.kg_limiter = limiter
ctx.minio_limiter = limiter
# Apply remaining overrides
for k, v in overrides.items():
setattr(ctx, k, v)
return ctx
# =============================================================================
# RaptorService Fixtures (kept for backward compatibility)
# =============================================================================
def create_mock_raptor_context():
"""Create a mock TaskContext suitable for RaptorService tests."""
ctx = MagicMock()
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.write_interceptor = None
ctx.progress_cb = MagicMock()
ctx.raw_task = {"type": ""}
ctx.parser_id = "naive"
ctx.parser_config = {}
ctx.name = "test.pdf"
ctx.pagerank = 0
ctx.id = "task_1"
return ctx
return make_task_context()
@pytest.fixture
def mock_raptor_context():
"""Provide a mock TaskContext for RaptorService tests."""
return create_mock_raptor_context()
return make_task_context()
# =============================================================================
# Embedding Binding Patch Helper
# =============================================================================
class patch_embedding_binding:
"""Context manager that patches embedding model binding at the external boundary.
Patches ``LLMBundle``, ``get_model_config_from_provider_instance``, and
``get_tenant_default_model_by_type`` so that ``TaskHandler._bind_embedding_model``
executes its real logic without making actual API calls.
Usage::
with patch_embedding_binding(vector_size=128):
handler = TaskHandler(ctx)
await handler.handle()
"""
def __init__(self, vector_size: int = 128):
self._vector_size = vector_size
self._patches = []
def __enter__(self):
mock_model = MagicMock()
mock_model.encode = MagicMock(
return_value=(
np.random.rand(1, self._vector_size).astype(np.float32),
10,
)
)
mock_model.max_length = 512
mock_model.llm_name = "mock_embedding"
mock_model.__enter__ = MagicMock(return_value=mock_model)
mock_model.__exit__ = MagicMock(return_value=False)
self._patches = [
patch(
"rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance",
return_value=MagicMock(),
),
patch(
"rag.svr.task_executor_refactor.task_handler.LLMBundle",
return_value=mock_model,
),
patch(
"rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type",
return_value=MagicMock(),
),
]
for p in self._patches:
p.__enter__()
return self
def __exit__(self, *args):
for p in reversed(self._patches):
p.__exit__(*args)
# =============================================================================
# Common mock callbacks
# =============================================================================
async def mock_thread_return_binary(func, *args, **kwargs):
"""Reusable mock for thread_pool_exec — returns fake binary."""
return b"fake pdf binary"
async def mock_thread_return_none(func, *args, **kwargs):
"""Reusable mock for thread_pool_exec — returns None."""
return None
# =============================================================================
# Patch helpers for integration tests
# =============================================================================
def patch_get_storage_binary():
"""Patch TaskHandler._get_storage_binary to return fake binary."""
return patch("rag.svr.task_executor_refactor.task_handler.TaskHandler._get_storage_binary",
new_callable=AsyncMock, return_value=b"fake pdf binary")
def patch_task_handler_settings(mock_settings):
"""Patch the settings module-level import in task_handler."""
return patch("rag.svr.task_executor_refactor.task_handler.settings", mock_settings)
# =============================================================================
# Shared Task Dictionary Factory
# =============================================================================
def make_task_dict(**overrides):
"""Build a task dict with sensible defaults for integration tests.
All ``_create_standard_task_dict`` / ``_create_raptor_task_dict`` / etc.
helpers in integration tests should be replaced with this single factory.
Usage::
task_dict = make_task_dict(task_type="raptor", doc_ids=["doc1"])
"""
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {"auto_keywords": 0, "auto_questions": 0, "enable_metadata": False},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
**overrides,
}
# =============================================================================
# Shared Pipeline Mock Block for Integration Tests
# =============================================================================
class patch_pipeline_mocks:
"""Context manager bundling common integration-test mock blocks.
Patches external boundaries so ``TaskHandler.handle()`` executes without
actual API calls. Use ``mode="raptor"`` or ``mode="graphrag"``.
Usage::
with patch_pipeline_mocks() as m:
m.get_model_config_from_provider_instance.return_value = MagicMock()
handler = TaskHandler(ctx)
await handler.handle()
"""
_MODULES = {
"task_handler": "rag.svr.task_executor_refactor.task_handler",
"chunk_service": "rag.svr.task_executor_refactor.chunk_service",
}
# (module_key, attr_name, use_AsyncMock)
_COMMON = [
("task_handler", "get_model_config_from_provider_instance", False),
("task_handler", "LLMBundle", False),
("task_handler", "get_tenant_default_model_by_type", False),
("task_handler", "search.index_name", False),
("task_handler", "thread_pool_exec", False),
("task_handler", "DocumentService", False),
]
_STANDARD = [
("task_handler", "File2DocumentService", False),
("chunk_service", "thread_pool_exec", False),
("task_handler", "ChunkService", False),
]
_RAPTOR = [
("task_handler", "KnowledgebaseService", False),
("task_handler", "RaptorService", False),
("task_handler", "ChunkService", False),
]
_GRAPH_RAG = [
("task_handler", "KnowledgebaseService", False),
("task_handler", "run_graphrag_for_kb", True),
]
def __init__(self, mode: str = "standard"):
self._mode = mode
self._stack = None
def __enter__(self):
import contextlib
from unittest.mock import patch, MagicMock, AsyncMock
prefixes = list(self._COMMON)
if self._mode == "standard":
prefixes += self._STANDARD
elif self._mode == "raptor":
prefixes += self._RAPTOR
elif self._mode == "graphrag":
prefixes += self._GRAPH_RAG
mocks = MagicMock()
ctx_managers = []
for mod_key, attr, use_async in prefixes:
target = f"{self._MODULES[mod_key]}.{attr}"
if use_async:
cm = patch(target, new_callable=AsyncMock)
else:
cm = patch(target)
mock_handle = cm.__enter__()
setattr(mocks, attr.replace(".", "_"), mock_handle)
ctx_managers.append(cm)
self._stack = contextlib.ExitStack()
self._ctx_managers = ctx_managers
return mocks
def __exit__(self, *args):
for cm in reversed(self._ctx_managers):
cm.__exit__(*args)

View File

@@ -18,12 +18,13 @@ Unit tests for ChunkBuilder module.
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from unittest.mock import MagicMock, patch
from rag.svr.task_executor_refactor.chunk_builder import (
get_parser,
run_chunking,
extract_outline,
)
from test.unit_test.rag.svr.task_executor_refactor.conftest import make_task_context
class TestGetParser:
@@ -49,151 +50,99 @@ class TestGetParser:
class TestRunChunking:
"""Tests for run_chunking function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.name = "test.pdf"
ctx.location = "/path/to/test.pdf"
ctx.from_page = 0
ctx.to_page = -1
ctx.language = "en"
ctx.kb_id = "kb_1"
ctx.parser_config = {}
ctx.tenant_id = "tenant_1"
ctx.progress_cb = MagicMock()
ctx.raw_task = {}
ctx.chunk_limiter = MagicMock()
ctx.chunk_limiter.__aenter__ = AsyncMock()
ctx.chunk_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_run_chunking_success(self):
"""Test successful chunking."""
ctx = self._create_mock_context()
ctx = make_task_context()
mock_chunker = MagicMock()
mock_chunker.chunk = MagicMock(return_value=[{"content_with_weight": "chunk1"}])
with patch("rag.svr.task_executor_refactor.chunk_builder.thread_pool_exec") as mock_thread:
# thread_pool_exec returns an awaitable that returns the list
mock_thread.return_value = [{"content_with_weight": "chunk1"}]
result = await run_chunking(mock_chunker, b"binary", ctx)
assert result is not None
assert len(result) == 1
@pytest.mark.asyncio
async def test_run_chunking_with_parser_config(self):
"""Test chunking merges table parser config."""
ctx = self._create_mock_context()
ctx = make_task_context()
ctx.raw_task = {"parser_config": {"chunk_token_num": 128}}
mock_chunker = MagicMock()
mock_chunker.chunk = MagicMock(return_value=[])
with patch("rag.svr.task_executor_refactor.chunk_builder.thread_pool_exec") as mock_thread:
with patch("rag.svr.task_executor_refactor.chunk_builder.thread_pool_exec") as mock_thread, \
patch("rag.svr.task_executor_refactor.chunk_builder.merge_table_parser_config_from_kb") as mock_merge:
mock_thread.return_value = []
with patch("rag.svr.task_executor_refactor.chunk_builder.merge_table_parser_config_from_kb") as mock_merge:
mock_merge.return_value = {"chunk_token_num": 128}
await run_chunking(mock_chunker, b"binary", ctx)
mock_merge.assert_called_once_with(ctx.raw_task)
mock_merge.return_value = {"chunk_token_num": 128}
await run_chunking(mock_chunker, b"binary", ctx)
mock_merge.assert_called_once_with(ctx.raw_task)
@pytest.mark.asyncio
async def test_run_chunking_exception(self):
"""Test chunking handles exception."""
ctx = self._create_mock_context()
ctx = make_task_context()
mock_chunker = MagicMock()
mock_chunker.chunk = MagicMock(side_effect=Exception("Test error"))
with patch("rag.svr.task_executor_refactor.chunk_builder.thread_pool_exec") as mock_thread:
mock_thread.side_effect = Exception("Test error")
with pytest.raises(Exception):
await run_chunking(mock_chunker, b"binary", ctx)
# Verify progress_cb was called with error message
ctx.progress_cb.assert_called()
class TestExtractOutline:
"""Tests for extract_outline function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.doc_id = "doc_1"
ctx.write_interceptor = None
ctx.progress_cb = MagicMock()
@staticmethod
def _ctx(recording_ctx=None, **overrides):
ctx = make_task_context(**overrides)
ctx.recording_context = recording_ctx or MagicMock()
return ctx
@pytest.mark.asyncio
async def test_extract_outline_with_data(self):
"""Test outline extraction when outline data is present."""
ctx = self._create_mock_context()
ctx = self._ctx()
outline_data = [{"title": "Chapter 1", "page": 1}]
cks = [{"__outline__": outline_data}]
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_builder.DocMetadataService") as mock_meta:
mock_meta.get_document_metadata.return_value = {}
mock_meta.update_document_metadata = MagicMock()
await extract_outline(cks, ctx)
mock_rec_ctx.record.assert_called_with("outline_data", outline_data)
# Outline should be popped from first chunk
ctx.recording_context.record.assert_called_with("outline_data", outline_data)
assert "__outline__" not in cks[0]
mock_meta.update_document_metadata.assert_called_once()
@pytest.mark.asyncio
async def test_extract_outline_without_data(self):
"""Test outline extraction when no outline data."""
ctx = self._create_mock_context()
ctx = self._ctx()
cks = [{"content_with_weight": "test"}]
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
await extract_outline(cks, ctx)
mock_rec_ctx.record.assert_called_with("outline_data", None)
ctx.recording_context.record.assert_called_with("outline_data", None)
@pytest.mark.asyncio
async def test_extract_outline_empty_chunks(self):
"""Test outline extraction with empty chunks list."""
ctx = self._create_mock_context()
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
ctx = self._ctx()
await extract_outline([], ctx)
mock_rec_ctx.record.assert_called_with("outline_data", None)
ctx.recording_context.record.assert_called_with("outline_data", None)
@pytest.mark.asyncio
async def test_extract_outline_with_write_interceptor(self):
"""Test outline extraction with write interceptor."""
ctx = self._create_mock_context()
ctx.write_interceptor = MagicMock()
ctx = self._ctx(write_interceptor=MagicMock())
outline_data = [{"title": "Chapter 1", "page": 1}]
cks = [{"__outline__": outline_data}]
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
await extract_outline(cks, ctx)
ctx.write_interceptor.intercept.assert_called_once_with(
"DocMetadataService.update_document_metadata"
)
@@ -201,19 +150,13 @@ class TestExtractOutline:
@pytest.mark.asyncio
async def test_extract_outline_persistence_exception(self):
"""Test outline extraction handles persistence exception."""
ctx = self._create_mock_context()
ctx = self._ctx()
outline_data = [{"title": "Chapter 1", "page": 1}]
cks = [{"__outline__": outline_data}]
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_builder.DocMetadataService") as mock_meta:
mock_meta.get_document_metadata.return_value = {}
mock_meta.update_document_metadata.side_effect = Exception("DB error")
# Should not raise exception, just log warning
await extract_outline(cks, ctx)
mock_rec_ctx.record.assert_called_with("outline_data", outline_data)
ctx.recording_context.record.assert_called_with("outline_data", outline_data)

View File

@@ -15,10 +15,16 @@
"""
Unit tests for ChunkPostProcessor module.
Mock strategy: the LLM prompt functions (``keyword_extraction``, ``question_proposal``,
``gen_metadata``, ``content_tagging``) are mocked since they make actual LLM API
calls. ``get_llm_cache`` / ``set_llm_cache`` run as real code, so cache
population and retrieval are exercised. ``rag_tokenizer`` is mocked because
it requires NLTK data in the test environment.
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from unittest.mock import MagicMock, patch
from rag.svr.task_executor_refactor.chunk_post_processor import (
extract_keywords,
generate_questions,
@@ -27,394 +33,314 @@ from rag.svr.task_executor_refactor.chunk_post_processor import (
count_with_key,
build_metadata_config,
)
from test.unit_test.rag.svr.task_executor_refactor.conftest import (
make_task_context,
MockChatModel,
)
class TestExtractKeywords:
class _BasePostProcessorTest:
"""Shared helpers for post-processor test classes."""
@staticmethod
def _mock_llm_binding(chat_model_cls=MockChatModel):
"""Patch model config lookup + LLMBundle to return a MockChatModel."""
p1 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance",
return_value=MagicMock())
p2 = patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle",
return_value=chat_model_cls())
return p1, p2
@staticmethod
def _patch_prompt_func(func_path: str, return_value):
"""Patch a prompt-level LLM function (the actual API call)."""
return patch(func_path, return_value=return_value)
class TestExtractKeywords(_BasePostProcessorTest):
"""Tests for extract_keywords function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.tenant_id = "tenant_1"
ctx.llm_id = "llm_1"
ctx.language = "en"
ctx.parser_config = {"auto_keywords": 5}
ctx.id = "task_1"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.chat_limiter = MagicMock()
ctx.chat_limiter.__aenter__ = AsyncMock()
ctx.chat_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_extract_keywords_success(self):
"""Test successful keyword extraction."""
ctx = self._create_mock_context()
"""Test successful keyword extraction — cache miss → LLM prompt runs."""
ctx = make_task_context(parser_config={"auto_keywords": 5})
docs = [
{"content_with_weight": "This is test content one"},
{"content_with_weight": "This is test content two"},
]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = "keyword1, keyword2"
with patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache"):
with patch("rag.svr.task_executor_refactor.chunk_post_processor.rag_tokenizer") as mock_tokenizer:
mock_tokenizer.tokenize.return_value = "keyword1 keyword2"
await extract_keywords(docs, ctx)
# Verify keywords were set
assert "important_kwd" in docs[0]
assert "important_tks" in docs[0]
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value=None) # cache miss
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache") # Redis stub
p5 = self._patch_prompt_func(
"rag.svr.task_executor_refactor.chunk_post_processor.keyword_extraction",
return_value="keyword1, keyword2",
)
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.rag_tokenizer")
with p1, p2, p3, p4, p5, p6 as mock_tok:
mock_tok.tokenize.return_value = "keyword1 keyword2"
await extract_keywords(docs, ctx)
assert "important_kwd" in docs[0]
assert "important_tks" in docs[0]
@pytest.mark.asyncio
async def test_extract_keywords_canceled(self):
"""Test keyword extraction when task is canceled."""
ctx = self._create_mock_context()
ctx.has_canceled_func = MagicMock(return_value=True)
ctx = make_task_context(parser_config={"auto_keywords": 5},
has_canceled_func=MagicMock(return_value=True))
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = None # No cache
await extract_keywords(docs, ctx)
# Should return early due to cancellation
assert "important_kwd" not in docs[0]
p1, p2 = self._mock_llm_binding()
with p1, p2:
await extract_keywords(docs, ctx)
assert "important_kwd" not in docs[0]
@pytest.mark.asyncio
async def test_extract_keywords_empty_docs(self):
"""Test keyword extraction with empty docs list."""
ctx = self._create_mock_context()
ctx = make_task_context(parser_config={"auto_keywords": 5})
docs = []
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
await extract_keywords(docs, ctx)
# Should complete without error
ctx.progress_cb.assert_called()
p1, p2 = self._mock_llm_binding()
with p1, p2:
await extract_keywords(docs, ctx)
ctx.progress_cb.assert_called()
class TestGenerateQuestions:
class TestGenerateQuestions(_BasePostProcessorTest):
"""Tests for generate_questions function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.tenant_id = "tenant_1"
ctx.llm_id = "llm_1"
ctx.language = "en"
ctx.parser_config = {"auto_questions": 3}
ctx.id = "task_1"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.chat_limiter = MagicMock()
ctx.chat_limiter.__aenter__ = AsyncMock()
ctx.chat_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_generate_questions_success(self):
"""Test successful question generation."""
ctx = self._create_mock_context()
docs = [
{"content_with_weight": "This is test content one"},
]
"""Test successful question generation — cache miss → LLM prompt runs."""
ctx = make_task_context(parser_config={"auto_questions": 3})
docs = [{"content_with_weight": "This is test content one"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = "Question 1\nQuestion 2"
with patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache"):
with patch("rag.svr.task_executor_refactor.chunk_post_processor.rag_tokenizer") as mock_tokenizer:
mock_tokenizer.tokenize.return_value = "Question 1 Question 2"
await generate_questions(docs, ctx)
# Verify questions were set
assert "question_kwd" in docs[0]
assert "question_tks" in docs[0]
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value=None)
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p5 = self._patch_prompt_func(
"rag.svr.task_executor_refactor.chunk_post_processor.question_proposal",
return_value="Question 1\nQuestion 2",
)
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.rag_tokenizer")
with p1, p2, p3, p4, p5, p6 as mock_tok:
mock_tok.tokenize.return_value = "Question 1 Question 2"
await generate_questions(docs, ctx)
assert "question_kwd" in docs[0]
assert "question_tks" in docs[0]
@pytest.mark.asyncio
async def test_generate_questions_canceled(self):
"""Test question generation when task is canceled."""
ctx = self._create_mock_context()
ctx.has_canceled_func = MagicMock(return_value=True)
ctx = make_task_context(parser_config={"auto_questions": 3},
has_canceled_func=MagicMock(return_value=True))
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = None # No cache
await generate_questions(docs, ctx)
# Should return early due to cancellation
assert "question_kwd" not in docs[0]
p1, p2 = self._mock_llm_binding()
with p1, p2:
await generate_questions(docs, ctx)
assert "question_kwd" not in docs[0]
class TestGenerateMetadata:
class TestGenerateMetadata(_BasePostProcessorTest):
"""Tests for generate_metadata function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.tenant_id = "tenant_1"
ctx.llm_id = "llm_1"
ctx.language = "en"
ctx.parser_config = {
"enable_metadata": True,
"metadata": [{"name": "category", "type": "string"}],
"built_in_metadata": ["author", "date"],
}
ctx.doc_id = "doc_1"
ctx.id = "task_1"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.write_interceptor = None
ctx.chat_limiter = MagicMock()
ctx.chat_limiter.__aenter__ = AsyncMock()
ctx.chat_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_generate_metadata_success(self):
"""Test successful metadata generation."""
ctx = self._create_mock_context()
docs = [
{"content_with_weight": "This is test content", "metadata_obj": {"category": "test"}},
]
"""Test successful metadata generation — cache miss → LLM prompt runs."""
ctx = make_task_context(
parser_config={
"enable_metadata": True,
"metadata": [{"name": "category", "type": "string"}],
"built_in_metadata": ["author", "date"],
},
)
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = {"category": "test"}
with patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache"):
with patch("rag.svr.task_executor_refactor.chunk_post_processor.update_metadata_to") as mock_update:
mock_update.return_value = {"category": "test"}
with patch("rag.svr.task_executor_refactor.chunk_post_processor.DocMetadataService") as mock_meta:
mock_meta.get_document_metadata.return_value = {}
mock_meta.update_document_metadata = MagicMock()
await generate_metadata(docs, ctx)
# Verify metadata_obj was processed
mock_meta.update_document_metadata.assert_called_once()
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value=None)
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p5 = self._patch_prompt_func(
"rag.svr.task_executor_refactor.chunk_post_processor.gen_metadata",
return_value={"category": "test"},
)
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.DocMetadataService")
with p1, p2, p3, p4, p5, p6 as mock_meta:
mock_meta.get_document_metadata.return_value = {}
mock_meta.update_document_metadata = MagicMock()
await generate_metadata(docs, ctx)
mock_meta.update_document_metadata.assert_called_once()
@pytest.mark.asyncio
async def test_generate_metadata_with_write_interceptor(self):
"""Test metadata generation with write interceptor."""
ctx = self._create_mock_context()
ctx.write_interceptor = MagicMock()
docs = [
{"content_with_weight": "This is test content", "metadata_obj": {"category": "test"}},
]
ctx = make_task_context(
parser_config={
"enable_metadata": True,
"metadata": [{"name": "category", "type": "string"}],
"built_in_metadata": ["author", "date"],
},
write_interceptor=MagicMock(),
)
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = {"category": "test"}
with patch("rag.svr.task_executor_refactor.chunk_post_processor.update_metadata_to") as mock_update:
mock_update.return_value = {"category": "test"}
with patch("rag.svr.task_executor_refactor.chunk_post_processor.DocMetadataService") as mock_meta:
mock_meta.get_document_metadata.return_value = {}
mock_meta.update_document_metadata = MagicMock()
await generate_metadata(docs, ctx)
ctx.write_interceptor.intercept.assert_called_once_with(
"DocMetadataService.update_document_metadata"
)
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value=None)
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p5 = self._patch_prompt_func(
"rag.svr.task_executor_refactor.chunk_post_processor.gen_metadata",
return_value={"category": "test"},
)
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.DocMetadataService")
with p1, p2, p3, p4, p5, p6:
await generate_metadata(docs, ctx)
ctx.write_interceptor.intercept.assert_called_once_with(
"DocMetadataService.update_document_metadata"
)
class TestApplyTags:
class TestApplyTags(_BasePostProcessorTest):
"""Tests for apply_tags function."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.tenant_id = "tenant_1"
ctx.llm_id = "llm_1"
ctx.language = "en"
ctx.kb_parser_config = {"tag_kb_ids": ["kb_1"], "topn_tags": 3}
ctx.id = "task_1"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.chat_limiter = MagicMock()
ctx.chat_limiter.__aenter__ = AsyncMock()
ctx.chat_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_apply_tags_success(self):
"""Test successful tag application."""
ctx = self._create_mock_context()
docs = [
{"content_with_weight": "This is test content"},
]
"""Test successful tag application with tag cache miss."""
ctx = make_task_context(
kb_parser_config={"tag_kb_ids": ["kb_1"], "topn_tags": 3},
)
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.settings") as mock_settings:
mock_settings.retriever.all_tags_in_portion.return_value = {"tag1": 10, "tag2": 5}
mock_settings.retriever.tag_content.return_value = True
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache") as mock_cache:
mock_cache.return_value = '{"tag1": 1}'
with patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache"):
await apply_tags(docs, ctx)
# Verify tags were applied
assert len(docs) == 1
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.settings")
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value='{"tag1": 1}') # cache hit → skip LLM
p5 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_tags_from_cache",
return_value=None)
p7 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_tags_to_cache")
with p1, p2, p3 as mock_settings, p4, p5, p6 as mock_get_tags, p7 as mock_set_tags:
mock_settings.retriever.all_tags_in_portion.return_value = {"tag1": 10, "tag2": 5}
mock_settings.retriever.tag_content.return_value = True
await apply_tags(docs, ctx)
assert len(docs) == 1
mock_get_tags.assert_called_once()
mock_set_tags.assert_called_once()
@pytest.mark.asyncio
async def test_apply_tags_canceled(self):
"""Test tag application when task is canceled."""
ctx = self._create_mock_context()
ctx.has_canceled_func = MagicMock(return_value=True)
docs = [
{"content_with_weight": "This is test content"},
]
ctx = make_task_context(
kb_parser_config={"tag_kb_ids": ["kb_1"], "topn_tags": 3},
has_canceled_func=MagicMock(return_value=True),
)
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_from_provider_instance") as mock_config:
mock_config.return_value = MagicMock()
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.settings")
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_tags_from_cache",
return_value=None)
p5 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_tags_to_cache")
with p1, p2, p3 as mock_settings, p4, p5:
mock_settings.retriever.all_tags_in_portion.return_value = {"tag1": 10}
await apply_tags(docs, ctx)
with patch("rag.svr.task_executor_refactor.chunk_post_processor.LLMBundle") as mock_llm:
mock_llm_instance = MagicMock()
mock_llm.return_value.__enter__ = MagicMock(return_value=mock_llm_instance)
mock_llm.return_value.__exit__ = MagicMock(return_value=False)
@pytest.mark.asyncio
async def test_apply_tags_tag_cache_miss(self):
"""Test apply_tags when get_tags_from_cache returns None (cache miss)."""
ctx = make_task_context(
kb_parser_config={"tag_kb_ids": ["kb_1"], "topn_tags": 3},
)
docs = [{"content_with_weight": "This is test content"}]
with patch("rag.svr.task_executor_refactor.chunk_post_processor.settings") as mock_settings:
mock_settings.retriever.all_tags_in_portion.return_value = {"tag1": 10}
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.settings")
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value='{"tag1": 1}') # cache hit → skip LLM
p5 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_tags_from_cache",
return_value=None)
p7 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_tags_to_cache")
with p1, p2, p3 as mock_settings, p4, p5, p6 as mock_get_tags, p7 as mock_set_tags:
mock_settings.retriever.all_tags_in_portion.return_value = {"tag1": 10, "tag2": 5}
mock_settings.retriever.tag_content.return_value = True
await apply_tags(docs, ctx)
mock_get_tags.assert_called_once_with(["kb_1"])
mock_set_tags.assert_called_once()
mock_settings.retriever.all_tags_in_portion.assert_called_once()
await apply_tags(docs, ctx)
@pytest.mark.asyncio
async def test_apply_tags_tag_cache_hit(self):
"""Test apply_tags when get_tags_from_cache returns valid data (cache hit)."""
ctx = make_task_context(
kb_parser_config={"tag_kb_ids": ["kb_1"], "topn_tags": 3},
)
docs = [{"content_with_weight": "This is test content"}]
# Should return early due to cancellation
p1, p2 = self._mock_llm_binding()
p3 = patch("rag.svr.task_executor_refactor.chunk_post_processor.settings")
p4 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_llm_cache",
return_value='{"tag1": 1}') # cache hit → skip LLM
p5 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_llm_cache")
p6 = patch("rag.svr.task_executor_refactor.chunk_post_processor.get_tags_from_cache",
return_value='{"cached_tag": 10}')
p7 = patch("rag.svr.task_executor_refactor.chunk_post_processor.set_tags_to_cache")
with p1, p2, p3 as mock_settings, p4, p5, p6 as mock_get_tags, p7 as mock_set_tags:
mock_settings.retriever.tag_content.return_value = True
await apply_tags(docs, ctx)
mock_get_tags.assert_called_once_with(["kb_1"])
mock_set_tags.assert_not_called()
mock_settings.retriever.all_tags_in_portion.assert_not_called()
class TestCountWithKey:
"""Tests for count_with_key function."""
def test_count_with_key_all_have_key(self):
"""Test counting when all docs have the key."""
docs = [{"tag": 1}, {"tag": 2}, {"tag": 3}]
result = count_with_key(docs, "tag")
assert result == 3
assert count_with_key(docs, "tag") == 3
def test_count_with_key_some_have_key(self):
"""Test counting when some docs have the key."""
docs = [{"tag": 1}, {"other": 2}, {"tag": 3}]
result = count_with_key(docs, "tag")
assert result == 2
assert count_with_key(docs, "tag") == 2
def test_count_with_key_none_have_key(self):
"""Test counting when no docs have the key."""
docs = [{"other": 1}, {"other": 2}]
result = count_with_key(docs, "tag")
assert result == 0
assert count_with_key(docs, "tag") == 0
def test_count_with_key_empty_docs(self):
"""Test counting with empty docs list."""
result = count_with_key([], "tag")
assert result == 0
assert count_with_key([], "tag") == 0
def test_count_with_key_falsy_value(self):
"""Test counting when key exists but has falsy value."""
docs = [{"tag": 0}, {"tag": ""}, {"tag": None}]
result = count_with_key(docs, "tag")
# Falsy values should not be counted (since d.get(key) returns falsy)
assert result == 0
assert count_with_key(docs, "tag") == 0
def test_count_with_key_truthy_value(self):
"""Test counting when key has truthy value."""
docs = [{"tag": 1}, {"tag": "value"}, {"tag": [1, 2]}]
result = count_with_key(docs, "tag")
assert result == 3
assert count_with_key(docs, "tag") == 3
class TestBuildMetadataConfig:
"""Tests for build_metadata_config function."""
def test_dict_without_properties_returns_schema(self):
"""When metadata is a dict without properties, return {type: object, properties: {}}."""
parser_config = {"metadata": {"type": "object"}, "built_in_metadata": []}
result = build_metadata_config(parser_config)
assert result == {"type": "object", "properties": {}}
assert build_metadata_config(parser_config) == {"type": "object", "properties": {}}
def test_dict_with_properties_and_built_in(self):
"""When metadata is a dict with properties AND built_in_metadata, merge them."""
parser_config = {
"metadata": {"type": "object", "properties": {"a": {"type": "string"}}},
"built_in_metadata": [{"key": "author", "description": "Author name", "enum": ["alice", "bob"]}],
}
result = build_metadata_config(parser_config)
assert result["type"] == "object"
assert "a" in result["properties"]
assert "author" in result["properties"]
def test_dict_with_properties_no_built_in(self):
"""When metadata is a dict with properties and no built_in, return as-is."""
parser_config = {
"metadata": {"type": "object", "properties": {"a": {"type": "string"}}},
"built_in_metadata": [],
@@ -423,38 +349,32 @@ class TestBuildMetadataConfig:
assert result == {"type": "object", "properties": {"a": {"type": "string"}}}
def test_list_with_built_in(self):
"""When metadata is a list and built_in_metadata is present, concatenate."""
parser_config = {
"metadata": [{"key": "category"}],
"built_in_metadata": [{"key": "author"}],
}
result = build_metadata_config(parser_config)
assert result == [{"key": "category"}, {"key": "author"}]
assert build_metadata_config(parser_config) == [{"key": "category"}, {"key": "author"}]
def test_list_without_built_in(self):
"""When metadata is a list and built_in_metadata is empty, return metadata as-is."""
parser_config = {"metadata": [{"key": "category"}], "built_in_metadata": []}
result = build_metadata_config(parser_config)
assert result == [{"key": "category"}]
assert build_metadata_config(parser_config) == [{"key": "category"}]
def test_other_type_with_built_in(self):
"""When metadata is not dict or list (empty list), return built_in_metadata only."""
parser_config = {"metadata": [], "built_in_metadata": [{"key": "author"}]}
result = build_metadata_config(parser_config)
assert result == [{"key": "author"}]
assert build_metadata_config(parser_config) == [{"key": "author"}]
def test_idempotent_same_input(self):
"""Same input produces structurally equal results."""
parser_config = {
"metadata": [{"key": "category"}],
"built_in_metadata": [{"key": "author"}],
}
result1 = build_metadata_config(parser_config)
result2 = build_metadata_config(parser_config)
assert result1 == result2
assert build_metadata_config(parser_config) == build_metadata_config(parser_config)
def test_missing_metadata_key(self):
"""When parser_config has no 'metadata' key, built_in_metadata alone is returned."""
parser_config = {"built_in_metadata": []}
assert build_metadata_config({"built_in_metadata": []}) == []
def test_metadata_is_none(self):
"""When metadata is None, built_in_metadata alone is returned."""
parser_config = {"metadata": None, "built_in_metadata": [{"key": "author"}]}
result = build_metadata_config(parser_config)
assert result == []
assert result == [{"key": "author"}]

View File

@@ -29,6 +29,7 @@ This test file now focuses on ChunkService-specific functionality:
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from rag.svr.task_executor_refactor.chunk_service import ChunkService
from test.unit_test.rag.svr.task_executor_refactor.conftest import make_task_context
class TestChunkServiceInit:
@@ -44,40 +45,11 @@ class TestChunkServiceInit:
class TestChunkServiceBuildChunks:
"""Tests for build_chunks method."""
def _create_mock_context(self, parser_id="naive", size=1000, parser_config=None, kb_parser_config=None):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.parser_id = parser_id
ctx.name = "test.pdf"
ctx.size = size
ctx.from_page = 0
ctx.to_page = -1
ctx.parser_config = parser_config or {}
ctx.kb_parser_config = kb_parser_config or {}
ctx.language = "en"
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.doc_id = "doc_1"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.write_interceptor = None
ctx.raw_task = {}
ctx.llm_id = "llm_1"
ctx.pagerank = 0
ctx.location = "/path/to/test.pdf"
ctx.chunk_limiter = MagicMock()
ctx.chunk_limiter.__aenter__ = AsyncMock()
ctx.chunk_limiter.__aexit__ = AsyncMock()
ctx.chat_limiter = MagicMock()
ctx.chat_limiter.__aenter__ = AsyncMock()
ctx.chat_limiter.__aexit__ = AsyncMock()
return ctx
@pytest.mark.asyncio
async def test_build_chunks_file_size_exceeded(self):
"""Test build_chunks returns empty list when file size exceeds limit."""
ctx = self._create_mock_context(size=1000000000) # Very large size
ctx = make_task_context(size=1000000000) # Very large size
service = ChunkService(ctx=ctx)
@@ -95,7 +67,7 @@ class TestChunkServiceBuildChunks:
@pytest.mark.asyncio
async def test_build_chunks_file_size_ok(self):
"""Test build_chunks proceeds when file size is within limit."""
ctx = self._create_mock_context(size=1000)
ctx = make_task_context(size=1000)
service = ChunkService(ctx=ctx)
@@ -123,138 +95,43 @@ class TestChunkServiceBuildChunks:
mock_get_parser.assert_called_once_with("naive")
@pytest.mark.asyncio
async def test_build_chunks_with_auto_keywords(self):
"""Test build_chunks triggers keyword extraction when configured."""
ctx = self._create_mock_context(parser_config={"auto_keywords": 5})
@pytest.mark.parametrize("task_kwargs,func_path,func_name", [
({"parser_config": {"auto_keywords": 5}}, "extract_keywords", "extract_keywords"),
({"parser_config": {"auto_questions": 3}}, "generate_questions", "generate_questions"),
({"kb_parser_config": {"tag_kb_ids": ["kb_1"]}}, "apply_tags", "apply_tags"),
({"parser_config": {"enable_metadata": True, "metadata": [{"name": "category", "type": "string"}]}},
"generate_metadata", "generate_metadata"),
])
async def test_build_chunks_with_post_processing(self, task_kwargs, func_path, func_name):
"""Test build_chunks triggers post-processing when configured."""
ctx = make_task_context(**task_kwargs)
service = ChunkService(ctx=ctx)
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings, \
patch("rag.svr.task_executor_refactor.chunk_service.get_parser") as mock_get_parser, \
patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking, \
patch("rag.svr.task_executor_refactor.chunk_service.extract_outline", new_callable=AsyncMock), \
patch.object(service, '_prepare_docs_and_upload', new_callable=AsyncMock) as mock_prepare, \
patch(f"rag.svr.task_executor_refactor.chunk_service.{func_path}", new_callable=AsyncMock) as mock_fn:
mock_settings.DOC_MAXIMUM_SIZE = 10000000
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_service.get_parser") as mock_get_parser:
mock_get_parser.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking:
mock_run_chunking.return_value = []
with patch("rag.svr.task_executor_refactor.chunk_service.extract_outline", new_callable=AsyncMock):
with patch.object(service, '_prepare_docs_and_upload', new_callable=AsyncMock) as mock_prepare:
mock_prepare.return_value = [{"id": "chunk_1", "content_with_weight": "test"}]
with patch("rag.svr.task_executor_refactor.chunk_service.extract_keywords", new_callable=AsyncMock) as mock_extract:
await service.build_chunks(b"test binary")
mock_extract.assert_called_once()
@pytest.mark.asyncio
async def test_build_chunks_with_auto_questions(self):
"""Test build_chunks triggers question generation when configured."""
ctx = self._create_mock_context(parser_config={"auto_questions": 3})
service = ChunkService(ctx=ctx)
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
mock_settings.DOC_MAXIMUM_SIZE = 10000000
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_service.get_parser") as mock_get_parser:
mock_get_parser.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking:
mock_run_chunking.return_value = []
with patch("rag.svr.task_executor_refactor.chunk_service.extract_outline", new_callable=AsyncMock):
with patch.object(service, '_prepare_docs_and_upload', new_callable=AsyncMock) as mock_prepare:
mock_prepare.return_value = [{"id": "chunk_1", "content_with_weight": "test"}]
with patch("rag.svr.task_executor_refactor.chunk_service.generate_questions", new_callable=AsyncMock) as mock_gen:
await service.build_chunks(b"test binary")
mock_gen.assert_called_once()
@pytest.mark.asyncio
async def test_build_chunks_with_tag_kb_ids(self):
"""Test build_chunks triggers tag application when tag_kb_ids configured."""
ctx = self._create_mock_context(kb_parser_config={"tag_kb_ids": ["kb_1"]})
service = ChunkService(ctx=ctx)
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
mock_settings.DOC_MAXIMUM_SIZE = 10000000
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_service.get_parser") as mock_get_parser:
mock_get_parser.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking:
mock_run_chunking.return_value = []
with patch("rag.svr.task_executor_refactor.chunk_service.extract_outline", new_callable=AsyncMock):
with patch.object(service, '_prepare_docs_and_upload', new_callable=AsyncMock) as mock_prepare:
mock_prepare.return_value = [{"id": "chunk_1", "content_with_weight": "test"}]
with patch("rag.svr.task_executor_refactor.chunk_service.apply_tags", new_callable=AsyncMock) as mock_apply:
await service.build_chunks(b"test binary")
mock_apply.assert_called_once()
@pytest.mark.asyncio
async def test_build_chunks_with_metadata(self):
"""Test build_chunks triggers metadata generation when configured."""
ctx = self._create_mock_context(
parser_config={
"enable_metadata": True,
"metadata": [{"name": "category", "type": "string"}]
}
)
service = ChunkService(ctx=ctx)
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
mock_settings.DOC_MAXIMUM_SIZE = 10000000
mock_rec_ctx = MagicMock()
ctx.recording_context = mock_rec_ctx
with patch("rag.svr.task_executor_refactor.chunk_service.get_parser") as mock_get_parser:
mock_get_parser.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking:
mock_run_chunking.return_value = []
with patch("rag.svr.task_executor_refactor.chunk_service.extract_outline", new_callable=AsyncMock):
with patch.object(service, '_prepare_docs_and_upload', new_callable=AsyncMock) as mock_prepare:
mock_prepare.return_value = [{"id": "chunk_1", "content_with_weight": "test"}]
with patch("rag.svr.task_executor_refactor.chunk_service.generate_metadata", new_callable=AsyncMock) as mock_meta:
await service.build_chunks(b"test binary")
mock_meta.assert_called_once()
mock_get_parser.return_value = MagicMock()
mock_run_chunking.return_value = []
mock_prepare.return_value = [{"id": "chunk_1", "content_with_weight": "test"}]
await service.build_chunks(b"test binary")
mock_fn.assert_called_once()
class TestChunkServicePrepareDocsAndUpload:
"""Tests for _prepare_docs_and_upload method."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.doc_id = "doc_1"
ctx.kb_id = "kb_1"
ctx.tenant_id = "tenant_1"
ctx.name = "test.pdf"
ctx.location = "/path/to/test.pdf"
ctx.pagerank = 0
ctx.progress_cb = MagicMock()
return ctx
@pytest.mark.asyncio
async def test_prepare_docs_and_upload_basic(self):
"""Test basic document preparation."""
ctx = self._create_mock_context()
ctx = make_task_context()
service = ChunkService(ctx=ctx)
cks = [{"content_with_weight": "test chunk"}]
@@ -274,7 +151,7 @@ class TestChunkServicePrepareDocsAndUpload:
@pytest.mark.asyncio
async def test_prepare_docs_and_upload_with_pagerank(self):
"""Test document preparation with pagerank."""
ctx = self._create_mock_context()
ctx = make_task_context()
ctx.pagerank = 5
service = ChunkService(ctx=ctx)
@@ -293,23 +170,11 @@ class TestChunkServicePrepareDocsAndUpload:
class TestChunkServiceInsertChunks:
"""Tests for insert_chunks method."""
def _create_mock_context(self):
"""Helper to create a mock TaskContext."""
ctx = MagicMock()
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.doc_id = "doc_1"
ctx.parser_id = "naive"
ctx.progress_cb = MagicMock()
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.write_interceptor = None
return ctx
@pytest.mark.asyncio
async def test_insert_chunks_success(self):
"""Test successful chunk insertion."""
ctx = self._create_mock_context()
ctx = make_task_context()
service = ChunkService(ctx=ctx)
chunks = [
@@ -338,7 +203,7 @@ class TestChunkServiceInsertChunks:
@pytest.mark.asyncio
async def test_insert_chunks_canceled(self):
"""Test chunk insertion when task is canceled."""
ctx = self._create_mock_context()
ctx = make_task_context()
ctx.has_canceled_func = MagicMock(return_value=True)
service = ChunkService(ctx=ctx)
@@ -363,7 +228,7 @@ class TestChunkServiceInsertChunks:
@pytest.mark.asyncio
async def test_insert_chunks_doc_store_error(self):
"""Test chunk insertion when doc store returns error."""
ctx = self._create_mock_context()
ctx = make_task_context()
service = ChunkService(ctx=ctx)
chunks = [{"id": "chunk_1", "content_with_weight": "test1"}]

View File

@@ -138,25 +138,18 @@ class TestComparisonReport:
assert "No comparison details" in md
class TestContextComparatorInit:
"""Tests for ContextComparator initialization."""
class TestContextComparatorCompareValue:
"""Tests for ContextComparator.compare_value method and initialization."""
def test_init_default_tolerance(self):
"""Test initialization with default tolerance."""
comparator = ContextComparator()
assert comparator.float_tolerance == 1e-6
assert ContextComparator().float_tolerance == 1e-6
def test_init_custom_tolerance(self):
"""Test initialization with custom tolerance."""
comparator = ContextComparator(float_tolerance=0.01)
assert comparator.float_tolerance == 0.01
class TestContextComparatorCompareValue:
"""Tests for ContextComparator.compare_value method."""
assert ContextComparator(float_tolerance=0.01).float_tolerance == 0.01
def setup_method(self):
"""Set up test fixtures."""
self.comparator = ContextComparator()
def test_compare_none_values(self):

View File

@@ -66,10 +66,11 @@ class TestDataflowServiceRunDataflow:
await service.run_dataflow()
@pytest.mark.asyncio
@pytest.mark.parametrize("output_key", ["chunks", "json"])
@patch("rag.svr.task_executor_refactor.dataflow_service.Pipeline")
@patch("rag.svr.task_executor_refactor.dataflow_service.UserCanvasService")
async def test_run_dataflow_with_chunks_output(self, mock_canvas, mock_pipeline_class, task_context):
"""Test run_dataflow processes 'chunks' output type end-to-end."""
async def test_run_dataflow_with_output_type(self, mock_canvas, mock_pipeline_class, task_context, output_key):
"""Test run_dataflow processes output end-to-end (chunks / json)."""
task_context._task["task_type"] = "dataflow"
task_context._task["dataflow_id"] = "dataflow_test"
task_context._task["tenant_id"] = "tenant_test"
@@ -79,59 +80,22 @@ class TestDataflowServiceRunDataflow:
task_context._write_interceptor = None
mock_canvas.get_by_id.return_value = (True, MagicMock(dsl='{"id": "test"}'))
chunks = {
"chunks": [
{"text": "Hello world", "content_with_weight": "Hello world"},
],
"embedding_token_consumption": 5,
}
data = {output_key: [{"text": "content", "content_with_weight": "content"}]}
data["embedding_token_consumption"] = 5
mock_pipeline = MagicMock()
mock_pipeline.run = AsyncMock(return_value=chunks)
mock_pipeline.run = AsyncMock(return_value=data)
mock_pipeline_class.return_value = mock_pipeline
# Patch internal heavy dependencies so run_dataflow completes
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(chunks["chunks"], 5)):
with patch.object(DataflowService, '_insert_chunks', new_callable=AsyncMock, return_value=True):
with patch.object(DataflowService, '_update_document_metadata'):
with patch.object(DataflowService, '_record_pipeline_log'):
with patch("api.db.services.document_service.DocumentService.increment_chunk_num"):
service = DataflowService(ctx=task_context)
await service.run_dataflow()
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock,
return_value=(data[output_key], 5)), \
patch.object(DataflowService, '_insert_chunks', new_callable=AsyncMock, return_value=True), \
patch.object(DataflowService, '_update_document_metadata'), \
patch.object(DataflowService, '_record_pipeline_log'), \
patch("api.db.services.document_service.DocumentService.increment_chunk_num"):
# Verify chunks were inserted
DataflowService._insert_chunks.assert_called_once()
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.dataflow_service.Pipeline")
@patch("rag.svr.task_executor_refactor.dataflow_service.UserCanvasService")
async def test_run_dataflow_with_json_output(self, mock_canvas, mock_pipeline_class, task_context):
"""Test run_dataflow processes 'json' output type."""
task_context._task["task_type"] = "dataflow"
task_context._task["dataflow_id"] = "dataflow_test"
task_context._task["tenant_id"] = "tenant_test"
task_context._task["kb_id"] = "kb_test"
task_context._task["doc_id"] = "doc_test"
task_context._task["name"] = "test.pdf"
task_context._write_interceptor = None
mock_canvas.get_by_id.return_value = (True, MagicMock(dsl='{"id": "test"}'))
chunks = {
"json": [
{"text": "JSON content"},
],
"embedding_token_consumption": 2,
}
mock_pipeline = MagicMock()
mock_pipeline.run = AsyncMock(return_value=chunks)
mock_pipeline_class.return_value = mock_pipeline
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(chunks["json"], 2)):
with patch.object(DataflowService, '_insert_chunks', new_callable=AsyncMock, return_value=True):
with patch.object(DataflowService, '_update_document_metadata'):
with patch.object(DataflowService, '_record_pipeline_log'):
with patch("api.db.services.document_service.DocumentService.increment_chunk_num"):
service = DataflowService(ctx=task_context)
await service.run_dataflow()
service = DataflowService(ctx=task_context)
await service.run_dataflow()
DataflowService._insert_chunks.assert_called_once()
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.dataflow_service.Pipeline")
@@ -154,13 +118,11 @@ class TestDataflowServiceRunDataflow:
mock_pipeline.run = AsyncMock(return_value=chunks)
mock_pipeline_class.return_value = mock_pipeline
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(None, 0)):
with patch.object(DataflowService, '_record_pipeline_log'):
service = DataflowService(ctx=task_context)
await service.run_dataflow()
# Should not insert chunks when embedding fails
service._record_pipeline_log.assert_called()
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(None, 0)), \
patch.object(DataflowService, '_record_pipeline_log'):
service = DataflowService(ctx=task_context)
await service.run_dataflow()
service._record_pipeline_log.assert_called()
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.dataflow_service.Pipeline")
@@ -190,16 +152,16 @@ class TestDataflowServiceRunDataflow:
billing_hook.on_pipeline_success = AsyncMock()
billing_hook.on_pipeline_error = AsyncMock()
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(chunks["chunks"], 1)):
with patch.object(DataflowService, '_insert_chunks', new_callable=AsyncMock, return_value=True):
with patch.object(DataflowService, '_update_document_metadata'):
with patch.object(DataflowService, '_record_pipeline_log'):
with patch("api.db.services.document_service.DocumentService.increment_chunk_num"):
service = DataflowService(ctx=task_context, billing_hook=billing_hook)
await service.run_dataflow()
with patch.object(DataflowService, '_embed_chunks', new_callable=AsyncMock, return_value=(chunks["chunks"], 1)), \
patch.object(DataflowService, '_insert_chunks', new_callable=AsyncMock, return_value=True), \
patch.object(DataflowService, '_update_document_metadata'), \
patch.object(DataflowService, '_record_pipeline_log'), \
patch("api.db.services.document_service.DocumentService.increment_chunk_num"):
billing_hook.on_pipeline_success.assert_called_once()
billing_hook.on_pipeline_error.assert_not_called()
service = DataflowService(ctx=task_context, billing_hook=billing_hook)
await service.run_dataflow()
billing_hook.on_pipeline_success.assert_called_once()
billing_hook.on_pipeline_error.assert_not_called()
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.dataflow_service.Pipeline")
@@ -378,4 +340,46 @@ class TestDataflowServiceInit:
hook = MagicMock()
service = DataflowService(ctx=ctx, billing_hook=hook)
assert service._task_context is ctx
assert service._billing_hook is hook
assert service._billing_hook is hook
class TestDataflowServiceLoadDsl:
"""Tests for _load_dsl with dataflow_id correction."""
@pytest.mark.asyncio
async def test_load_dsl_for_dataflow_task_type_returns_unchanged_id(self):
"""When task_type == 'dataflow', dataflow_id is returned unchanged."""
ctx = MagicMock()
ctx.task_type = "dataflow"
dataflow_id = "original_dataflow_id"
with patch("rag.svr.task_executor_refactor.dataflow_service.UserCanvasService") as mock_canvas:
mock_canvas.get_by_id.return_value = (True, MagicMock(dsl='{"id": "test"}'))
service = DataflowService(ctx=ctx)
dsl, corrected_id = await service._load_dsl(dataflow_id)
assert dsl == '{"id": "test"}'
assert corrected_id == "original_dataflow_id"
mock_canvas.get_by_id.assert_called_once_with(dataflow_id)
@pytest.mark.asyncio
async def test_load_dsl_for_pipeline_log_task_type_returns_corrected_id(self):
"""When task_type != 'dataflow', dataflow_id comes from pipeline_log.pipeline_id."""
ctx = MagicMock()
ctx.task_type = "raptor"
dataflow_id = "pipeline_log_id"
with patch("rag.svr.task_executor_refactor.dataflow_service.PipelineOperationLogService") as mock_log:
mock_log_instance = MagicMock()
mock_log_instance.dsl = '{"id": "test_pipeline"}'
mock_log_instance.pipeline_id = "corrected_pipeline_id"
mock_log.get_by_id.return_value = (True, mock_log_instance)
service = DataflowService(ctx=ctx)
dsl, corrected_id = await service._load_dsl(dataflow_id)
assert dsl == '{"id": "test_pipeline"}'
assert corrected_id == "corrected_pipeline_id"
mock_log.get_by_id.assert_called_once_with(dataflow_id)

View File

@@ -17,13 +17,13 @@
Unit tests for EmbeddingService module.
All tests validate behavior through the public API (embed_chunks) rather than
reaching into private orchestration methods like _encode_single, _encode_batch,
or _run_encode. Those internal boundaries may be reshaped during a refactor
without changing the external behavior; the suite should not break in that case.
reaching into private orchestration methods. The new async implementation uses
thread_pool_exec for model.encode calls; tests mock that boundary.
"""
import numpy as np
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from rag.svr.task_executor_refactor.embedding_service import EmbeddingService
@@ -56,17 +56,18 @@ class TestEmbeddingServiceInit:
class TestEmbeddingServiceEmbedChunks:
"""Tests for the public embed_chunks method.
Internal helpers _encode_single, _encode_batch, and _run_encode are
exercised through this public entry point so the suite stays resilient to
method-boundary reshuffles.
The async implementation uses thread_pool_exec for model.encode calls.
Tests mock thread_pool_exec at the module level to control returned vectors.
"""
@patch.object(EmbeddingService, '_run_encode')
def test_embed_chunks_basic(self, mock_run_encode):
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_basic(self, mock_thread_pool):
"""Test basic chunk embedding."""
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 10)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
@@ -74,23 +75,20 @@ class TestEmbeddingServiceEmbedChunks:
docs = [
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
]
tk_count, vector_size = service.embed_chunks(docs, model)
tk_count, vector_size = await service.embed_chunks(docs, model)
assert tk_count > 0
assert vector_size == 2
assert "q_2_vec" in docs[0]
@patch.object(EmbeddingService, '_run_encode')
def test_embed_chunks_uses_embedding_utils(self, mock_run_encode):
"""Test that embed_chunks uses EmbeddingUtils internally.
The internal path runs _encode_batch -> EmbeddingUtils.truncate_texts
-> _run_encode. We verify via the public embed_chunks that the chain
is wired correctly without asserting on individual private method calls.
"""
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_uses_embedding_utils(self, mock_thread_pool):
"""Test that embed_chunks uses thread_pool_exec for encoding."""
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 10)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
@@ -98,16 +96,19 @@ class TestEmbeddingServiceEmbedChunks:
docs = [
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
]
service.embed_chunks(docs, model)
await service.embed_chunks(docs, model)
mock_run_encode.assert_called()
# thread_pool_exec should be called at least once for encoding
mock_thread_pool.assert_called()
@patch.object(EmbeddingService, '_run_encode')
def test_embed_chunks_with_title_content_combination(self, mock_run_encode):
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_with_title_content_combination(self, mock_thread_pool):
"""Test that title and content vectors are combined."""
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 10)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
@@ -115,21 +116,23 @@ class TestEmbeddingServiceEmbedChunks:
docs = [
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
]
_, vector_size = service.embed_chunks(docs, model, parser_config={"filename_embd_weight": 0.5})
_, vector_size = await service.embed_chunks(docs, model, parser_config={"filename_embd_weight": 0.5})
assert vector_size == 2
@patch.object(EmbeddingService, '_run_encode')
def test_embed_chunks_handles_long_text(self, mock_run_encode):
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_handles_long_text(self, mock_thread_pool):
"""Test that long texts are handled by embedding pipeline.
Even with content exceeding model.max_length, embed_chunks produces
valid vectors, meaning truncation (via EmbeddingUtils) is wired
correctly in the encode path.
"""
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 10)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
@@ -137,9 +140,125 @@ class TestEmbeddingServiceEmbedChunks:
docs = [
{"docnm_kwd": "Title1", "content_with_weight": "a" * 200},
]
tk_count, vector_size = service.embed_chunks(docs, model)
tk_count, vector_size = await service.embed_chunks(docs, model)
# Public contract: embed_chunks returns valid token counts and vectors
assert tk_count > 0
assert vector_size == 2
assert "q_2_vec" in docs[0]
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_empty_docs(self, mock_thread_pool):
"""Test embedding with empty docs list returns zero results."""
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx)
model = MagicMock()
model.max_length = 100
tk_count, vector_size = await service.embed_chunks([], model)
assert tk_count == 0
assert vector_size == 0
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_no_title(self, mock_thread_pool):
"""Test embedding when chunks have no title — content vectors used directly."""
mock_thread_pool.return_value = (np.array([[3.0, 4.0]]), 5)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
docs = [{"content_with_weight": "Content only, no title"}]
tk_count, vector_size = await service.embed_chunks(docs, model)
# With no title, only content is encoded (1 call); vector_size comes from content vec
assert vector_size == 2
assert "q_2_vec" in docs[0]
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_title_weight_zero(self, mock_thread_pool):
"""Test embedding with filename_embd_weight=0.0 — no title contribution."""
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 5)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
docs = [{"docnm_kwd": "Title1", "content_with_weight": "Content1"}]
_, vector_size = await service.embed_chunks(docs, model,
parser_config={"filename_embd_weight": 0.0})
assert vector_size == 2
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_title_weight_one(self, mock_thread_pool):
"""Test embedding with filename_embd_weight=1.0 — full title contribution."""
mock_thread_pool.return_value = (np.array([[1.0, 2.0]]), 5)
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
docs = [{"docnm_kwd": "Title1", "content_with_weight": "Content1"}]
_, vector_size = await service.embed_chunks(docs, model,
parser_config={"filename_embd_weight": 1.0})
assert vector_size == 2
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_encode_failure_propagates(self, mock_thread_pool):
"""Test that model.encode exceptions are propagated to caller."""
mock_thread_pool.side_effect = RuntimeError("embedding service unavailable")
ctx = MagicMock()
ctx.progress_cb = None
ctx.embed_limiter = AsyncMockLimiter()
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
model = MagicMock()
model.max_length = 100
docs = [{"docnm_kwd": "Title1", "content_with_weight": "Content1"}]
with pytest.raises(RuntimeError, match="embedding service unavailable"):
await service.embed_chunks(docs, model)
@pytest.mark.asyncio
@patch("rag.svr.task_executor_refactor.embedding_service.thread_pool_exec", new_callable=AsyncMock)
async def test_embed_chunks_multiple_batches(self, mock_thread_pool):
"""Test embedding with more chunks than batch size — multiple encode calls."""
# Each call returns vectors matching input count
def side_effect(func, texts, *args, **kw):
n = len(texts) if isinstance(texts, list) else 1
return np.random.rand(n, 2).astype(np.float32), 10 * n
mock_thread_pool.side_effect = side_effect
ctx = MagicMock()
ctx.progress_cb = MagicMock()
ctx.embed_limiter = AsyncMockLimiter()
# batch_size=2, 5 chunks with titles → 1 title + ceil(5/2)=3 content = 4 calls
service = EmbeddingService(ctx=ctx, embedding_batch_size=2)
model = MagicMock()
model.max_length = 100
docs = [{"docnm_kwd": f"Title{i}", "content_with_weight": f"Content{i}"} for i in range(5)]
_, vector_size = await service.embed_chunks(docs, model)
assert mock_thread_pool.call_count == 4
assert vector_size > 0
# Reuse from conftest
from test.unit_test.rag.svr.task_executor_refactor.conftest import AsyncMockLimiter

View File

@@ -60,6 +60,58 @@ class TestPostProcessorProcessTableParserMetadata:
mock_merge.assert_called_once()
@pytest.mark.asyncio
async def test_processes_table_parser_manual_mode(self):
"""Test that table parser in manual mode aggregates and persists metadata."""
ctx = MagicMock()
ctx.parser_id = "table"
ctx.raw_task = {"parser_config": {}}
ctx.write_interceptor = None
chunks = [{"col_key": "val"}]
with patch("rag.svr.task_executor_refactor.post_processor.merge_table_parser_config_from_kb") as mock_merge, \
patch("rag.svr.task_executor_refactor.post_processor.aggregate_table_manual_doc_metadata") as mock_agg, \
patch("rag.svr.task_executor_refactor.post_processor.table_parser_strip_doc_metadata_keys") as mock_strip, \
patch("rag.svr.task_executor_refactor.post_processor.update_metadata_to") as mock_update, \
patch("rag.svr.task_executor_refactor.post_processor.DocMetadataService") as mock_meta:
mock_merge.return_value = {"table_column_mode": "manual"}
mock_agg.return_value = {"col_key": ["val1", "val2"]}
mock_strip.return_value = set()
mock_meta.get_document_metadata.return_value = {}
mock_update.return_value = {"col_key": ["val1", "val2"]}
service = PostProcessor(ctx=ctx)
await service.process_table_parser_metadata("doc_1", chunks)
mock_agg.assert_called_once_with(chunks, ctx.raw_task)
mock_meta.update_document_metadata.assert_called_once()
@pytest.mark.asyncio
async def test_processes_table_parser_with_write_interceptor(self):
"""Test table parser with write interceptor bypasses DB."""
ctx = MagicMock()
ctx.parser_id = "table"
ctx.raw_task = {}
ctx.write_interceptor = MagicMock()
with patch("rag.svr.task_executor_refactor.post_processor.merge_table_parser_config_from_kb") as mock_merge, \
patch("rag.svr.task_executor_refactor.post_processor.aggregate_table_manual_doc_metadata") as mock_agg, \
patch("rag.svr.task_executor_refactor.post_processor.table_parser_strip_doc_metadata_keys") as mock_strip, \
patch("rag.svr.task_executor_refactor.post_processor.DocMetadataService") as mock_meta:
mock_merge.return_value = {"table_column_mode": "manual"}
mock_agg.return_value = {"key": ["v"]}
mock_strip.return_value = set()
mock_meta.get_document_metadata.return_value = {}
service = PostProcessor(ctx=ctx)
await service.process_table_parser_metadata("doc_1", [])
ctx.write_interceptor.intercept.assert_called_once_with(
"DocMetadataService.update_document_metadata"
)
class TestPostProcessorInsertTocChunk:
"""Tests for insert_toc_chunk method."""

View File

@@ -33,6 +33,7 @@ import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from rag.svr.task_executor_refactor.raptor_service import RaptorService
from test.unit_test.rag.svr.task_executor_refactor.conftest import make_task_context
# =============================================================================
@@ -216,7 +217,8 @@ class TestRaptorServiceRunRaptorForKb:
# ---- Basic dispatch (file-level scope) ----
def test_run_raptor_for_kb_file_scope_delegates_to_file_level(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_file_scope_delegates_to_file_level(
self, mock_raptor_context, sample_chunks, raptor_config_file_scope
):
"""When scope='file', _run_file_level_raptor is called."""
@@ -233,20 +235,7 @@ class TestRaptorServiceRunRaptorForKb:
patch.object(svc, "_run_dataset_level_raptor", new_callable=AsyncMock) as mock_dataset:
mock_file.return_value = (sample_chunks, 42)
AsyncMock(return_value=(sample_chunks, 42, []))
with patch.object(RaptorService, "run_raptor_for_kb", new=AsyncMock(wraps=svc.run_raptor_for_kb)):
pass # let's just call directly
# Direct call since we need to invoke the async method properly
import asyncio
loop = asyncio.new_event_loop()
try:
chunks, tk_count, cleanup = loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
)
finally:
loop.close()
chunks, tk_count, cleanup = await svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
mock_file.assert_called_once()
mock_dataset.assert_not_called()
@@ -255,7 +244,8 @@ class TestRaptorServiceRunRaptorForKb:
# ---- Basic dispatch (dataset-level scope) ----
def test_run_raptor_for_kb_dataset_scope_delegates_to_dataset_level(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_dataset_scope_delegates_to_dataset_level(
self, mock_raptor_context, sample_chunks, raptor_config_dataset_scope
):
"""When scope='dataset', _run_dataset_level_raptor is called."""
@@ -271,15 +261,7 @@ class TestRaptorServiceRunRaptorForKb:
patch.object(svc, "_run_dataset_level_raptor", new_callable=AsyncMock) as mock_dataset:
mock_dataset.return_value = (sample_chunks, 99)
import asyncio
loop = asyncio.new_event_loop()
try:
chunks, tk_count, cleanup = loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_dataset_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
)
finally:
loop.close()
chunks, tk_count, cleanup = await svc.run_raptor_for_kb(raptor_config_dataset_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
mock_dataset.assert_called_once()
mock_file.assert_not_called()
@@ -288,7 +270,8 @@ class TestRaptorServiceRunRaptorForKb:
# ---- Empty / no documents ----
def test_run_raptor_for_kb_empty_doc_ids(self, mock_raptor_context, raptor_config_file_scope):
@pytest.mark.asyncio
async def test_run_raptor_for_kb_empty_doc_ids(self, mock_raptor_context, raptor_config_file_scope):
"""Empty doc_ids returns empty results."""
svc = RaptorService(mock_raptor_context)
chat_mdl = MagicMock()
@@ -299,15 +282,7 @@ class TestRaptorServiceRunRaptorForKb:
patch.object(svc, "_run_dataset_level_raptor", new_callable=AsyncMock):
mock_file.return_value = ([], 0)
import asyncio
loop = asyncio.new_event_loop()
try:
chunks, tk_count, cleanup = loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, [])
)
finally:
loop.close()
chunks, tk_count, cleanup = await svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, [])
assert chunks == []
assert tk_count == 0
@@ -315,15 +290,11 @@ class TestRaptorServiceRunRaptorForKb:
# ---- Cleanup scheduling through the public API ----
def test_run_raptor_for_kb_returns_cleanup_list(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_returns_cleanup_list(
self, mock_raptor_context, raptor_config_file_scope
):
"""Cleanup list from internal method is propagated to caller.
_run_file_level_raptor receives cleanup_raptor_chunks by reference (as
a positional arg) and may mutate it. This test verifies the public
method propagates whatever ends up in that list.
"""
"""Cleanup list from internal method is propagated to caller."""
svc = RaptorService(mock_raptor_context)
doc_ids = ["doc_1"]
chat_mdl = MagicMock()
@@ -336,28 +307,19 @@ class TestRaptorServiceRunRaptorForKb:
}), patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file:
async def mock_run_file(*args, **kwargs):
# _run_file_level_raptor takes 12 positional args;
# cleanup_raptor_chunks is args[11] (0-indexed, last positional).
cleanup_list = args[11]
cleanup_list.append(("doc_1", "tree_builder_a"))
return [{"id": "c1"}], 10
mock_file.side_effect = mock_run_file
import asyncio
loop = asyncio.new_event_loop()
try:
chunks, tk_count, cleanup = loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, doc_ids)
)
finally:
loop.close()
chunks, tk_count, cleanup = await svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, doc_ids)
assert cleanup == expected_cleanup
# ---- Dispatch with missing raptor config key ----
def test_run_raptor_for_kb_defaults_to_file_scope_when_no_raptor_key(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_defaults_to_file_scope_when_no_raptor_key(
self, mock_raptor_context
):
"""When kb_parser_config has no 'raptor' key, defaults to file scope."""
@@ -373,22 +335,15 @@ class TestRaptorServiceRunRaptorForKb:
patch.object(svc, "_run_dataset_level_raptor", new_callable=AsyncMock) as mock_dataset:
mock_file.return_value = ([], 0)
import asyncio
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
svc.run_raptor_for_kb(config, chat_mdl, embd_mdl, 128, doc_ids)
)
finally:
loop.close()
await svc.run_raptor_for_kb(config, chat_mdl, embd_mdl, 128, doc_ids)
mock_file.assert_called_once()
mock_dataset.assert_not_called()
# ---- Vector dimension name construction ----
def test_run_raptor_for_kb_passes_vector_size_to_file_level(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_passes_vector_size_to_file_level(
self, mock_raptor_context, sample_chunks, raptor_config_file_scope
):
"""Vector size is used to construct vctr_nm and passed to internal method."""
@@ -403,15 +358,7 @@ class TestRaptorServiceRunRaptorForKb:
}), patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file:
mock_file.return_value = (sample_chunks, 10)
import asyncio
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
)
finally:
loop.close()
await svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, vector_size, doc_ids)
# Verify _run_file_level_raptor received vctr_nm with the correct vector size
# Positional args: 0=raptor_config, 1=tree_builder, 2=clustering_method,
@@ -421,7 +368,8 @@ class TestRaptorServiceRunRaptorForKb:
# ---- Document info collection through public API ----
def test_run_raptor_for_kb_collects_doc_info(
@pytest.mark.asyncio
async def test_run_raptor_for_kb_collects_doc_info(
self, mock_raptor_context, raptor_config_file_scope
):
"""Document info is collected before dispatching to internal methods."""
@@ -436,17 +384,66 @@ class TestRaptorServiceRunRaptorForKb:
patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file:
mock_file.return_value = ([], 0)
import asyncio
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, doc_ids)
)
finally:
loop.close()
await svc.run_raptor_for_kb(raptor_config_file_scope, chat_mdl, embd_mdl, 128, doc_ids)
mock_collect.assert_called_once_with(doc_ids)
# Verify doc_info_by_id was passed as positional arg[7] to _run_file_level_raptor
positional_args = mock_file.call_args[0]
assert positional_args[7] == expected_info
class TestRaptorServiceFileLevelRaptorCheckpoint:
"""Tests for _run_file_level_raptor checkpoint behavior.
Verifies the fix that moves progress_cb and continue to the outer if
block so progress is reported even when existing_methods == {tree_builder}.
"""
@pytest.mark.asyncio
async def test_file_level_raptor_existing_methods_exact_match_updates_progress(self):
"""When existing_methods == {tree_builder}, progress_cb is still called."""
ctx = make_task_context()
svc = RaptorService(ctx)
doc_ids = ["doc_1"]
doc_info_by_id = {
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}}
}
raptor_config = {
"scope": "file",
"max_cluster": 64, "prompt": "test prompt",
"max_token": 256, "threshold": 0.1, "random_seed": 0,
"clustering_method": "gmm", "tree_builder": "raptor",
"ext": {},
}
with patch.object(svc, "_get_raptor_chunk_methods", new_callable=AsyncMock) as mock_methods, \
patch.object(svc, "_should_skip_raptor", return_value=False):
mock_methods.return_value = {"raptor"}
result = await svc._run_file_level_raptor(
raptor_config=raptor_config, tree_builder="raptor",
clustering_method="gmm", chat_mdl=MagicMock(),
embd_mdl=MagicMock(), vctr_nm="q_128_vec",
doc_ids=doc_ids, doc_info_by_id=doc_info_by_id,
max_errors=3, res=[], tk_count=0,
cleanup_raptor_chunks=[],
)
msg_calls = [
call.kwargs.get("msg", "")
for call in ctx.progress_cb.call_args_list
if call.kwargs.get("msg") is not None
]
assert any("already has" in m for m in msg_calls), \
f"Expected 'already has' progress message, got: {msg_calls}"
prog_calls = [
call.kwargs.get("prog")
for call in ctx.progress_cb.call_args_list
if call.kwargs.get("prog") is not None
]
assert len(prog_calls) > 0, \
"Expected progress_cb to be called with prog update"
assert result[0] == []

View File

@@ -216,10 +216,10 @@ class TestTaskContextLanguageAndModelProperties:
"""Tests for language and model properties."""
def test_language_default(self):
"""Test language property defaults to 'en'."""
"""Test language property defaults to 'Chinese'."""
task = {"id": "task_1", "tenant_id": "tenant_1"}
ctx = _make_ctx(task=task)
assert ctx.language == "en"
assert ctx.language == "Chinese"
def test_language(self):
"""Test language property."""

View File

@@ -16,21 +16,27 @@
"""
Unit tests for TaskHandler module.
All orchestration tests validate behavior through the public handle()/handle_task()
entry points. Internal helpers (_run_standard_chunking, _run_dataflow, _run_raptor,
_run_graphrag, _bind_embedding_model, _get_storage_binary, etc.) are exercised
implicitly; no test reaches directly into those private orchestration methods.
Mock strategy: external boundaries (LLMBundle, model config services, settings)
are mocked so that ``handle()`` and ``_bind_embedding_model`` execute their
real logic. Heavy orchestration methods (``_run_standard_chunking``,
``_run_raptor``, ``_run_graphrag``) are mocked since they are tested
exhaustively in the integration test suite.
Stable pure helpers (_build_toc, _get_vector_size) are tested directly since they
are side-effect-free data transformations.
Stable pure helpers (_build_toc) are tested directly.
"""
import pytest
import numpy as np
from unittest.mock import MagicMock, AsyncMock, patch
from rag.svr.task_executor_refactor.task_handler import TaskHandler
# Reuse shared helpers from conftest
from test.unit_test.rag.svr.task_executor_refactor.conftest import (
patch_embedding_binding,
create_mock_settings,
make_task_context,
)
class TestTaskHandlerHandleTask:
"""Tests for the public handle_task() entry point."""
@@ -68,63 +74,86 @@ class TestTaskHandlerHandleTask:
ctx.recording_context = MagicMock()
handler = TaskHandler(ctx=ctx)
handler.handle = AsyncMock(side_effect=Exception("test error"))
# Should raise the exception
with pytest.raises(Exception, match="test error"):
await handler.handle_task()
mock_doc_store.delete.assert_called()
finally:
settings.docStoreConn = orig
@pytest.mark.asyncio
async def test_handle_task_cleanup_skips_when_index_missing(self):
"""Cancel cleanup should not call delete when the index doesn't exist."""
from common import settings
mock_doc_store = MagicMock()
mock_doc_store.index_exist = MagicMock(return_value=False)
mock_doc_store.delete = MagicMock()
orig = settings.docStoreConn
settings.docStoreConn = mock_doc_store
try:
ctx = MagicMock()
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.doc_id = "doc_1"
ctx.has_canceled_func = MagicMock(return_value=True)
ctx.recording_context = MagicMock()
handler = TaskHandler(ctx=ctx)
handler.handle = AsyncMock(side_effect=Exception("test error"))
with pytest.raises(Exception, match="test error"):
await handler.handle_task()
mock_doc_store.delete.assert_not_called()
finally:
settings.docStoreConn = orig
class TestTaskHandlerHandle:
"""Tests for the public handle() method.
Internal orchestration methods (_run_standard_chunking, _run_dataflow,
_run_raptor, _run_graphrag, _bind_embedding_model) are exercised through
handle() so the suite stays resilient when those private methods change.
External boundaries (LLMBundle, model config services, settings) are mocked
so that ``_bind_embedding_model`` and ``_init_kb`` execute their real logic
through ``handle()``. Only the heavy orchestration methods
(``_run_standard_chunking``, ``_run_raptor``, ``_run_graphrag``) are mocked.
"""
# ── Context factory: make_task_context from conftest — see import above
@pytest.mark.asyncio
async def test_handle_memory_task(self):
"""Test handle dispatches memory tasks correctly."""
ctx = MagicMock()
ctx.task_type = "memory"
ctx.id = "task_1"
ctx.raw_task = {"memory_id": "mem_1"}
ctx.write_interceptor = None
ctx.has_canceled_func = MagicMock(return_value=False)
"""Test handle returns after dispatching memory task — no further processing."""
ctx = make_task_context(task_type="memory")
ctx.raw_task = {"memory_id": "mem_1", "id": "task_1"}
with patch("rag.svr.task_executor_refactor.task_handler.handle_save_to_memory_task",
new_callable=AsyncMock) as mock_handle:
with patch("rag.svr.task_executor_refactor.task_handler.handle_save_to_memory_task", new_callable=AsyncMock) as mock_handle:
handler = TaskHandler(ctx=ctx)
handler._bind_embedding_model = AsyncMock()
handler._get_vector_size = MagicMock(return_value=1024)
handler._init_kb = MagicMock()
handler._run_standard_chunking = AsyncMock()
handler._run_dataflow = AsyncMock()
await handler.handle()
mock_handle.assert_called_once_with(ctx.raw_task)
# After memory task, should return immediately — no further routing
handler._run_standard_chunking.assert_not_called()
handler._run_dataflow.assert_not_called()
@pytest.mark.asyncio
async def test_handle_dataflow_task(self):
"""Test handle dispatches dataflow tasks."""
ctx = MagicMock()
ctx.task_type = "dataflow"
ctx.id = "task_1"
ctx.doc_id = "doc_1"
ctx.has_canceled_func = MagicMock(return_value=False)
"""Test handle dispatches dataflow tasks (after embedding binding + init_kb)."""
ctx = make_task_context(task_type="dataflow", doc_id="doc_1")
handler = TaskHandler(ctx=ctx)
handler._run_dataflow = AsyncMock()
await handler.handle()
handler._run_dataflow.assert_called_once()
with patch_embedding_binding(), \
patch("rag.svr.task_executor_refactor.task_handler.settings", create_mock_settings()), \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"):
handler = TaskHandler(ctx=ctx)
handler._run_dataflow = AsyncMock()
await handler.handle()
handler._run_dataflow.assert_called_once()
@pytest.mark.asyncio
async def test_handle_canceled_task(self):
"""Test handle returns early when task is canceled."""
ctx = MagicMock()
ctx.task_type = "standard"
ctx.id = "task_1"
ctx.has_canceled_func = MagicMock(return_value=True)
ctx.progress_cb = MagicMock()
ctx = make_task_context(has_canceled_func=MagicMock(return_value=True))
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -132,102 +161,87 @@ class TestTaskHandlerHandle:
@pytest.mark.asyncio
async def test_handle_standard_chunking(self):
"""Test handle dispatches standard chunking end-to-end."""
ctx = MagicMock()
ctx.task_type = "standard"
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.doc_id = "doc_1"
ctx.embd_id = "embd_1"
ctx.language = "en"
ctx.parser_config = {}
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.progress_cb = MagicMock()
ctx.recording_context = MagicMock()
ctx.name = "test.pdf"
ctx.from_page = 0
ctx.to_page = -1
"""Test handle routes to standard chunking.
handler = TaskHandler(ctx=ctx)
handler._bind_embedding_model = AsyncMock(return_value=MagicMock())
handler._get_vector_size = MagicMock(return_value=128)
handler._init_kb = MagicMock()
handler._run_standard_chunking = AsyncMock()
``_bind_embedding_model`` and ``_init_kb`` run their real code;
only the external boundary (LLM API, settings) is mocked.
"""
ctx = make_task_context()
await handler.handle()
handler._run_standard_chunking.assert_called_once()
with patch_embedding_binding(), \
patch("rag.svr.task_executor_refactor.task_handler.settings", create_mock_settings()), \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"):
handler = TaskHandler(ctx=ctx)
handler._run_standard_chunking = AsyncMock()
await handler.handle()
handler._run_standard_chunking.assert_called_once()
@pytest.mark.asyncio
async def test_handle_raptor_task(self):
"""Test handle dispatches raptor tasks."""
ctx = MagicMock()
ctx.task_type = "raptor"
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.embd_id = "embd_1"
ctx.language = "en"
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.progress_cb = MagicMock()
ctx.recording_context = MagicMock()
"""Test handle routes to RAPTOR with real embedding binding."""
ctx = make_task_context(task_type="raptor")
handler = TaskHandler(ctx=ctx)
handler._bind_embedding_model = AsyncMock(return_value=MagicMock())
handler._get_vector_size = MagicMock(return_value=128)
handler._init_kb = MagicMock()
handler._run_raptor = AsyncMock()
with patch_embedding_binding(), \
patch("rag.svr.task_executor_refactor.task_handler.settings", create_mock_settings()), \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"):
await handler.handle()
handler._run_raptor.assert_called_once()
handler = TaskHandler(ctx=ctx)
handler._run_raptor = AsyncMock()
await handler.handle()
handler._run_raptor.assert_called_once()
@pytest.mark.asyncio
async def test_handle_graphrag_task(self):
"""Test handle dispatches graphrag tasks."""
ctx = MagicMock()
ctx.task_type = "graphrag"
ctx.id = "task_1"
ctx.tenant_id = "tenant_1"
ctx.kb_id = "kb_1"
ctx.embd_id = "embd_1"
ctx.language = "en"
ctx.has_canceled_func = MagicMock(return_value=False)
ctx.progress_cb = MagicMock()
ctx.recording_context = MagicMock()
"""Test handle routes to GraphRAG with real embedding binding."""
ctx = make_task_context(task_type="graphrag")
handler = TaskHandler(ctx=ctx)
handler._bind_embedding_model = AsyncMock(return_value=MagicMock())
handler._get_vector_size = MagicMock(return_value=128)
handler._init_kb = MagicMock()
handler._run_graphrag = AsyncMock()
with patch_embedding_binding(), \
patch("rag.svr.task_executor_refactor.task_handler.settings", create_mock_settings()), \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"):
await handler.handle()
handler._run_graphrag.assert_called_once()
handler = TaskHandler(ctx=ctx)
handler._run_graphrag = AsyncMock()
await handler.handle()
handler._run_graphrag.assert_called_once()
@pytest.mark.asyncio
async def test_handle_embedding_model_failure(self):
"""Test handle returns early when embedding model binding fails."""
ctx = MagicMock()
ctx.task_type = "standard"
ctx.id = "task_1"
ctx.has_canceled_func = MagicMock(return_value=False)
"""Test handle returns early when embedding model binding fails.
handler = TaskHandler(ctx=ctx)
handler._bind_embedding_model = AsyncMock(return_value=None)
``LLMBundle`` is patched to raise, so ``_bind_embedding_model``
itself raises — no need to mock the private method.
"""
ctx = make_task_context()
await handler.handle()
# Should not call _run_standard_chunking when model is None
assert not hasattr(handler, '_run_standard_chunking_called')
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_cfg, \
patch("rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type") as mock_default, \
patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle:
mock_cfg.return_value = MagicMock()
mock_default.return_value = MagicMock()
mock_bundle.side_effect = RuntimeError("embedding service unavailable")
class TestTaskHandlerGetVectorSize:
"""Tests for _get_vector_size — stable pure helper."""
handler = TaskHandler(ctx=ctx)
with pytest.raises(RuntimeError, match="embedding service unavailable"):
await handler.handle()
def test_get_vector_size(self):
mock_model = MagicMock()
mock_model.encode.return_value = (np.array([[1.0, 2.0, 3.0]]), 10)
result = TaskHandler._get_vector_size(mock_model)
assert result == 3
@pytest.mark.asyncio
async def test_handle_storage_binary_none_raises_file_not_found(self):
"""Verify that None binary from storage raises FileNotFoundError."""
ctx = make_task_context()
with patch_embedding_binding(), \
patch("rag.svr.task_executor_refactor.task_handler.settings", create_mock_settings()), \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"), \
patch("rag.svr.task_executor_refactor.task_handler.File2DocumentService.get_storage_address",
return_value=("bucket_test", "name_test")), \
patch.object(TaskHandler, "_get_storage_binary", new_callable=AsyncMock, return_value=None):
handler = TaskHandler(ctx=ctx)
# Do NOT mock _run_standard_chunking — we want real code path for the check
with pytest.raises(FileNotFoundError, match="Can not find file <test.pdf> from minio"):
await handler.handle()
class TestTaskHandlerBuildToc:
@@ -243,18 +257,19 @@ class TestTaskHandlerBuildToc:
docs = [{"id": "chunk_1", "content_with_weight": "text", "page_num_int": [1], "top_int": [0]}]
def mock_asyncio_run(coro):
# Close the coroutine to prevent "never awaited" warnings
coro.close()
return []
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_cfg:
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_cfg, \
patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle, \
patch("rag.svr.task_executor_refactor.task_handler.asyncio.run", side_effect=mock_asyncio_run):
mock_cfg.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle:
mock_msg = MagicMock()
mock_bundle.return_value.__enter__.return_value = mock_msg
with patch("rag.svr.task_executor_refactor.task_handler.asyncio.run", side_effect=mock_asyncio_run):
result = TaskHandler._build_toc(ctx, docs, MagicMock())
assert result is None
mock_msg = MagicMock()
mock_bundle.return_value.__enter__.return_value = mock_msg
result = TaskHandler._build_toc(ctx, docs, MagicMock())
assert result is None
def test_build_toc_with_results(self):
"""Test _build_toc builds TOC chunk when results exist."""
@@ -267,21 +282,22 @@ class TestTaskHandlerBuildToc:
toc_result = [{"chunk_id": "0", "title": "Section 1"}]
def mock_asyncio_run(coro):
# Close the coroutine to prevent "never awaited" warnings
coro.close()
return toc_result
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_cfg:
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_cfg, \
patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle, \
patch("rag.svr.task_executor_refactor.task_handler.asyncio.run", side_effect=mock_asyncio_run):
mock_cfg.return_value = MagicMock()
with patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle:
mock_msg = MagicMock()
mock_bundle.return_value.__enter__.return_value = mock_msg
with patch("rag.svr.task_executor_refactor.task_handler.asyncio.run", side_effect=mock_asyncio_run):
result = TaskHandler._build_toc(ctx, docs, MagicMock())
assert result is not None
assert "toc_kwd" in result
assert result["toc_kwd"] == "toc"
assert result["available_int"] == 0
mock_msg = MagicMock()
mock_bundle.return_value.__enter__.return_value = mock_msg
result = TaskHandler._build_toc(ctx, docs, MagicMock())
assert result is not None
assert "toc_kwd" in result
assert result["toc_kwd"] == "toc"
assert result["available_int"] == 0
class TestTaskHandlerInit:
@@ -297,4 +313,4 @@ class TestTaskHandlerInit:
def test_init_default_hook_none(self):
ctx = MagicMock()
handler = TaskHandler(ctx=ctx)
assert handler._billing_hook is None
assert handler._billing_hook is None

View File

@@ -19,7 +19,6 @@ Integration tests for TaskHandler orchestration.
import asyncio
import gc
import uuid
from typing import Any, Dict
from unittest.mock import MagicMock, AsyncMock, patch
@@ -37,6 +36,11 @@ from test.unit_test.rag.svr.task_executor_refactor.conftest import (
create_default_chunks,
create_mock_settings,
create_mock_chunk_service,
make_task_dict,
patch_get_storage_binary,
patch_task_handler_settings,
mock_thread_return_binary,
mock_thread_return_none,
)
@@ -82,48 +86,13 @@ def create_task_context(
return ctx
# Common patcher for _get_storage_binary since it imports settings internally
def patch_get_storage_binary():
return patch.object(TaskHandler, '_get_storage_binary', new_callable=AsyncMock, return_value=b"fake pdf binary")
def patch_task_handler_settings(mock_settings):
"""Patch the settings module-level import in task_handler."""
return patch("rag.svr.task_executor_refactor.task_handler.settings", mock_settings)
class TestStandardChunkingPipelineIntegration:
"""P0: Integration tests for the complete standard chunking pipeline."""
def _create_standard_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {
"auto_keywords": 0,
"auto_questions": 0,
"enable_metadata": False,
},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_full_chunking_pipeline_records_task_status(self):
"""Verify that the complete pipeline records task_status as 'completed'."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -152,11 +121,8 @@ class TestStandardChunkingPipelineIntegration:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -168,7 +134,7 @@ class TestStandardChunkingPipelineIntegration:
@pytest.mark.asyncio
async def test_full_chunking_pipeline_records_insertion_result(self):
"""Verify that insertion_result is recorded as 'success'."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -197,11 +163,8 @@ class TestStandardChunkingPipelineIntegration:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -213,7 +176,7 @@ class TestStandardChunkingPipelineIntegration:
@pytest.mark.asyncio
async def test_full_chunking_pipeline_records_chunk_ids(self):
"""Verify that chunk_ids_count is recorded after build_chunks."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -245,11 +208,8 @@ class TestStandardChunkingPipelineIntegration:
mock_chunk_service_cls.return_value = mock_chunk_service
mock_run_toc.return_value = [] # TOC returns empty when not enabled
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -262,7 +222,7 @@ class TestStandardChunkingPipelineIntegration:
@pytest.mark.asyncio
async def test_full_chunking_pipeline_records_token_count(self):
"""Verify that token_count and vector_size are recorded after embedding."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -291,11 +251,8 @@ class TestStandardChunkingPipelineIntegration:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -311,7 +268,7 @@ class TestStandardChunkingPipelineIntegration:
@pytest.mark.asyncio
async def test_full_chunking_pipeline_progress_callback_invoked(self):
"""Verify that progress_callback is invoked multiple times during pipeline."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -340,11 +297,8 @@ class TestStandardChunkingPipelineIntegration:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -357,31 +311,10 @@ class TestStandardChunkingPipelineIntegration:
class TestTaskCancellationCleanupIntegration:
"""P0: Integration tests for task cancellation cleanup flow."""
def _create_standard_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_canceled_task_calls_docstore_delete(self):
"""Verify that docStoreConn.delete is called when task is canceled."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict, is_canceled=True)
mock_settings = create_mock_settings()
@@ -411,7 +344,7 @@ class TestTaskCancellationCleanupIntegration:
@pytest.mark.asyncio
async def test_canceled_task_progress_callback_with_negative_one(self):
"""Verify that progress_callback is called with -1 when task is canceled."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict, is_canceled=True)
mock_settings = create_mock_settings()
@@ -450,7 +383,7 @@ class TestTaskCancellationCleanupIntegration:
@pytest.mark.asyncio
async def test_canceled_task_does_not_proceed_to_chunking(self):
"""Verify that canceled task does not proceed to embedding model binding."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict, is_canceled=True)
mock_settings = create_mock_settings()
@@ -481,30 +414,10 @@ class TestTaskCancellationCleanupIntegration:
class TestRaptorPipelineIntegration:
"""P1: Integration tests for the RAPTOR pipeline."""
def _create_raptor_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": GRAPH_RAPTOR_FAKE_DOC_ID,
"doc_ids": ["doc1", "doc2"],
"name": "raptor_task",
"parser_id": "naive",
"parser_config": {"raptor": {"use_raptor": False}},
"kb_parser_config": {"raptor": {"use_raptor": False}},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "raptor",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_raptor_pipeline_records_task_status(self):
"""Verify that RAPTOR pipeline records task_status."""
task_dict = self._create_raptor_task_dict()
task_dict = make_task_dict(doc_id=GRAPH_RAPTOR_FAKE_DOC_ID, doc_ids=["doc1", "doc2"], task_type="raptor", parser_config={"raptor": {"use_raptor": False}}, kb_parser_config={"raptor": {"use_raptor": False}})
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -534,10 +447,7 @@ class TestRaptorPipelineIntegration:
mock_chunk_service.return_value.insert_chunks = AsyncMock(return_value=True)
mock_doc_service.increment_chunk_num = MagicMock()
async def mock_thread_impl(func, *args, **kwargs):
return None
mock_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_none
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -549,7 +459,7 @@ class TestRaptorPipelineIntegration:
@pytest.mark.asyncio
async def test_raptor_pipeline_enables_raptor_if_not_configured(self):
"""Verify that RAPTOR is enabled if not already configured."""
task_dict = self._create_raptor_task_dict()
task_dict = make_task_dict(doc_id=GRAPH_RAPTOR_FAKE_DOC_ID, doc_ids=["doc1", "doc2"], task_type="raptor", parser_config={"raptor": {"use_raptor": False}}, kb_parser_config={"raptor": {"use_raptor": False}})
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -579,10 +489,7 @@ class TestRaptorPipelineIntegration:
mock_chunk_service.return_value.insert_chunks = AsyncMock(return_value=True)
mock_doc_service.increment_chunk_num = MagicMock()
async def mock_thread_impl(func, *args, **kwargs):
return None
mock_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_none
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -598,31 +505,10 @@ class TestRaptorPipelineIntegration:
class TestEmbeddingModelBindingFailureIntegration:
"""P1: Integration tests for embedding model binding failure."""
def _create_standard_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_embedding_binding_failure_raises_exception(self):
"""Verify that embedding model binding failure raises an exception."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_get_config, \
@@ -639,7 +525,7 @@ class TestEmbeddingModelBindingFailureIntegration:
@pytest.mark.asyncio
async def test_embedding_binding_failure_calls_progress_callback(self):
"""Verify that embedding model binding failure calls progress_callback."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_get_config, \
@@ -659,49 +545,26 @@ class TestEmbeddingModelBindingFailureIntegration:
class TestDataflowPipelineIntegration:
"""P2: Integration tests for the dataflow pipeline."""
def _create_dataflow_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": CANVAS_DEBUG_DOC_ID,
"name": "dataflow_debug",
"parser_id": "naive",
"parser_config": {},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "dataflow",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_dataflow_pipeline_calls_dataflow_service(self):
"""Verify that dataflow pipeline calls DataflowService.run_dataflow()."""
task_dict = self._create_dataflow_task_dict()
task_dict = make_task_dict(doc_id=CANVAS_DEBUG_DOC_ID, task_type="dataflow")
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
with patch("rag.svr.task_executor_refactor.task_handler.DataflowService") as mock_dataflow_service:
mock_instance = MagicMock()
mock_instance.run_dataflow = AsyncMock(return_value=None)
mock_dataflow_service.return_value = mock_instance
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_get_config, \
patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle, \
patch("rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type") as mock_get_default, \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_idx"), \
patch("rag.svr.task_executor_refactor.task_handler.settings") as mock_settings, \
patch("rag.svr.task_executor_refactor.task_handler.DataflowService") as mock_dataflow_service:
handler = TaskHandler(ctx=ctx)
await handler.handle()
mock_get_config.return_value = MagicMock()
mock_get_default.return_value = MagicMock()
mock_bundle.return_value = mock_embedding
mock_settings.docStoreConn = MagicMock()
mock_settings.docStoreConn.create_idx = MagicMock()
mock_dataflow_service.assert_called_once()
mock_instance.run_dataflow.assert_called_once()
@pytest.mark.asyncio
async def test_dataflow_debug_mode_calls_dataflow_service(self):
"""Verify that dataflow debug mode also calls DataflowService."""
task_dict = self._create_dataflow_task_dict()
ctx = create_task_context(task_dict)
with patch("rag.svr.task_executor_refactor.task_handler.DataflowService") as mock_dataflow_service:
mock_instance = MagicMock()
mock_instance.run_dataflow = AsyncMock(return_value=None)
mock_dataflow_service.return_value = mock_instance
@@ -716,37 +579,11 @@ class TestDataflowPipelineIntegration:
class TestTocAsyncFlowIntegration:
"""P2: Integration tests for TOC async flow."""
def _create_toc_enabled_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {
"auto_keywords": 0,
"auto_questions": 0,
"enable_metadata": False,
"toc_extraction": True,
},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_toc_async_flow_creates_toc_thread(self):
"""Verify that TOC async flow creates a TOC thread when enabled."""
task_dict = self._create_toc_enabled_task_dict()
task_dict = make_task_dict(parser_config={"auto_keywords": 0, "auto_questions": 0, "enable_metadata": False, "toc_extraction": True})
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -779,11 +616,8 @@ class TestTocAsyncFlowIntegration:
mock_run_toc.return_value = [{"title": "Test TOC", "level": 1}]
mock_post_doc_service.increment_chunk_num = MagicMock()
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -811,7 +645,7 @@ class TestTocAsyncFlowIntegration:
by pytest-asyncio.
"""
task_dict = self._create_toc_enabled_task_dict()
task_dict = make_task_dict(parser_config={"auto_keywords": 0, "auto_questions": 0, "enable_metadata": False, "toc_extraction": True})
task_dict["parser_config"]["toc_extraction"] = False
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
@@ -842,11 +676,8 @@ class TestTocAsyncFlowIntegration:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -873,35 +704,10 @@ class TestTocAsyncFlowIntegration:
class TestRecordingContextDataFlowAssertions:
"""P2: Integration tests for RecordingContext data flow assertions."""
def _create_standard_task_dict(self) -> Dict[str, Any]:
return {
"id": f"task_{uuid.uuid4().hex[:8]}",
"tenant_id": "tenant_test",
"kb_id": "kb_test",
"doc_id": "doc_test",
"name": "test_document.pdf",
"location": "/path/to/test_document.pdf",
"size": 1024,
"parser_id": "naive",
"parser_config": {
"auto_keywords": 0,
"auto_questions": 0,
"enable_metadata": False,
},
"kb_parser_config": {},
"language": "en",
"llm_id": "llm_test",
"embd_id": "embd_test",
"from_page": 0,
"to_page": -1,
"task_type": "standard",
"pagerank": 0,
}
@pytest.mark.asyncio
async def test_recording_context_captures_file_size_check(self):
"""Verify that RecordingContext captures file_size_exceeded result."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -930,11 +736,8 @@ class TestRecordingContextDataFlowAssertions:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -947,7 +750,7 @@ class TestRecordingContextDataFlowAssertions:
@pytest.mark.asyncio
async def test_recording_context_captures_parser_id(self):
"""Verify that RecordingContext captures parser_id from task context."""
task_dict = self._create_standard_task_dict()
task_dict = make_task_dict()
ctx = create_task_context(task_dict)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
@@ -976,11 +779,8 @@ class TestRecordingContextDataFlowAssertions:
mock_doc_service.update_document_metadata = MagicMock()
mock_chunk_service_cls.return_value = mock_chunk_service
async def mock_thread_impl(func, *args, **kwargs):
return b"fake pdf binary"
mock_thread_exec.side_effect = mock_thread_impl
mock_chunk_thread_exec.side_effect = mock_thread_impl
mock_thread_exec.side_effect = mock_thread_return_binary
mock_chunk_thread_exec.side_effect = mock_thread_return_binary
handler = TaskHandler(ctx=ctx)
await handler.handle()
@@ -991,3 +791,66 @@ class TestRecordingContextDataFlowAssertions:
assert task_status == "completed", f"Expected task_status='completed', got {task_status}"
# Verify the parser_id is accessible from the task context
assert ctx.parser_id == "naive", f"Expected parser_id='naive', got {ctx.parser_id}"
class TestGraphragPipelineIntegration:
"""P2: Integration tests for GraphRAG pipeline default configuration."""
@pytest.mark.asyncio
async def test_graphrag_pipeline_configures_full_defaults(self):
"""Verify that GraphRAG configures all default parameters when not already set."""
task_dict = make_task_dict(doc_ids=["doc1", "doc2"], task_type="graphrag")
rec_ctx = RecordingContext()
ctx = create_task_context(task_dict, recording_context=rec_ctx)
mock_embedding = create_mock_embedding_model(vector_size=128)
mock_settings = create_mock_settings()
mock_kb = MagicMock()
mock_kb.id = "kb_test"
mock_kb.parser_config = {}
with patch_task_handler_settings(mock_settings), \
patch("rag.svr.task_executor_refactor.chunk_service.settings", mock_settings), \
patch("rag.svr.task_executor_refactor.task_handler.get_model_config_from_provider_instance") as mock_get_config, \
patch("rag.svr.task_executor_refactor.task_handler.LLMBundle") as mock_bundle, \
patch("rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type") as mock_get_default, \
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
patch("rag.svr.task_executor_refactor.task_handler.KnowledgebaseService") as mock_kb_service, \
patch("rag.svr.task_executor_refactor.task_handler.run_graphrag_for_kb") as mock_run_graphrag, \
patch("rag.svr.task_executor_refactor.task_handler.DocumentService"):
mock_get_config.return_value = MagicMock()
mock_get_default.return_value = MagicMock()
mock_bundle.return_value = mock_embedding
mock_index_name.return_value = "test_index"
mock_kb_service.get_by_id.return_value = (True, mock_kb)
mock_kb_service.update_by_id.return_value = True
mock_run_graphrag.return_value = {"status": "completed"}
mock_thread_exec.side_effect = mock_thread_return_none
handler = TaskHandler(ctx=ctx)
await handler.handle()
# Verify update_by_id was called with full default config
mock_kb_service.update_by_id.assert_called_once()
call_args = mock_kb_service.update_by_id.call_args
config = call_args[0][1]["parser_config"]["graphrag"]
assert config["use_graphrag"] is True
assert "organization" in config["entity_types"]
assert "person" in config["entity_types"]
assert "geo" in config["entity_types"]
assert "event" in config["entity_types"]
assert "category" in config["entity_types"]
assert config["method"] == "light"
assert "batch_chunk_token_size" in config
assert "retry_attempts" in config
assert "retry_backoff_seconds" in config
assert "retry_backoff_max_seconds" in config
assert "build_subgraph_timeout_per_chunk_seconds" in config
assert "build_subgraph_min_timeout_seconds" in config
assert "merge_timeout_seconds" in config
assert "resolution_timeout_seconds" in config
assert "community_timeout_seconds" in config
assert "lock_acquire_timeout_seconds" in config, \
"All GraphRAG default config parameters should be present"