mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 14:15:40 +08:00
Refactor: Task Executor (#15154)
### What problem does this PR solve?
1. Break huge function into smaller pieces
2. Add unit test for the smaller pieces function
3. Layer-ed design
a. infra layer - task_context.py, recording_context.py,
write_operation_interceptor.py, ...
b. service layer - *_service.py
c. business layer - task_handler.py
4. Default behavior: use "refactor-ed version" - can switch to original
version by change env variable
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
- [x] Performance Improvement
---------
Co-authored-by: Liu An <asiro@qq.com>
Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
This commit is contained in:
@@ -161,19 +161,19 @@ async def extract_by_llm(tenant_id: str, tenant_llm_id: int, extract_conf: dict,
|
||||
)
|
||||
else:
|
||||
llm_config = get_model_config_by_type_and_name(tenant_id, LLMType.CHAT, llm_id)
|
||||
llm = LLMBundle(tenant_id, llm_config)
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.15, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Prepared prompts and LLM."})
|
||||
res = await llm.async_chat(system_prompt, user_prompts, extract_conf)
|
||||
res_json = get_json_result_from_llm_response(res)
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.35, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Get extracted result from LLM."})
|
||||
return [{
|
||||
"content": extracted_content["content"],
|
||||
"valid_at": format_iso_8601_to_ymd_hms(extracted_content["valid_at"]),
|
||||
"invalid_at": format_iso_8601_to_ymd_hms(extracted_content["invalid_at"]) if extracted_content.get("invalid_at") else "",
|
||||
"message_type": message_type
|
||||
} for message_type, extracted_content_list in res_json.items() for extracted_content in extracted_content_list]
|
||||
with LLMBundle(tenant_id, llm_config) as llm:
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.15, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Prepared prompts and LLM."})
|
||||
res = await llm.async_chat(system_prompt, user_prompts, extract_conf)
|
||||
res_json = get_json_result_from_llm_response(res)
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.35, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Get extracted result from LLM."})
|
||||
return [{
|
||||
"content": extracted_content["content"],
|
||||
"valid_at": format_iso_8601_to_ymd_hms(extracted_content["valid_at"]),
|
||||
"invalid_at": format_iso_8601_to_ymd_hms(extracted_content["invalid_at"]) if extracted_content.get("invalid_at") else "",
|
||||
"message_type": message_type
|
||||
} for message_type, extracted_content_list in res_json.items() for extracted_content in extracted_content_list]
|
||||
|
||||
|
||||
async def embed_and_save(memory, message_list: list[dict], task_id: str=None):
|
||||
@@ -185,48 +185,48 @@ async def embed_and_save(memory, message_list: list[dict], task_id: str=None):
|
||||
)
|
||||
else:
|
||||
embd_model_config = get_model_config_by_type_and_name(memory.tenant_id, LLMType.EMBEDDING, memory.embd_id)
|
||||
embedding_model = LLMBundle(memory.tenant_id, embd_model_config)
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.65, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Prepared embedding model."})
|
||||
vector_list, _ = embedding_model.encode([msg["content"] for msg in message_list])
|
||||
for idx, msg in enumerate(message_list):
|
||||
msg["content_embed"] = vector_list[idx]
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.85, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Embedded extracted content."})
|
||||
vector_dimension = len(vector_list[0])
|
||||
if not MessageService.has_index(memory.tenant_id, memory.id):
|
||||
created = MessageService.create_index(memory.tenant_id, memory.id, vector_size=vector_dimension)
|
||||
if not created:
|
||||
error_msg = "Failed to create message index."
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
|
||||
new_msg_size = sum([MessageService.calculate_message_size(m) for m in message_list])
|
||||
current_memory_size = get_memory_size_cache(memory.tenant_id, memory.id)
|
||||
if new_msg_size + current_memory_size > memory.memory_size:
|
||||
size_to_delete = current_memory_size + new_msg_size - memory.memory_size
|
||||
if memory.forgetting_policy == "FIFO":
|
||||
message_ids_to_delete, delete_size = MessageService.pick_messages_to_delete_by_fifo(memory.id, memory.tenant_id,
|
||||
size_to_delete)
|
||||
MessageService.delete_message({"message_id": message_ids_to_delete}, memory.tenant_id, memory.id)
|
||||
decrease_memory_size_cache(memory.id, delete_size)
|
||||
else:
|
||||
error_msg = "Failed to insert message into memory. Memory size reached limit and cannot decide which to delete."
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
fail_cases = MessageService.insert_message(message_list, memory.tenant_id, memory.id)
|
||||
if fail_cases:
|
||||
error_msg = "Failed to insert message into memory. Details: " + "; ".join(fail_cases)
|
||||
with LLMBundle(memory.tenant_id, embd_model_config) as embedding_model:
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
TaskService.update_progress(task_id, {"progress": 0.65, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Prepared embedding model."})
|
||||
vector_list, _ = embedding_model.encode([msg["content"] for msg in message_list])
|
||||
for idx, msg in enumerate(message_list):
|
||||
msg["content_embed"] = vector_list[idx]
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.85, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Embedded extracted content."})
|
||||
vector_dimension = len(vector_list[0])
|
||||
if not MessageService.has_index(memory.tenant_id, memory.id):
|
||||
created = MessageService.create_index(memory.tenant_id, memory.id, vector_size=vector_dimension)
|
||||
if not created:
|
||||
error_msg = "Failed to create message index."
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.95, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Saved messages to storage."})
|
||||
increase_memory_size_cache(memory.id, new_msg_size)
|
||||
return True, "Message saved successfully."
|
||||
new_msg_size = sum([MessageService.calculate_message_size(m) for m in message_list])
|
||||
current_memory_size = get_memory_size_cache(memory.tenant_id, memory.id)
|
||||
if new_msg_size + current_memory_size > memory.memory_size:
|
||||
size_to_delete = current_memory_size + new_msg_size - memory.memory_size
|
||||
if memory.forgetting_policy == "FIFO":
|
||||
message_ids_to_delete, delete_size = MessageService.pick_messages_to_delete_by_fifo(memory.id, memory.tenant_id,
|
||||
size_to_delete)
|
||||
MessageService.delete_message({"message_id": message_ids_to_delete}, memory.tenant_id, memory.id)
|
||||
decrease_memory_size_cache(memory.id, delete_size)
|
||||
else:
|
||||
error_msg = "Failed to insert message into memory. Memory size reached limit and cannot decide which to delete."
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
fail_cases = MessageService.insert_message(message_list, memory.tenant_id, memory.id)
|
||||
if fail_cases:
|
||||
error_msg = "Failed to insert message into memory. Details: " + "; ".join(fail_cases)
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": -1, "progress_msg": timestamp_to_date(current_timestamp())+ " " + error_msg})
|
||||
return False, error_msg
|
||||
|
||||
if task_id:
|
||||
TaskService.update_progress(task_id, {"progress": 0.95, "progress_msg": timestamp_to_date(current_timestamp())+ " " + "Saved messages to storage."})
|
||||
increase_memory_size_cache(memory.id, new_msg_size)
|
||||
return True, "Message saved successfully."
|
||||
|
||||
|
||||
def query_message(filter_dict: dict, params: dict):
|
||||
|
||||
@@ -1098,12 +1098,12 @@ def queue_raptor_o_graphrag_tasks(sample_doc, ty, priority, fake_doc_id="", doc_
|
||||
|
||||
task["doc_ids"] = doc_ids
|
||||
DocumentService.begin2parse(task["doc_id"], keep_progress=True)
|
||||
assert REDIS_CONN.queue_product(settings.get_svr_queue_name(priority), message=task), "Can't access Redis. Please check the Redis' status."
|
||||
assert REDIS_CONN.queue_product(settings.get_svr_queue_name(priority, ty), message=task), "Can't access Redis. Please check the Redis' status."
|
||||
return task["id"]
|
||||
|
||||
|
||||
def get_queue_length(priority):
|
||||
group_info = REDIS_CONN.queue_info(settings.get_svr_queue_name(priority), SVR_CONSUMER_GROUP_NAME)
|
||||
def get_queue_length(priority, suffix="common"):
|
||||
group_info = REDIS_CONN.queue_info(settings.get_svr_queue_name(priority, suffix), SVR_CONSUMER_GROUP_NAME)
|
||||
if not group_info:
|
||||
return 0
|
||||
return int(group_info.get("lag", 0) or 0)
|
||||
|
||||
@@ -86,6 +86,19 @@ class LLMBundle(LLM4Tenant):
|
||||
def __init__(self, tenant_id: str, model_config: dict, lang="Chinese", **kwargs):
|
||||
super().__init__(tenant_id, model_config, lang, **kwargs)
|
||||
|
||||
def close(self):
|
||||
"""Release resources held by this LLMBundle instance."""
|
||||
super().close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit context manager and release resources."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def bind_tools(self, toolcall_session, tools):
|
||||
if not self.is_tools:
|
||||
logging.warning(f"Model {self.model_config['llm_name']} does not support tool call, but you have assigned one or more tools to it!")
|
||||
@@ -124,7 +137,7 @@ class LLMBundle(LLM4Tenant):
|
||||
|
||||
embeddings, used_tokens = self.mdl.encode(safe_texts)
|
||||
if self.model_config["llm_factory"] == "Builtin":
|
||||
logging.info("LLMBundle.encode_queries query: {}, emd len: {}, used_tokens: {}. Builtin model don't need to update token usage".format(texts, len(embeddings), used_tokens))
|
||||
logging.debug("LLMBundle.encode_queries query: {}, emd len: {}, used_tokens: {}. Builtin model don't need to update token usage".format(texts, len(embeddings), used_tokens))
|
||||
elif not TenantLLMService.increase_usage_by_id(self.model_config["id"], used_tokens):
|
||||
logging.error("LLMBundle.encode can't update token usage for <tenant redacted>/EMBEDDING used_tokens: {}".format(used_tokens))
|
||||
|
||||
|
||||
@@ -419,6 +419,9 @@ def queue_tasks(doc: dict, bucket: str, name: str, priority: int):
|
||||
else:
|
||||
parse_task_array.append(new_task())
|
||||
|
||||
# Determine suffix based on parser_id (consistent with SAAS version line 444)
|
||||
suffix = "common" if doc["parser_id"] != "resume" else "resume"
|
||||
|
||||
chunking_config = DocumentService.get_chunking_config(doc["id"])
|
||||
for task in parse_task_array:
|
||||
hasher = xxhash.xxh64()
|
||||
@@ -456,7 +459,7 @@ def queue_tasks(doc: dict, bucket: str, name: str, priority: int):
|
||||
unfinished_task_array = [task for task in parse_task_array if task["progress"] < 1.0]
|
||||
for unfinished_task in unfinished_task_array:
|
||||
assert REDIS_CONN.queue_product(
|
||||
settings.get_svr_queue_name(priority), message=unfinished_task
|
||||
settings.get_svr_queue_name(priority, suffix), message=unfinished_task
|
||||
), "Can't access Redis. Please check the Redis' status."
|
||||
|
||||
|
||||
@@ -547,7 +550,7 @@ def queue_dataflow(tenant_id:str, flow_id:str, task_id:str, doc_id:str=CANVAS_DE
|
||||
task["file"] = file
|
||||
|
||||
if not REDIS_CONN.queue_product(
|
||||
settings.get_svr_queue_name(priority), message=task
|
||||
settings.get_svr_queue_name(priority, "common"), message=task
|
||||
):
|
||||
return False, "Can't access Redis. Please check the Redis' status."
|
||||
|
||||
|
||||
@@ -520,3 +520,31 @@ class LLM4Tenant:
|
||||
except Exception:
|
||||
# Skip langfuse tracing if connection fails
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""Release resources held by this LLM4Tenant instance.
|
||||
|
||||
This method should be called when the instance is no longer needed
|
||||
to properly release resources such as:
|
||||
- Langfuse tracing client (flush and shutdown)
|
||||
- Underlying model instance resources (HTTP sessions, etc.)
|
||||
"""
|
||||
# Flush and shutdown Langfuse client if it was initialized
|
||||
if self.langfuse:
|
||||
try:
|
||||
self.langfuse.flush()
|
||||
if hasattr(self.langfuse, 'shutdown'):
|
||||
self.langfuse.shutdown()
|
||||
except Exception:
|
||||
# Ignore errors during cleanup
|
||||
pass
|
||||
finally:
|
||||
self.langfuse = None
|
||||
|
||||
# Release underlying model instance if it has a close method
|
||||
if self.mdl and hasattr(self.mdl, 'close') and callable(getattr(self.mdl, 'close')):
|
||||
try:
|
||||
self.mdl.close()
|
||||
except Exception:
|
||||
# Ignore errors during cleanup
|
||||
pass
|
||||
|
||||
@@ -246,7 +246,7 @@ class ForgettingPolicy(StrEnum):
|
||||
# ENV_TRACE_MALLOC_ENABLED = "TRACE_MALLOC_ENABLED"
|
||||
|
||||
PAGERANK_FLD = "pagerank_fea"
|
||||
SVR_QUEUE_NAME = "rag_flow_svr_queue"
|
||||
SVR_QUEUE_NAME = "te"
|
||||
SVR_CONSUMER_GROUP_NAME = "rag_flow_svr_task_broker"
|
||||
TAG_FLD = "tag_feas"
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def singleton(cls, *args, **kw):
|
||||
instances = {}
|
||||
@@ -24,4 +29,58 @@ def singleton(cls, *args, **kw):
|
||||
instances[key] = cls(*args, **kw)
|
||||
return instances[key]
|
||||
|
||||
return _singleton
|
||||
return _singleton
|
||||
|
||||
|
||||
def timing(func=None, *, name=None, context=None):
|
||||
"""Decorator that records function execution time.
|
||||
|
||||
Usage:
|
||||
@timing
|
||||
async def my_func(): ...
|
||||
|
||||
@timing(name="custom_name")
|
||||
def my_func(): ...
|
||||
|
||||
@timing(context=recording_ctx)
|
||||
async def my_func(): ...
|
||||
|
||||
Args:
|
||||
func: The function to decorate (auto-passed when used as @timing)
|
||||
name: Custom name for the timing record, defaults to function name
|
||||
context: A RecordingContext-like object to record timing data into.
|
||||
If not provided, will try to use global recording_context from task_executor.
|
||||
Timing data will be recorded as "{name}_time".
|
||||
"""
|
||||
if func is None:
|
||||
return functools.partial(timing, name=name, context=context)
|
||||
|
||||
func_name = name or func.__name__
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
log.debug(f"[TIMING] {func_name} took {elapsed:.3f}s")
|
||||
if context is not None:
|
||||
context.record(f"{func_name}_time", elapsed)
|
||||
return async_wrapper
|
||||
else:
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
log.debug(f"[TIMING] {func_name} took {elapsed:.3f}s")
|
||||
if context is not None:
|
||||
context.record(f"{func_name}_time", elapsed)
|
||||
return sync_wrapper
|
||||
@@ -133,13 +133,30 @@ PARALLEL_DEVICES: int = 0
|
||||
STORAGE_IMPL_TYPE = os.getenv('STORAGE_IMPL', 'MINIO')
|
||||
STORAGE_IMPL = None
|
||||
|
||||
def get_svr_queue_name(priority: int) -> str:
|
||||
if priority == 0:
|
||||
return SVR_QUEUE_NAME
|
||||
return f"{SVR_QUEUE_NAME}_{priority}"
|
||||
def get_svr_queue_name(priority: int, suffix: str = "common") -> str:
|
||||
"""
|
||||
Generate queue name with two dimensions: priority and suffix.
|
||||
|
||||
Args:
|
||||
priority: Task priority (0=low, 1=high)
|
||||
suffix: Task type suffix (common/resume/graphrag/raptor/mindmap)
|
||||
Currently only "common" is used, other suffixes are reserved.
|
||||
|
||||
Returns:
|
||||
Queue name string
|
||||
|
||||
Examples:
|
||||
get_svr_queue_name(0, "common") -> "te.0.common"
|
||||
get_svr_queue_name(1, "common") -> "te.1.common"
|
||||
get_svr_queue_name(0) -> "te.0.common" # default suffix="common"
|
||||
|
||||
def get_svr_queue_names():
|
||||
return [get_svr_queue_name(priority) for priority in [1, 0]]
|
||||
"""
|
||||
return f"{SVR_QUEUE_NAME}.{priority}.common"
|
||||
|
||||
|
||||
def get_svr_queue_names(suffix:str):
|
||||
"""Return queue names sorted by priority (high to low)."""
|
||||
return [get_svr_queue_name(priority, suffix) for priority in [1, 0]]
|
||||
|
||||
def init_secret_key():
|
||||
secret_key = os.environ.get("RAGFLOW_SECRET_KEY")
|
||||
|
||||
@@ -210,7 +210,7 @@ function task_exe() {
|
||||
JEMALLOC_PATH="$(pkg-config --variable=libdir jemalloc)/libjemalloc.so"
|
||||
while true; do
|
||||
LD_PRELOAD="$JEMALLOC_PATH" \
|
||||
"$PY" rag/svr/task_executor.py "${host_id}_${consumer_id}" &
|
||||
"$PY" rag/svr/task_executor.py -i "${host_id}_${consumer_id}" -t "common" &
|
||||
wait;
|
||||
sleep 1;
|
||||
done
|
||||
|
||||
@@ -73,7 +73,7 @@ task_exe(){
|
||||
local retry_count=0
|
||||
while ! $STOP && [ $retry_count -lt $MAX_RETRIES ]; do
|
||||
echo "Starting task_executor.py for task $task_id (Attempt $((retry_count+1)))"
|
||||
LD_PRELOAD=$JEMALLOC_PATH $PY rag/svr/task_executor.py "$task_id"
|
||||
LD_PRELOAD=$JEMALLOC_PATH $PY rag/svr/task_executor.py -i "$task_id"
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo "task_executor.py for task $task_id exited successfully."
|
||||
|
||||
@@ -49,7 +49,7 @@ class Pipeline(Graph):
|
||||
message += "[CANCEL]"
|
||||
try:
|
||||
bin = REDIS_CONN.get(log_key)
|
||||
obj = json.loads(bin.encode("utf-8"))
|
||||
obj = json.loads(bin.encode("utf-8")) if bin else []
|
||||
if obj:
|
||||
if obj[-1]["component_id"] == component_name:
|
||||
obj[-1]["trace"].append(
|
||||
|
||||
@@ -26,9 +26,9 @@ from common.connection_utils import timeout
|
||||
from rag.flow.base import ProcessBase, ProcessParamBase
|
||||
from rag.flow.parser.pdf_chunk_metadata import finalize_pdf_chunk
|
||||
from rag.flow.tokenizer.schema import TokenizerFromUpstream
|
||||
from rag.svr.task_executor_limiter import embed_limiter
|
||||
from rag.nlp import rag_tokenizer
|
||||
from common import settings
|
||||
from rag.svr.task_executor import embed_limiter
|
||||
from common.token_utils import truncate
|
||||
|
||||
from common.misc_utils import thread_pool_exec
|
||||
|
||||
@@ -8,9 +8,11 @@ Task
|
||||
- Decide levels yourself to keep a coherent hierarchy. Keep peers at the same depth.
|
||||
|
||||
Output
|
||||
- Return a valid JSON array only (no extra text).
|
||||
- Each element must be {"level": "1|2|3", "title": <original title string>}.
|
||||
- title must be the original title string.
|
||||
- Return a valid JSON array only (no extra text, no markdown code blocks).
|
||||
- Each element MUST be a JSON object with exactly this structure: {"level": "1", "title": "some title"}.
|
||||
- title must be the original title string exactly.
|
||||
- DO NOT return arrays of arrays like [["1", "title"]] or other formats.
|
||||
- The output must be parseable by json.loads() directly.
|
||||
|
||||
Examples
|
||||
|
||||
|
||||
@@ -887,6 +887,23 @@ async def run_toc_from_text(chunks, chat_mdl, callback=None):
|
||||
if not toc_with_levels:
|
||||
return []
|
||||
|
||||
# Normalize TOC items to ensure consistent dict format
|
||||
normalized_levels = []
|
||||
for item in toc_with_levels:
|
||||
if isinstance(item, dict):
|
||||
# Already in correct format
|
||||
normalized_levels.append(item)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
# Convert ["level", "title"] or similar to dict
|
||||
normalized_levels.append({"level": str(item[0]), "title": str(item[1])})
|
||||
else:
|
||||
logging.warning(f"Unexpected TOC item format (type={type(item).__name__}), skipping: {item}")
|
||||
|
||||
toc_with_levels = normalized_levels
|
||||
if not toc_with_levels:
|
||||
logging.warning("No valid TOC items after normalization.")
|
||||
return []
|
||||
|
||||
# Merge structure and content (by index)
|
||||
prune = len(toc_with_levels) > 512
|
||||
max_lvl = "0"
|
||||
|
||||
@@ -12,9 +12,13 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
from rag.svr.task_executor_refactor.task_manager import TaskManager
|
||||
from rag.svr.task_executor_refactor.recording_context import timed_with_recording, get_recording_context, \
|
||||
RecordingContext, set_recording_context, NullRecordingContext
|
||||
|
||||
start_ts = time.time()
|
||||
|
||||
# LiteLLM fetches a model cost map from GitHub during import unless this is set.
|
||||
@@ -89,7 +93,13 @@ from rag.utils.redis_conn import REDIS_CONN, RedisDistributedLock
|
||||
from rag.graphrag.utils import chat_limiter
|
||||
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
|
||||
from common.exceptions import TaskCanceledException
|
||||
from common.asyncio_utils import LoopLocalSemaphore
|
||||
from rag.svr.task_executor_limiter import (
|
||||
task_limiter,
|
||||
chunk_limiter,
|
||||
embed_limiter,
|
||||
minio_limiter,
|
||||
kg_limiter,
|
||||
)
|
||||
from common import settings
|
||||
from common.constants import PAGERANK_FLD, TAG_FLD, SVR_CONSUMER_GROUP_NAME
|
||||
from rag.utils.table_es_metadata import (
|
||||
@@ -97,6 +107,7 @@ from rag.utils.table_es_metadata import (
|
||||
merge_table_parser_config_from_kb,
|
||||
table_parser_strip_doc_metadata_keys,
|
||||
)
|
||||
from rag.nlp import search as nlp_search
|
||||
|
||||
BATCH_SIZE = 64
|
||||
|
||||
@@ -129,9 +140,10 @@ TASK_TYPE_TO_PIPELINE_TASK_TYPE = {
|
||||
}
|
||||
|
||||
UNACKED_ITERATOR = None
|
||||
# Task type and executor index (consistent with SAAS version)
|
||||
TASK_TYPE = "common"
|
||||
TE_IDX = "0"
|
||||
|
||||
CONSUMER_NO = "0" if len(sys.argv) < 2 else sys.argv[1]
|
||||
CONSUMER_NAME = "task_executor_" + CONSUMER_NO
|
||||
BOOT_AT = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
PENDING_TASKS = 0
|
||||
LAG_TASKS = 0
|
||||
@@ -140,18 +152,9 @@ FAILED_TASKS = 0
|
||||
|
||||
CURRENT_TASKS = {}
|
||||
|
||||
MAX_CONCURRENT_TASKS = int(os.environ.get('MAX_CONCURRENT_TASKS', "5"))
|
||||
MAX_CONCURRENT_CHUNK_BUILDERS = int(os.environ.get('MAX_CONCURRENT_CHUNK_BUILDERS', "1"))
|
||||
MAX_CONCURRENT_MINIO = int(os.environ.get('MAX_CONCURRENT_MINIO', '10'))
|
||||
task_limiter = LoopLocalSemaphore(MAX_CONCURRENT_TASKS)
|
||||
chunk_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
embed_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
minio_limiter = LoopLocalSemaphore(MAX_CONCURRENT_MINIO)
|
||||
kg_limiter = LoopLocalSemaphore(2)
|
||||
WORKER_HEARTBEAT_TIMEOUT = int(os.environ.get('WORKER_HEARTBEAT_TIMEOUT', '120'))
|
||||
stop_event = threading.Event()
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
logging.info("Received interrupt signal, shutting down...")
|
||||
stop_event.set()
|
||||
@@ -197,7 +200,8 @@ async def collect():
|
||||
global CONSUMER_NAME, DONE_TASKS, FAILED_TASKS
|
||||
global UNACKED_ITERATOR
|
||||
|
||||
svr_queue_names = settings.get_svr_queue_names()
|
||||
svr_queue_names = settings.get_svr_queue_names(TASK_TYPE)
|
||||
|
||||
redis_msg = None
|
||||
try:
|
||||
if not UNACKED_ITERATOR:
|
||||
@@ -261,12 +265,16 @@ async def get_storage_binary(bucket, name):
|
||||
return await thread_pool_exec(settings.STORAGE_IMPL.get, bucket, name)
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
@timeout(60 * 80, 1)
|
||||
async def build_chunks(task, progress_callback):
|
||||
if task["size"] > settings.DOC_MAXIMUM_SIZE:
|
||||
set_progress(task["id"], prog=-1, msg="File size exceeds( <= %dMb )" %
|
||||
(int(settings.DOC_MAXIMUM_SIZE / 1024 / 1024)))
|
||||
get_recording_context().record("file_size_exceeded", True)
|
||||
return []
|
||||
get_recording_context().record("file_size_exceeded", False)
|
||||
get_recording_context().record("parser_id", task["parser_id"])
|
||||
|
||||
chunker = FACTORY[task["parser_id"].lower()]
|
||||
try:
|
||||
@@ -299,6 +307,23 @@ async def build_chunks(task, progress_callback):
|
||||
f"roles_keys={list((parser_config_for_chunk.get('table_column_roles') or {}).keys())}"
|
||||
)
|
||||
|
||||
# Record chunk configuration for comparison
|
||||
from common.float_utils import normalize_overlapped_percent
|
||||
chunk_config = {
|
||||
"parser_id": task["parser_id"],
|
||||
"chunk_token_num": parser_config_for_chunk.get("chunk_token_num", 128),
|
||||
"overlapped_percent": normalize_overlapped_percent(
|
||||
parser_config_for_chunk.get("overlapped_percent", 0)
|
||||
),
|
||||
"delimiter": parser_config_for_chunk.get("delimiter", "\n!?。;!?"),
|
||||
"from_page": task["from_page"],
|
||||
"to_page": task["to_page"],
|
||||
"language": task["language"],
|
||||
"layout_recognizer": parser_config_for_chunk.get("layout_recognizer"),
|
||||
}
|
||||
get_recording_context().record("chunk_config", chunk_config)
|
||||
get_recording_context().record("parser_config_after_merge", parser_config_for_chunk)
|
||||
|
||||
try:
|
||||
async with chunk_limiter:
|
||||
task_language = task.get("language") or "Chinese"
|
||||
@@ -322,15 +347,22 @@ async def build_chunks(task, progress_callback):
|
||||
logging.exception("Chunking {}/{} got exception".format(task["location"], task["name"]))
|
||||
raise
|
||||
|
||||
# Record raw chunks for comparison
|
||||
get_recording_context().record("raw_chunks", cks)
|
||||
|
||||
# Extract and persist PDF outline if the parser attached it.
|
||||
outline_data = cks[0].get("__outline__") if cks else None
|
||||
get_recording_context().record("outline_data", outline_data)
|
||||
|
||||
if cks and cks[0].get("__outline__"):
|
||||
outline = cks[0].pop("__outline__")
|
||||
try:
|
||||
DocMetadataService.update_document_metadata(
|
||||
ret = DocMetadataService.update_document_metadata(
|
||||
task["doc_id"],
|
||||
update_metadata_to({"outline": outline},
|
||||
DocMetadataService.get_document_metadata(task["doc_id"]) or {})
|
||||
)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
logging.info("Persisted PDF outline (%d entries) for doc %s", len(outline), task["doc_id"])
|
||||
except Exception as e:
|
||||
logging.warning("Failed to persist PDF outline for doc %s: %s", task["doc_id"], e)
|
||||
@@ -385,6 +417,9 @@ async def build_chunks(task, progress_callback):
|
||||
el = timer() - st
|
||||
logging.info("MINIO PUT({}) cost {:.3f} s".format(task["name"], el))
|
||||
|
||||
# Record docs after MinIO upload
|
||||
get_recording_context().record("docs_after_prep", docs)
|
||||
|
||||
if task["parser_config"].get("auto_keywords", 0):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate keywords for every chunk ...")
|
||||
@@ -419,6 +454,10 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Keywords generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record keywords extraction count
|
||||
keywords = [d for d in docs if d.get("important_kwd")]
|
||||
get_recording_context().record("keywords_extracted", keywords)
|
||||
|
||||
if task["parser_config"].get("auto_questions", 0):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate questions for every chunk ...")
|
||||
@@ -452,6 +491,10 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record question generation
|
||||
questions = [d for d in docs if d.get("question_kwd")]
|
||||
get_recording_context().record("questions_generated", questions)
|
||||
|
||||
if task["parser_config"].get("enable_metadata", False) and (task["parser_config"].get("metadata") or task["parser_config"].get("built_in_metadata")):
|
||||
st = timer()
|
||||
progress_callback(msg="Start to generate meta-data for every chunk ...")
|
||||
@@ -510,9 +553,14 @@ async def build_chunks(task, progress_callback):
|
||||
existing_meta = DocMetadataService.get_document_metadata(task["doc_id"])
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
DocMetadataService.update_document_metadata(task["doc_id"], metadata)
|
||||
ret = DocMetadataService.update_document_metadata(task["doc_id"], metadata)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
progress_callback(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record metadata generation count
|
||||
metadata_list = [d for d in docs if d.get("metadata_obj")]
|
||||
get_recording_context().record("metadata_list_generated", metadata_list)
|
||||
|
||||
if task["kb_parser_config"].get("tag_kb_ids", []):
|
||||
progress_callback(msg="Start to tag for every chunk ...")
|
||||
kb_ids = task["kb_parser_config"]["tag_kb_ids"]
|
||||
@@ -578,9 +626,19 @@ async def build_chunks(task, progress_callback):
|
||||
raise
|
||||
progress_callback(msg="Tagging {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
# Record tags applied
|
||||
tags_applied = [d for d in docs if d.get(TAG_FLD)]
|
||||
get_recording_context().record("tags_applied", tags_applied)
|
||||
|
||||
# Record final chunks for comparison
|
||||
get_recording_context().record("final_chunks", docs)
|
||||
final_chunk_ids = [c.get("id") for c in docs if isinstance(c, dict) and "id" in c]
|
||||
get_recording_context().record("final_chunk_ids_count", len(final_chunk_ids))
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
def build_TOC(task, docs, progress_callback):
|
||||
progress_callback(msg="Start to generate table of content ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(task["tenant_id"], LLMType.CHAT, task["llm_id"])
|
||||
@@ -634,6 +692,7 @@ def init_kb(row, vector_size: int):
|
||||
return settings.docStoreConn.create_idx(idxnm, row.get("kb_id", ""), vector_size, parser_id)
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def embedding(docs, mdl, parser_config=None, callback=None):
|
||||
if parser_config is None:
|
||||
parser_config = {}
|
||||
@@ -686,6 +745,7 @@ async def embedding(docs, mdl, parser_config=None, callback=None):
|
||||
return tk_count, vector_size
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def run_dataflow(task: dict):
|
||||
from api.db.services.canvas_service import UserCanvasService
|
||||
from rag.flow.pipeline import Pipeline
|
||||
@@ -708,32 +768,47 @@ async def run_dataflow(task: dict):
|
||||
pipeline = Pipeline(dsl, tenant_id=task["tenant_id"], doc_id=doc_id, task_id=task_id, flow_id=dataflow_id)
|
||||
chunks = await pipeline.run(file=task["file"]) if task.get("file") else await pipeline.run()
|
||||
if doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
get_recording_context().record("dataflow_debug_result", "canvas_debug_mode")
|
||||
get_recording_context().record("dataflow_chunks", chunks)
|
||||
return
|
||||
|
||||
if not chunks:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
get_recording_context().record("pipeline_output_count", 0)
|
||||
get_recording_context().record("pipeline_output_type", "empty")
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
embedding_token_consumption = chunks.get("embedding_token_consumption", 0)
|
||||
# The output key may exist with an empty payload; check presence, not truthiness.
|
||||
if "chunks" in chunks:
|
||||
chunks = copy.deepcopy(chunks["chunks"])
|
||||
output_type = "chunks"
|
||||
elif "json" in chunks:
|
||||
chunks = copy.deepcopy(chunks["json"])
|
||||
output_type = "json"
|
||||
elif "markdown" in chunks:
|
||||
chunks = [{"text": [chunks["markdown"]]}] if chunks["markdown"] else []
|
||||
output_type = "markdown"
|
||||
elif "text" in chunks:
|
||||
chunks = [{"text": [chunks["text"]]}] if chunks["text"] else []
|
||||
output_type = "text"
|
||||
elif "html" in chunks:
|
||||
chunks = [{"text": [chunks["html"]]}] if chunks["html"] else []
|
||||
output_type = "html"
|
||||
else:
|
||||
chunks = []
|
||||
output_type = "empty"
|
||||
|
||||
get_recording_context().record("pipeline_output_type", output_type)
|
||||
get_recording_context().record("pipeline_output_count", len(chunks))
|
||||
|
||||
# An empty normalized payload means "nothing parsed", so stop before embedding/indexing.
|
||||
if not chunks:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
keys = [k for o in chunks for k in list(o.keys())]
|
||||
@@ -763,6 +838,8 @@ async def run_dataflow(task: dict):
|
||||
if i % (len(texts) // settings.EMBEDDING_BATCH_SIZE / 100 + 1) == 1:
|
||||
set_progress(task_id, prog=prog, msg=f"{i + 1} / {len(texts) // settings.EMBEDDING_BATCH_SIZE}")
|
||||
vects = np.vstack(vects_batches) if vects_batches else np.array([])
|
||||
get_recording_context().record("embedding_token_consumption", embedding_token_consumption)
|
||||
get_recording_context().record("vector_size", len(vects[0]) if len(vects) > 0 else 0)
|
||||
|
||||
assert len(vects) == len(chunks)
|
||||
for i, ck in enumerate(chunks):
|
||||
@@ -772,8 +849,9 @@ async def run_dataflow(task: dict):
|
||||
raise
|
||||
except Exception as e:
|
||||
set_progress(task_id, prog=-1, msg=f"[ERROR]: {e}")
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
metadata = {}
|
||||
@@ -814,26 +892,31 @@ async def run_dataflow(task: dict):
|
||||
existing_meta = DocMetadataService.get_document_metadata(doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
get_recording_context().record("run_dataflow_metadata", metadata)
|
||||
ret = DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
|
||||
start_ts = timer()
|
||||
set_progress(task_id, prog=0.82, msg="[DOC Engine]:\nStart to index...")
|
||||
e = await insert_chunks(task_id, task["tenant_id"], task["kb_id"], chunks, partial(set_progress, task_id, 0, 100000000))
|
||||
if not e:
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline))
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
return
|
||||
|
||||
time_cost = timer() - start_ts
|
||||
task_time_cost = timer() - task_start_ts
|
||||
set_progress(task_id, prog=1., msg="Indexing done ({:.2f}s). Task done ({:.2f}s)".format(time_cost, task_time_cost))
|
||||
DocumentService.increment_chunk_num(doc_id, task_dataset_id, embedding_token_consumption, len(chunks),
|
||||
ret = DocumentService.increment_chunk_num(doc_id, task_dataset_id, embedding_token_consumption, len(chunks),
|
||||
task_time_cost)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
logging.info("[Done], chunks({}), token({}), elapsed:{:.2f}".format(len(chunks), embedding_token_consumption,
|
||||
task_time_cost))
|
||||
PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id, task_type=PipelineTaskType.PARSE,
|
||||
get_recording_context().record("dataflow_chunks", chunks)
|
||||
ret = PipelineOperationLogService.create(document_id=doc_id, pipeline_id=dataflow_id, task_type=PipelineTaskType.PARSE,
|
||||
dsl=str(pipeline))
|
||||
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.create", ret)
|
||||
|
||||
RAPTOR_METHOD_SEARCH_LIMIT = 10000
|
||||
|
||||
@@ -901,19 +984,18 @@ async def has_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, tree_builde
|
||||
|
||||
async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_method: str | None = None):
|
||||
"""Delete RAPTOR summaries for doc_id, optionally preserving one method."""
|
||||
from rag.nlp import search as nlp_search
|
||||
|
||||
if keep_method is None:
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing all RAPTOR summaries (doc=%s tenant=%s kb=%s)",
|
||||
doc_id, tenant_id, kb_id,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": doc_id, "raptor_kwd": ["raptor"]},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
return 0
|
||||
|
||||
field_map = await get_raptor_chunk_field_map(doc_id, tenant_id, kb_id)
|
||||
@@ -929,12 +1011,13 @@ async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_met
|
||||
"delete_raptor_chunks: removing %d stale RAPTOR chunks (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
len(chunk_ids), doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": list(chunk_ids)},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
return len(chunk_ids)
|
||||
|
||||
|
||||
@@ -1171,6 +1254,7 @@ async def delete_image(kb_id, chunk_id):
|
||||
raise
|
||||
|
||||
|
||||
@timed_with_recording
|
||||
async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progress_callback):
|
||||
"""
|
||||
Insert chunks into document store (Elasticsearch OR Infinity).
|
||||
@@ -1205,8 +1289,9 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
mothers.append(mom_ck)
|
||||
|
||||
for b in range(0, len(mothers), settings.DOC_BULK_SIZE):
|
||||
await thread_pool_exec(settings.docStoreConn.insert, mothers[b:b + settings.DOC_BULK_SIZE],
|
||||
ret = await thread_pool_exec(settings.docStoreConn.insert, mothers[b:b + settings.DOC_BULK_SIZE],
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.insert", ret)
|
||||
task_canceled = has_canceled(task_id)
|
||||
if task_canceled:
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
@@ -1215,6 +1300,7 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
for b in range(0, len(chunks), settings.DOC_BULK_SIZE):
|
||||
doc_store_result = await thread_pool_exec(settings.docStoreConn.insert, chunks[b:b + settings.DOC_BULK_SIZE],
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.insert", doc_store_result)
|
||||
task_canceled = has_canceled(task_id)
|
||||
if task_canceled:
|
||||
# Roll back partial RAPTOR summary inserts so the next run is not
|
||||
@@ -1225,12 +1311,13 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
]
|
||||
if raptor_ids_to_rollback:
|
||||
try:
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": raptor_ids_to_rollback},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
logging.info(
|
||||
"insert_chunks: rolled back %d partial RAPTOR chunks after cancellation (task=%s)",
|
||||
len(raptor_ids_to_rollback), task_id,
|
||||
@@ -1252,10 +1339,12 @@ async def insert_chunks(task_id, task_tenant_id, task_dataset_id, chunks, progre
|
||||
chunk_ids_str = " ".join(chunk_ids)
|
||||
try:
|
||||
TaskService.update_chunk_ids(task_id, chunk_ids_str)
|
||||
get_recording_context().save_func_return_value("TaskService.update_chunk_ids", None)
|
||||
except DoesNotExist:
|
||||
logging.warning(f"do_handle_task update_chunk_ids failed since task {task_id} is unknown.")
|
||||
doc_store_result = await thread_pool_exec(settings.docStoreConn.delete, {"id": chunk_ids},
|
||||
search.index_name(task_tenant_id), task_dataset_id, )
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", doc_store_result)
|
||||
tasks = []
|
||||
for chunk_id in chunk_ids:
|
||||
tasks.append(asyncio.create_task(delete_image(task_dataset_id, chunk_id)))
|
||||
@@ -1277,7 +1366,8 @@ async def do_handle_task(task):
|
||||
task_type = task.get("task_type", "")
|
||||
|
||||
if task_type == "memory":
|
||||
await handle_save_to_memory_task(task)
|
||||
result = await handle_save_to_memory_task(task)
|
||||
get_recording_context().save_func_return_value("handle_save_to_memory_task", result)
|
||||
return
|
||||
|
||||
if task_type == "dataflow" and task.get("doc_id", "") == CANVAS_DEBUG_DOC_ID:
|
||||
@@ -1355,7 +1445,9 @@ async def do_handle_task(task):
|
||||
},
|
||||
}
|
||||
)
|
||||
if not KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config}):
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
get_recording_context().save_func_return_value("KnowledgebaseService.update_by_id", update_result)
|
||||
if not update_result:
|
||||
progress_callback(prog=-1.0, msg="Internal error: Invalid RAPTOR configuration")
|
||||
return
|
||||
|
||||
@@ -1373,6 +1465,8 @@ async def do_handle_task(task):
|
||||
callback=progress_callback,
|
||||
doc_ids=task.get("doc_ids", []),
|
||||
)
|
||||
get_recording_context().record("raptor_chunks", chunks)
|
||||
get_recording_context().record("raptor_token_count", token_count)
|
||||
if fake_doc_ids := task.get("doc_ids", []):
|
||||
task_doc_id = fake_doc_ids[0] # use the first document ID to represent this task for logging purposes
|
||||
# Either using graphrag or Standard chunking methods
|
||||
@@ -1409,7 +1503,9 @@ async def do_handle_task(task):
|
||||
}
|
||||
}
|
||||
)
|
||||
if not KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config}):
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
get_recording_context().save_func_return_value("KnowledgebaseService.update_by_id", update_result)
|
||||
if not update_result:
|
||||
progress_callback(prog=-1.0, msg="Internal error: Invalid GraphRAG configuration")
|
||||
return
|
||||
|
||||
@@ -1434,6 +1530,7 @@ async def do_handle_task(task):
|
||||
with_community=with_community,
|
||||
)
|
||||
logging.info(f"GraphRAG task result for task {task}:\n{result}")
|
||||
get_recording_context().record("graphrag_result", result)
|
||||
progress_callback(prog=1.0, msg="Knowledge Graph done ({:.2f}s)".format(timer() - start_ts))
|
||||
return
|
||||
elif task_type == "mindmap":
|
||||
@@ -1445,6 +1542,11 @@ async def do_handle_task(task):
|
||||
task['llm_id'] = doc_task_llm_id
|
||||
start_ts = timer()
|
||||
chunks = await build_chunks(task, progress_callback)
|
||||
get_recording_context().record("chunks", chunks)
|
||||
# Record chunk_ids_count for comparison
|
||||
chunk_ids = [c.get("id") for c in chunks if isinstance(c, dict) and "id" in c]
|
||||
get_recording_context().record("chunk_ids_count", len(chunk_ids))
|
||||
# Record chunks array for content comparison (first, middle, last, random)
|
||||
logging.info("Build document {}: {:.2f}s".format(task_document_name, timer() - start_ts))
|
||||
if not chunks:
|
||||
progress_callback(1., msg=f"No chunk built from {task_document_name}")
|
||||
@@ -1461,6 +1563,8 @@ async def do_handle_task(task):
|
||||
logging.exception(error_message)
|
||||
token_count = 0
|
||||
raise
|
||||
get_recording_context().record("token_count", token_count)
|
||||
get_recording_context().record("vector_size", vector_size)
|
||||
progress_message = "Embedding chunks ({:.2f}s)".format(timer() - start_ts)
|
||||
logging.info(progress_message)
|
||||
progress_callback(msg=progress_message)
|
||||
@@ -1479,7 +1583,9 @@ async def do_handle_task(task):
|
||||
|
||||
try:
|
||||
if not await _maybe_insert_chunks(chunks):
|
||||
get_recording_context().record("insertion_result", "failed")
|
||||
return
|
||||
get_recording_context().record("insertion_result", "success")
|
||||
if has_canceled(task_id):
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
return
|
||||
@@ -1487,12 +1593,15 @@ async def do_handle_task(task):
|
||||
if raptor_cleanup_chunks:
|
||||
cleaned_chunks = 0
|
||||
for cleanup_doc_id, keep_method in raptor_cleanup_chunks:
|
||||
cleaned_chunks += await delete_raptor_chunks(
|
||||
ret = await delete_raptor_chunks(
|
||||
cleanup_doc_id,
|
||||
task_tenant_id,
|
||||
task_dataset_id,
|
||||
keep_method=keep_method,
|
||||
)
|
||||
cleaned_chunks += ret
|
||||
get_recording_context().save_func_return_value("delete_raptor_chunks", ret)
|
||||
|
||||
if cleaned_chunks:
|
||||
progress_callback(msg=f"Cleaned up {cleaned_chunks} stale RAPTOR chunks.")
|
||||
|
||||
@@ -1502,7 +1611,8 @@ async def do_handle_task(task):
|
||||
)
|
||||
)
|
||||
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
ret = DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
|
||||
# Table parser (manual): push metadata/both column values to document-level metadata for UI / chat filters
|
||||
if task.get("parser_id", "").lower() == "table":
|
||||
@@ -1525,7 +1635,8 @@ async def do_handle_task(task):
|
||||
f"table_strip_key_count={len(strip_keys)}, agg_keys={list(agg.keys())}"
|
||||
)
|
||||
try:
|
||||
DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
ret = DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
get_recording_context().save_func_return_value("DocMetadataService.update_document_metadata", ret)
|
||||
logging.debug("[TABLE_META_DEBUG] update_document_metadata succeeded")
|
||||
except Exception as ue:
|
||||
logging.error(
|
||||
@@ -1546,15 +1657,20 @@ async def do_handle_task(task):
|
||||
if toc_thread:
|
||||
d = await toc_thread
|
||||
if d:
|
||||
get_recording_context().record("toc_chunk", [d])
|
||||
if not await _maybe_insert_chunks([d]):
|
||||
get_recording_context().record("toc_inserted", False)
|
||||
return
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, 0, 1, 0)
|
||||
get_recording_context().record("toc_inserted", True)
|
||||
ret = DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, 0, 1, 0)
|
||||
get_recording_context().save_func_return_value("DocumentService.increment_chunk_num", ret)
|
||||
|
||||
if has_canceled(task_id):
|
||||
progress_callback(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
task_time_cost = timer() - task_start_ts
|
||||
get_recording_context().record("task_status", "completed")
|
||||
progress_callback(prog=1.0, msg="Task done ({:.2f}s)".format(task_time_cost))
|
||||
logging.info(
|
||||
"Chunk doc({}), page({}-{}), chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
@@ -1573,12 +1689,13 @@ async def do_handle_task(task):
|
||||
task_dataset_id,
|
||||
)
|
||||
if exists:
|
||||
await thread_pool_exec(
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": task_doc_id},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
get_recording_context().save_func_return_value("docStoreConn.delete", ret)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
f"Remove doc({task_doc_id}) from docStore failed when task({task_id}) canceled, exception: {e}")
|
||||
@@ -1596,9 +1713,28 @@ async def handle_task():
|
||||
PipelineTaskType.PARSE) or PipelineTaskType.PARSE
|
||||
task_id = task["id"]
|
||||
try:
|
||||
logging.info(f"handle_task begin for task {json.dumps(task)}")
|
||||
CURRENT_TASKS[task["id"]] = copy.deepcopy(task)
|
||||
await do_handle_task(task)
|
||||
run_mode = os.environ.get("TE_RUN_MODE", "0")
|
||||
logging.info(f"TE_RUN_MODE is {run_mode}")
|
||||
|
||||
# Check if dry-run comparison is enabled via environment variable
|
||||
if run_mode == "1": # dry run mode - compare
|
||||
set_recording_context(RecordingContext())
|
||||
await do_handle_task(task) # original execution
|
||||
# dry run mode
|
||||
logging.info(f"-----dry run task:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
await TaskManager.dry_run_task(task, get_recording_context(), chat_limiter, minio_limiter, chunk_limiter,
|
||||
embed_limiter,kg_limiter, set_progress, has_canceled)
|
||||
elif run_mode == "0": # use refactor-ed version
|
||||
# switch to refactor-ed version
|
||||
logging.info(f"-----run refactor-ed task executor:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
await TaskManager.run_refactored_task(task, chat_limiter, minio_limiter, chunk_limiter,
|
||||
embed_limiter,kg_limiter, set_progress, has_canceled)
|
||||
else: # original version
|
||||
logging.info(f"-----run original task executor:{task_id}, {task.get('name', '')}, doc id:{task.get('doc_id', '')}")
|
||||
set_recording_context(NullRecordingContext())
|
||||
await do_handle_task(task)
|
||||
|
||||
DONE_TASKS += 1
|
||||
CURRENT_TASKS.pop(task_id, None)
|
||||
logging.info(f"handle_task done for task {json.dumps(task)}")
|
||||
@@ -1626,9 +1762,10 @@ async def handle_task():
|
||||
referred_document_id = None
|
||||
if task_type in ["graphrag", "raptor", "mindmap"]:
|
||||
referred_document_id = task["doc_ids"][0]
|
||||
PipelineOperationLogService.record_pipeline_operation(document_id=task["doc_id"], pipeline_id="",
|
||||
ret = PipelineOperationLogService.record_pipeline_operation(document_id=task["doc_id"], pipeline_id="",
|
||||
task_type=pipeline_task_type,
|
||||
task_id=task_id, referred_document_id=referred_document_id)
|
||||
get_recording_context().save_func_return_value("PipelineOperationLogService.record_pipeline_operation", ret)
|
||||
|
||||
redis_msg.ack()
|
||||
|
||||
@@ -1685,7 +1822,8 @@ async def report_status():
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to report heartbeat: {e}")
|
||||
else:
|
||||
logging.info(f"{CONSUMER_NAME} reported heartbeat: {heartbeat}")
|
||||
logging.debug(f"{CONSUMER_NAME} reported heartbeat: {heartbeat}")
|
||||
pass
|
||||
|
||||
# Clean up own expired heartbeat
|
||||
try:
|
||||
@@ -1752,6 +1890,7 @@ async def main():
|
||||
/____/
|
||||
""")
|
||||
logging.info(f'RAGFlow ingestion version: {get_ragflow_version()}')
|
||||
logging.info(f"ENABLE_DRY_RUN_COMPARISON: {os.environ.get("ENABLE_DRY_RUN_COMPARISON", "0")}")
|
||||
show_configs()
|
||||
settings.init_settings()
|
||||
settings.check_and_install_torch()
|
||||
@@ -1786,6 +1925,17 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Parse command line arguments (consistent with SAAS version)
|
||||
parser = argparse.ArgumentParser(description='Task Executor')
|
||||
parser.add_argument("-i", "--index", type=str, default='0')
|
||||
parser.add_argument("-t", "--type", type=str, default="common", help="[common, graphrag, raptor, resume]")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Update global variables
|
||||
TASK_TYPE = args.type
|
||||
TE_IDX = args.index
|
||||
CONSUMER_NAME = f"task_executor_{TASK_TYPE}_{TE_IDX}"
|
||||
|
||||
faulthandler.enable()
|
||||
init_root_logger(CONSUMER_NAME)
|
||||
try:
|
||||
|
||||
28
rag/svr/task_executor_limiter.py
Normal file
28
rag/svr/task_executor_limiter.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
import os
|
||||
|
||||
from common.asyncio_utils import LoopLocalSemaphore
|
||||
|
||||
MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "5"))
|
||||
MAX_CONCURRENT_CHUNK_BUILDERS = int(os.environ.get("MAX_CONCURRENT_CHUNK_BUILDERS", "1"))
|
||||
MAX_CONCURRENT_MINIO = int(os.environ.get("MAX_CONCURRENT_MINIO", "10"))
|
||||
|
||||
task_limiter = LoopLocalSemaphore(MAX_CONCURRENT_TASKS)
|
||||
chunk_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
embed_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS)
|
||||
minio_limiter = LoopLocalSemaphore(MAX_CONCURRENT_MINIO)
|
||||
kg_limiter = LoopLocalSemaphore(2)
|
||||
136
rag/svr/task_executor_refactor/chunk_builder.py
Normal file
136
rag/svr/task_executor_refactor/chunk_builder.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Chunk Builder Module.
|
||||
|
||||
Provides parser factory and document chunking logic:
|
||||
- Parser module registration and selection
|
||||
- Document chunking via parser
|
||||
- PDF outline extraction
|
||||
"""
|
||||
|
||||
import logging
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List
|
||||
|
||||
from common.constants import ParserType
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from rag.utils.table_es_metadata import merge_table_parser_config_from_kb
|
||||
|
||||
|
||||
def get_parser(parser_id: str):
|
||||
"""Get parser module by ID.
|
||||
|
||||
Args:
|
||||
parser_id: The parser identifier.
|
||||
|
||||
Returns:
|
||||
The parser module for the given parser ID.
|
||||
"""
|
||||
from rag.app import laws, paper, presentation, manual, qa, table, book, resume, picture, naive, one, audio, email, tag
|
||||
|
||||
factory = {
|
||||
"general": naive,
|
||||
ParserType.NAIVE.value: naive,
|
||||
ParserType.PAPER.value: paper,
|
||||
ParserType.BOOK.value: book,
|
||||
ParserType.PRESENTATION.value: presentation,
|
||||
ParserType.MANUAL.value: manual,
|
||||
ParserType.LAWS.value: laws,
|
||||
ParserType.QA.value: qa,
|
||||
ParserType.TABLE.value: table,
|
||||
ParserType.RESUME.value: resume,
|
||||
ParserType.PICTURE.value: picture,
|
||||
ParserType.ONE.value: one,
|
||||
ParserType.AUDIO.value: audio,
|
||||
ParserType.EMAIL.value: email,
|
||||
ParserType.KG.value: naive,
|
||||
ParserType.TAG.value: tag,
|
||||
}
|
||||
return factory[parser_id.lower()]
|
||||
|
||||
|
||||
async def run_chunking(
|
||||
chunker,
|
||||
binary: bytes,
|
||||
ctx: TaskContext,
|
||||
) -> List[Dict]:
|
||||
"""Run document chunking via parser.
|
||||
|
||||
Args:
|
||||
chunker: The parser module to use.
|
||||
binary: Binary content of the document.
|
||||
ctx: TaskContext containing task configuration.
|
||||
|
||||
Returns:
|
||||
List of chunk dictionaries.
|
||||
"""
|
||||
st = timer()
|
||||
try:
|
||||
# Merge table parser config
|
||||
parser_config = merge_table_parser_config_from_kb(ctx.raw_task)
|
||||
|
||||
async with ctx.chunk_limiter:
|
||||
cks = await thread_pool_exec(
|
||||
chunker.chunk,
|
||||
ctx.name,
|
||||
binary=binary,
|
||||
from_page=ctx.from_page,
|
||||
to_page=ctx.to_page,
|
||||
lang=ctx.language,
|
||||
callback=ctx.progress_cb,
|
||||
kb_id=ctx.kb_id,
|
||||
parser_config=parser_config,
|
||||
tenant_id=ctx.tenant_id,
|
||||
)
|
||||
logging.info("Chunking({}) {}/{} done".format(timer() - st, ctx.location, ctx.name))
|
||||
ctx.recording_context.record("parser_config_after_merge", parser_config)
|
||||
return cks
|
||||
except Exception as e:
|
||||
ctx.progress_cb(-1, msg="Internal server error while chunking: %s" % str(e).replace("'", ""))
|
||||
logging.exception("Chunking {}/{} got exception".format(ctx.location, ctx.name))
|
||||
raise
|
||||
|
||||
|
||||
async def extract_outline(cks: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Extract and persist PDF outline if present.
|
||||
|
||||
Args:
|
||||
cks: List of chunk dictionaries.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
outline_data = cks[0].get("__outline__") if cks else None
|
||||
ctx.recording_context.record("outline_data", outline_data)
|
||||
|
||||
if cks and cks[0].get("__outline__"):
|
||||
outline = cks[0].pop("__outline__")
|
||||
try:
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
temp_doc = DocMetadataService.get_document_metadata(ctx.doc_id) or {}
|
||||
DocMetadataService.update_document_metadata(
|
||||
ctx.doc_id,
|
||||
update_metadata_to({"outline": outline}, temp_doc)
|
||||
)
|
||||
|
||||
logging.info("Persisted PDF outline (%d entries) for doc %s", len(outline), ctx.doc_id)
|
||||
except Exception as e:
|
||||
logging.warning("Failed to persist PDF outline for doc %s: %s", ctx.doc_id, e)
|
||||
308
rag/svr/task_executor_refactor/chunk_post_processor.py
Normal file
308
rag/svr/task_executor_refactor/chunk_post_processor.py
Normal file
@@ -0,0 +1,308 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Chunk Post-Processor Module.
|
||||
|
||||
Provides post-processing functions for chunks:
|
||||
- Keyword extraction
|
||||
- Question generation
|
||||
- Metadata generation
|
||||
- Content tagging
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List
|
||||
|
||||
from common.constants import TAG_FLD, LLMType
|
||||
from common.metadata_utils import turn2jsonschema, update_metadata_to
|
||||
from common import settings
|
||||
from rag.nlp import rag_tokenizer
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_type_and_name
|
||||
from rag.prompts.generator import gen_metadata, keyword_extraction, question_proposal, content_tagging
|
||||
from rag.graphrag.utils import get_llm_cache, set_llm_cache
|
||||
|
||||
|
||||
async def extract_keywords(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Extract keywords for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate keywords for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
async def doc_keyword_extraction(chat_mdl, d, topn):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "keywords", {"topn": topn})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await keyword_extraction(chat_mdl, d["content_with_weight"], topn)
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "keywords", {"topn": topn})
|
||||
if cached:
|
||||
d["important_kwd"] = [k for k in re.split(r"[,,;;、\r\n]+", cached) if k.strip()]
|
||||
d["important_tks"] = rag_tokenizer.tokenize(" ".join(d["important_kwd"]))
|
||||
return
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(
|
||||
asyncio.create_task(doc_keyword_extraction(chat_model, doc, ctx.parser_config["auto_keywords"])))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in doc_keyword_extraction: {}".format(e))
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Keywords generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
async def generate_questions(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Generate questions for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate questions for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
async def doc_question_proposal(chat_mdl, d, topn):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "question", {"topn": topn})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await question_proposal(chat_mdl, d["content_with_weight"], topn)
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "question", {"topn": topn})
|
||||
if cached:
|
||||
d["question_kwd"] = cached.split("\n")
|
||||
d["question_tks"] = rag_tokenizer.tokenize("\n".join(d["question_kwd"]))
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(
|
||||
asyncio.create_task(doc_question_proposal(chat_model, doc, ctx.parser_config["auto_questions"])))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in doc_question_proposal", exc_info=e)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Question generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
def build_metadata_config(parser_config: dict) -> list:
|
||||
"""Build the metadata configuration from parser_config.
|
||||
|
||||
Extracts and normalizes ``metadata`` and ``built_in_metadata`` from the
|
||||
parser configuration into a single list or dict that is passed to the LLM
|
||||
cache and generation functions.
|
||||
|
||||
This should be called once per ``generate_metadata`` invocation — the result
|
||||
is identical for every chunk within the same document parse session so
|
||||
extracting it avoids rebuilding inside the per-chunk async task.
|
||||
|
||||
Args:
|
||||
parser_config: Configuration dict from the parser, expected to contain
|
||||
``metadata`` (dict or list) and optionally ``built_in_metadata``
|
||||
(list of metadata item dicts).
|
||||
|
||||
Returns:
|
||||
A list or dict representing the merged metadata configuration.
|
||||
"""
|
||||
metadata_conf = parser_config.get("metadata", [])
|
||||
built_in_metadata = list(parser_config.get("built_in_metadata") or [])
|
||||
if isinstance(metadata_conf, dict):
|
||||
if not isinstance(metadata_conf.get("properties"), dict):
|
||||
metadata_conf = {"type": "object", "properties": {}}
|
||||
if built_in_metadata:
|
||||
metadata_conf = {
|
||||
**metadata_conf,
|
||||
"properties": {
|
||||
**metadata_conf.get("properties", {}),
|
||||
**turn2jsonschema(built_in_metadata).get("properties", {}),
|
||||
},
|
||||
}
|
||||
elif isinstance(metadata_conf, list):
|
||||
metadata_conf = metadata_conf + built_in_metadata
|
||||
else:
|
||||
metadata_conf = built_in_metadata
|
||||
return metadata_conf
|
||||
|
||||
|
||||
async def generate_metadata(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Generate metadata for chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
st = timer()
|
||||
ctx.progress_cb(msg="Start to generate meta-data for every chunk ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(ctx.tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
metadata_conf = build_metadata_config(ctx.parser_config)
|
||||
|
||||
async def gen_metadata_task(chat_mdl, d):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], "metadata",
|
||||
metadata_conf)
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
async with chat_limiter:
|
||||
cached = await gen_metadata(chat_mdl,
|
||||
turn2jsonschema(metadata_conf),
|
||||
d["content_with_weight"])
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, "metadata",
|
||||
metadata_conf)
|
||||
if cached:
|
||||
d["metadata_obj"] = cached
|
||||
|
||||
tasks = []
|
||||
for doc in docs:
|
||||
tasks.append(asyncio.create_task(gen_metadata_task(chat_model, doc)))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error in gen_metadata", exc_info=e)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
|
||||
metadata = {}
|
||||
for doc in docs:
|
||||
if "metadata_obj" in doc:
|
||||
metadata = update_metadata_to(metadata, doc["metadata_obj"])
|
||||
del doc["metadata_obj"]
|
||||
if metadata:
|
||||
existing_meta = DocMetadataService.get_document_metadata(ctx.doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(ctx.doc_id, metadata)
|
||||
ctx.progress_cb(msg="Metadata generation {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
async def apply_tags(docs: List[Dict], ctx: TaskContext) -> None:
|
||||
"""Apply tags to chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to process.
|
||||
ctx: TaskContext containing task configuration.
|
||||
"""
|
||||
chat_limiter = ctx.chat_limiter
|
||||
|
||||
ctx.progress_cb(msg="Start to tag for every chunk ...")
|
||||
kb_ids = ctx.kb_parser_config["tag_kb_ids"]
|
||||
tenant_id = ctx.tenant_id
|
||||
topn_tags = ctx.kb_parser_config.get("topn_tags", 3)
|
||||
S = 1000
|
||||
st = timer()
|
||||
examples = []
|
||||
all_tags = settings.retriever.all_tags_in_portion(tenant_id, kb_ids, S)
|
||||
chat_model_config = get_model_config_by_type_and_name(tenant_id, LLMType.CHAT, ctx.llm_id)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
docs_to_tag = []
|
||||
for doc in docs:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
if settings.retriever.tag_content(tenant_id, kb_ids, doc, all_tags, topn_tags=topn_tags, S=S) and len(
|
||||
doc.get(TAG_FLD, [])) > 0:
|
||||
examples.append({"content": doc["content_with_weight"], TAG_FLD: doc[TAG_FLD]})
|
||||
else:
|
||||
docs_to_tag.append(doc)
|
||||
|
||||
async def doc_content_tagging(chat_mdl, d, topn_tags):
|
||||
cached = get_llm_cache(chat_mdl.llm_name, d["content_with_weight"], all_tags, {"topn": topn_tags})
|
||||
if not cached:
|
||||
if ctx.has_canceled_func(ctx.id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
picked_examples = random.choices(examples, k=2) if len(examples) > 2 else examples
|
||||
if not picked_examples:
|
||||
picked_examples.append({"content": "This is an example", TAG_FLD: {'example': 1}})
|
||||
async with chat_limiter:
|
||||
cached = await content_tagging(
|
||||
chat_mdl,
|
||||
d["content_with_weight"],
|
||||
all_tags,
|
||||
picked_examples,
|
||||
topn_tags,
|
||||
)
|
||||
if cached:
|
||||
cached = json.dumps(cached)
|
||||
if cached:
|
||||
set_llm_cache(chat_mdl.llm_name, d["content_with_weight"], cached, all_tags, {"topn": topn_tags})
|
||||
d[TAG_FLD] = json.loads(cached)
|
||||
|
||||
tasks = []
|
||||
for doc in docs_to_tag:
|
||||
tasks.append(asyncio.create_task(doc_content_tagging(chat_model, doc, topn_tags)))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error("Error tagging docs: {}".format(e))
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
ctx.progress_cb(msg="Tagging {} chunks completed in {:.2f}s".format(len(docs), timer() - st))
|
||||
|
||||
|
||||
def count_with_key(docs: List[Dict], key: str) -> int:
|
||||
"""Count docs that have a specific key.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries.
|
||||
key: The key to check for.
|
||||
|
||||
Returns:
|
||||
Count of docs that have the key.
|
||||
"""
|
||||
return sum(1 for d in docs if d.get(key))
|
||||
479
rag/svr/task_executor_refactor/chunk_service.py
Normal file
479
rag/svr/task_executor_refactor/chunk_service.py
Normal file
@@ -0,0 +1,479 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Chunk Service Module.
|
||||
|
||||
Provides [`ChunkService`](rag/svr/task_executor_refactor/chunk_service.py:50) for document chunking,
|
||||
post-processing (keywords, questions, metadata, tags), MinIO upload, and chunk insertion into document store.
|
||||
|
||||
This module orchestrates the chunk building pipeline by delegating to:
|
||||
- [`chunk_builder`](rag/svr/task_executor_refactor/chunk_builder.py): Parser selection and document chunking
|
||||
- [`chunk_post_processor`](rag/svr/task_executor_refactor/chunk_post_processor.py): Post-processing functions
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from timeit import default_timer as timer
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import xxhash
|
||||
from common import settings
|
||||
from common.constants import PAGERANK_FLD, TAG_FLD
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from common.float_utils import normalize_overlapped_percent
|
||||
from rag.nlp import search
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.utils.base64_image import image2id
|
||||
|
||||
from api.db.services.task_service import TaskService
|
||||
from rag.svr.task_executor_refactor.constants import GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
|
||||
# Re-export for backward compatibility
|
||||
from rag.svr.task_executor_refactor.chunk_builder import (
|
||||
get_parser,
|
||||
run_chunking,
|
||||
extract_outline,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.chunk_post_processor import (
|
||||
extract_keywords,
|
||||
generate_questions,
|
||||
generate_metadata,
|
||||
apply_tags,
|
||||
)
|
||||
|
||||
|
||||
class ChunkService:
|
||||
"""Service for document chunking and post-processing.
|
||||
|
||||
This service handles:
|
||||
- Document chunking via parser modules (delegated to chunk_builder)
|
||||
- MinIO upload of chunk images
|
||||
- Keyword extraction (delegated to chunk_post_processor)
|
||||
- Question generation (delegated to chunk_post_processor)
|
||||
- Metadata generation (delegated to chunk_post_processor)
|
||||
- Content tagging (delegated to chunk_post_processor)
|
||||
- Table of contents generation
|
||||
- Chunk insertion into document store
|
||||
|
||||
All intermediate results are recorded via RecordingContext for comparison.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
):
|
||||
"""Initialize ChunkService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
async def build_chunks(
|
||||
self,
|
||||
storage_binary: bytes,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build chunks from document binary.
|
||||
|
||||
This is the main entry point for chunk building. It orchestrates:
|
||||
1. File size validation
|
||||
2. Parser selection and chunking (delegated to chunk_builder)
|
||||
3. Outline extraction (delegated to chunk_builder)
|
||||
4. MinIO upload
|
||||
5. Post-processing (delegated to chunk_post_processor)
|
||||
|
||||
Args:
|
||||
storage_binary: Binary content of the document.
|
||||
|
||||
Returns:
|
||||
List of chunk dictionaries ready for embedding.
|
||||
"""
|
||||
ctx = self._task_context
|
||||
# Validate file size
|
||||
if ctx.size > settings.DOC_MAXIMUM_SIZE:
|
||||
self._progress(prog=-1, msg="File size exceeds( <= %dMb )" %
|
||||
(int(settings.DOC_MAXIMUM_SIZE / 1024 / 1024)))
|
||||
self._task_context.recording_context.record("file_size_exceeded", True)
|
||||
return []
|
||||
ctx.recording_context.record("file_size_exceeded", False)
|
||||
ctx.recording_context.record("parser_id", ctx.parser_id)
|
||||
|
||||
# Get parser
|
||||
chunker = get_parser(ctx.parser_id)
|
||||
|
||||
# record config for compare
|
||||
chunk_config = {
|
||||
"parser_id": ctx.parser_id,
|
||||
"chunk_token_num": ctx.parser_config.get("chunk_token_num", 128),
|
||||
"overlapped_percent": normalize_overlapped_percent(
|
||||
ctx.parser_config.get("overlapped_percent", 0)
|
||||
),
|
||||
"delimiter": ctx.parser_config.get("delimiter", "\n!?。;!?"),
|
||||
"from_page": ctx.from_page,
|
||||
"to_page": ctx.to_page,
|
||||
"language": ctx.language,
|
||||
"layout_recognizer": ctx.parser_config.get("layout_recognizer"),
|
||||
}
|
||||
ctx.recording_context.record("chunk_config", chunk_config)
|
||||
|
||||
# Run chunking (delegated)
|
||||
cks = await run_chunking(chunker, storage_binary, ctx)
|
||||
|
||||
# Record raw chunks
|
||||
self._task_context.recording_context.record("raw_chunks", cks)
|
||||
|
||||
# Extract outline (delegated)
|
||||
await extract_outline(cks, ctx)
|
||||
|
||||
# Prepare docs and upload to MinIO
|
||||
docs = await self._prepare_docs_and_upload(cks)
|
||||
|
||||
# Record docs after prep
|
||||
self._task_context.recording_context.record("docs_after_prep", docs)
|
||||
|
||||
# Post-processing (delegated to chunk_post_processor)
|
||||
if ctx.parser_config.get("auto_keywords", 0):
|
||||
await extract_keywords(docs, ctx)
|
||||
keywords = [d for d in docs if d.get("important_kwd")]
|
||||
self._task_context.recording_context.record("keywords_extracted", keywords)
|
||||
|
||||
if ctx.parser_config.get("auto_questions", 0):
|
||||
await generate_questions(docs, ctx)
|
||||
questions = [d for d in docs if d.get("question_kwd")]
|
||||
self._task_context.recording_context.record("questions_generated", questions)
|
||||
|
||||
if ctx.parser_config.get("enable_metadata", False) and (
|
||||
ctx.parser_config.get("metadata") or ctx.parser_config.get("built_in_metadata")
|
||||
):
|
||||
await generate_metadata(docs, ctx)
|
||||
metadata_list = [d for d in docs if d.get("metadata_obj")]
|
||||
self._task_context.recording_context.record("metadata_list_generated", metadata_list)
|
||||
|
||||
if ctx.kb_parser_config.get("tag_kb_ids", []):
|
||||
await apply_tags(docs, ctx)
|
||||
tags_applied = [d for d in docs if d.get(TAG_FLD)]
|
||||
self._task_context.recording_context.record("tags_applied", tags_applied)
|
||||
|
||||
# Record final chunks
|
||||
self._task_context.recording_context.record("final_chunks", docs)
|
||||
final_chunk_ids = [c.get("id") for c in docs if isinstance(c, dict) and "id" in c]
|
||||
self._task_context.recording_context.record("final_chunk_ids_count", len(final_chunk_ids))
|
||||
|
||||
return docs
|
||||
|
||||
async def _prepare_docs_and_upload(self, cks: List[Dict]) -> List[Dict]:
|
||||
"""Prepare docs and upload images to MinIO."""
|
||||
ctx = self._task_context
|
||||
docs = []
|
||||
doc = {
|
||||
"doc_id": ctx.doc_id,
|
||||
"kb_id": str(ctx.kb_id)
|
||||
}
|
||||
if ctx.pagerank:
|
||||
doc[PAGERANK_FLD] = int(ctx.pagerank)
|
||||
|
||||
st = timer()
|
||||
|
||||
async def upload_to_minio(document, chunk):
|
||||
try:
|
||||
d = copy.deepcopy(document)
|
||||
d.update(chunk)
|
||||
d["id"] = xxhash.xxh64(
|
||||
(chunk["content_with_weight"] + str(d["doc_id"])).encode("utf-8", "surrogatepass")).hexdigest()
|
||||
d["create_time"] = str(datetime.now()).replace("T", " ")[:19]
|
||||
d["create_timestamp_flt"] = datetime.now().timestamp()
|
||||
|
||||
if d.get("img_id"):
|
||||
docs.append(d)
|
||||
return
|
||||
|
||||
if not d.get("image"):
|
||||
_ = d.pop("image", None)
|
||||
d["img_id"] = ""
|
||||
docs.append(d)
|
||||
return
|
||||
|
||||
await image2id(d, partial(settings.STORAGE_IMPL.put, tenant_id=ctx.tenant_id), d["id"], ctx.kb_id)
|
||||
docs.append(d)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Saving image of chunk {}/{}/{} got exception".format(ctx.location, ctx.name, d["id"]))
|
||||
raise
|
||||
|
||||
tasks = []
|
||||
for ck in cks:
|
||||
tasks.append(asyncio.create_task(upload_to_minio(doc, ck)))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error(f"MINIO PUT({ctx.name}) got exception: {e}")
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
|
||||
el = timer() - st
|
||||
logging.info("MINIO PUT({}) cost {:.3f} s".format(ctx.name, el))
|
||||
return docs
|
||||
|
||||
def _progress(self, prog=None, msg=None):
|
||||
"""Progress callback helper."""
|
||||
if prog is not None or msg is not None:
|
||||
self._task_context.progress_cb(prog=prog, msg=msg)
|
||||
|
||||
# =========================================================================
|
||||
# Insert Service Methods (merged from insert_service.py)
|
||||
# =========================================================================
|
||||
|
||||
async def insert_chunks(
|
||||
self,
|
||||
task_id: str,
|
||||
task_tenant_id: str,
|
||||
task_dataset_id: str,
|
||||
chunks: List[Dict[str, Any]],
|
||||
doc_bulk_size: int = None,
|
||||
) -> bool:
|
||||
"""Insert chunks into document store.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier.
|
||||
task_tenant_id: Tenant ID.
|
||||
task_dataset_id: Dataset/knowledge base ID.
|
||||
chunks: List of chunk dictionaries to insert.
|
||||
doc_bulk_size: Batch size for document store inserts.
|
||||
|
||||
Returns:
|
||||
True if all chunks were inserted successfully, False otherwise.
|
||||
"""
|
||||
doc_bulk_size = doc_bulk_size or settings.DOC_BULK_SIZE
|
||||
|
||||
# Create mother chunks (summary chunks)
|
||||
mothers = self._create_mother_chunks(chunks)
|
||||
|
||||
# Insert mother chunks
|
||||
if not await self._insert_mother_chunks(task_id, task_tenant_id, task_dataset_id, mothers, doc_bulk_size):
|
||||
return False
|
||||
|
||||
# Insert main chunks
|
||||
return await self._insert_main_chunks(task_id, task_tenant_id, task_dataset_id, chunks, doc_bulk_size)
|
||||
|
||||
@classmethod
|
||||
def _create_mother_chunks(cls, chunks: List[Dict]) -> List[Dict]:
|
||||
"""Create mother chunks from summary fields.
|
||||
|
||||
Mother chunks are summary/abstract chunks that are stored separately.
|
||||
"""
|
||||
mothers = []
|
||||
mother_ids = set()
|
||||
|
||||
for ck in chunks:
|
||||
mom = ck.get("mom") or ck.get("mom_with_weight") or ""
|
||||
if not mom:
|
||||
continue
|
||||
|
||||
mom_id = xxhash.xxh64(mom.encode("utf-8")).hexdigest()
|
||||
ck["mom_id"] = mom_id
|
||||
|
||||
if mom_id in mother_ids:
|
||||
continue
|
||||
|
||||
mother_ids.add(mom_id)
|
||||
mom_ck = copy.deepcopy(ck)
|
||||
mom_ck["id"] = mom_id
|
||||
mom_ck["content_with_weight"] = mom
|
||||
mom_ck["available_int"] = 0
|
||||
|
||||
# Keep only essential fields
|
||||
allowed_fields = [
|
||||
"id", "content_with_weight", "doc_id", "docnm_kwd",
|
||||
"kb_id", "available_int", "position_int",
|
||||
"create_timestamp_flt", "page_num_int", "top_int"
|
||||
]
|
||||
for fld in list(mom_ck.keys()):
|
||||
if fld not in allowed_fields:
|
||||
del mom_ck[fld]
|
||||
|
||||
mothers.append(mom_ck)
|
||||
|
||||
return mothers
|
||||
|
||||
async def _insert_mother_chunks(
|
||||
self,
|
||||
task_id: str,
|
||||
task_tenant_id: str,
|
||||
task_dataset_id: str,
|
||||
mothers: List[Dict],
|
||||
doc_bulk_size: int,
|
||||
) -> bool:
|
||||
"""Insert mother chunks in batches."""
|
||||
for b in range(0, len(mothers), doc_bulk_size):
|
||||
await self._intercept_doc_store_insert(
|
||||
mothers[b:b + doc_bulk_size],
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id
|
||||
)
|
||||
|
||||
if self._task_context.has_canceled_func(task_id):
|
||||
self._task_context.progress_cb(-1, msg="Task has been canceled.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _intercept_doc_store_delete(self, condition: dict, index_name: str, task_dataset_id: str) -> Any:
|
||||
if self._task_context.write_interceptor:
|
||||
return self._task_context.write_interceptor.intercept("docStoreConn.delete")
|
||||
else:
|
||||
return await thread_pool_exec(settings.docStoreConn.delete, condition, index_name, task_dataset_id)
|
||||
|
||||
async def _intercept_doc_store_insert(self, chunks: list, index_name: str, task_dataset_id: str) -> Any:
|
||||
if self._task_context.write_interceptor:
|
||||
if self._task_context.doc_id == GRAPH_RAPTOR_FAKE_DOC_ID: # raptor - non-determinisic
|
||||
return self._task_context.write_interceptor.intercept("docStoreConn.insert", [])
|
||||
return self._task_context.write_interceptor.intercept("docStoreConn.insert")
|
||||
else:
|
||||
return await thread_pool_exec(settings.docStoreConn.insert, chunks, index_name, task_dataset_id)
|
||||
|
||||
async def _insert_main_chunks(
|
||||
self,
|
||||
task_id: str,
|
||||
task_tenant_id: str,
|
||||
task_dataset_id: str,
|
||||
chunks: List[Dict],
|
||||
doc_bulk_size: int,
|
||||
) -> bool:
|
||||
"""Insert main chunks in batches with cancellation handling."""
|
||||
for b in range(0, len(chunks), doc_bulk_size):
|
||||
doc_store_result = await self._intercept_doc_store_insert(
|
||||
chunks[b:b + doc_bulk_size],
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id
|
||||
)
|
||||
|
||||
if self._task_context.has_canceled_func(task_id):
|
||||
# Roll back partial RAPTOR summary inserts
|
||||
await self._rollback_raptor_chunks(
|
||||
task_id, task_tenant_id, task_dataset_id, chunks, b, doc_bulk_size
|
||||
)
|
||||
self._task_context.progress_cb(-1, msg="Task has been canceled.")
|
||||
return False
|
||||
|
||||
if b % 128 == 0:
|
||||
self._task_context.progress_cb(prog=0.8 + 0.1 * (b + 1) / len(chunks),msg="")
|
||||
|
||||
if doc_store_result:
|
||||
error_message = (
|
||||
f"Insert chunk error: {doc_store_result}, "
|
||||
"please check log file and Elasticsearch/Infinity status!"
|
||||
)
|
||||
self._task_context.progress_cb(-1, msg=error_message)
|
||||
raise Exception(error_message)
|
||||
|
||||
# Update chunk IDs in task
|
||||
chunk_ids = [chunk["id"] for chunk in chunks[:b + doc_bulk_size]]
|
||||
if not await self._update_task_chunk_ids(task_id, chunk_ids):
|
||||
# Roll back on failure
|
||||
await self._rollback_insertion(task_tenant_id, task_dataset_id, chunk_ids)
|
||||
self._task_context.progress_cb(
|
||||
-1,
|
||||
msg=f"Chunk updates failed since task {task_id} is unknown."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _rollback_raptor_chunks(
|
||||
self,
|
||||
task_id: str,
|
||||
task_tenant_id: str,
|
||||
task_dataset_id: str,
|
||||
chunks: List[Dict],
|
||||
up_to_batch: int,
|
||||
doc_bulk_size: int,
|
||||
):
|
||||
"""Roll back partial RAPTOR summary inserts after cancellation."""
|
||||
raptor_ids = [
|
||||
c["id"] for c in chunks[:up_to_batch + doc_bulk_size]
|
||||
if c.get("raptor_kwd") == "raptor"
|
||||
]
|
||||
|
||||
if raptor_ids:
|
||||
try:
|
||||
await self._intercept_doc_store_delete(
|
||||
{"id": raptor_ids}, search.index_name(task_tenant_id), task_dataset_id
|
||||
)
|
||||
logging.info(
|
||||
"insert_chunks: rolled back %d partial RAPTOR chunks after cancellation (task=%s)",
|
||||
len(raptor_ids), task_id,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"insert_chunks: failed to roll back partial RAPTOR chunks after cancellation (task=%s)",
|
||||
task_id,
|
||||
)
|
||||
|
||||
async def _update_task_chunk_ids(self, task_id: str, chunk_ids: List[str]) -> bool:
|
||||
"""Update chunk IDs in the task record."""
|
||||
from peewee import DoesNotExist
|
||||
|
||||
try:
|
||||
if self._task_context.write_interceptor:
|
||||
if self._task_context.doc_id == GRAPH_RAPTOR_FAKE_DOC_ID:
|
||||
self._task_context.write_interceptor.intercept("TaskService.update_chunk_ids", True)
|
||||
else:
|
||||
self._task_context.write_interceptor.intercept("TaskService.update_chunk_ids")
|
||||
else:
|
||||
TaskService.update_chunk_ids(task_id, " ".join(chunk_ids))
|
||||
return True
|
||||
except DoesNotExist:
|
||||
logging.warning(f"do_handle_task update_chunk_ids failed since task {task_id} is unknown.")
|
||||
return False
|
||||
|
||||
async def _rollback_insertion(
|
||||
self,
|
||||
task_tenant_id: str,
|
||||
task_dataset_id: str,
|
||||
chunk_ids: List[str],
|
||||
):
|
||||
"""Roll back an insertion by deleting chunks and images."""
|
||||
await self._intercept_doc_store_delete(
|
||||
{"id": chunk_ids}, search.index_name(task_tenant_id), task_dataset_id
|
||||
)
|
||||
|
||||
# Delete associated images
|
||||
tasks = []
|
||||
for chunk_id in chunk_ids:
|
||||
tasks.append(asyncio.create_task(self._delete_image(task_dataset_id, chunk_id)))
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except Exception as e:
|
||||
logging.error(f"delete_image failed: {e}")
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
|
||||
async def _delete_image(self, kb_id: str, chunk_id: str):
|
||||
"""Delete a chunk's image from storage."""
|
||||
try:
|
||||
async with self._task_context.minio_limiter:
|
||||
settings.STORAGE_IMPL.delete(kb_id, chunk_id)
|
||||
except Exception:
|
||||
logging.exception(f"Deleting image of chunk {chunk_id} got exception")
|
||||
raise
|
||||
570
rag/svr/task_executor_refactor/comparator.py
Normal file
570
rag/svr/task_executor_refactor/comparator.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Comparison Logic Module.
|
||||
|
||||
This module provides the [`ContextComparator`](rag/svr/task_executor_refactor/comparator.py:100) class, which compares
|
||||
intermediate results from two [`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:54) instances:
|
||||
one from production execution and one from dry-run execution.
|
||||
|
||||
The comparison supports various data types with appropriate strategies:
|
||||
- Basic types (int, str, bool): Direct equality comparison
|
||||
- Float numbers: Configurable tolerance range
|
||||
- Lists: Length comparison + ID set comparison + full content comparison (all chunks)
|
||||
- Dicts: Key set comparison + recursive value comparison
|
||||
- None: Equality comparison
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional, Set
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import BaseRecordingContext
|
||||
from rag.svr.task_executor_refactor.report_generator import (
|
||||
ComparisonResult,
|
||||
ComparisonReport,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import ALLOWED_METHOD_NAMES
|
||||
|
||||
|
||||
class ContextComparator:
|
||||
"""Compare two RecordingContext instances for intermediate results.
|
||||
|
||||
This class compares the recorded data from production execution against
|
||||
dry-run execution, generating a detailed report of matches and mismatches.
|
||||
|
||||
Usage:
|
||||
comparator = ContextComparator()
|
||||
report = comparator.compare("task_123", ctx_production, ctx_dry_run)
|
||||
print(report.summary())
|
||||
"""
|
||||
|
||||
# Default tolerance for float comparison
|
||||
DEFAULT_FLOAT_TOLERANCE = 1e-6
|
||||
|
||||
# Keys to strip from dict values before comparison (non-deterministic values)
|
||||
DICT_KEYS_TO_STRIP = {"seconds", "_created_time", "_elapsed_time"}
|
||||
|
||||
# Keys that represent counts and should be compared as numbers
|
||||
COUNT_KEYS = {
|
||||
"outline_entry_count",
|
||||
"tags_applied_count",
|
||||
"final_chunk_count",
|
||||
"final_chunk_ids_count",
|
||||
"chunk_count",
|
||||
"chunk_ids_count",
|
||||
"token_count",
|
||||
"raptor_token_count",
|
||||
}
|
||||
|
||||
# Keys that contain chunk data for comparison
|
||||
CHUNK_KEYS = {
|
||||
"toc_chunk",
|
||||
"raw_chunks",
|
||||
"final_chunks",
|
||||
"chunks",
|
||||
"raptor_chunks",
|
||||
"docs_after_prep",
|
||||
"dataflow_chunks",
|
||||
}
|
||||
|
||||
def __init__(self, float_tolerance: float = None):
|
||||
"""Initialize the Comparator.
|
||||
|
||||
Args:
|
||||
float_tolerance: Tolerance for float comparison.
|
||||
Defaults to DEFAULT_FLOAT_TOLERANCE.
|
||||
"""
|
||||
self.float_tolerance = self.DEFAULT_FLOAT_TOLERANCE if float_tolerance is None else float_tolerance
|
||||
|
||||
def _strip_non_deterministic_fields(self, data: dict) -> dict:
|
||||
"""Remove non-deterministic fields (like 'seconds') from dict values.
|
||||
|
||||
This creates a shallow copy of the data dict with specified keys
|
||||
removed from any nested dict values.
|
||||
|
||||
Args:
|
||||
data: The input dictionary to process.
|
||||
|
||||
Returns:
|
||||
A new dictionary with non-deterministic fields removed.
|
||||
"""
|
||||
import copy
|
||||
result = copy.copy(data)
|
||||
for key, value in result.items():
|
||||
if isinstance(value, dict):
|
||||
# Create a new dict without the non-deterministic keys
|
||||
cleaned = {
|
||||
k: v for k, v in value.items()
|
||||
if k not in self.DICT_KEYS_TO_STRIP
|
||||
}
|
||||
result[key] = cleaned
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _get_key_values_to_compare(prod_data_all:dict):
|
||||
prod_data = dict()
|
||||
for key, value in prod_data_all.items():
|
||||
if key in ALLOWED_METHOD_NAMES:
|
||||
continue
|
||||
if key.endswith("_time"):
|
||||
continue
|
||||
if key.startswith("settings.docStoreConn."):
|
||||
continue
|
||||
prod_data[key] = value
|
||||
return prod_data
|
||||
|
||||
def compare(
|
||||
self,
|
||||
task_id: str,
|
||||
ctx_production: BaseRecordingContext,
|
||||
ctx_dry_run: BaseRecordingContext,
|
||||
comparison_keys: List[str] = None,
|
||||
) -> ComparisonReport:
|
||||
"""Compare two RecordingContext instances.
|
||||
|
||||
Args:
|
||||
task_id: The task identifier.
|
||||
ctx_production: RecordingContext from production execution.
|
||||
ctx_dry_run: RecordingContext from dry-run execution.
|
||||
comparison_keys: Optional list of keys to compare.
|
||||
If None, all keys from both contexts will be compared.
|
||||
|
||||
Returns:
|
||||
A ComparisonReport with the comparison results.
|
||||
"""
|
||||
report = ComparisonReport(task_id=task_id)
|
||||
|
||||
# Get all keys from both contexts
|
||||
prod_data_all = ctx_production.get_all_func_return_values() if ctx_production else {}
|
||||
prod_data = self._get_key_values_to_compare(prod_data_all)
|
||||
dry_run_data_all = ctx_dry_run.get_all_func_return_values() if ctx_dry_run else {}
|
||||
dry_run_data = self._get_key_values_to_compare(dry_run_data_all)
|
||||
|
||||
# Strip non-deterministic fields (like 'seconds') from dict values
|
||||
prod_data = self._strip_non_deterministic_fields(prod_data)
|
||||
dry_run_data = self._strip_non_deterministic_fields(dry_run_data)
|
||||
|
||||
# Determine keys to compare
|
||||
if comparison_keys:
|
||||
keys_to_compare = set(comparison_keys)
|
||||
else:
|
||||
keys_to_compare = set(prod_data.keys()) | set(dry_run_data.keys())
|
||||
|
||||
# Find missing keys
|
||||
prod_keys = set(prod_data.keys())
|
||||
dry_run_keys = set(dry_run_data.keys())
|
||||
|
||||
report.missing_in_production = sorted(dry_run_keys - prod_keys)
|
||||
report.missing_in_dry_run = sorted(prod_keys - dry_run_keys)
|
||||
|
||||
# Compare each key
|
||||
for key in sorted(keys_to_compare):
|
||||
if key in prod_data and key in dry_run_data:
|
||||
result = self.compare_value(key, prod_data[key], dry_run_data[key])
|
||||
report.details.append(result)
|
||||
if result.match:
|
||||
report.matched_keys += 1
|
||||
else:
|
||||
report.mismatched_keys += 1
|
||||
logging.info(f"---prod:{prod_data[key]} diff with dry run:{dry_run_data[key]}")
|
||||
|
||||
report.total_keys = report.matched_keys + report.mismatched_keys
|
||||
return report
|
||||
|
||||
def compare_value(
|
||||
self,
|
||||
key: str,
|
||||
prod_value: Any,
|
||||
dry_run_value: Any,
|
||||
) -> ComparisonResult:
|
||||
"""Compare a single value with appropriate strategy.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_value: Value from production context.
|
||||
dry_run_value: Value from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
# Handle None cases
|
||||
if prod_value is None and dry_run_value is None:
|
||||
return ComparisonResult(key=key, match=True)
|
||||
if prod_value is None or dry_run_value is None:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details="One value is None",
|
||||
)
|
||||
|
||||
# Handle booleans
|
||||
if isinstance(prod_value, bool) and isinstance(dry_run_value, bool):
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "Boolean values differ",
|
||||
)
|
||||
|
||||
# Handle lists (chunks)
|
||||
if isinstance(prod_value, list) and isinstance(dry_run_value, list):
|
||||
if key in self.CHUNK_KEYS:
|
||||
return self._compare_chunks(key, prod_value, dry_run_value)
|
||||
return self._compare_lists(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle dicts
|
||||
if isinstance(prod_value, dict) and isinstance(dry_run_value, dict):
|
||||
return self._compare_dicts(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle numbers
|
||||
if isinstance(prod_value, (int, float)) and isinstance(dry_run_value, (int, float)):
|
||||
return self._compare_numbers(key, prod_value, dry_run_value)
|
||||
|
||||
# Handle strings
|
||||
if isinstance(prod_value, str) and isinstance(dry_run_value, str):
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "String values differ",
|
||||
)
|
||||
|
||||
# Default: try direct equality
|
||||
match = prod_value == dry_run_value
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=match,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=None if match else "Values differ",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _compare_lists(cls, key: str, prod_list: list, dry_run_list: list) -> ComparisonResult:
|
||||
"""Compare two lists.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_list: List from production context.
|
||||
dry_run_list: List from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
if len(prod_list) != len(dry_run_list):
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
diff_details=f"Length differs: {len(prod_list)} vs {len(dry_run_list)}",
|
||||
)
|
||||
|
||||
# Try element-wise comparison
|
||||
for i, (p, d) in enumerate(zip(prod_list, dry_run_list)):
|
||||
if p != d:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
diff_details=f"Element {i} differs",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=len(prod_list),
|
||||
dry_run_value=len(dry_run_list),
|
||||
)
|
||||
|
||||
def _compare_chunks(
|
||||
self,
|
||||
key: str,
|
||||
prod_chunks: list,
|
||||
dry_run_chunks: list,
|
||||
) -> ComparisonResult:
|
||||
"""Compare chunk lists with multi-level strategy.
|
||||
|
||||
Comparison levels:
|
||||
1. Length comparison
|
||||
2. ID set comparison
|
||||
3. Full content comparison (all chunks)
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_chunks: Chunks from production context.
|
||||
dry_run_chunks: Chunks from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
# Level 1: Length comparison
|
||||
if len(prod_chunks) != len(dry_run_chunks):
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
diff_details=f"Chunk count differs: {len(prod_chunks)} vs {len(dry_run_chunks)}",
|
||||
)
|
||||
|
||||
# Level 2: ID set comparison
|
||||
prod_ids = self._extract_chunk_ids(prod_chunks)
|
||||
dry_run_ids = self._extract_chunk_ids(dry_run_chunks)
|
||||
|
||||
if prod_ids != dry_run_ids:
|
||||
missing_ids = prod_ids - dry_run_ids
|
||||
extra_ids = dry_run_ids - prod_ids
|
||||
details = f"Chunk IDs differ, total prod:{len(prod_ids)}, dry run:{len(dry_run_ids)}"
|
||||
if missing_ids:
|
||||
details += f", missing in dry-run: {len(missing_ids)}"
|
||||
if extra_ids:
|
||||
details += f", extra in dry-run: {len(extra_ids)}"
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_ids),
|
||||
dry_run_value=len(dry_run_ids),
|
||||
diff_details=details,
|
||||
)
|
||||
|
||||
# Level 3: Full content comparison (all chunks)
|
||||
content_diffs = self._compare_all_chunks(prod_chunks, dry_run_chunks)
|
||||
if content_diffs:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
diff_details=f"Content differs in samples: {'; '.join(content_diffs[:3])}",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=len(prod_chunks),
|
||||
dry_run_value=len(dry_run_chunks),
|
||||
)
|
||||
|
||||
def _compare_all_chunks(
|
||||
self,
|
||||
prod_chunks: list,
|
||||
dry_run_chunks: list,
|
||||
) -> List[str]:
|
||||
"""Compare ALL chunks from both lists.
|
||||
|
||||
Args:
|
||||
prod_chunks: Chunks from production context.
|
||||
dry_run_chunks: Chunks from dry-run context.
|
||||
|
||||
Returns:
|
||||
List of difference descriptions.
|
||||
"""
|
||||
if not prod_chunks or not dry_run_chunks:
|
||||
return []
|
||||
|
||||
diffs = []
|
||||
n = len(prod_chunks)
|
||||
|
||||
# Check if chunks have valid IDs
|
||||
prod_has_id = any(self._get_chunk_id(c) for c in prod_chunks)
|
||||
dry_run_has_id = any(self._get_chunk_id(c) for c in dry_run_chunks)
|
||||
use_index_matching = not prod_has_id or not dry_run_has_id
|
||||
|
||||
# Build index by chunk ID for matching (only if IDs are available)
|
||||
if not use_index_matching:
|
||||
dry_run_by_id = {self._get_chunk_id(c): c for c in dry_run_chunks}
|
||||
else:
|
||||
dry_run_by_id = None
|
||||
|
||||
# Compare ALL chunks
|
||||
for idx in range(n):
|
||||
prod_chunk = prod_chunks[idx]
|
||||
chunk_id = self._get_chunk_id(prod_chunk)
|
||||
|
||||
if use_index_matching:
|
||||
# Use index position for matching
|
||||
if idx < len(dry_run_chunks):
|
||||
dry_run_chunk = dry_run_chunks[idx]
|
||||
else:
|
||||
dry_run_chunk = None
|
||||
else:
|
||||
# Use ID for matching
|
||||
dry_run_chunk = dry_run_by_id.get(chunk_id)
|
||||
|
||||
if dry_run_chunk is None:
|
||||
diffs.append(f"Chunk {idx} (id={chunk_id}) not found in dry-run")
|
||||
continue
|
||||
|
||||
# Compare content
|
||||
content_diff = self._compare_chunk_content(prod_chunk, dry_run_chunk)
|
||||
if content_diff:
|
||||
diffs.append(f"Chunk {idx} (id={chunk_id}): {content_diff}")
|
||||
|
||||
return diffs
|
||||
|
||||
@classmethod
|
||||
def _compare_chunk_content(cls, prod_chunk: dict, dry_run_chunk: dict) -> Optional[str]:
|
||||
"""Compare content of two chunks.
|
||||
|
||||
Args:
|
||||
prod_chunk: Chunk from production context.
|
||||
dry_run_chunk: Chunk from dry-run context.
|
||||
|
||||
Returns:
|
||||
Difference description or None if matched.
|
||||
"""
|
||||
# Compare key fields
|
||||
key_fields = ["content_with_weight", "content_ltks", "doc_id", "kb_id"]
|
||||
for fld in key_fields:
|
||||
if prod_chunk.get(fld) != dry_run_chunk.get(fld):
|
||||
return f"Field '{fld}' differs, prod_chunk:{prod_chunk.get(fld)}, dry_run_chunk:{dry_run_chunk}"
|
||||
|
||||
# Compare vector fields
|
||||
prod_vec_keys = {k for k in prod_chunk if k.startswith("q_") and k.endswith("_vec")}
|
||||
dry_run_vec_keys = {k for k in dry_run_chunk if k.startswith("q_") and k.endswith("_vec")}
|
||||
|
||||
if prod_vec_keys != dry_run_vec_keys:
|
||||
return f"Vector fields differ: {prod_vec_keys} vs {dry_run_vec_keys}"
|
||||
|
||||
for vec_key in prod_vec_keys:
|
||||
p_vec = prod_chunk.get(vec_key)
|
||||
d_vec = dry_run_chunk.get(vec_key)
|
||||
if p_vec != d_vec:
|
||||
return f"Vector '{vec_key}' differs"
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _extract_chunk_ids(cls, chunks: list) -> Set[str]:
|
||||
"""Extract chunk IDs from a list of chunks.
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries.
|
||||
|
||||
Returns:
|
||||
Set of chunk IDs.
|
||||
"""
|
||||
ids = set()
|
||||
for c in chunks:
|
||||
if isinstance(c, dict) and "id" in c:
|
||||
ids.add(str(c["id"]))
|
||||
return ids
|
||||
|
||||
@classmethod
|
||||
def _get_chunk_id(cls, chunk: Any) -> str:
|
||||
"""Get chunk ID from a chunk dictionary.
|
||||
|
||||
Args:
|
||||
chunk: A chunk dictionary.
|
||||
|
||||
Returns:
|
||||
Chunk ID as string, or empty string if not found.
|
||||
"""
|
||||
if isinstance(chunk, dict):
|
||||
return str(chunk.get("id", ""))
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _compare_dicts(cls, key: str, prod_dict: dict, dry_run_dict: dict) -> ComparisonResult:
|
||||
"""Compare two dictionaries.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_dict: Dict from production context.
|
||||
dry_run_dict: Dict from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
prod_keys = set(prod_dict.keys())
|
||||
dry_run_keys = set(dry_run_dict.keys())
|
||||
|
||||
if prod_keys != dry_run_keys:
|
||||
missing = prod_keys - dry_run_keys
|
||||
extra = dry_run_keys - prod_keys
|
||||
details = "Keys differ"
|
||||
if missing:
|
||||
details += f", missing in dry-run: {missing}"
|
||||
if extra:
|
||||
details += f", extra in dry-run: {extra}"
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=sorted(prod_keys),
|
||||
dry_run_value=sorted(dry_run_keys),
|
||||
diff_details=details,
|
||||
)
|
||||
|
||||
# Compare values for each key
|
||||
for k in prod_keys:
|
||||
p_val = prod_dict[k]
|
||||
d_val = dry_run_dict[k]
|
||||
if p_val != d_val:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_dict,
|
||||
dry_run_value=dry_run_dict,
|
||||
diff_details=f"Value for key '{k}' differs",
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=prod_dict,
|
||||
dry_run_value=dry_run_dict,
|
||||
)
|
||||
|
||||
def _compare_numbers(
|
||||
self,
|
||||
key: str,
|
||||
prod_value: float,
|
||||
dry_run_value: float,
|
||||
) -> ComparisonResult:
|
||||
"""Compare two numbers with tolerance.
|
||||
|
||||
Args:
|
||||
key: The key being compared.
|
||||
prod_value: Number from production context.
|
||||
dry_run_value: Number from dry-run context.
|
||||
|
||||
Returns:
|
||||
A ComparisonResult with the comparison.
|
||||
"""
|
||||
diff = abs(prod_value - dry_run_value)
|
||||
if diff <= self.float_tolerance:
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=True,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
)
|
||||
|
||||
return ComparisonResult(
|
||||
key=key,
|
||||
match=False,
|
||||
production_value=prod_value,
|
||||
dry_run_value=dry_run_value,
|
||||
diff_details=f"Difference {diff} exceeds tolerance {self.float_tolerance}",
|
||||
)
|
||||
24
rag/svr/task_executor_refactor/constants.py
Normal file
24
rag/svr/task_executor_refactor/constants.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Shared constants for task executor modules.
|
||||
|
||||
This module exists to break circular imports between task_executor.py and
|
||||
task_executor_refactor modules.
|
||||
"""
|
||||
|
||||
CANVAS_DEBUG_DOC_ID = "dataflow_x"
|
||||
GRAPH_RAPTOR_FAKE_DOC_ID = "graph_raptor_x"
|
||||
389
rag/svr/task_executor_refactor/dataflow_service.py
Normal file
389
rag/svr/task_executor_refactor/dataflow_service.py
Normal file
@@ -0,0 +1,389 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Dataflow Service Module.
|
||||
|
||||
Provides [`DataflowService`](rag/svr/task_executor_refactor/dataflow_service.py:42) for dataflow
|
||||
pipeline execution.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from timeit import default_timer as timer
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import xxhash
|
||||
from common import settings
|
||||
from rag.svr.task_executor_refactor.embedding_utils import EmbeddingUtils
|
||||
from rag.flow.pipeline import Pipeline
|
||||
|
||||
from api.db.services.canvas_service import UserCanvasService
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from api.db.services.pipeline_operation_log_service import PipelineOperationLogService
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_type_and_name
|
||||
from common.constants import LLMType, PipelineTaskType
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.nlp import rag_tokenizer, add_positions
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class BillingHook(abc.ABC):
|
||||
"""Abstract base for billing hooks on pipeline success/error.
|
||||
|
||||
Implementations override the no-op methods to integrate with billing
|
||||
systems (e.g., consume quota on success, release hold on error).
|
||||
"""
|
||||
|
||||
async def on_pipeline_success(self) -> None:
|
||||
"""Called when the dataflow pipeline completes successfully."""
|
||||
|
||||
async def on_pipeline_error(self) -> None:
|
||||
"""Called when the dataflow pipeline encounters an error."""
|
||||
|
||||
|
||||
class DataflowService:
|
||||
"""Service for dataflow pipeline execution.
|
||||
|
||||
This service handles:
|
||||
- Dataflow DSL loading and execution
|
||||
- Chunk embedding for dataflow output
|
||||
- Chunk metadata processing and indexing
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
embedding_batch_size: int = None,
|
||||
doc_bulk_size: int = None,
|
||||
):
|
||||
"""Initialize DataflowService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
embedding_batch_size: Batch size for embedding operations.
|
||||
doc_bulk_size: Batch size for document store inserts.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
self._billing_hook = billing_hook
|
||||
self._embedding_batch_size = embedding_batch_size or self._get_default_embedding_batch_size()
|
||||
self._doc_bulk_size = doc_bulk_size or self._get_default_bulk_size()
|
||||
|
||||
async def run_dataflow(self) -> None:
|
||||
"""Run a dataflow pipeline."""
|
||||
ctx = self._task_context
|
||||
pipeline = None
|
||||
try:
|
||||
task_start_ts = timer()
|
||||
dataflow_id = ctx.dataflow_id
|
||||
doc_id = ctx.doc_id
|
||||
task_id = ctx.id
|
||||
task_dataset_id = ctx.kb_id
|
||||
|
||||
# Load DSL
|
||||
dsl = await self._load_dsl(dataflow_id)
|
||||
if dsl is None:
|
||||
return
|
||||
|
||||
# Run pipeline
|
||||
pipeline = Pipeline(
|
||||
dsl, tenant_id=ctx.tenant_id, doc_id=doc_id,
|
||||
task_id=task_id, flow_id=dataflow_id
|
||||
)
|
||||
chunks = await pipeline.run(file=ctx.file) if ctx.file else await pipeline.run()
|
||||
|
||||
if doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
ctx.recording_context.record("dataflow_debug_result", "canvas_debug_mode")
|
||||
ctx.recording_context.record("dataflow_chunks", chunks)
|
||||
return
|
||||
|
||||
if not chunks:
|
||||
ctx.recording_context.record("pipeline_output_count", 0)
|
||||
ctx.recording_context.record("pipeline_output_type", "empty")
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
embedding_token_consumption = chunks.get("embedding_token_consumption", 0)
|
||||
output_type = DataflowService._get_output_type(chunks)
|
||||
chunks = self._normalize_chunks(chunks)
|
||||
|
||||
ctx.recording_context.record("pipeline_output_type", output_type)
|
||||
ctx.recording_context.record("pipeline_output_count", len(chunks))
|
||||
|
||||
if not chunks:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
# Embed chunks if needed
|
||||
keys = [k for o in chunks for k in list(o.keys())]
|
||||
if not any([re.match(r"q_[0-9]+_vec", k) for k in keys]):
|
||||
chunks, embedding_token_consumption = await self._embed_chunks(
|
||||
chunks, embedding_token_consumption
|
||||
)
|
||||
if chunks is None:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
# Process chunks
|
||||
metadata = self._process_chunks(chunks)
|
||||
|
||||
# Update document metadata
|
||||
if metadata:
|
||||
self._update_document_metadata(doc_id, metadata)
|
||||
|
||||
# Insert chunks
|
||||
start_ts = timer()
|
||||
self._progress(prog=0.82, msg="[DOC Engine]:\nStart to index...")
|
||||
e = await self._insert_chunks(
|
||||
task_id, ctx.tenant_id, ctx.kb_id, chunks
|
||||
)
|
||||
if not e:
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
return
|
||||
|
||||
time_cost = timer() - start_ts
|
||||
task_time_cost = timer() - task_start_ts
|
||||
self._progress(
|
||||
prog=1.,
|
||||
msg="Indexing done ({:.2f}s). Task done ({:.2f}s)".format(time_cost, task_time_cost)
|
||||
)
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(
|
||||
doc_id, task_dataset_id, embedding_token_consumption, len(chunks), task_time_cost
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"[Done], chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
len(chunks), embedding_token_consumption, task_time_cost
|
||||
)
|
||||
)
|
||||
ctx.recording_context.record("dataflow_chunks", chunks)
|
||||
self._record_pipeline_log(doc_id, dataflow_id, pipeline)
|
||||
|
||||
# Billing hook: pipeline succeeded
|
||||
if self._billing_hook:
|
||||
await self._billing_hook.on_pipeline_success()
|
||||
except Exception:
|
||||
if self._billing_hook:
|
||||
await self._billing_hook.on_pipeline_error()
|
||||
raise
|
||||
|
||||
async def _load_dsl(self, dataflow_id: str) -> Optional[str]:
|
||||
"""Load dataflow DSL from service."""
|
||||
ctx = self._task_context
|
||||
if ctx.task_type == "dataflow":
|
||||
e, cvs = UserCanvasService.get_by_id(dataflow_id)
|
||||
assert e, "User pipeline not found."
|
||||
return cvs.dsl
|
||||
else:
|
||||
e, pipeline_log = PipelineOperationLogService.get_by_id(dataflow_id)
|
||||
assert e, "Pipeline log not found."
|
||||
return pipeline_log.dsl
|
||||
|
||||
@staticmethod
|
||||
def _get_output_type(chunks: Dict) -> str:
|
||||
"""Determine output type from chunks dict."""
|
||||
if "chunks" in chunks:
|
||||
return "chunks"
|
||||
elif "json" in chunks:
|
||||
return "json"
|
||||
elif "markdown" in chunks:
|
||||
return "markdown"
|
||||
elif "text" in chunks:
|
||||
return "text"
|
||||
elif "html" in chunks:
|
||||
return "html"
|
||||
return "empty"
|
||||
|
||||
@classmethod
|
||||
def _normalize_chunks(cls, chunks: Dict) -> List[Dict]:
|
||||
"""Normalize chunks from various output formats."""
|
||||
if "chunks" in chunks:
|
||||
return copy.deepcopy(chunks["chunks"])
|
||||
elif "json" in chunks:
|
||||
return copy.deepcopy(chunks["json"])
|
||||
elif "markdown" in chunks:
|
||||
return [{"text": [chunks["markdown"]]}] if chunks["markdown"] else []
|
||||
elif "text" in chunks:
|
||||
return [{"text": [chunks["text"]]}] if chunks["text"] else []
|
||||
elif "html" in chunks:
|
||||
return [{"text": [chunks["html"]]}] if chunks["html"] else []
|
||||
return []
|
||||
|
||||
async def _embed_chunks(
|
||||
self, chunks: List[Dict], token_consumption: int
|
||||
) -> Tuple[Optional[List[Dict]], int]:
|
||||
"""Embed chunks using the embedding model."""
|
||||
ctx = self._task_context
|
||||
try:
|
||||
self._progress(prog=0.82, msg="\n-------------------------------------\nStart to embedding...")
|
||||
e, kb = self._get_kb_by_id(ctx.kb_id)
|
||||
embedding_id = kb.embd_id
|
||||
embd_model_config = get_model_config_by_type_and_name(
|
||||
ctx.tenant_id, LLMType.EMBEDDING, embedding_id
|
||||
)
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
with LLMBundle(ctx.tenant_id, embd_model_config) as embedding_model:
|
||||
|
||||
# Prepare texts for embedding using EmbeddingUtils
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
delta = 0.20 / (len(texts) // self._embedding_batch_size + 1)
|
||||
prog = 0.8
|
||||
|
||||
# Batch encode using EmbeddingUtils
|
||||
vects_batches = []
|
||||
for i in range(0, len(texts), self._embedding_batch_size):
|
||||
batch = texts[i: i + self._embedding_batch_size]
|
||||
async with ctx.embed_limiter:
|
||||
vts, c = await thread_pool_exec(
|
||||
self._encode_batch, batch, embedding_model
|
||||
)
|
||||
vects_batches.append(vts)
|
||||
token_consumption += c
|
||||
prog += delta
|
||||
if i % (len(texts) // self._embedding_batch_size / 100 + 1) == 1:
|
||||
self._progress(
|
||||
prog=prog,
|
||||
msg=f"{i + 1} / {len(texts) // self._embedding_batch_size}"
|
||||
)
|
||||
|
||||
# Stack vectors using EmbeddingUtils
|
||||
vects = EmbeddingUtils.stack_vectors(vects_batches)
|
||||
if len(vects) != len(chunks):
|
||||
raise ValueError(f"Vector count mismatch: {len(vects)} vs {len(chunks)}")
|
||||
|
||||
# Attach vectors using EmbeddingUtils
|
||||
EmbeddingUtils.attach_vectors(chunks, vects)
|
||||
|
||||
return chunks, token_consumption
|
||||
|
||||
except Exception as e:
|
||||
ctx.progress_cb(prog=-1, msg=f"[ERROR]: {e}")
|
||||
return None, token_consumption
|
||||
|
||||
@classmethod
|
||||
async def _encode_batch(cls, txts: List[str], embedding_model) -> Tuple[np.ndarray, int]:
|
||||
"""Batch encode texts using the embedding model with truncation."""
|
||||
truncated = EmbeddingUtils.truncate_texts(txts, embedding_model.max_length)
|
||||
return embedding_model.encode(truncated)
|
||||
|
||||
def _process_chunks(self, chunks: List[Dict]) -> Dict:
|
||||
"""Process chunks for metadata and indexing."""
|
||||
ctx = self._task_context
|
||||
metadata = {}
|
||||
for ck in chunks:
|
||||
ck["doc_id"] = ctx.doc_id
|
||||
ck["kb_id"] = [str(ctx.kb_id)]
|
||||
ck["docnm_kwd"] = ctx.name
|
||||
ck["create_time"] = str(datetime.now()).replace("T", " ")[:19]
|
||||
ck["create_timestamp_flt"] = datetime.now().timestamp()
|
||||
|
||||
if not ck.get("id"):
|
||||
ck["id"] = xxhash.xxh64((ck["text"] + str(ck["doc_id"])).encode("utf-8")).hexdigest()
|
||||
|
||||
if "questions" in ck:
|
||||
if "question_tks" not in ck:
|
||||
ck["question_kwd"] = ck["questions"].split("\n")
|
||||
ck["question_tks"] = rag_tokenizer.tokenize(str(ck["questions"]))
|
||||
del ck["questions"]
|
||||
|
||||
if "keywords" in ck:
|
||||
if "important_tks" not in ck:
|
||||
ck["important_kwd"] = [k for k in re.split(r"[,,;;、\r\n]+", ck["keywords"]) if k.strip()]
|
||||
ck["important_tks"] = rag_tokenizer.tokenize(str(ck["keywords"]))
|
||||
del ck["keywords"]
|
||||
|
||||
if "summary" in ck:
|
||||
if "content_ltks" not in ck:
|
||||
ck["content_ltks"] = rag_tokenizer.tokenize(str(ck["summary"]))
|
||||
ck["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(ck["content_ltks"])
|
||||
del ck["summary"]
|
||||
|
||||
if "metadata" in ck:
|
||||
metadata = update_metadata_to(metadata, ck["metadata"])
|
||||
del ck["metadata"]
|
||||
|
||||
if "content_with_weight" not in ck:
|
||||
ck["content_with_weight"] = ck["text"]
|
||||
del ck["text"]
|
||||
|
||||
if "positions" in ck:
|
||||
add_positions(ck, ck["positions"])
|
||||
del ck["positions"]
|
||||
|
||||
return metadata
|
||||
|
||||
def _update_document_metadata(self, doc_id: str, metadata: Dict) -> None:
|
||||
"""Update document metadata."""
|
||||
existing_meta = DocMetadataService.get_document_metadata(doc_id)
|
||||
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
|
||||
metadata = update_metadata_to(metadata, existing_meta)
|
||||
self._task_context.recording_context.record("run_dataflow_metadata", metadata)
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(doc_id, metadata)
|
||||
|
||||
async def _insert_chunks(
|
||||
self, task_id: str, tenant_id: str, kb_id: str, chunks: List[Dict]
|
||||
) -> bool:
|
||||
"""Insert chunks into document store."""
|
||||
from rag.svr.task_executor_refactor.chunk_service import ChunkService
|
||||
chunk_service = ChunkService(self._task_context)
|
||||
return await chunk_service.insert_chunks(task_id, tenant_id, kb_id, chunks)
|
||||
|
||||
def _record_pipeline_log(self, doc_id: str, dataflow_id: str, pipeline) -> None:
|
||||
"""Record pipeline operation log."""
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("PipelineOperationLogService.create")
|
||||
else:
|
||||
PipelineOperationLogService.create(
|
||||
document_id=doc_id, pipeline_id=dataflow_id,
|
||||
task_type=PipelineTaskType.PARSE, dsl=str(pipeline)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_kb_by_id(cls, kb_id: str):
|
||||
"""Get knowledge base by ID."""
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
return KnowledgebaseService.get_by_id(kb_id)
|
||||
|
||||
def _progress(self, prog=None, msg=None):
|
||||
"""Progress callback helper."""
|
||||
if prog is not None or msg is not None:
|
||||
self._task_context.progress_cb(prog=prog, msg=msg)
|
||||
|
||||
@classmethod
|
||||
def _get_default_embedding_batch_size(cls) -> int:
|
||||
"""Get default embedding batch size."""
|
||||
return settings.EMBEDDING_BATCH_SIZE
|
||||
|
||||
@classmethod
|
||||
def _get_default_bulk_size(cls) -> int:
|
||||
"""Get default bulk size."""
|
||||
return settings.DOC_BULK_SIZE
|
||||
127
rag/svr/task_executor_refactor/embedding_service.py
Normal file
127
rag/svr/task_executor_refactor/embedding_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Embedding Service Module.
|
||||
|
||||
Provides [`EmbeddingService`](rag/svr/task_executor_refactor/embedding_service.py:42) for vector embedding operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
from common import settings
|
||||
from rag.svr.task_executor_refactor.embedding_utils import EmbeddingUtils
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
"""Service for vector embedding operations.
|
||||
|
||||
This service handles:
|
||||
- Batch encoding of text chunks
|
||||
- Title + content vector combination
|
||||
- Embedding model rate limiting
|
||||
|
||||
All intermediate results are recorded via RecordingContext for comparison.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
embedding_batch_size: int = None,
|
||||
):
|
||||
"""Initialize EmbeddingService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
embedding_batch_size: Batch size for embedding operations.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
self._embedding_batch_size = embedding_batch_size or settings.EMBEDDING_BATCH_SIZE
|
||||
|
||||
def embed_chunks(
|
||||
self,
|
||||
docs: List[Dict[str, Any]],
|
||||
embedding_model,
|
||||
parser_config: Dict = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Embed a list of chunks.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to embed.
|
||||
embedding_model: The embedding model bundle (LLMBundle).
|
||||
parser_config: Parser configuration for filename embedding weight.
|
||||
|
||||
Returns:
|
||||
Tuple of (token_count, vector_size).
|
||||
"""
|
||||
if parser_config is None:
|
||||
parser_config = {}
|
||||
|
||||
# Prepare text for embedding using EmbeddingUtils
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
|
||||
# Encode titles using EmbeddingUtils for truncation
|
||||
tk_count = 0
|
||||
if len(titles) > 0 and len(titles) == len(contents):
|
||||
vts, c = self._encode_single([titles[0]], embedding_model)
|
||||
tts = np.tile(vts[0], (len(contents), 1))
|
||||
tk_count += c
|
||||
else:
|
||||
tts = None
|
||||
|
||||
# Batch encode contents using EmbeddingUtils
|
||||
vects_batches = []
|
||||
for i in range(0, len(contents), self._embedding_batch_size):
|
||||
batch = contents[i: i + self._embedding_batch_size]
|
||||
vts, c = self._encode_batch(batch, embedding_model)
|
||||
vects_batches.append(vts)
|
||||
tk_count += c
|
||||
if self._task_context.progress_cb:
|
||||
self._task_context.progress_cb(prog=0.7 + 0.2 * (i + 1) / len(contents), msg="")
|
||||
|
||||
# Stack vectors using EmbeddingUtils
|
||||
cnts = EmbeddingUtils.stack_vectors(vects_batches)
|
||||
|
||||
# Combine title and content vectors using EmbeddingUtils
|
||||
title_weight = parser_config.get("filename_embd_weight", EmbeddingUtils.DEFAULT_TITLE_WEIGHT)
|
||||
vects = EmbeddingUtils.combine_title_content_vectors(tts, cnts, title_weight)
|
||||
|
||||
assert len(vects) == len(docs)
|
||||
|
||||
# Attach vectors to docs using EmbeddingUtils
|
||||
vector_size = EmbeddingUtils.attach_vectors(docs, vects)
|
||||
|
||||
return tk_count, vector_size
|
||||
|
||||
def _encode_single(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Encode a single batch of texts."""
|
||||
return self._run_encode(texts, model)
|
||||
|
||||
def _encode_batch(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Encode a batch of texts with rate limiting and truncation."""
|
||||
# Use EmbeddingUtils for truncation
|
||||
truncated = EmbeddingUtils.truncate_texts(texts, model.max_length)
|
||||
return self._run_encode(truncated, model)
|
||||
|
||||
def _run_encode(self, texts: List[str], model) -> Tuple[np.ndarray, int]:
|
||||
"""Run encoding with rate limiting."""
|
||||
async def _encode():
|
||||
async with self._task_context.embed_limiter:
|
||||
return model.encode(texts)
|
||||
return asyncio.get_event_loop().run_until_complete(_encode())
|
||||
223
rag/svr/task_executor_refactor/embedding_utils.py
Normal file
223
rag/svr/task_executor_refactor/embedding_utils.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Embedding Utils Module.
|
||||
|
||||
Provides utility functions for vector embedding operations to avoid code duplication
|
||||
across different services (e.g., [`EmbeddingService`](rag/svr/task_executor_refactor/embedding_service.py),
|
||||
[`DataflowService`](rag/svr/task_executor_refactor/dataflow_service.py)).
|
||||
|
||||
This module centralizes:
|
||||
- Batch encoding of texts with truncation
|
||||
- Vector stacking from multiple batches
|
||||
- Vector attachment to chunk dictionaries
|
||||
- Title and content vector combination with configurable weights
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common.token_utils import truncate
|
||||
|
||||
|
||||
class EmbeddingUtils:
|
||||
"""Utility class for common embedding operations.
|
||||
|
||||
This class provides static methods for:
|
||||
- Preparing texts for embedding (title/content extraction, HTML normalization)
|
||||
- Batch encoding with truncation
|
||||
- Stacking vector batches
|
||||
- Attaching vectors to chunk dictionaries
|
||||
- Combining title and content vectors with weights
|
||||
"""
|
||||
|
||||
DEFAULT_TITLE_WEIGHT = 0.1
|
||||
DEFAULT_TITLE_PLACEHOLDER = "Title"
|
||||
CONTENT_PLACEHOLDER_FOR_WHITESPACE = "None"
|
||||
|
||||
@classmethod
|
||||
def prepare_texts_for_embedding(
|
||||
cls,
|
||||
docs: List[Dict[str, Any]],
|
||||
use_question_kwd: bool = True,
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Prepare title and content texts for embedding.
|
||||
|
||||
Extracts titles from 'docnm_kwd' field and contents from 'question_kwd'
|
||||
(if available and use_question_kwd is True) or 'content_with_weight'.
|
||||
Table HTML tags are normalized to spaces.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries.
|
||||
use_question_kwd: Whether to use 'question_kwd' as content if available.
|
||||
|
||||
Returns:
|
||||
Tuple of (titles, contents) lists.
|
||||
"""
|
||||
titles = []
|
||||
contents = []
|
||||
for d in docs:
|
||||
title = d.get("docnm_kwd", cls.DEFAULT_TITLE_PLACEHOLDER)
|
||||
titles.append(title)
|
||||
|
||||
content = cls._extract_content(d, use_question_kwd=use_question_kwd)
|
||||
content = cls._normalize_table_html(content)
|
||||
content = cls._handle_whitespace(content)
|
||||
|
||||
contents.append(content)
|
||||
return titles, contents
|
||||
|
||||
@classmethod
|
||||
def prepare_texts_for_dataflow_embedding(
|
||||
cls,
|
||||
chunks: List[Dict[str, Any]],
|
||||
) -> List[str]:
|
||||
"""Prepare texts for dataflow embedding.
|
||||
|
||||
Extracts content from 'questions', 'summary', or 'text' fields
|
||||
(in priority order).
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries from dataflow output.
|
||||
|
||||
Returns:
|
||||
List of text strings for embedding.
|
||||
"""
|
||||
texts = []
|
||||
for chunk in chunks:
|
||||
text = chunk.get("questions", chunk.get("summary", chunk.get("text", "")))
|
||||
texts.append(text)
|
||||
return texts
|
||||
|
||||
@classmethod
|
||||
def truncate_texts(cls, texts: List[str], max_length: int) -> List[str]:
|
||||
"""Truncate texts to the specified maximum length.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to truncate.
|
||||
max_length: Maximum length for each text (will subtract 10 for safety margin).
|
||||
|
||||
Returns:
|
||||
List of truncated text strings.
|
||||
"""
|
||||
safe_max_length = max_length - 10
|
||||
return [truncate(text, safe_max_length) for text in texts]
|
||||
|
||||
@classmethod
|
||||
def stack_vectors(cls, vects_batches: List[np.ndarray]) -> np.ndarray:
|
||||
"""Stack a list of vector batches into a single array.
|
||||
|
||||
Args:
|
||||
vects_batches: List of numpy arrays from batch encoding.
|
||||
|
||||
Returns:
|
||||
Stacked numpy array, or empty array if no batches provided.
|
||||
"""
|
||||
return np.vstack(vects_batches) if vects_batches else np.array([])
|
||||
|
||||
@classmethod
|
||||
def attach_vectors(
|
||||
cls,
|
||||
docs: List[Dict[str, Any]],
|
||||
vectors: np.ndarray,
|
||||
vector_key_template: str = "q_%d_vec",
|
||||
) -> int:
|
||||
"""Attach vectors to chunk dictionaries.
|
||||
|
||||
Args:
|
||||
docs: List of chunk dictionaries to modify in-place.
|
||||
vectors: Numpy array of vectors to attach.
|
||||
vector_key_template: Format string for the vector key (default: "q_%d_vec").
|
||||
|
||||
Returns:
|
||||
The size of each vector (assumes uniform size).
|
||||
"""
|
||||
vector_size = 0
|
||||
if len(vectors) != len(docs):
|
||||
raise ValueError(f"vectors/docs length mismatch: {len(vectors)} != {len(docs)}")
|
||||
for i, doc in enumerate(docs):
|
||||
vector = vectors[i].tolist()
|
||||
vector_size = len(vector)
|
||||
key = vector_key_template % vector_size
|
||||
doc[key] = vector
|
||||
return vector_size
|
||||
|
||||
@classmethod
|
||||
def combine_title_content_vectors(
|
||||
cls,
|
||||
title_vecs: Optional[np.ndarray],
|
||||
content_vecs: np.ndarray,
|
||||
title_weight: Optional[float] = None,
|
||||
) -> np.ndarray:
|
||||
"""Combine title and content vectors with a configurable weight.
|
||||
|
||||
Args:
|
||||
title_vecs: Title embedding vectors (may be None).
|
||||
content_vecs: Content embedding vectors.
|
||||
title_weight: Weight for title vectors (0.0 to 1.0). Defaults to 0.1.
|
||||
|
||||
Returns:
|
||||
Combined vector array. If title_vecs is None or shapes don't match,
|
||||
returns content_vecs unchanged.
|
||||
"""
|
||||
if title_weight is None:
|
||||
title_weight = cls.DEFAULT_TITLE_WEIGHT
|
||||
if not title_weight:
|
||||
title_weight = cls.DEFAULT_TITLE_WEIGHT
|
||||
|
||||
if (
|
||||
title_vecs is not None
|
||||
and content_vecs.ndim == 2
|
||||
and title_vecs.shape == content_vecs.shape
|
||||
):
|
||||
return title_weight * title_vecs + (1 - title_weight) * content_vecs
|
||||
return content_vecs
|
||||
|
||||
@classmethod
|
||||
def _extract_content(
|
||||
cls,
|
||||
doc: Dict[str, Any],
|
||||
use_question_kwd: bool = True,
|
||||
) -> str:
|
||||
"""Extract content from a chunk dictionary.
|
||||
|
||||
Priority: question_kwd (joined by newline) -> content_with_weight.
|
||||
"""
|
||||
if use_question_kwd:
|
||||
question_kwd = doc.get("question_kwd", [])
|
||||
if question_kwd:
|
||||
return "\n".join(question_kwd)
|
||||
return doc.get("content_with_weight", "")
|
||||
|
||||
@classmethod
|
||||
def _normalize_table_html(cls, text: str) -> str:
|
||||
"""Normalize table HTML tags to spaces.
|
||||
|
||||
Replaces table-related HTML tags (table, td, caption, tr, th) with spaces.
|
||||
"""
|
||||
return re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", text)
|
||||
|
||||
@classmethod
|
||||
def _handle_whitespace(cls, text: str) -> str:
|
||||
"""Replace whitespace-only content with a placeholder.
|
||||
|
||||
Prevents embedding models from receiving empty or meaningless input.
|
||||
"""
|
||||
if not text.strip():
|
||||
return cls.CONTENT_PLACEHOLDER_FOR_WHITESPACE
|
||||
return text
|
||||
156
rag/svr/task_executor_refactor/post_processor.py
Normal file
156
rag/svr/task_executor_refactor/post_processor.py
Normal file
@@ -0,0 +1,156 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Post Processor Module.
|
||||
|
||||
Provides [`PostProcessor`](rag/svr/task_executor_refactor/post_processor.py:42) for post-indexing
|
||||
operations like table parser metadata aggregation and TOC insertion.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.doc_metadata_service import DocMetadataService
|
||||
from common.metadata_utils import update_metadata_to
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.utils.table_es_metadata import (
|
||||
aggregate_table_manual_doc_metadata,
|
||||
merge_table_parser_config_from_kb,
|
||||
table_parser_strip_doc_metadata_keys,
|
||||
)
|
||||
|
||||
class PostProcessor:
|
||||
"""Service for post-indexing operations.
|
||||
|
||||
This service handles:
|
||||
- Table parser metadata aggregation
|
||||
- Document metadata updates
|
||||
- TOC (Table of Contents) chunk insertion
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
):
|
||||
"""Initialize PostProcessor.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
async def process_table_parser_metadata(
|
||||
self,
|
||||
task_doc_id: str,
|
||||
chunks: List[Dict],
|
||||
) -> None:
|
||||
"""Process table parser metadata aggregation.
|
||||
|
||||
Args:
|
||||
task_doc_id: Document ID.
|
||||
chunks: List of chunk dictionaries.
|
||||
"""
|
||||
ctx = self._task_context
|
||||
if ctx.parser_id.lower() != "table":
|
||||
return
|
||||
|
||||
eff_pc = merge_table_parser_config_from_kb(ctx.raw_task)
|
||||
logging.debug(
|
||||
f"[TABLE_META_DEBUG] table post-index: table_column_mode={eff_pc.get('table_column_mode')!r}"
|
||||
)
|
||||
|
||||
if eff_pc.get("table_column_mode") != "manual":
|
||||
return
|
||||
|
||||
try:
|
||||
agg = aggregate_table_manual_doc_metadata(chunks, ctx.raw_task)
|
||||
logging.debug(f"[TABLE_META_DEBUG] aggregated metadata: {agg}")
|
||||
|
||||
strip_keys = table_parser_strip_doc_metadata_keys(eff_pc)
|
||||
existing = DocMetadataService.get_document_metadata(task_doc_id)
|
||||
existing = existing if isinstance(existing, dict) else {}
|
||||
|
||||
preserved = {k: v for k, v in existing.items() if k not in strip_keys}
|
||||
merged = update_metadata_to(dict(preserved), agg)
|
||||
|
||||
logging.debug(
|
||||
f"[TABLE_META_DEBUG] calling update_document_metadata for doc_id={task_doc_id}, "
|
||||
f"meta_fields keys={list(merged.keys())}, "
|
||||
f"table_strip_key_count={len(strip_keys)}, agg_keys={list(agg.keys())}"
|
||||
)
|
||||
|
||||
try:
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
else:
|
||||
DocMetadataService.update_document_metadata(task_doc_id, merged)
|
||||
logging.debug("[TABLE_META_DEBUG] update_document_metadata succeeded")
|
||||
except Exception as ue:
|
||||
logging.error(
|
||||
"update_document_metadata failed (table parser, doc_id=%s): %s",
|
||||
task_doc_id,
|
||||
ue,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
"Table parser document metadata aggregation failed (doc_id=%s): %s",
|
||||
task_doc_id,
|
||||
e,
|
||||
)
|
||||
|
||||
async def insert_toc_chunk(
|
||||
self,
|
||||
toc_chunk: Optional[Dict],
|
||||
chunk_service,
|
||||
) -> bool:
|
||||
"""Insert TOC chunk into document store.
|
||||
|
||||
Args:
|
||||
toc_chunk: TOC chunk dictionary or None.
|
||||
chunk_service: ChunkService instance for chunk insertion.
|
||||
|
||||
Returns:
|
||||
True if TOC chunk was inserted successfully, False otherwise.
|
||||
"""
|
||||
ctx = self._task_context
|
||||
if toc_chunk is None:
|
||||
return False
|
||||
|
||||
if self._task_context.has_canceled_func(ctx.id):
|
||||
self._task_context.progress_cb(-1, msg="Task has been canceled.")
|
||||
return False
|
||||
|
||||
insert_result = await chunk_service.insert_chunks(ctx.id, ctx.tenant_id, ctx.kb_id, [toc_chunk])
|
||||
|
||||
if not insert_result:
|
||||
self._task_context.recording_context.record("toc_inserted", False)
|
||||
return False
|
||||
|
||||
self._task_context.recording_context.record("toc_inserted", True)
|
||||
|
||||
if self._task_context.write_interceptor:
|
||||
self._task_context.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(ctx.doc_id, ctx.kb_id, 0, 1, 0)
|
||||
|
||||
return True
|
||||
|
||||
def _progress(self, prog=None, msg=None):
|
||||
"""Progress callback helper."""
|
||||
if prog is not None or msg is not None:
|
||||
self._task_context.progress_cb(prog=prog, msg=msg)
|
||||
468
rag/svr/task_executor_refactor/raptor_service.py
Normal file
468
rag/svr/task_executor_refactor/raptor_service.py
Normal file
@@ -0,0 +1,468 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Raptor Service Module.
|
||||
|
||||
Provides [`RaptorService`](rag/svr/task_executor_refactor/raptor_service.py:48) for RAPTOR
|
||||
(Recursive Abstractive Processing for Tree-Organized Retrieval) summary generation.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.task_service import GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
from common import settings
|
||||
from common.constants import PAGERANK_FLD
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from common.token_utils import num_tokens_from_string
|
||||
from rag.nlp import rag_tokenizer, search
|
||||
from rag.utils.raptor_utils import (
|
||||
collect_raptor_chunk_ids,
|
||||
collect_raptor_methods,
|
||||
get_raptor_clustering_method,
|
||||
get_raptor_tree_builder,
|
||||
get_skip_reason,
|
||||
make_raptor_summary_chunk_id,
|
||||
should_skip_raptor,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
|
||||
|
||||
class RaptorService:
|
||||
"""Service for RAPTOR summary generation.
|
||||
|
||||
This service handles:
|
||||
- RAPTOR chunk method detection (checkpoint)
|
||||
- RAPTOR summary generation per document or dataset-level
|
||||
- Stale RAPTOR chunk cleanup
|
||||
- Auto-disable rules for certain file types
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
):
|
||||
"""Initialize RaptorService.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
|
||||
async def run_raptor_for_kb(
|
||||
self,
|
||||
kb_parser_config: Dict,
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
vector_size: int,
|
||||
doc_ids: List[str],
|
||||
) -> Tuple[List[Dict], int, List[Tuple[str, Optional[str]]]]:
|
||||
"""Generate RAPTOR summaries for selected documents.
|
||||
|
||||
Args:
|
||||
kb_parser_config: Knowledge base parser configuration.
|
||||
chat_mdl: Chat model bundle for RAPTOR.
|
||||
embd_mdl: Embedding model bundle for RAPTOR.
|
||||
vector_size: Vector dimension size.
|
||||
doc_ids: List of document IDs to process.
|
||||
|
||||
Returns:
|
||||
Tuple of (chunks, token_count, cleanup_raptor_chunks).
|
||||
"""
|
||||
raptor_config = kb_parser_config.get("raptor", {})
|
||||
tree_builder = get_raptor_tree_builder(raptor_config)
|
||||
clustering_method = get_raptor_clustering_method(raptor_config)
|
||||
vctr_nm = "q_%d_vec" % vector_size
|
||||
|
||||
res = []
|
||||
tk_count = 0
|
||||
cleanup_raptor_chunks = []
|
||||
max_errors = int(os.environ.get("RAPTOR_MAX_ERRORS", 3))
|
||||
|
||||
# Collect document info
|
||||
doc_info_by_id = self._collect_doc_info(doc_ids)
|
||||
|
||||
# Determine scope
|
||||
if raptor_config.get("scope", "file") == "file":
|
||||
res, tk_count = await self._run_file_level_raptor(
|
||||
raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
)
|
||||
else:
|
||||
res, tk_count = await self._run_dataset_level_raptor(
|
||||
raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
)
|
||||
|
||||
return res, tk_count, cleanup_raptor_chunks
|
||||
|
||||
@classmethod
|
||||
def _collect_doc_info(cls, doc_ids: List[str]) -> Dict[str, Dict]:
|
||||
"""Collect document info for all doc_ids."""
|
||||
doc_info_by_id = {}
|
||||
for doc_id in set(doc_ids):
|
||||
ok, source_doc = DocumentService.get_by_id(doc_id)
|
||||
if not ok or not source_doc:
|
||||
continue
|
||||
doc_info_by_id[doc_id] = {
|
||||
"name": getattr(source_doc, "name", ""),
|
||||
"type": getattr(source_doc, "type", ""),
|
||||
"parser_id": getattr(source_doc, "parser_id", ""),
|
||||
"parser_config": getattr(source_doc, "parser_config", {}) or {},
|
||||
}
|
||||
return doc_info_by_id
|
||||
|
||||
async def _run_file_level_raptor(
|
||||
self, raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
):
|
||||
"""Run RAPTOR at file level (per document)."""
|
||||
ctx = self._task_context
|
||||
fake_doc_id = GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
if self._task_context.write_interceptor: # dry run mode
|
||||
dataset_methods = set()
|
||||
else:
|
||||
dataset_methods = await self._get_raptor_chunk_methods(fake_doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
remove_dataset_summaries = bool(dataset_methods)
|
||||
has_file_level_target = False
|
||||
|
||||
if dataset_methods:
|
||||
self._task_context.progress_cb(msg="[RAPTOR] will remove dataset-level summaries after file-level summaries are available.")
|
||||
|
||||
for x, doc_id in enumerate(doc_ids):
|
||||
if self._should_skip_raptor(doc_id, doc_info_by_id, raptor_config):
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
continue
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if tree_builder in existing_methods:
|
||||
has_file_level_target = True
|
||||
if existing_methods != {tree_builder}:
|
||||
self._schedule_raptor_cleanup(
|
||||
doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} will remove old RAPTOR summaries after insert.")
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} already has {tree_builder} RAPTOR chunks, skipping.")
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
continue
|
||||
|
||||
if existing_methods:
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} will migrate RAPTOR summaries to {tree_builder} after insert.")
|
||||
|
||||
chunks = self._load_doc_chunks(doc_id, vctr_nm)
|
||||
if not chunks:
|
||||
continue
|
||||
|
||||
before_generate = len(res)
|
||||
new_chunks, new_tk_count = await self._generate_raptor(
|
||||
chunks, doc_id, raptor_config, chat_mdl, embd_mdl,
|
||||
tree_builder, clustering_method, max_errors, doc_info_by_id
|
||||
)
|
||||
res.extend(new_chunks)
|
||||
tk_count += new_tk_count
|
||||
|
||||
if len(res) > before_generate:
|
||||
has_file_level_target = True
|
||||
if existing_methods:
|
||||
self._schedule_raptor_cleanup(
|
||||
doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(prog=(x + 1.) / len(doc_ids))
|
||||
|
||||
if remove_dataset_summaries:
|
||||
if has_file_level_target:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, None, cleanup_raptor_chunks
|
||||
)
|
||||
else:
|
||||
self._task_context.progress_cb(msg="[RAPTOR] kept dataset-level summaries because no file-level summaries were built.")
|
||||
|
||||
return res, tk_count
|
||||
|
||||
async def _run_dataset_level_raptor(
|
||||
self, raptor_config, tree_builder, clustering_method,
|
||||
chat_mdl, embd_mdl, vctr_nm, doc_ids, doc_info_by_id,
|
||||
max_errors, res, tk_count, cleanup_raptor_chunks
|
||||
):
|
||||
"""Run RAPTOR at dataset level (all documents combined)."""
|
||||
ctx = self._task_context
|
||||
fake_doc_id = GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
migrated_file_docs = 0
|
||||
file_cleanup_doc_ids = []
|
||||
skipped_doc_ids = set()
|
||||
|
||||
for doc_id in set(doc_ids):
|
||||
if self._should_skip_raptor(doc_id, doc_info_by_id, raptor_config):
|
||||
skipped_doc_ids.add(doc_id)
|
||||
continue
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if existing_methods:
|
||||
file_cleanup_doc_ids.append(doc_id)
|
||||
migrated_file_docs += 1
|
||||
|
||||
if migrated_file_docs:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[RAPTOR] will remove file-level summaries for {migrated_file_docs} docs after dataset-level build succeeds."
|
||||
)
|
||||
|
||||
if self._task_context.write_interceptor:
|
||||
existing_methods = set()
|
||||
else:
|
||||
existing_methods = await self._get_raptor_chunk_methods(fake_doc_id, ctx.tenant_id, ctx.kb_id)
|
||||
if tree_builder in existing_methods:
|
||||
if existing_methods != {tree_builder}:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
self._task_context.progress_cb(msg="[RAPTOR] will remove old dataset-level RAPTOR summaries after insert.")
|
||||
for doc_id in file_cleanup_doc_ids:
|
||||
self._schedule_raptor_cleanup(doc_id, None, cleanup_raptor_chunks)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] dataset-level {tree_builder} summaries already exist, skipping.")
|
||||
return res, tk_count
|
||||
|
||||
migrate_dataset_summaries = bool(existing_methods)
|
||||
if migrate_dataset_summaries:
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] will migrate dataset-level RAPTOR summaries to {tree_builder} after insert.")
|
||||
|
||||
chunks = self._load_all_doc_chunks(doc_ids, vctr_nm, skipped_doc_ids)
|
||||
if not chunks:
|
||||
if skipped_doc_ids and len(skipped_doc_ids) == len(set(doc_ids)):
|
||||
self._task_context.progress_cb(msg="[RAPTOR] all documents were skipped by RAPTOR auto-disable rules.")
|
||||
return res, tk_count
|
||||
self._task_context.progress_cb(msg="[ERROR] No valid chunks with vectors found. Please ensure documents are parsed with the current embedding model.")
|
||||
return res, tk_count
|
||||
|
||||
before_generate = len(res)
|
||||
new_chunks, new_tk_count = await self._generate_raptor(
|
||||
chunks, fake_doc_id, raptor_config, chat_mdl, embd_mdl,
|
||||
tree_builder, clustering_method, max_errors, doc_info_by_id
|
||||
)
|
||||
res.extend(new_chunks)
|
||||
tk_count += new_tk_count
|
||||
|
||||
if len(res) > before_generate:
|
||||
for doc_id in file_cleanup_doc_ids:
|
||||
self._schedule_raptor_cleanup(doc_id, None, cleanup_raptor_chunks)
|
||||
if migrate_dataset_summaries:
|
||||
self._schedule_raptor_cleanup(
|
||||
fake_doc_id, tree_builder, cleanup_raptor_chunks
|
||||
)
|
||||
|
||||
return res, tk_count
|
||||
|
||||
def _should_skip_raptor(
|
||||
self, doc_id: str, doc_info_by_id: Dict, raptor_config: Dict
|
||||
) -> bool:
|
||||
"""Check if RAPTOR should be skipped for a document."""
|
||||
ctx = self._task_context
|
||||
doc_info = doc_info_by_id.get(doc_id, {})
|
||||
file_type = doc_info.get("type") or ctx.raw_task.get("type", "")
|
||||
parser_id = doc_info.get("parser_id") or ctx.parser_id
|
||||
parser_config = doc_info.get("parser_config") or ctx.parser_config
|
||||
|
||||
if should_skip_raptor(file_type, parser_id, parser_config, raptor_config):
|
||||
skip_reason = get_skip_reason(file_type, parser_id, parser_config)
|
||||
doc_name = doc_info.get("name") or doc_id
|
||||
logging.info("Skipping Raptor for document %s: %s", doc_name, skip_reason)
|
||||
self._task_context.progress_cb(msg=f"[RAPTOR] doc:{doc_id} skipped: {skip_reason}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load_doc_chunks(self, doc_id: str, vctr_nm: str) -> List[Tuple[str, np.ndarray]]:
|
||||
"""Load chunks for a single document."""
|
||||
ctx = self._task_context
|
||||
chunks = []
|
||||
skipped_chunks = 0
|
||||
|
||||
fields = ["content_with_weight", vctr_nm]
|
||||
for d in settings.retriever.chunk_list(
|
||||
doc_id, ctx.tenant_id, [str(ctx.kb_id)],
|
||||
fields=fields,
|
||||
sort_by_position=True
|
||||
):
|
||||
if vctr_nm not in d or d[vctr_nm] is None:
|
||||
skipped_chunks += 1
|
||||
logging.warning(f"RAPTOR: Chunk missing vector field '{vctr_nm}' in doc {doc_id}, skipping")
|
||||
continue
|
||||
chunks.append((d["content_with_weight"], np.array(d[vctr_nm])))
|
||||
|
||||
if skipped_chunks > 0:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[WARN] Skipped {skipped_chunks} chunks without vector field '{vctr_nm}' for doc {doc_id}."
|
||||
)
|
||||
if not chunks:
|
||||
logging.warning(f"RAPTOR: No valid chunks with vectors found for doc {doc_id}")
|
||||
self._task_context.progress_cb(msg=f"[WARN] No valid chunks with vectors found for doc {doc_id}, skipping")
|
||||
|
||||
return chunks
|
||||
|
||||
def _load_all_doc_chunks(
|
||||
self, doc_ids: List[str], vctr_nm: str, skipped_doc_ids: Set[str]
|
||||
) -> List[Tuple[str, np.ndarray]]:
|
||||
"""Load chunks for all documents."""
|
||||
ctx = self._task_context
|
||||
chunks = []
|
||||
skipped_chunks = 0
|
||||
|
||||
fields = ["content_with_weight", vctr_nm]
|
||||
for doc_id in doc_ids:
|
||||
if doc_id in skipped_doc_ids:
|
||||
continue
|
||||
for d in settings.retriever.chunk_list(
|
||||
doc_id, ctx.tenant_id, [str(ctx.kb_id)],
|
||||
fields=fields,
|
||||
sort_by_position=True
|
||||
):
|
||||
if vctr_nm not in d or d[vctr_nm] is None:
|
||||
skipped_chunks += 1
|
||||
logging.warning(f"RAPTOR: Chunk missing vector field '{vctr_nm}' in doc {doc_id}, skipping")
|
||||
continue
|
||||
chunks.append((d["content_with_weight"], np.array(d[vctr_nm])))
|
||||
|
||||
if skipped_chunks > 0:
|
||||
self._task_context.progress_cb(
|
||||
msg=f"[WARN] Skipped {skipped_chunks} chunks without vector field '{vctr_nm}'."
|
||||
)
|
||||
|
||||
return chunks
|
||||
|
||||
async def _generate_raptor(
|
||||
self,
|
||||
chunks: List[Tuple[str, np.ndarray]],
|
||||
doc_id: str,
|
||||
raptor_config: Dict,
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
tree_builder: str,
|
||||
clustering_method: str,
|
||||
max_errors: int,
|
||||
doc_info_by_id: Dict,
|
||||
) -> Tuple[List[Dict], int]:
|
||||
"""Run RAPTOR and generate summary chunks."""
|
||||
ctx = self._task_context
|
||||
from rag.raptor import RecursiveAbstractiveProcessing4TreeOrganizedRetrieval as Raptor
|
||||
|
||||
raptor_ext_config = raptor_config.get("ext") or {}
|
||||
vctr_nm = "q_%d_vec" % len(chunks[0][1]) if chunks else "q_768_vec"
|
||||
|
||||
raptor = Raptor(
|
||||
raptor_config.get("max_cluster", 64),
|
||||
chat_mdl,
|
||||
embd_mdl,
|
||||
raptor_config["prompt"],
|
||||
raptor_config["max_token"],
|
||||
raptor_config["threshold"],
|
||||
max_errors=max_errors,
|
||||
tree_builder=tree_builder,
|
||||
clustering_method=clustering_method,
|
||||
psi_exact_max_leaves=raptor_ext_config.get("psi_exact_max_leaves", 4096),
|
||||
psi_bucket_size=raptor_ext_config.get("psi_bucket_size", 1024),
|
||||
)
|
||||
|
||||
original_length = len(chunks)
|
||||
processed_chunks, layers = await raptor(
|
||||
chunks, raptor_config["random_seed"], self._task_context.progress_cb, ctx.id
|
||||
)
|
||||
|
||||
effective_doc_name = ctx.name if doc_id == GRAPH_RAPTOR_FAKE_DOC_ID else doc_info_by_id.get(doc_id, {}).get("name") or ctx.name
|
||||
|
||||
doc = {
|
||||
"doc_id": doc_id,
|
||||
"kb_id": [str(ctx.kb_id)],
|
||||
"docnm_kwd": effective_doc_name,
|
||||
"title_tks": rag_tokenizer.tokenize(effective_doc_name),
|
||||
"raptor_kwd": "raptor",
|
||||
"extra": {"raptor_method": tree_builder},
|
||||
}
|
||||
if ctx.pagerank:
|
||||
doc[PAGERANK_FLD] = int(ctx.pagerank)
|
||||
|
||||
# Build index→layer mapping
|
||||
chunk_layer = {}
|
||||
for layer_idx, (layer_start, layer_end) in enumerate(layers):
|
||||
if layer_idx == 0:
|
||||
continue
|
||||
for ci in range(layer_start, layer_end):
|
||||
chunk_layer[ci] = layer_idx
|
||||
|
||||
res = []
|
||||
tk_count = 0
|
||||
for idx, (content, vctr) in enumerate(processed_chunks[original_length:], start=original_length):
|
||||
d = copy.deepcopy(doc)
|
||||
d["id"] = make_raptor_summary_chunk_id(content, doc_id)
|
||||
d["create_time"] = str(datetime.now()).replace("T", " ")[:19]
|
||||
d["create_timestamp_flt"] = datetime.now().timestamp()
|
||||
d[vctr_nm] = vctr.tolist()
|
||||
d["content_with_weight"] = content
|
||||
d["content_ltks"] = rag_tokenizer.tokenize(content)
|
||||
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"])
|
||||
d["raptor_layer_int"] = chunk_layer.get(idx, 1)
|
||||
res.append(d)
|
||||
tk_count += num_tokens_from_string(content)
|
||||
|
||||
return res, tk_count
|
||||
|
||||
@classmethod
|
||||
def _schedule_raptor_cleanup(cls, doc_id: str, keep_method: Optional[str], cleanup_list: List):
|
||||
"""Queue stale RAPTOR summaries for deletion."""
|
||||
cleanup_plan = (doc_id, keep_method)
|
||||
if cleanup_plan not in cleanup_list:
|
||||
cleanup_list.append(cleanup_plan)
|
||||
|
||||
@classmethod
|
||||
async def _get_raptor_chunk_methods(cls, doc_id: str, tenant_id: str, kb_id: str) -> Set[str]:
|
||||
"""Get RAPTOR chunk methods for a document."""
|
||||
from common.doc_store.doc_store_base import OrderByExpr
|
||||
|
||||
async def search_fields(fields: list, condition: dict, order_by=None):
|
||||
res = await thread_pool_exec(
|
||||
settings.docStoreConn.search,
|
||||
fields, [], condition, [], order_by or OrderByExpr(),
|
||||
0, 10000, search.index_name(tenant_id), [kb_id]
|
||||
)
|
||||
return settings.docStoreConn.get_fields(res, fields)
|
||||
|
||||
try:
|
||||
primary = await search_fields(
|
||||
["raptor_kwd", "extra"], {"doc_id": doc_id, "raptor_kwd": ["raptor"]}
|
||||
)
|
||||
if collect_raptor_chunk_ids(primary):
|
||||
return collect_raptor_methods(primary)
|
||||
|
||||
return collect_raptor_methods(
|
||||
await search_fields(
|
||||
["raptor_kwd", "extra"],
|
||||
{"doc_id": doc_id},
|
||||
OrderByExpr().desc("create_timestamp_flt"),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Failed to check RAPTOR chunks for doc %s", doc_id)
|
||||
raise
|
||||
97
rag/svr/task_executor_refactor/raptor_utils.py
Normal file
97
rag/svr/task_executor_refactor/raptor_utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
RAPTOR chunk management utilities.
|
||||
|
||||
Provides functions for managing RAPTOR summary chunks,
|
||||
including detection, retrieval, and deletion.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from common import settings
|
||||
from rag.nlp import search as nlp_search
|
||||
from rag.utils.raptor_utils import (
|
||||
collect_raptor_chunk_ids,
|
||||
)
|
||||
|
||||
RAPTOR_METHOD_SEARCH_LIMIT = 10000
|
||||
|
||||
|
||||
async def get_raptor_chunk_field_map(doc_id: str, tenant_id: str, kb_id: str) -> dict:
|
||||
"""Return stored RAPTOR marker fields for a document."""
|
||||
from common.doc_store.doc_store_base import OrderByExpr
|
||||
|
||||
async def search_fields(fields: list[str], condition: dict, order_by=None):
|
||||
"""Search chunk fields in the current knowledge base."""
|
||||
res = await thread_pool_exec(
|
||||
settings.docStoreConn.search,
|
||||
fields, [], condition, [], order_by or OrderByExpr(),
|
||||
0, RAPTOR_METHOD_SEARCH_LIMIT, nlp_search.index_name(tenant_id), [kb_id]
|
||||
)
|
||||
return settings.docStoreConn.get_fields(res, fields)
|
||||
|
||||
primary = await search_fields(["raptor_kwd", "extra"], {"doc_id": doc_id, "raptor_kwd": ["raptor"]})
|
||||
if collect_raptor_chunk_ids(primary):
|
||||
return primary
|
||||
|
||||
try:
|
||||
return await search_fields(
|
||||
["raptor_kwd", "extra"],
|
||||
{"doc_id": doc_id},
|
||||
OrderByExpr().desc("create_timestamp_flt"),
|
||||
)
|
||||
except Exception:
|
||||
logging.debug("RAPTOR fallback method lookup with extra field failed for doc %s", doc_id, exc_info=True)
|
||||
return primary
|
||||
|
||||
|
||||
async def delete_raptor_chunks(doc_id: str, tenant_id: str, kb_id: str, keep_method: str | None = None) -> int:
|
||||
"""Delete RAPTOR summaries for doc_id, optionally preserving one method."""
|
||||
if keep_method is None:
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing all RAPTOR summaries (doc=%s tenant=%s kb=%s)",
|
||||
doc_id, tenant_id, kb_id,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": doc_id, "raptor_kwd": ["raptor"]},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
field_map = await get_raptor_chunk_field_map(doc_id, tenant_id, kb_id)
|
||||
chunk_ids = collect_raptor_chunk_ids(field_map, exclude_methods={keep_method})
|
||||
if not chunk_ids:
|
||||
logging.debug(
|
||||
"delete_raptor_chunks: no stale RAPTOR chunks to remove (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
return 0
|
||||
|
||||
logging.info(
|
||||
"delete_raptor_chunks: removing %d stale RAPTOR chunks (doc=%s tenant=%s kb=%s keep=%s)",
|
||||
len(chunk_ids), doc_id, tenant_id, kb_id, keep_method,
|
||||
)
|
||||
await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"id": list(chunk_ids)},
|
||||
nlp_search.index_name(tenant_id),
|
||||
kb_id,
|
||||
)
|
||||
return len(chunk_ids)
|
||||
419
rag/svr/task_executor_refactor/recording_context.py
Normal file
419
rag/svr/task_executor_refactor/recording_context.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Recording Context Module.
|
||||
|
||||
This module provides the [`BaseRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:48) abstract base class,
|
||||
[`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:89) concrete class, and
|
||||
[`NullRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:204) no-op class, which capture
|
||||
actual execution results from the production code path (e.g., [`do_handle_task()`](rag/svr/task_executor.py))
|
||||
for later comparison with dry-run results.
|
||||
|
||||
The recording context is used throughout the task execution pipeline to collect
|
||||
intermediate metrics and final results at various stages:
|
||||
|
||||
1. **File validation**: Records file size check results and parser ID
|
||||
2. **Chunking**: Records raw chunks after document splitting
|
||||
3. **Outline extraction**: Records whether outline was extracted and entry count
|
||||
4. **MinIO upload**: Records document count after image upload
|
||||
5. **Post-processing**: Records counts for keywords, questions, metadata, and tags
|
||||
6. **Final results**: Records final chunks and their IDs for comparison
|
||||
|
||||
The module also provides context variable management functions and a timing
|
||||
decorator that automatically integrates with the current recording context.
|
||||
|
||||
Usage example::
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import RecordingContext
|
||||
|
||||
ctx = RecordingContext()
|
||||
ctx.record("raw_chunk_count", 42)
|
||||
ctx.record("final_chunks", chunks)
|
||||
|
||||
# Later, in comparison:
|
||||
comparator.compare(task_id, ctx, dry_run_records)
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import functools
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
|
||||
|
||||
class BaseRecordingContext(ABC):
|
||||
"""Abstract base class for recording context implementations.
|
||||
|
||||
Defines the common interface shared by
|
||||
[`RecordingContext`](rag/svr/task_executor_refactor/recording_context.py:89) and
|
||||
[`NullRecordingContext`](rag/svr/task_executor_refactor/recording_context.py:204).
|
||||
|
||||
Variables typed as ``BaseRecordingContext`` can hold either implementation,
|
||||
enabling production/dry-run polymorphism without conditional branches.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
"""Record a value with the given key."""
|
||||
|
||||
@abstractmethod
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
"""Record a function's return value into a list associated with func_name."""
|
||||
|
||||
@abstractmethod
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
"""Get the list of recorded return values for a function."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a recorded value by key."""
|
||||
|
||||
@abstractmethod
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
"""Get all recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Clear all recorded data."""
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None:
|
||||
"""Clear all recorded data and timing records."""
|
||||
|
||||
@abstractmethod
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
"""Timing context manager to record execution duration."""
|
||||
|
||||
@abstractmethod
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation."""
|
||||
|
||||
|
||||
class RecordingContext(BaseRecordingContext):
|
||||
"""Captures actual execution results from production code for comparison.
|
||||
|
||||
This class acts as a dictionary-like container that stores key-value pairs
|
||||
representing various metrics and intermediate results collected during
|
||||
the production execution of a document processing task. It also supports
|
||||
timing measurements via the [`measure()`](rag/svr/task_executor_refactor/recording_context.py:78) context manager.
|
||||
|
||||
The recorded data is later consumed by the [`Comparator`](rag/svr/task_executor_refactor/comparator.py:130)
|
||||
to compare against dry-run execution results.
|
||||
|
||||
Example:
|
||||
>>> ctx = RecordingContext()
|
||||
>>> ctx.record("chunk_count", 100)
|
||||
>>> ctx.get("chunk_count")
|
||||
100
|
||||
>>> ctx.get("missing_key", "default")
|
||||
'default'
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new RecordingContext."""
|
||||
self._data: Dict[str, Any] = {}
|
||||
self.records: List[Tuple[str, float]] = []
|
||||
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
"""Record a value with the given key.
|
||||
|
||||
This method stores the provided value under the specified key in the
|
||||
internal data dictionary. If the key already exists, the value will
|
||||
be overwritten.
|
||||
|
||||
Args:
|
||||
key: The key to store the value under. Should be a descriptive
|
||||
string that identifies the metric or result being recorded.
|
||||
value: The value to record. Can be any Python object, including
|
||||
primitives, lists, dicts, or complex objects.
|
||||
"""
|
||||
self._data[key] = value
|
||||
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
"""Record a function's return value into a list associated with func_name.
|
||||
|
||||
Each func_name has a corresponding return_values_list. This method appends
|
||||
the return_value to the list for the given func_name. If the list does not
|
||||
exist, it will be created.
|
||||
|
||||
Args:
|
||||
func_name: The name of the function whose return value is being recorded.
|
||||
return_value: The return value to record.
|
||||
"""
|
||||
if func_name not in self._data:
|
||||
self._data[func_name] = []
|
||||
self._data[func_name].append(return_value)
|
||||
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
"""Get the list of recorded return values for a function.
|
||||
|
||||
Args:
|
||||
func_name: The name of the function.
|
||||
|
||||
Returns:
|
||||
A list of recorded return values, or an empty list if not found.
|
||||
"""
|
||||
return self._data.get(func_name, [])
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a recorded value by key.
|
||||
|
||||
Retrieves the value associated with the given key. If the key does
|
||||
not exist, returns the provided default value.
|
||||
|
||||
Args:
|
||||
key: The key to look up in the recorded data.
|
||||
default: Default value to return if the key is not found.
|
||||
Defaults to None.
|
||||
|
||||
Returns:
|
||||
The recorded value associated with the key, or the default value
|
||||
if the key does not exist.
|
||||
"""
|
||||
return self._data.get(key, default)
|
||||
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
"""Get all recorded data.
|
||||
|
||||
Returns a shallow copy of all recorded data as a dictionary.
|
||||
Modifications to the returned dictionary will not affect the
|
||||
internal state of this context.
|
||||
|
||||
Returns:
|
||||
A new dictionary containing all recorded key-value pairs.
|
||||
"""
|
||||
return dict(self._data)
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists in recorded data.
|
||||
|
||||
Args:
|
||||
key: The key to check for existence.
|
||||
|
||||
Returns:
|
||||
True if the key exists in the recorded data, False otherwise.
|
||||
"""
|
||||
return key in self._data
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all recorded data.
|
||||
|
||||
Removes all key-value pairs from the internal data dictionary
|
||||
and clears all timing records, resetting the context to its
|
||||
initial empty state.
|
||||
"""
|
||||
self._data.clear()
|
||||
self.records.clear()
|
||||
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
"""Timing context manager to record execution duration.
|
||||
|
||||
Records the elapsed time (in seconds) for the operation specified
|
||||
by `name`.
|
||||
|
||||
Usage::
|
||||
|
||||
with ctx.measure("build_chunks"):
|
||||
...
|
||||
|
||||
Args:
|
||||
name: A descriptive name for the timed operation.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
self.records.append((name, elapsed))
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all recorded data and timing records."""
|
||||
self.clear()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the RecordingContext.
|
||||
|
||||
Returns:
|
||||
A string showing the class name and all recorded data.
|
||||
"""
|
||||
return f"RecordingContext({self._data})"
|
||||
|
||||
|
||||
class NullRecordingContext(BaseRecordingContext):
|
||||
"""No-op RecordingContext for production mode.
|
||||
|
||||
Accepts all RecordingContext API calls but performs no allocation.
|
||||
Eliminates memory overhead in production where recorded data is unused.
|
||||
|
||||
Uses __slots__ for zero instance memory footprint.
|
||||
|
||||
Usage:
|
||||
>>> ctx = NullRecordingContext()
|
||||
>>> ctx.record("chunks", large_list) # no-op, no memory allocated
|
||||
>>> ctx.get("chunks") # always returns None
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def record(self, key: str, value: Any) -> None:
|
||||
pass
|
||||
|
||||
def save_func_return_value(self, func_name: str, return_value: Any) -> None:
|
||||
pass
|
||||
|
||||
def get_func_return_values(self, func_name: str) -> List[Any]:
|
||||
return []
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return default
|
||||
|
||||
def get_all_func_return_values(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def measure(self, name: str):
|
||||
yield
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "NullRecordingContext()"
|
||||
|
||||
|
||||
# Module-level singleton to avoid repeated allocations
|
||||
_NULL_RECORDING_CONTEXT = NullRecordingContext()
|
||||
|
||||
|
||||
# Context variable for coroutine / thread isolation
|
||||
_recording_ctx_var: contextvars.ContextVar[BaseRecordingContext] = contextvars.ContextVar("recording_context")
|
||||
|
||||
|
||||
def get_recording_context() -> BaseRecordingContext:
|
||||
"""Get the BaseRecordingContext for the current execution context.
|
||||
|
||||
Returns the BaseRecordingContext bound to the current coroutine / thread.
|
||||
If no context has been bound, raise RuntimeError.
|
||||
|
||||
Returns:
|
||||
The current BaseRecordingContext, raise RuntimeError if not set.
|
||||
"""
|
||||
context = _recording_ctx_var.get(None)
|
||||
if context is None:
|
||||
raise RuntimeError("no context")
|
||||
return context
|
||||
|
||||
|
||||
def set_recording_context(ctx: BaseRecordingContext) -> None:
|
||||
"""Bind a BaseRecordingContext to the current execution context.
|
||||
|
||||
Args:
|
||||
ctx: The BaseRecordingContext to bind, or None to unbind.
|
||||
"""
|
||||
_recording_ctx_var.set(ctx)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def recording_context_manager(ctx: BaseRecordingContext = None):
|
||||
"""Context manager that sets and restores the BaseRecordingContext.
|
||||
|
||||
Usage::
|
||||
|
||||
with recording_context_manager(RecordingContext()) as ctx:
|
||||
ctx.record("key", "value")
|
||||
|
||||
Args:
|
||||
ctx: The BaseRecordingContext to use. If None, a new one is created.
|
||||
|
||||
Yields:
|
||||
The BaseRecordingContext that was set.
|
||||
"""
|
||||
if ctx is None:
|
||||
ctx = RecordingContext()
|
||||
token = _recording_ctx_var.set(ctx)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
_recording_ctx_var.reset(token)
|
||||
|
||||
|
||||
def timed_with_recording(
|
||||
func: Callable = None,
|
||||
*,
|
||||
recording_context: BaseRecordingContext = None,
|
||||
) -> Callable:
|
||||
"""Decorator that automatically uses the current BaseRecordingContext for timing.
|
||||
|
||||
Supports two usage forms:
|
||||
|
||||
1. Direct decoration (automatically uses context variable):
|
||||
|
||||
@timed_with_recording
|
||||
def foo(): ...
|
||||
|
||||
2. Parameterized decoration with explicit BaseRecordingContext:
|
||||
|
||||
@timed_with_recording(recording_context=my_ctx)
|
||||
def foo(): ...
|
||||
|
||||
The decorator records the execution time of the decorated function
|
||||
into the BaseRecordingContext's timing records.
|
||||
|
||||
Args:
|
||||
func: The function to decorate (used when called without parentheses).
|
||||
recording_context: Optional BaseRecordingContext to use for timing.
|
||||
If not provided, uses the context variable's current value.
|
||||
|
||||
Returns:
|
||||
The decorated function.
|
||||
"""
|
||||
from common.decorator import timing
|
||||
|
||||
if func is not None and callable(func):
|
||||
# Used as @timed_with_recording without parentheses
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = recording_context or get_recording_context()
|
||||
if ctx is not None:
|
||||
return timing(context=ctx)(func)(*args, **kwargs)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Used as @timed_with_recording(...) with parentheses
|
||||
def decorator(the_func: Callable) -> Callable:
|
||||
@functools.wraps(the_func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ctx = recording_context or get_recording_context()
|
||||
if ctx is not None:
|
||||
return timing(context=ctx)(the_func)(*args, **kwargs)
|
||||
return the_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
140
rag/svr/task_executor_refactor/report_generator.py
Normal file
140
rag/svr/task_executor_refactor/report_generator.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Report Generator Module.
|
||||
|
||||
Provides data classes for comparison result reporting:
|
||||
- [`ComparisonResult`](rag/svr/task_executor_refactor/report_generator.py:40): Single key comparison result
|
||||
- [`ComparisonReport`](rag/svr/task_executor_refactor/report_generator.py:66): Full comparison report with serialization
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonResult:
|
||||
"""Result of comparing a single key between two contexts.
|
||||
|
||||
Attributes:
|
||||
key: The key being compared.
|
||||
match: Whether the values match.
|
||||
production_value: Value from production context.
|
||||
dry_run_value: Value from dry-run context.
|
||||
diff_details: Optional description of the difference.
|
||||
"""
|
||||
|
||||
key: str
|
||||
match: bool
|
||||
production_value: Any = None
|
||||
dry_run_value: Any = None
|
||||
diff_details: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"key": self.key,
|
||||
"match": self.match,
|
||||
"diff_details": self.diff_details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonReport:
|
||||
"""Report of comparing two RecordingContext instances.
|
||||
|
||||
Attributes:
|
||||
task_id: The task identifier.
|
||||
total_keys: Total number of keys compared.
|
||||
matched_keys: Number of keys that matched.
|
||||
mismatched_keys: Number of keys that mismatched.
|
||||
missing_in_production: Keys missing in production context.
|
||||
missing_in_dry_run: Keys missing in dry-run context.
|
||||
details: List of individual comparison results.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
total_keys: int = 0
|
||||
matched_keys: int = 0
|
||||
mismatched_keys: int = 0
|
||||
missing_in_production: List[str] = field(default_factory=list)
|
||||
missing_in_dry_run: List[str] = field(default_factory=list)
|
||||
details: List["ComparisonResult"] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a summary string.
|
||||
|
||||
Returns:
|
||||
A human-readable summary of the comparison.
|
||||
"""
|
||||
if self.total_keys == 0:
|
||||
return f"Task {self.task_id}: No keys to compare"
|
||||
match_rate = (self.matched_keys / self.total_keys) * 100
|
||||
return (
|
||||
f"Task {self.task_id}: {self.matched_keys}/{self.total_keys} "
|
||||
f"keys matched ({match_rate:.1f}%)"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for serialization.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the report.
|
||||
"""
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"total_keys": self.total_keys,
|
||||
"matched_keys": self.matched_keys,
|
||||
"mismatched_keys": self.mismatched_keys,
|
||||
"missing_in_production": self.missing_in_production,
|
||||
"missing_in_dry_run": self.missing_in_dry_run,
|
||||
"details": [d.to_dict() for d in self.details],
|
||||
"summary": self.summary(),
|
||||
}
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Generate a mark-down report.
|
||||
|
||||
Returns:
|
||||
A markdown-formatted report string.
|
||||
"""
|
||||
lines = [
|
||||
f"# Comparison Report: {self.task_id}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- **Total keys**: {self.total_keys}",
|
||||
f"- **Matched**: {self.matched_keys}",
|
||||
f"- **Mismatched**: {self.mismatched_keys}",
|
||||
f"- **Missing in production**: {', '.join(self.missing_in_production) or 'None'}",
|
||||
f"- **Missing in dry-run**: {', '.join(self.missing_in_dry_run) or 'None'}",
|
||||
"",
|
||||
"## Details",
|
||||
"",
|
||||
]
|
||||
|
||||
if self.details:
|
||||
lines.append("| Key | Match | Details |")
|
||||
lines.append("|-----|-------|---------|")
|
||||
for d in self.details:
|
||||
match_str = "✅" if d.match else "❌"
|
||||
details_str = d.diff_details or "-"
|
||||
lines.append(f"| {d.key} | {match_str} | {details_str} |")
|
||||
else:
|
||||
lines.append("No comparison details available.")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
520
rag/svr/task_executor_refactor/task_context.py
Normal file
520
rag/svr/task_executor_refactor/task_context.py
Normal file
@@ -0,0 +1,520 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Task Context Module.
|
||||
|
||||
Provides [`TaskContext`](rag/svr/task_executor_refactor/task_context.py) as a typed wrapper
|
||||
around the task dictionary, providing convenient property accessors for all
|
||||
commonly used task attributes throughout the task executor refactor codebase.
|
||||
|
||||
This module defines:
|
||||
- [`TaskDict`](rag/svr/task_executor_refactor/task_context.py): TypedDict for the raw task dictionary.
|
||||
- [`TaskLimiters`](rag/svr/task_executor_refactor/task_context.py): Dataclass encapsulating all rate limiters.
|
||||
- [`TaskCallbacks`](rag/svr/task_executor_refactor/task_context.py): Dataclass encapsulating all callback functions.
|
||||
- [`TaskContext`](rag/svr/task_executor_refactor/task_context.py): Main facade combining the above components.
|
||||
|
||||
Usage example::
|
||||
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext, TaskLimiters, TaskCallbacks
|
||||
|
||||
ctx = TaskContext(
|
||||
task=task_dict,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=progress_callback,
|
||||
has_canceled=has_canceled_func,
|
||||
),
|
||||
write_interceptor=write_interceptor,
|
||||
recording_context=recording_context,
|
||||
)
|
||||
|
||||
# Access task properties directly
|
||||
task_id = ctx.id
|
||||
tenant_id = ctx.tenant_id
|
||||
kb_id = ctx.kb_id
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, List, Optional, Required, TypedDict
|
||||
|
||||
from rag.svr.task_executor_refactor.recording_context import BaseRecordingContext
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import WriteOperationInterceptor
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Type Definitions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TaskDict(TypedDict, total=False):
|
||||
"""TypedDict defining the structure of the raw task dictionary.
|
||||
|
||||
All fields are optional except 'id' and 'tenant_id' which are required.
|
||||
"""
|
||||
|
||||
id: Required[str]
|
||||
"""Task identifier (required)."""
|
||||
|
||||
tenant_id: Required[str]
|
||||
"""Tenant identifier (required)."""
|
||||
|
||||
kb_id: str
|
||||
"""Knowledge base / dataset identifier."""
|
||||
|
||||
doc_id: str
|
||||
"""Document identifier."""
|
||||
|
||||
doc_ids: List[str]
|
||||
"""List of document identifiers (for batch tasks like RAPTOR/GraphRAG)."""
|
||||
|
||||
name: str
|
||||
"""Document name."""
|
||||
|
||||
location: str
|
||||
"""Document location/path."""
|
||||
|
||||
size: int
|
||||
"""Document file size in bytes."""
|
||||
|
||||
parser_id: str
|
||||
"""Parser identifier (e.g., 'naive', 'table', 'paper')."""
|
||||
|
||||
parser_config: Dict[str, Any]
|
||||
"""Document-level parser configuration."""
|
||||
|
||||
kb_parser_config: Dict[str, Any]
|
||||
|
||||
"""Knowledge base level parser configuration."""
|
||||
|
||||
language: str
|
||||
"""Document language (e.g., 'en', 'zh')."""
|
||||
|
||||
llm_id: str
|
||||
"""LLM model identifier."""
|
||||
|
||||
embd_id: str
|
||||
"""Embedding model identifier."""
|
||||
|
||||
from_page: int
|
||||
"""Starting page number for processing (0-based)."""
|
||||
|
||||
to_page: int
|
||||
"""Ending page number for processing (-1 means all pages)."""
|
||||
|
||||
task_type: str
|
||||
"""Task type (e.g., 'dataflow', 'raptor', 'graphrag', 'memory')."""
|
||||
|
||||
dataflow_id: str
|
||||
"""Dataflow/pipeline identifier."""
|
||||
|
||||
pagerank: int
|
||||
"""PageRank value for document scoring."""
|
||||
|
||||
file: Any
|
||||
"""File object for dataflow processing."""
|
||||
|
||||
memory_id: str
|
||||
"""Memory identifier for memory tasks."""
|
||||
|
||||
source_id: str
|
||||
"""Source identifier for memory tasks."""
|
||||
|
||||
message_dict: Dict[str, Any]
|
||||
"""Message dictionary for memory tasks."""
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskLimiters:
|
||||
"""Encapsulates all rate limiters for task execution.
|
||||
|
||||
Each limiter is an asyncio.Semaphore used to control concurrency
|
||||
for different types of operations.
|
||||
"""
|
||||
|
||||
chat: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for chat model rate limiting."""
|
||||
|
||||
minio: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for MinIO rate limiting."""
|
||||
|
||||
chunk: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for chunk building rate limiting."""
|
||||
|
||||
embed: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for embedding rate limiting."""
|
||||
|
||||
kg: asyncio.Semaphore = None
|
||||
"""Asyncio semaphore for knowledge graph rate limiting."""
|
||||
|
||||
|
||||
def _noop_progress(**kwargs: Any) -> None:
|
||||
"""No-op progress callback."""
|
||||
pass
|
||||
|
||||
|
||||
def _not_canceled(task_id: str) -> bool:
|
||||
"""Default cancellation check - always returns False."""
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskCallbacks:
|
||||
"""Encapsulates all callback functions for task execution."""
|
||||
|
||||
progress: Callable = field(default_factory=lambda: _noop_progress)
|
||||
"""Callback function for progress updates (raw, requires task_id, from_page, to_page)."""
|
||||
|
||||
has_canceled: Callable = field(default_factory=lambda: _not_canceled)
|
||||
"""Function to check if task is canceled."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Class
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TaskContext:
|
||||
"""Typed wrapper around the task dictionary providing convenient property accessors.
|
||||
|
||||
This class uses composition to encapsulate:
|
||||
1. The raw task dictionary (TaskDict)
|
||||
2. Execution limiters (TaskLimiters)
|
||||
3. Callback functions (TaskCallbacks)
|
||||
4. Optional write operation interceptor
|
||||
5. Optional recording context for intermediate results
|
||||
|
||||
The properties provide a clean interface for accessing task attributes
|
||||
without needing to use dictionary access with string keys throughout
|
||||
the codebase.
|
||||
"""
|
||||
|
||||
# Default values for optional task fields
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"kb_id": "",
|
||||
"doc_id": "",
|
||||
"doc_ids": [],
|
||||
"name": "",
|
||||
"location": "",
|
||||
"size": 0,
|
||||
"parser_id": "",
|
||||
"parser_config": {},
|
||||
"kb_parser_config": {},
|
||||
"language": "en",
|
||||
"llm_id": "",
|
||||
"embd_id": "",
|
||||
"from_page": 0,
|
||||
"to_page": -1,
|
||||
"task_type": "",
|
||||
"dataflow_id": "",
|
||||
"pagerank": 0,
|
||||
"memory_id": "",
|
||||
"source_id": "",
|
||||
"message_dict": {},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task: TaskDict,
|
||||
limiters: TaskLimiters,
|
||||
callbacks: TaskCallbacks,
|
||||
write_interceptor: WriteOperationInterceptor = None,
|
||||
recording_context: BaseRecordingContext = None,
|
||||
):
|
||||
"""Initialize TaskContext.
|
||||
|
||||
Args:
|
||||
task: The raw task dictionary containing all task attributes.
|
||||
limiters: TaskLimiters dataclass containing all rate limiters.
|
||||
callbacks: TaskCallbacks dataclass containing all callback functions.
|
||||
write_interceptor: Optional interceptor for write operations.
|
||||
recording_context: Optional BaseRecordingContext for intermediate result
|
||||
capture. Must be injected via constructor.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields ('id', 'tenant_id') are missing from task.
|
||||
"""
|
||||
# Validate required fields
|
||||
if "id" not in task:
|
||||
raise ValueError("Task must contain 'id'")
|
||||
if "tenant_id" not in task:
|
||||
raise ValueError("Task must contain 'tenant_id'")
|
||||
|
||||
self._task = task
|
||||
self.limiters = limiters
|
||||
self.callbacks = callbacks
|
||||
self._write_interceptor = write_interceptor
|
||||
self._recording_context = recording_context
|
||||
|
||||
|
||||
# Prepare progress callback and set it on the context
|
||||
progress_cb = partial(
|
||||
callbacks.progress,
|
||||
self.id,
|
||||
self.from_page,
|
||||
self.to_page,
|
||||
)
|
||||
self._progress_cb = progress_cb
|
||||
|
||||
# =========================================================================
|
||||
# Core task identity properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Task identifier."""
|
||||
return self._task["id"]
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
"""Tenant identifier."""
|
||||
return self._task["tenant_id"]
|
||||
|
||||
@property
|
||||
def kb_id(self) -> str:
|
||||
"""Knowledge base / dataset identifier."""
|
||||
return self._task.get("kb_id", self._DEFAULTS["kb_id"])
|
||||
|
||||
@property
|
||||
def doc_id(self) -> str:
|
||||
"""Document identifier."""
|
||||
return self._task.get("doc_id", self._DEFAULTS["doc_id"])
|
||||
|
||||
@property
|
||||
def doc_ids(self) -> List[str]:
|
||||
"""List of document identifiers (for batch tasks like RAPTOR/GraphRAG)."""
|
||||
return self._task.get("doc_ids", list(self._DEFAULTS["doc_ids"]))
|
||||
|
||||
# =========================================================================
|
||||
# Document metadata properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Document name."""
|
||||
return self._task.get("name", self._DEFAULTS["name"])
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Document location/path."""
|
||||
return self._task.get("location", self._DEFAULTS["location"])
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Document file size in bytes."""
|
||||
return self._task.get("size", self._DEFAULTS["size"])
|
||||
|
||||
# =========================================================================
|
||||
# Parser configuration properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def parser_id(self) -> str:
|
||||
"""Parser identifier (e.g., 'naive', 'table', 'paper')."""
|
||||
return self._task.get("parser_id", self._DEFAULTS["parser_id"])
|
||||
|
||||
@property
|
||||
def parser_config(self) -> Dict[str, Any]:
|
||||
"""Document-level parser configuration."""
|
||||
return self._task.get("parser_config", {})
|
||||
|
||||
@property
|
||||
def kb_parser_config(self) -> Dict[str, Any]:
|
||||
"""Knowledge base level parser configuration."""
|
||||
return self._task.get("kb_parser_config", {})
|
||||
|
||||
# =========================================================================
|
||||
# Language and model properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""Document language (e.g., 'en', 'zh')."""
|
||||
return self._task.get("language", self._DEFAULTS["language"])
|
||||
|
||||
@property
|
||||
def llm_id(self) -> str:
|
||||
"""LLM model identifier."""
|
||||
return self._task.get("llm_id", self._DEFAULTS["llm_id"])
|
||||
|
||||
@property
|
||||
def embd_id(self) -> str:
|
||||
"""Embedding model identifier."""
|
||||
return self._task.get("embd_id", self._DEFAULTS["embd_id"])
|
||||
|
||||
# =========================================================================
|
||||
# Page range properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def from_page(self) -> int:
|
||||
"""Starting page number for processing (0-based)."""
|
||||
return self._task.get("from_page", self._DEFAULTS["from_page"])
|
||||
|
||||
@property
|
||||
def to_page(self) -> int:
|
||||
"""Ending page number for processing (-1 means all pages)."""
|
||||
return self._task.get("to_page", self._DEFAULTS["to_page"])
|
||||
|
||||
# =========================================================================
|
||||
# Task type and routing properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def task_type(self) -> str:
|
||||
"""Task type (e.g., 'dataflow', 'raptor', 'graphrag', 'memory')."""
|
||||
return self._task.get("task_type", self._DEFAULTS["task_type"])
|
||||
|
||||
@property
|
||||
def dataflow_id(self) -> str:
|
||||
"""Dataflow/pipeline identifier."""
|
||||
return self._task.get("dataflow_id", self._DEFAULTS["dataflow_id"])
|
||||
|
||||
# =========================================================================
|
||||
# Additional properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def pagerank(self) -> int:
|
||||
"""PageRank value for document scoring."""
|
||||
return self._task.get("pagerank", self._DEFAULTS["pagerank"])
|
||||
|
||||
@property
|
||||
def file(self) -> Optional[Any]:
|
||||
"""File object for dataflow processing."""
|
||||
return self._task.get("file")
|
||||
|
||||
# =========================================================================
|
||||
# Memory task specific properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def memory_id(self) -> str:
|
||||
"""Memory identifier for memory tasks."""
|
||||
return self._task.get("memory_id", self._DEFAULTS["memory_id"])
|
||||
|
||||
@property
|
||||
def source_id(self) -> str:
|
||||
"""Source identifier for memory tasks."""
|
||||
return self._task.get("source_id", self._DEFAULTS["source_id"])
|
||||
|
||||
@property
|
||||
def message_dict(self) -> Dict[str, Any]:
|
||||
"""Message dictionary for memory tasks."""
|
||||
return self._task.get("message_dict", {})
|
||||
|
||||
# =========================================================================
|
||||
# Raw task dictionary access
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def raw_task(self) -> Dict[str, Any]:
|
||||
"""Return the raw task dictionary."""
|
||||
return self._task
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from the task dictionary with a default.
|
||||
|
||||
Args:
|
||||
key: The key to look up.
|
||||
default: Default value if key is not found.
|
||||
|
||||
Returns:
|
||||
The value associated with the key, or default if not found.
|
||||
"""
|
||||
return self._task.get(key, default)
|
||||
|
||||
# =========================================================================
|
||||
# Limiter properties (proxies to TaskLimiters dataclass)
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def chat_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for chat model rate limiting."""
|
||||
return self.limiters.chat or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def minio_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for MinIO rate limiting."""
|
||||
return self.limiters.minio or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def chunk_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for chunk building rate limiting."""
|
||||
return self.limiters.chunk or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def embed_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for embedding rate limiting."""
|
||||
return self.limiters.embed or asyncio.Semaphore(1)
|
||||
|
||||
@property
|
||||
def kg_limiter(self) -> asyncio.Semaphore:
|
||||
"""Asyncio semaphore for knowledge graph rate limiting."""
|
||||
return self.limiters.kg or asyncio.Semaphore(1)
|
||||
|
||||
# =========================================================================
|
||||
# Context and interceptor properties
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def recording_context(self) -> BaseRecordingContext:
|
||||
"""BaseRecordingContext for this task.
|
||||
|
||||
Must be injected via constructor. Raises RuntimeError if accessed
|
||||
before initialization or if no context was provided.
|
||||
"""
|
||||
if self._recording_context is None:
|
||||
raise RuntimeError("recording_context accessed but not injected into TaskContext")
|
||||
return self._recording_context
|
||||
|
||||
@property
|
||||
def write_interceptor(self) -> WriteOperationInterceptor:
|
||||
"""Write operation interceptor for comparison mode."""
|
||||
return self._write_interceptor
|
||||
|
||||
# =========================================================================
|
||||
# Callback properties (proxies to TaskCallbacks dataclass)
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def has_canceled_func(self) -> Callable:
|
||||
"""Function to check if task is canceled."""
|
||||
return self.callbacks.has_canceled
|
||||
|
||||
# =========================================================================
|
||||
# Pre-bound progress callback
|
||||
# =========================================================================
|
||||
|
||||
@property
|
||||
def progress_cb(self) -> Callable:
|
||||
"""Pre-bound progress callback (task_id, from_page, to_page already bound).
|
||||
|
||||
Use this property in services for progress updates.
|
||||
Falls back to progress_callback if progress_cb is not set.
|
||||
"""
|
||||
return self._progress_cb
|
||||
492
rag/svr/task_executor_refactor/task_executor_refactoring_plan.md
Normal file
492
rag/svr/task_executor_refactor/task_executor_refactoring_plan.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Task Executor Refactoring Plan
|
||||
|
||||
## 1. Current State Analysis
|
||||
|
||||
### 1.1 Original File
|
||||
- **File Location**: `rag/svr/task_executor.py`
|
||||
- **Lines of Code**: Approximately 1,780 lines
|
||||
- **Primary Responsibilities**: Task consumption, document chunking, vectorization, index building, RAPTOR/GraphRAG processing, heartbeat reporting
|
||||
|
||||
### 1.2 Identified Issues
|
||||
|
||||
| Issue Type | Specific Manifestation |
|
||||
|------------|------------------------|
|
||||
| Single Responsibility Violation | One file handles 7+ different responsibilities |
|
||||
| Global State | Global variables like `DONE_TASKS`, `FAILED_TASKS`, `CURRENT_TASKS` |
|
||||
| Tight Coupling | Direct dependencies on `TaskService`, `DocumentService`, `REDIS_CONN`, etc. |
|
||||
| Untestable | Functions depend on global state and external services, difficult to mock |
|
||||
| Hardcoded Configuration | `BATCH_SIZE`, `FACTORY`, etc. hardcoded in the file |
|
||||
|
||||
---
|
||||
|
||||
## 2. Implemented Architecture
|
||||
|
||||
### 2.1 Actual Module Structure
|
||||
|
||||
```
|
||||
rag/svr/task_executor_refactor/
|
||||
├── task_context.py # Task context encapsulation (~450 lines)
|
||||
├── recording_context.py # Execution result recording context (~330 lines)
|
||||
├── write_operation_interceptor.py # Write operation interceptor (~130 lines)
|
||||
├── chunk_service.py # Document chunking service (~430 lines)
|
||||
├── chunk_builder.py # Chunk building logic (~130 lines)
|
||||
├── chunk_post_processor.py # Post-chunking logic (~350 lines)
|
||||
├── embedding_service.py # Embedding service (~130 lines)
|
||||
├── embedding_utils.py # Embedding utility functions (~210 lines)
|
||||
├── raptor_service.py # RAPTOR processing service (~520 lines)
|
||||
├── raptor_utils.py # RAPTOR utility functions (~100 lines)
|
||||
├── dataflow_service.py # Dataflow pipeline service (~430 lines)
|
||||
├── post_processor.py # Post-processing service (~150 lines)
|
||||
├── comparator.py # Comparator (~550 lines)
|
||||
├── report_generator.py # Report generator (~130 lines)
|
||||
├── task_handler.py # Task handler entry point (~630 lines)
|
||||
├── task_manager.py # Task manager (~200 lines)
|
||||
├── constants.py # Constant definitions (~25 lines)
|
||||
└── insert_service.py # Insert service (~150 lines)
|
||||
|
||||
test/unit_test/rag/svr/task_executor_refactor/
|
||||
├── conftest.py # Shared test fixtures (~260 lines)
|
||||
├── test_task_context.py # TaskContext tests (~410 lines)
|
||||
├── test_recording_context.py # RecordingContext tests (~330 lines)
|
||||
├── test_write_operation_interceptor.py # Interceptor tests (~450 lines)
|
||||
├── test_chunk_service.py # ChunkService tests (~560 lines)
|
||||
├── test_chunk_builder.py # ChunkBuilder tests (~290 lines)
|
||||
├── test_chunk_post_processor.py # ChunkPostProcessor tests (~550 lines)
|
||||
├── test_embedding_service.py # EmbeddingService tests (~190 lines)
|
||||
├── test_embedding_utils.py # EmbeddingUtils tests (~370 lines)
|
||||
├── test_raptor_service.py # RaptorService tests (~350 lines)
|
||||
├── test_dataflow_service.py # DataflowService tests (~250 lines)
|
||||
├── test_post_processor.py # PostProcessor tests (~120 lines)
|
||||
├── test_comparator.py # Comparator tests (~570 lines)
|
||||
├── test_task_handler.py # TaskHandler unit tests (~800 lines)
|
||||
├── test_task_handler_integration.py # TaskHandler integration tests (~1400 lines)
|
||||
└── test_constants.py # Constants tests (~40 lines)
|
||||
```
|
||||
|
||||
### 2.2 Layered Architecture Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Business Layer │
|
||||
│ task_handler.py │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ TaskHandler Class │ │
|
||||
│ │ ├── handle_task() # Entry point, handles cancellation and exceptions │ │
|
||||
│ │ ├── handle() # Task type routing dispatch │ │
|
||||
│ │ ├── _run_dataflow() # Dataflow pipeline execution │ │
|
||||
│ │ ├── _run_raptor() # RAPTOR summary generation │ │
|
||||
│ │ ├── _run_graphrag() # GraphRAG knowledge graph │ │
|
||||
│ │ └── _run_standard_chunking() # Standard chunking flow │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Entry Functions: │
|
||||
│ ├── run_refactored_task() # Refactored version entry │
|
||||
│ └── dry_run_task() # Comparison mode entry │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │ ChunkService │ │ EmbeddingService │ │ RaptorService │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ build_chunks() │ │ embed_chunks() │ │ run_raptor_ │ │
|
||||
│ │ insert_chunks() │ │ │ │ for_kb() │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │DataflowService │ │ PostProcessor │ │ InsertService │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ run_dataflow() │ │ process_table_ │ │ insert_chunks()│ │
|
||||
│ │ │ │ parser_ │ │ │ │
|
||||
│ │ │ │ metadata() │ │ │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ ChunkBuilder │ │ChunkPostProcessor│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Chunk building │ │ Post-processing │ │
|
||||
│ │ logic │ │ logic │ │
|
||||
│ └─────────────────┘ └──────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
|
||||
│ │ TaskContext │ │ RecordingContext │ │ Comparator │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Task property │ │ Execution result │ │ Production vs │ │
|
||||
│ │ accessors │ │ recording │ │ Dry-run │ │
|
||||
│ │ Rate limiter │ │ Function return │ │ Difference │ │
|
||||
│ │ encapsulation │ │ value recording │ │ report gen │ │
|
||||
│ │ Interceptor │ │ Timing decorator │ │ │ │
|
||||
│ │ references │ │ │ │ │ │
|
||||
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ WriteOperationInterceptor │ │ ReportGenerator │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Whitelist method interception │ │ Difference report │ │
|
||||
│ │ Pre-recorded return value replay │ │ Formatted output │ │
|
||||
│ └──────────────────────────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ TaskManager │ │ Constants & Utils │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Task lifecycle management │ │ CANVAS_DEBUG_ │ │
|
||||
│ │ Task state tracking │ │ DOC_ID │ │
|
||||
│ └──────────────────────────────────┘ │ GRAPH_RAPTOR_ │ │
|
||||
│ │ FAKE_DOC_ID │ │
|
||||
│ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Design Patterns
|
||||
|
||||
### 3.1 Dependency Injection
|
||||
|
||||
All services receive `TaskContext` through constructors, rather than directly importing global state:
|
||||
|
||||
```python
|
||||
class ChunkService:
|
||||
def __init__(self, ctx: TaskContext):
|
||||
self._task_context = ctx
|
||||
```
|
||||
|
||||
### 3.2 Interceptor Pattern
|
||||
|
||||
`WriteOperationInterceptor` is used to replay production execution return values in comparison mode:
|
||||
|
||||
```python
|
||||
# Comparison mode: intercept write operations
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
```
|
||||
|
||||
### 3.3 Recording Context Pattern
|
||||
|
||||
`RecordingContext` captures intermediate results for comparison:
|
||||
|
||||
```python
|
||||
# Record intermediate results
|
||||
get_recording_context().record("chunks", chunks)
|
||||
get_recording_context().record("token_count", token_count)
|
||||
```
|
||||
|
||||
### 3.4 Factory Pattern
|
||||
|
||||
Parser modules are registered through factory mapping:
|
||||
|
||||
```python
|
||||
PARSER_FACTORY = {}
|
||||
|
||||
def register_parser(parser_id: str, parser_module):
|
||||
PARSER_FACTORY[parser_id] = parser_module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Task Execution Flow
|
||||
|
||||
### 4.1 Standard Task Flow
|
||||
|
||||
```
|
||||
run_refactored_task()
|
||||
│
|
||||
▼
|
||||
TaskContext Creation
|
||||
│
|
||||
▼
|
||||
TaskHandler.handle_task()
|
||||
│
|
||||
├── try: handle()
|
||||
│ │
|
||||
│ ├── Task type judgment
|
||||
│ │ ├── "memory" → handle_save_to_memory_task()
|
||||
│ │ ├── "dataflow" → DataflowService.run_dataflow()
|
||||
│ │ ├── "raptor" → _run_raptor()
|
||||
│ │ ├── "graphrag" → _run_graphrag()
|
||||
│ │ ├── "mindmap" → Placeholder
|
||||
│ │ └── Others → _run_standard_chunking()
|
||||
│ │
|
||||
│ └── _run_standard_chunking()
|
||||
│ │
|
||||
│ ├── Bind embedding model
|
||||
│ ├── Retrieve storage binary
|
||||
│ ├── ChunkService.build_chunks()
|
||||
│ │ ├── File size validation
|
||||
│ │ ├── Parser chunking
|
||||
│ │ ├── Outline extraction
|
||||
│ │ ├── MinIO upload
|
||||
│ │ ├── Keyword extraction
|
||||
│ │ ├── Question generation
|
||||
│ │ ├── Metadata generation
|
||||
│ │ └── Content tagging
|
||||
│ ├── EmbeddingService.embed_chunks()
|
||||
│ ├── TOC generation (async)
|
||||
│ ├── ChunkService.insert_chunks()
|
||||
│ ├── PostProcessor.process_table_parser_metadata()
|
||||
│ ├── TOC insertion
|
||||
│ └── DocumentService.increment_chunk_num()
|
||||
│
|
||||
└── finally: Cancel task cleanup
|
||||
```
|
||||
|
||||
### 4.2 Comparison Mode Flow
|
||||
|
||||
```
|
||||
dry_run_task()
|
||||
│
|
||||
├── Create WriteOperationInterceptor (using pre-recorded values from recording_ctx1)
|
||||
├── Create new RecordingContext (recording_ctx2)
|
||||
├── Set recording_context to recording_ctx2
|
||||
│
|
||||
▼
|
||||
TaskHandler.handle_task() # Execute with interceptor replay
|
||||
│
|
||||
▼
|
||||
ContextComparator.compare(task_id, recording_ctx1, recording_ctx2)
|
||||
│
|
||||
├── Key-by-key comparison
|
||||
├── Generate difference report
|
||||
└── Output mismatched_keys and remaining_values
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
### 5.1 Test Coverage Status
|
||||
|
||||
| Module | Test File | Test Lines | Coverage Focus |
|
||||
|--------|-----------|------------|----------------|
|
||||
| `TaskContext` | `test_task_context.py` | ~410 | Property accessors, rate limiters, interceptors |
|
||||
| `RecordingContext` | `test_recording_context.py` | ~330 | Record/retrieve, function return values, timing |
|
||||
| `WriteOperationInterceptor` | `test_write_operation_interceptor.py` | ~450 | Whitelist validation, FIFO replay |
|
||||
| `ChunkService` | `test_chunk_service.py` | ~560 | Chunking logic, post-processing, insertion |
|
||||
| `ChunkBuilder` | `test_chunk_builder.py` | ~290 | Chunk building logic |
|
||||
| `ChunkPostProcessor` | `test_chunk_post_processor.py` | ~550 | Post-processing logic |
|
||||
| `EmbeddingService` | `test_embedding_service.py` | ~190 | Batch encoding, vector stacking |
|
||||
| `EmbeddingUtils` | `test_embedding_utils.py` | ~370 | Text preparation, truncation, stacking |
|
||||
| `RaptorService` | `test_raptor_service.py` | ~350 | RAPTOR execution |
|
||||
| `DataflowService` | `test_dataflow_service.py` | ~250 | Dataflow execution |
|
||||
| `PostProcessor` | `test_post_processor.py` | ~120 | Table metadata processing |
|
||||
| `Comparator` | `test_comparator.py` | ~570 | Various type comparison logic |
|
||||
| `TaskHandler` | `test_task_handler.py` | ~800 | Routing, model binding, task types |
|
||||
| `TaskHandler` | `test_task_handler_integration.py` | ~1400 | Full flow integration tests |
|
||||
| `constants.py` | `test_constants.py` | ~40 | Constant value validation |
|
||||
|
||||
**Total Test Code**: Approximately 6,700+ lines
|
||||
|
||||
### 5.2 Mock Strategy
|
||||
|
||||
```python
|
||||
# conftest.py shared fixtures
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task():
|
||||
"""Standard test task"""
|
||||
return {
|
||||
"id": "task-001",
|
||||
"task_type": "standard",
|
||||
"tenant_id": "tenant-001",
|
||||
"kb_id": "kb-001",
|
||||
"doc_id": "doc-001",
|
||||
"name": "test.pdf",
|
||||
...
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task_context(mock_task):
|
||||
"""TaskContext fixture"""
|
||||
return TaskContext(
|
||||
task=mock_task,
|
||||
chat_limiter=asyncio.Semaphore(1),
|
||||
minio_limiter=asyncio.Semaphore(1),
|
||||
chunk_limiter=asyncio.Semaphore(1),
|
||||
embed_limiter=asyncio.Semaphore(1),
|
||||
kg_limiter=asyncio.Semaphore(1),
|
||||
progress_callback=lambda **kwargs: None,
|
||||
has_canceled_func=lambda task_id: False,
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 Test Coverage Targets
|
||||
|
||||
| Module | Current Coverage | Target Coverage | Notes |
|
||||
|--------|-----------------|-----------------|-------|
|
||||
| `task_context.py` | ~90% | 95%+ | Good |
|
||||
| `recording_context.py` | ~85% | 90%+ | Good |
|
||||
| `write_operation_interceptor.py` | ~90% | 95%+ | Good |
|
||||
| `chunk_service.py` | ~80% | 90%+ | Good |
|
||||
| `chunk_builder.py` | ~75% | 85%+ | Needs more edge case tests |
|
||||
| `chunk_post_processor.py` | ~80% | 90%+ | Good |
|
||||
| `embedding_service.py` | ~85% | 90%+ | Good |
|
||||
| `raptor_service.py` | ~70% | 85%+ | Improved |
|
||||
| `dataflow_service.py` | ~75% | 85%+ | Good |
|
||||
| `post_processor.py` | ~75% | 85%+ | Good |
|
||||
| `comparator.py` | ~85% | 90%+ | Good |
|
||||
| `task_handler.py` | ~75% | 85%+ | Needs more integration tests |
|
||||
|
||||
---
|
||||
|
||||
## 6. Backward Compatibility Strategy
|
||||
|
||||
### 6.1 Dual Code Path Coexistence
|
||||
|
||||
Original `task_executor.py` is preserved, importing refactored modules:
|
||||
|
||||
```python
|
||||
# rag/svr/task_executor.py (modified)
|
||||
from rag.svr.task_executor_refactor.task_handler import dry_run_task, run_refactored_task
|
||||
from rag.svr.task_executor_refactor.recording_context import timed_with_recording, get_recording_context, \
|
||||
RecordingContext, set_recording_context
|
||||
```
|
||||
|
||||
### 6.2 Migration Plan
|
||||
|
||||
| Phase | Status | Description |
|
||||
|-------|--------|-------------|
|
||||
| Phase 1 | ✅ Completed | Dual code paths parallel, `run_refactored_task()` and `dry_run_task()` available |
|
||||
| Phase 2 | ⏳ Pending | Switch default execution to refactored code, keep old code as fallback |
|
||||
| Phase 3 | ⏳ Pending | Remove old code after validation period |
|
||||
|
||||
---
|
||||
|
||||
## 7. Equivalence Guarantee Strategy
|
||||
|
||||
### 7.1 Comparison Mode
|
||||
|
||||
The refactoring introduces a unique comparison mode to verify equivalence:
|
||||
|
||||
1. **Production Execution**: Run original code path, record all intermediate results to `RecordingContext`
|
||||
2. **Dry Run**: Use `WriteOperationInterceptor` to replay production results, record new intermediate results
|
||||
3. **Comparison**: `ContextComparator` compares differences between two contexts
|
||||
|
||||
### 7.2 Comparison Strategy
|
||||
|
||||
| Data Type | Comparison Strategy |
|
||||
|-----------|---------------------|
|
||||
| Primitives (int, str, bool) | Direct equality |
|
||||
| Floating point | Tolerance range |
|
||||
| Lists | Length + ID set + sampled content |
|
||||
| Dictionaries | Key set + recursive value comparison |
|
||||
| None | Equal |
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation | Status |
|
||||
|------|------------|--------|
|
||||
| Refactoring introduces bugs | Comparison mode verifies equivalence | ✅ Implemented |
|
||||
| Performance regression | Benchmark comparison | ⏳ Pending |
|
||||
| Memory increase | RecordingContext stores intermediate results | ⚠️ Needs monitoring |
|
||||
| Insufficient test coverage | Supplement RaptorService tests | ✅ Improved |
|
||||
| Large modules | Split chunk_service.py | ✅ Split |
|
||||
|
||||
---
|
||||
|
||||
## 9. Future Improvement Suggestions
|
||||
|
||||
### 9.1 High Priority
|
||||
|
||||
1. **Performance Benchmarking**: Compare performance before and after refactoring
|
||||
2. **Improve Integration Tests**: Add more end-to-end test scenarios
|
||||
3. **Fix Type Annotations**: Add `Any` type for `default_value` and similar parameters
|
||||
|
||||
### 9.2 Medium Priority
|
||||
|
||||
4. **Improve Exception Handling**: Preserve more context information when wrapping exceptions
|
||||
5. **Documentation Improvement**: Add usage examples to docstrings
|
||||
|
||||
### 9.3 Low Priority
|
||||
|
||||
6. **Memory Optimization**: Consider streaming recording for large tasks
|
||||
7. **Code Cleanup**: Remove unused imports and functions
|
||||
|
||||
---
|
||||
|
||||
## 10. Code Statistics
|
||||
|
||||
### 10.1 Source Code
|
||||
|
||||
| Module | Lines | Type |
|
||||
|--------|-------|------|
|
||||
| `task_context.py` | ~450 | Infrastructure |
|
||||
| `recording_context.py` | ~330 | Infrastructure |
|
||||
| `write_operation_interceptor.py` | ~130 | Infrastructure |
|
||||
| `comparator.py` | ~550 | Infrastructure |
|
||||
| `report_generator.py` | ~130 | Infrastructure |
|
||||
| `constants.py` | ~25 | Infrastructure |
|
||||
| `task_manager.py` | ~200 | Infrastructure |
|
||||
| `chunk_service.py` | ~430 | Service |
|
||||
| `chunk_builder.py` | ~130 | Service |
|
||||
| `chunk_post_processor.py` | ~350 | Service |
|
||||
| `embedding_service.py` | ~130 | Service |
|
||||
| `embedding_utils.py` | ~210 | Utility |
|
||||
| `raptor_service.py` | ~520 | Service |
|
||||
| `raptor_utils.py` | ~100 | Utility |
|
||||
| `dataflow_service.py` | ~430 | Service |
|
||||
| `post_processor.py` | ~150 | Service |
|
||||
| `insert_service.py` | ~150 | Service |
|
||||
| `task_handler.py` | ~630 | Business |
|
||||
| **Source Code Total** | **~4,900** | |
|
||||
|
||||
### 10.2 Test Code
|
||||
|
||||
| Test File | Lines |
|
||||
|-----------|-------|
|
||||
| `conftest.py` | ~260 |
|
||||
| `test_task_context.py` | ~410 |
|
||||
| `test_recording_context.py` | ~330 |
|
||||
| `test_write_operation_interceptor.py` | ~450 |
|
||||
| `test_chunk_service.py` | ~560 |
|
||||
| `test_chunk_builder.py` | ~290 |
|
||||
| `test_chunk_post_processor.py` | ~550 |
|
||||
| `test_embedding_service.py` | ~190 |
|
||||
| `test_embedding_utils.py` | ~370 |
|
||||
| `test_raptor_service.py` | ~350 |
|
||||
| `test_dataflow_service.py` | ~250 |
|
||||
| `test_post_processor.py` | ~120 |
|
||||
| `test_comparator.py` | ~570 |
|
||||
| `test_task_handler.py` | ~800 |
|
||||
| `test_task_handler_integration.py` | ~1400 |
|
||||
| `test_constants.py` | ~40 |
|
||||
| **Test Code Total** | **~6,700+** |
|
||||
|
||||
### 10.3 Documentation
|
||||
|
||||
| Document | Lines |
|
||||
|----------|-------|
|
||||
| `task_executor_refactoring_plan.md` | This document |
|
||||
|
||||
---
|
||||
|
||||
## 11. Time Estimation
|
||||
|
||||
| Phase | Completed | Estimated Time |
|
||||
|-------|-----------|----------------|
|
||||
| Infrastructure Preparation | ✅ Completed | - |
|
||||
| Core Logic Decoupling | ✅ Completed | - |
|
||||
| Advanced Feature Decoupling | ✅ Completed | - |
|
||||
| Test Writing | ✅ Mostly Completed | - |
|
||||
| Performance Benchmarking | ⏳ Pending | 1-2 days |
|
||||
| Migration to Production | ⏳ Pending | 1-2 days |
|
||||
| **Remaining Total** | | **2-4 days** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Summary
|
||||
|
||||
This refactoring has successfully decomposed the monolithic `task_executor.py` into a layered, testable module architecture:
|
||||
|
||||
- ✅ **Layered Architecture**: Infrastructure Layer → Service Layer → Business Layer
|
||||
- ✅ **Dependency Injection**: Execution resources injected via `TaskContext`
|
||||
- ✅ **Comparison Mode**: Innovative Production vs Dry-run comparison framework
|
||||
- ✅ **Test Coverage**: Approximately 6,700+ lines of test code
|
||||
- ✅ **Module Decomposition**: Large modules split into smaller responsibility units
|
||||
- ⚠️ **Pending Improvements**: Performance benchmarking, production migration validation
|
||||
|
||||
**Overall Status**: Core refactoring completed, test coverage is good, ready for validation and migration phases.
|
||||
576
rag/svr/task_executor_refactor/task_handler.py
Normal file
576
rag/svr/task_executor_refactor/task_handler.py
Normal file
@@ -0,0 +1,576 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Task Handler Module.
|
||||
|
||||
Provides [`TaskHandler`](rag/svr/task_executor_refactor/task_handler.py:56) as the main entry point
|
||||
for handling document processing tasks with refactored, testable methods.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import xxhash
|
||||
|
||||
from timeit import default_timer as timer
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
from api.db.joint_services.memory_message_service import handle_save_to_memory_task
|
||||
from api.db.joint_services.tenant_model_service import (
|
||||
get_model_config_by_type_and_name,
|
||||
get_tenant_default_model_by_type,
|
||||
)
|
||||
from api.db.services.llm_service import LLMBundle
|
||||
from api.db.services.task_service import GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
from common.constants import LLMType
|
||||
from common.exceptions import TaskCanceledException
|
||||
from common.misc_utils import thread_pool_exec
|
||||
from rag.nlp import search
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID
|
||||
from rag.svr.task_executor_refactor.chunk_service import ChunkService
|
||||
from rag.svr.task_executor_refactor.dataflow_service import BillingHook, DataflowService
|
||||
from rag.svr.task_executor_refactor.embedding_service import EmbeddingService
|
||||
from rag.svr.task_executor_refactor.post_processor import PostProcessor
|
||||
from rag.svr.task_executor_refactor.raptor_service import RaptorService
|
||||
from rag.svr.task_executor_refactor.raptor_utils import delete_raptor_chunks
|
||||
from rag.svr.task_executor_refactor.recording_context import RecordingContext
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.graphrag.general.index import run_graphrag_for_kb
|
||||
from api.db.services.file2document_service import File2DocumentService
|
||||
from rag.prompts.generator import run_toc_from_text
|
||||
from common import settings
|
||||
|
||||
|
||||
class TaskHandler:
|
||||
"""Main task handler for document processing.
|
||||
|
||||
This class orchestrates the entire document processing pipeline:
|
||||
1. Task type detection (memory, dataflow, raptor, graphrag, standard)
|
||||
2. Model binding (embedding, chat)
|
||||
3. Chunk building or RAPTOR/GraphRAG execution
|
||||
4. Embedding
|
||||
5. Indexing
|
||||
6. Post-processing (TOC, table metadata)
|
||||
|
||||
All intermediate results are recorded via RecordingContext for comparison.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: TaskContext,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
):
|
||||
"""Initialize TaskHandler.
|
||||
|
||||
Args:
|
||||
ctx: TaskContext containing task configuration and execution resources.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
"""
|
||||
self._task_context = ctx
|
||||
self._billing_hook = billing_hook
|
||||
|
||||
async def handle_task(self) -> None:
|
||||
try:
|
||||
await self.handle()
|
||||
finally:
|
||||
task_id = self._task_context.id
|
||||
task_tenant_id = self._task_context.tenant_id
|
||||
task_dataset_id = self._task_context.kb_id
|
||||
task_doc_id = self._task_context.doc_id
|
||||
if self._task_context.has_canceled_func(task_id):
|
||||
try:
|
||||
exists = await thread_pool_exec(
|
||||
settings.docStoreConn.index_exist,
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
if exists:
|
||||
ret = await thread_pool_exec(
|
||||
settings.docStoreConn.delete,
|
||||
{"doc_id": task_doc_id},
|
||||
search.index_name(task_tenant_id),
|
||||
task_dataset_id,
|
||||
)
|
||||
self._task_context.recording_context.save_func_return_value("docStoreConn.delete", ret)
|
||||
except Exception as e:
|
||||
logging.exception(
|
||||
f"Remove doc({task_doc_id}) from docStore failed when task({task_id}) canceled, exception: {e}")
|
||||
|
||||
async def handle(self) -> None:
|
||||
"""Handle a document processing task."""
|
||||
ctx = self._task_context
|
||||
task_type = ctx.task_type
|
||||
task_id = ctx.id
|
||||
|
||||
# Handle memory tasks
|
||||
if task_type == "memory":
|
||||
# ignore when it's dry run - no change on handle_save_to_memory_task when refactor
|
||||
if isinstance(ctx.write_interceptor, RecordingContext):
|
||||
logging.info(f"dry run, ignore handle_save_to_memory_task {task_id}")
|
||||
else:
|
||||
# actual run - not dry run
|
||||
await handle_save_to_memory_task(ctx.raw_task)
|
||||
|
||||
# Handle dataflow debug mode
|
||||
if task_type == "dataflow" and ctx.doc_id == CANVAS_DEBUG_DOC_ID:
|
||||
await self._run_dataflow()
|
||||
return
|
||||
|
||||
if task_type.startswith("dataflow"):
|
||||
await self._run_dataflow()
|
||||
return
|
||||
|
||||
# Check if task is canceled
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
# Bind embedding model
|
||||
embedding_model = await self._bind_embedding_model()
|
||||
if embedding_model is None:
|
||||
return
|
||||
|
||||
with embedding_model:
|
||||
vector_size = self._get_vector_size(embedding_model)
|
||||
self._init_kb(vector_size)
|
||||
|
||||
# Route to appropriate handler
|
||||
if task_type == "raptor":
|
||||
await self._run_raptor(embedding_model, vector_size)
|
||||
elif task_type == "graphrag":
|
||||
await self._run_graphrag(embedding_model)
|
||||
elif task_type == "mindmap":
|
||||
ctx.progress_cb(1, "place holder")
|
||||
elif task_type == "evaluation":
|
||||
await self._run_evaluation()
|
||||
elif task_type == "reembedding":
|
||||
await self._run_reembedding()
|
||||
elif task_type == "clone":
|
||||
await self._run_clone()
|
||||
else:
|
||||
await self._run_standard_chunking(embedding_model)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _get_vector_size(cls, embedding_model: LLMBundle) -> int:
|
||||
"""Get vector size from embedding model."""
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
return len(vts[0])
|
||||
|
||||
def _init_kb(self, vector_size: int) -> None:
|
||||
"""Initialize knowledge base index."""
|
||||
ctx = self._task_context
|
||||
idxnm = search.index_name(ctx.tenant_id)
|
||||
parser_id = ctx.parser_id
|
||||
# Create index if not exists
|
||||
settings.docStoreConn.create_idx(idxnm, ctx.kb_id, vector_size, parser_id)
|
||||
|
||||
async def _run_dataflow(self) -> None:
|
||||
"""Run dataflow pipeline."""
|
||||
dataflow_service = DataflowService(
|
||||
ctx=self._task_context,
|
||||
billing_hook=self._billing_hook,
|
||||
)
|
||||
await dataflow_service.run_dataflow()
|
||||
|
||||
async def _run_evaluation(self) -> None:
|
||||
"""Run evaluation task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Evaluation task placeholder")
|
||||
|
||||
async def _run_reembedding(self) -> None:
|
||||
"""Run reembedding task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Reembedding task placeholder")
|
||||
|
||||
async def _run_clone(self) -> None:
|
||||
"""Run clone task."""
|
||||
ctx = self._task_context
|
||||
ctx.progress_cb(1, "Clone task placeholder")
|
||||
|
||||
async def _bind_embedding_model(self) -> Optional[LLMBundle]:
|
||||
"""Bind embedding model to task."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_embedding_id = ctx.embd_id
|
||||
task_language = ctx.language
|
||||
|
||||
try:
|
||||
if task_embedding_id:
|
||||
embd_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.EMBEDDING, task_embedding_id
|
||||
)
|
||||
else:
|
||||
embd_model_config = get_tenant_default_model_by_type(
|
||||
task_tenant_id, LLMType.EMBEDDING
|
||||
)
|
||||
embedding_model = LLMBundle(task_tenant_id, embd_model_config, lang=task_language)
|
||||
vts, _ = embedding_model.encode(["ok"])
|
||||
return embedding_model
|
||||
except Exception as e:
|
||||
error_message = f'Fail to bind embedding model: {str(e)}'
|
||||
ctx.progress_cb(-1, msg=error_message)
|
||||
logging.exception(error_message)
|
||||
raise
|
||||
|
||||
async def _run_raptor(
|
||||
self,
|
||||
embedding_model: LLMBundle,
|
||||
vector_size: int,
|
||||
) -> None:
|
||||
"""Run RAPTOR summary generation."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
kb_task_llm_id = ctx.kb_parser_config.get("llm_id") or ctx.llm_id
|
||||
|
||||
ok, kb = KnowledgebaseService.get_by_id(task_dataset_id)
|
||||
if not ok:
|
||||
ctx.progress_cb(prog=-1.0, msg="Cannot found valid dataset for RAPTOR task")
|
||||
return
|
||||
|
||||
kb_parser_config = kb.parser_config
|
||||
if not kb_parser_config.get("raptor", {}).get("use_raptor", False):
|
||||
kb_parser_config.update({
|
||||
"raptor": {
|
||||
"use_raptor": True,
|
||||
"prompt": "Please summarize the following paragraphs. Be careful with the numbers, do not make things up. Paragraphs as following:\n {cluster_content}\nThe above is the content you need to summarize.",
|
||||
"max_token": 256,
|
||||
"threshold": 0.1,
|
||||
"max_cluster": 64,
|
||||
"random_seed": 0,
|
||||
"scope": "file",
|
||||
"clustering_method": "gmm",
|
||||
"tree_builder": "raptor",
|
||||
},
|
||||
})
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
|
||||
if not update_result:
|
||||
ctx.progress_cb(prog=-1.0, msg="Internal error: Invalid RAPTOR configuration")
|
||||
return
|
||||
|
||||
# Bind LLM for raptor
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.CHAT, kb_task_llm_id
|
||||
)
|
||||
with LLMBundle(task_tenant_id, chat_model_config, lang=ctx.language) as chat_model:
|
||||
|
||||
# Run RAPTOR
|
||||
raptor_service = RaptorService(ctx=ctx)
|
||||
|
||||
async with ctx.kg_limiter:
|
||||
chunks, token_count, raptor_cleanup_chunks = await raptor_service.run_raptor_for_kb(
|
||||
kb_parser_config=kb_parser_config,
|
||||
chat_mdl=chat_model,
|
||||
embd_mdl=embedding_model,
|
||||
vector_size=vector_size,
|
||||
doc_ids=ctx.doc_ids,
|
||||
)
|
||||
|
||||
ctx.recording_context.record("raptor_chunks", chunks)
|
||||
ctx.recording_context.record("raptor_token_count", token_count)
|
||||
|
||||
# Insert RAPTOR chunks
|
||||
if chunks:
|
||||
task_doc_id = (ctx.doc_ids or [GRAPH_RAPTOR_FAKE_DOC_ID])[0]
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
insert_result = await chunk_service.insert_chunks(ctx.id, task_tenant_id, task_dataset_id, chunks)
|
||||
if insert_result:
|
||||
ctx.recording_context.record("insertion_result", "success")
|
||||
else:
|
||||
ctx.recording_context.record("insertion_result", "failed")
|
||||
|
||||
# Cleanup stale RAPTOR chunks
|
||||
cleaned_chunks = 0
|
||||
for cleanup_doc_id, keep_method in raptor_cleanup_chunks:
|
||||
ret = await self._delete_raptor_chunks(
|
||||
cleanup_doc_id, task_tenant_id, task_dataset_id, keep_method
|
||||
)
|
||||
cleaned_chunks += ret
|
||||
|
||||
if cleaned_chunks:
|
||||
ctx.progress_cb(msg=f"Cleaned up {cleaned_chunks} stale RAPTOR chunks.")
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, len(chunks), 0)
|
||||
|
||||
ctx.recording_context.record("task_status", "completed")
|
||||
ctx.progress_cb(prog=1.0, msg="RAPTOR done")
|
||||
|
||||
async def _run_graphrag(
|
||||
self,
|
||||
embedding_model: LLMBundle
|
||||
) -> None:
|
||||
"""Run GraphRAG."""
|
||||
ctx = self._task_context
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
kb_task_llm_id = ctx.kb_parser_config.get("llm_id") or ctx.llm_id
|
||||
task_language = ctx.language
|
||||
|
||||
ok, kb = KnowledgebaseService.get_by_id(task_dataset_id)
|
||||
if not ok:
|
||||
ctx.progress_cb(prog=-1.0, msg="Cannot found valid dataset for GraphRAG task")
|
||||
return
|
||||
|
||||
kb_parser_config = kb.parser_config
|
||||
if not kb_parser_config.get("graphrag", {}).get("use_graphrag", False):
|
||||
kb_parser_config.update({
|
||||
"graphrag": {
|
||||
"use_graphrag": True,
|
||||
"entity_types": ["organization", "person", "geo", "event", "category"],
|
||||
"method": "light",
|
||||
}
|
||||
})
|
||||
if ctx.write_interceptor:
|
||||
update_result = ctx.write_interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
else:
|
||||
update_result = KnowledgebaseService.update_by_id(kb.id, {"parser_config": kb_parser_config})
|
||||
if not update_result:
|
||||
ctx.progress_cb(prog=-1.0, msg="Internal error: Invalid GraphRAG configuration")
|
||||
return
|
||||
|
||||
graphrag_conf = kb_parser_config.get("graphrag", {})
|
||||
start_ts = timer()
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
task_tenant_id, LLMType.CHAT, kb_task_llm_id
|
||||
)
|
||||
with LLMBundle(task_tenant_id, chat_model_config, lang=task_language) as chat_model:
|
||||
|
||||
with_resolution = graphrag_conf.get("resolution", False)
|
||||
with_community = graphrag_conf.get("community", False)
|
||||
|
||||
async with ctx.kg_limiter:
|
||||
result = await run_graphrag_for_kb(
|
||||
row=ctx.raw_task,
|
||||
doc_ids=ctx.doc_ids,
|
||||
language=task_language,
|
||||
kb_parser_config=kb_parser_config,
|
||||
chat_model=chat_model,
|
||||
embedding_model=embedding_model,
|
||||
callback=ctx.progress_cb,
|
||||
with_resolution=with_resolution,
|
||||
with_community=with_community,
|
||||
)
|
||||
logging.info(f"GraphRAG task result for task {ctx.raw_task}:\n{result}")
|
||||
|
||||
ctx.recording_context.record("graphrag_result", result)
|
||||
ctx.progress_cb(prog=1.0, msg="Knowledge Graph done ({:.2f}s)".format(timer() - start_ts))
|
||||
|
||||
async def _run_standard_chunking(
|
||||
self,
|
||||
embedding_model: LLMBundle
|
||||
) -> None:
|
||||
"""Run standard chunking pipeline."""
|
||||
ctx = self._task_context
|
||||
task_id = ctx.id
|
||||
task_tenant_id = ctx.tenant_id
|
||||
task_dataset_id = ctx.kb_id
|
||||
task_doc_id = ctx.doc_id
|
||||
task_start_ts = timer()
|
||||
doc_task_llm_id = ctx.parser_config.get("llm_id") or ctx.llm_id
|
||||
ctx.raw_task['llm_id'] = doc_task_llm_id
|
||||
|
||||
# Build chunks
|
||||
start_ts = timer()
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
|
||||
# Get storage binary
|
||||
bucket, name = File2DocumentService.get_storage_address(doc_id=ctx.doc_id)
|
||||
binary = await self._get_storage_binary(bucket, name)
|
||||
|
||||
chunks = await chunk_service.build_chunks(binary)
|
||||
ctx.recording_context.record("chunks", chunks)
|
||||
chunk_ids = [c.get("id") for c in chunks if isinstance(c, dict) and "id" in c]
|
||||
ctx.recording_context.record("chunk_ids_count", len(chunk_ids))
|
||||
|
||||
logging.info("Build document {}: {:.2f}s".format(ctx.name, timer() - start_ts))
|
||||
|
||||
if not chunks:
|
||||
ctx.progress_cb(1., msg=f"No chunk built from {ctx.name}")
|
||||
return
|
||||
|
||||
ctx.progress_cb(msg="Generate {} chunks".format(len(chunks)))
|
||||
|
||||
# Embed chunks
|
||||
start_ts = timer()
|
||||
embedding_service = EmbeddingService(ctx=ctx)
|
||||
try:
|
||||
token_count, vector_size = embedding_service.embed_chunks(
|
||||
chunks, embedding_model, ctx.parser_config
|
||||
)
|
||||
except TaskCanceledException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_message = "Generate embedding error:{}".format(str(e))
|
||||
ctx.progress_cb(-1, error_message)
|
||||
logging.exception(error_message)
|
||||
raise
|
||||
|
||||
ctx.recording_context.record("token_count", token_count)
|
||||
ctx.recording_context.record("vector_size", vector_size)
|
||||
progress_message = "Embedding chunks ({:.2f}s)".format(timer() - start_ts)
|
||||
logging.info(progress_message)
|
||||
ctx.progress_cb(msg=progress_message)
|
||||
|
||||
# Build TOC if needed
|
||||
toc_thread = None
|
||||
if ctx.parser_id.lower() == "naive" and ctx.parser_config.get("toc_extraction", False):
|
||||
toc_thread = asyncio.create_task(asyncio.to_thread(self._build_toc, ctx, chunks, ctx.progress_cb))
|
||||
|
||||
# Insert chunks
|
||||
chunk_count = len(set([chunk["id"] for chunk in chunks]))
|
||||
start_ts = timer()
|
||||
|
||||
chunk_service = ChunkService(ctx=ctx)
|
||||
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
insert_result = await chunk_service.insert_chunks(
|
||||
task_id, task_tenant_id, task_dataset_id, chunks
|
||||
)
|
||||
|
||||
if not insert_result:
|
||||
ctx.recording_context.record("insertion_result", "failed")
|
||||
return
|
||||
ctx.recording_context.record("insertion_result", "success")
|
||||
|
||||
# Post-processing
|
||||
post_processor = PostProcessor(ctx=ctx)
|
||||
await post_processor.process_table_parser_metadata(task_doc_id, chunks)
|
||||
|
||||
ctx.progress_cb(msg="Indexing done ({:.2f}s).".format(timer() - start_ts))
|
||||
|
||||
toc_chunk = await self._process_toc_thread(toc_thread)
|
||||
if toc_chunk:
|
||||
ctx.recording_context.record("toc_chunk", [toc_chunk])
|
||||
await post_processor.insert_toc_chunk(toc_chunk, chunk_service)
|
||||
|
||||
if ctx.has_canceled_func(task_id):
|
||||
ctx.progress_cb(-1, msg="Task has been canceled.")
|
||||
return
|
||||
|
||||
# Update document stats
|
||||
if ctx.write_interceptor:
|
||||
ctx.write_interceptor.intercept("DocumentService.increment_chunk_num")
|
||||
else:
|
||||
DocumentService.increment_chunk_num(task_doc_id, task_dataset_id, token_count, chunk_count, 0)
|
||||
|
||||
task_time_cost = timer() - task_start_ts
|
||||
ctx.recording_context.record("task_status", "completed")
|
||||
ctx.progress_cb(prog=1.0, msg="Task done ({:.2f}s)".format(task_time_cost))
|
||||
|
||||
logging.info(
|
||||
"Chunk doc({}), page({}-{}), chunks({}), token({}), elapsed:{:.2f}".format(
|
||||
ctx.name, ctx.from_page, ctx.to_page,
|
||||
len(chunks), token_count, task_time_cost
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _process_toc_thread(self, toc_thread):
|
||||
try:
|
||||
if toc_thread:
|
||||
return await toc_thread
|
||||
else:
|
||||
return None
|
||||
finally:
|
||||
if toc_thread is not None and not toc_thread.done():
|
||||
toc_thread.cancel()
|
||||
|
||||
@classmethod
|
||||
async def _get_storage_binary(cls, bucket: str, name: str) -> bytes:
|
||||
from common import settings
|
||||
"""Get binary from storage."""
|
||||
return await thread_pool_exec(settings.STORAGE_IMPL.get, bucket, name)
|
||||
|
||||
@classmethod
|
||||
def _build_toc(cls, ctx: TaskContext, docs: List[Dict], progress_cb: Callable) -> Optional[Dict]:
|
||||
"""Build table of contents."""
|
||||
progress_cb(msg="Start to generate table of content ...")
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
ctx.tenant_id, LLMType.CHAT, ctx.llm_id
|
||||
)
|
||||
with LLMBundle(ctx.tenant_id, chat_model_config, lang=ctx.language) as chat_mdl:
|
||||
|
||||
docs = sorted(docs, key=lambda d: (
|
||||
d.get("page_num_int", 0)[0] if isinstance(d.get("page_num_int", 0), list) else d.get("page_num_int", 0),
|
||||
d.get("top_int", 0)[0] if isinstance(d.get("top_int", 0), list) else d.get("top_int", 0)
|
||||
))
|
||||
|
||||
# NOTE: asyncio.run() creates a new event loop in the worker thread
|
||||
# (this method is called via asyncio.to_thread), which is the
|
||||
# intended pattern for bridging sync -> async in a thread context.
|
||||
toc: list[dict] = asyncio.run(
|
||||
run_toc_from_text([d["content_with_weight"] for d in docs], chat_mdl, progress_cb)
|
||||
)
|
||||
logging.info("------------ T O C -------------\n" + json.dumps(toc, ensure_ascii=False, indent=' '))
|
||||
|
||||
for ii, item in enumerate(toc):
|
||||
try:
|
||||
chunk_val = item.pop("chunk_id", None)
|
||||
if chunk_val is None or str(chunk_val).strip() == "":
|
||||
logging.warning(f"Index {ii}: chunk_id is missing or empty. Skipping.")
|
||||
continue
|
||||
curr_idx = int(chunk_val or -1)
|
||||
if curr_idx >= len(docs):
|
||||
logging.error(f"Index {ii}: chunk_id {curr_idx} exceeds docs length {len(docs)}.")
|
||||
continue
|
||||
item["ids"] = [docs[curr_idx]["id"]]
|
||||
if ii + 1 < len(toc):
|
||||
next_chunk_val = toc[ii + 1].get("chunk_id", "")
|
||||
if str(next_chunk_val).strip() != "":
|
||||
next_idx = int(next_chunk_val)
|
||||
for jj in range(curr_idx + 1, min(next_idx + 1, len(docs))):
|
||||
item["ids"].append(docs[jj]["id"])
|
||||
else:
|
||||
logging.warning(f"Index {ii + 1}: next chunk_id is empty, range fill skipped.")
|
||||
except (ValueError, TypeError) as e:
|
||||
logging.error(f"Index {ii}: Data conversion error - {e}")
|
||||
except Exception as e:
|
||||
logging.exception(f"Index {ii}: Unexpected error - {e}")
|
||||
|
||||
if toc:
|
||||
import copy
|
||||
d = copy.deepcopy(docs[-1])
|
||||
d["content_with_weight"] = json.dumps(toc, ensure_ascii=False)
|
||||
d["toc_kwd"] = "toc"
|
||||
d["available_int"] = 0
|
||||
d["page_num_int"] = [100000000]
|
||||
d["id"] = xxhash.xxh64(
|
||||
(d["content_with_weight"] + str(d["doc_id"])).encode("utf-8", "surrogatepass")).hexdigest()
|
||||
return d
|
||||
return None
|
||||
|
||||
async def _delete_raptor_chunks(
|
||||
self, doc_id: str, tenant_id: str, kb_id: str, keep_method: Optional[str]
|
||||
) -> int:
|
||||
"""Delete RAPTOR chunks."""
|
||||
if self._task_context.write_interceptor:
|
||||
return self._task_context.write_interceptor.intercept("delete_raptor_chunks")
|
||||
else:
|
||||
return await delete_raptor_chunks(doc_id, tenant_id, kb_id, keep_method)
|
||||
177
rag/svr/task_executor_refactor/task_manager.py
Normal file
177
rag/svr/task_executor_refactor/task_manager.py
Normal file
@@ -0,0 +1,177 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Task Manager Module.
|
||||
|
||||
Provides [`TaskManager`](rag/svr/task_executor_refactor/task_manager.py:50) as the entry point
|
||||
for executing document processing tasks, supporting both production and dry-run (comparison) modes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from rag.svr.task_executor_refactor.comparator import ContextComparator
|
||||
from rag.svr.task_executor_refactor.task_context import TaskCallbacks, TaskDict, TaskLimiters
|
||||
from rag.svr.task_executor_refactor.dataflow_service import BillingHook
|
||||
from rag.svr.task_executor_refactor.recording_context import (
|
||||
BaseRecordingContext,
|
||||
RecordingContext,
|
||||
_NULL_RECORDING_CONTEXT,
|
||||
set_recording_context, recording_context_manager,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext
|
||||
from rag.svr.task_executor_refactor.task_handler import TaskHandler
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import (
|
||||
WriteOperationInterceptor,
|
||||
)
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Entry point for executing document processing tasks.
|
||||
|
||||
This class provides methods for:
|
||||
- Production task execution (run_refactored_task)
|
||||
- Dry-run task execution with comparison (dry_run_task)
|
||||
|
||||
Usage:
|
||||
manager = TaskManager()
|
||||
await manager.run_refactored_task(task, chat_limiter, ...)
|
||||
# or
|
||||
await manager.dry_run_task(task, recording_ctx1, ...)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def run_refactored_task(
|
||||
cls,
|
||||
task: dict,
|
||||
chat_limiter: Any,
|
||||
minio_limiter: Any,
|
||||
chunk_limiter: Any,
|
||||
embed_limiter: Any,
|
||||
kg_limiter: Any,
|
||||
set_progress: Any,
|
||||
has_canceled: Any,
|
||||
billing_hook: Optional[BillingHook] = None,
|
||||
) -> None:
|
||||
"""Run a document processing task in production mode.
|
||||
|
||||
Args:
|
||||
task: Task configuration dictionary.
|
||||
chat_limiter: Rate limiter for chat operations.
|
||||
minio_limiter: Rate limiter for MinIO operations.
|
||||
chunk_limiter: Rate limiter for chunking operations.
|
||||
embed_limiter: Rate limiter for embedding operations.
|
||||
kg_limiter: Rate limiter for knowledge graph operations.
|
||||
set_progress: Progress callback function.
|
||||
has_canceled: Function to check if task is canceled.
|
||||
billing_hook: Optional billing hook for pipeline success/error callbacks.
|
||||
"""
|
||||
with recording_context_manager(_NULL_RECORDING_CONTEXT):
|
||||
# Use NullRecordingContext in production to avoid memory allocation
|
||||
set_recording_context(_NULL_RECORDING_CONTEXT)
|
||||
|
||||
# Create TaskContext with all execution resources
|
||||
task_context = TaskContext(
|
||||
task=task,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=set_progress,
|
||||
has_canceled=has_canceled,
|
||||
),
|
||||
recording_context=_NULL_RECORDING_CONTEXT,
|
||||
)
|
||||
|
||||
# Execute with TaskHandler
|
||||
handler = TaskHandler(ctx=task_context, billing_hook=billing_hook)
|
||||
await handler.handle_task()
|
||||
|
||||
@classmethod
|
||||
async def dry_run_task(
|
||||
cls,
|
||||
task: TaskDict,
|
||||
recording_ctx1: BaseRecordingContext,
|
||||
chat_limiter: Any,
|
||||
minio_limiter: Any,
|
||||
chunk_limiter: Any,
|
||||
embed_limiter: Any,
|
||||
kg_limiter: Any,
|
||||
set_progress: Any,
|
||||
has_canceled: Any,
|
||||
) -> None:
|
||||
"""Run a document processing task in dry-run mode for comparison.
|
||||
|
||||
This executes the task with a write operation interceptor that records
|
||||
all write operations, then compares the results with the production run.
|
||||
|
||||
Args:
|
||||
task: Task configuration dictionary.
|
||||
recording_ctx1: RecordingContext from production execution.
|
||||
chat_limiter: Rate limiter for chat operations.
|
||||
minio_limiter: Rate limiter for MinIO operations.
|
||||
chunk_limiter: Rate limiter for chunking operations.
|
||||
embed_limiter: Rate limiter for embedding operations.
|
||||
kg_limiter: Rate limiter for knowledge graph operations.
|
||||
set_progress: Progress callback function.
|
||||
has_canceled: Function to check if task is canceled.
|
||||
"""
|
||||
interceptor = WriteOperationInterceptor(recording_ctx1.get_all_func_return_values())
|
||||
recording_ctx2 = RecordingContext()
|
||||
|
||||
with recording_context_manager(recording_ctx2):
|
||||
set_recording_context(recording_ctx2)
|
||||
|
||||
# Create TaskContext with all execution resources
|
||||
task_context = TaskContext(
|
||||
task=task,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=set_progress,
|
||||
has_canceled=has_canceled,
|
||||
),
|
||||
write_interceptor=interceptor,
|
||||
recording_context=recording_ctx2,
|
||||
)
|
||||
|
||||
# Execute with TaskHandler
|
||||
handler = TaskHandler(ctx=task_context)
|
||||
await handler.handle_task()
|
||||
|
||||
# Compare results
|
||||
comp: ContextComparator = ContextComparator()
|
||||
comp_result = comp.compare(task_context.id, recording_ctx1, recording_ctx2)
|
||||
logging.info(f"-------{task_context.name}, compare result:{comp_result.to_markdown()}")
|
||||
if interceptor.remaining_values_count() > 0 or comp_result.mismatched_keys > 0:
|
||||
logging.info(f"------task:{task_context.id} {task_context.name} differs, "
|
||||
f"interceptor.remaining_values_count():{interceptor.remaining_values_count()}, "
|
||||
f"mismatched_keys:{comp_result.mismatched_keys}")
|
||||
if interceptor.remaining_values_count() > 0:
|
||||
logging.info(f"------task:{task_context.id}, remaining values:{interceptor.remaining_values()}")
|
||||
if comp_result.mismatched_keys > 0:
|
||||
logging.info(f"-------compare result:{comp_result.details}")
|
||||
else:
|
||||
logging.info(f"------task:{task_context.id} {task_context.name} same result for prod and dry run ")
|
||||
138
rag/svr/task_executor_refactor/write_operation_interceptor.py
Normal file
138
rag/svr/task_executor_refactor/write_operation_interceptor.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Write Operation Interceptor Module
|
||||
|
||||
Provides a mechanism to intercept write operations during comparison mode.
|
||||
The interceptor consumes pre-recorded return values (from production execution)
|
||||
and returns them one by one when the corresponding methods are called.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Set of allowed method names that can be intercepted
|
||||
ALLOWED_METHOD_NAMES = {
|
||||
"KnowledgebaseService.update_by_id",
|
||||
"TaskService.update_chunk_ids",
|
||||
"DocumentService.increment_chunk_num",
|
||||
"DocMetadataService.update_document_metadata",
|
||||
"PipelineOperationLogService.record_pipeline_operation",
|
||||
"PipelineOperationLogService.create",
|
||||
"delete_raptor_chunks",
|
||||
"handle_save_to_memory_task",
|
||||
"docStoreConn.insert",
|
||||
"docStoreConn.delete"
|
||||
}
|
||||
|
||||
_NO_DEFAULT = object()
|
||||
|
||||
|
||||
class WriteOperationInterceptor:
|
||||
"""Intercepts write operations and returns pre-recorded values.
|
||||
|
||||
This interceptor is used in comparison mode to replay production execution
|
||||
results. When a method is called, the interceptor pops the first recorded
|
||||
return value from the corresponding list and returns it.
|
||||
|
||||
Usage:
|
||||
# Create interceptor with pre-recorded values
|
||||
interceptor = WriteOperationInterceptor({
|
||||
"build_chunks": [chunks1, chunks2],
|
||||
"embedding": [(token_count1, vector_size1)],
|
||||
...
|
||||
})
|
||||
|
||||
# Intercept a method call
|
||||
result = interceptor.intercept("build_chunks") # Returns chunks1
|
||||
result = interceptor.intercept("build_chunks") # Returns chunks2
|
||||
"""
|
||||
|
||||
def __init__(self, recorded_values: Dict[str, List[Any]]):
|
||||
"""Initialize the interceptor with pre-recorded values.
|
||||
|
||||
Args:
|
||||
recorded_values: A dictionary where keys are method names and
|
||||
values are lists of pre-recorded return values. Each call
|
||||
to intercept() will pop and return the first value from
|
||||
the corresponding list.
|
||||
|
||||
Note:
|
||||
If a key from ALLOWED_METHOD_NAMES is not in recorded_values,
|
||||
it will be initialized with an empty list. This allows the
|
||||
interceptor to be created even if not all methods have recorded
|
||||
values, and it will fall through to original execution when
|
||||
no recorded values are available.
|
||||
"""
|
||||
self._recorded_values: Dict[str, List[Any]] = dict()
|
||||
for key in ALLOWED_METHOD_NAMES:
|
||||
self._recorded_values[key] = list(recorded_values.get(key, []))
|
||||
|
||||
def intercept(self, method_name: str, default_value = _NO_DEFAULT) -> Any:
|
||||
"""Intercept a method call and return the next pre-recorded value.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method being intercepted.
|
||||
default_value: default value
|
||||
|
||||
Returns:
|
||||
The next pre-recorded return value for this method.
|
||||
|
||||
Raises:
|
||||
ValueError: If method_name is not in the allowed method names set.
|
||||
KeyError: If method_name has no recorded values list.
|
||||
IndexError: If the recorded values list for method_name is empty.
|
||||
"""
|
||||
if method_name not in ALLOWED_METHOD_NAMES:
|
||||
raise ValueError(
|
||||
f"Cannot intercept method '{method_name}'. "
|
||||
f"Allowed method names: {ALLOWED_METHOD_NAMES}"
|
||||
)
|
||||
|
||||
if method_name not in self._recorded_values:
|
||||
raise KeyError(f"No recorded values found for method '{method_name}'")
|
||||
|
||||
values_list = self._recorded_values[method_name]
|
||||
if not values_list:
|
||||
if default_value is not _NO_DEFAULT:
|
||||
logging.info(f"return default value for {method_name}")
|
||||
return default_value
|
||||
raise IndexError(f"No more recorded values for method '{method_name}'")
|
||||
|
||||
return values_list.pop(0)
|
||||
|
||||
|
||||
def remaining_count(self, method_name: str) -> int:
|
||||
"""Get the number of remaining recorded values for a method.
|
||||
|
||||
Args:
|
||||
method_name: Name of the method to check.
|
||||
|
||||
Returns:
|
||||
Number of remaining recorded values.
|
||||
"""
|
||||
if method_name not in self._recorded_values:
|
||||
return 0
|
||||
return len(self._recorded_values[method_name])
|
||||
|
||||
|
||||
def remaining_values(self):
|
||||
return {k: list(v) for k, v in self._recorded_values.items()}
|
||||
|
||||
def remaining_values_count(self):
|
||||
return sum(len(values) for values in self._recorded_values.values())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WriteOperationInterceptor(total_recorded={self._recorded_values})"
|
||||
@@ -33,7 +33,7 @@ test_image = base64.b64decode(test_image_base64)
|
||||
async def image2id(d: dict, storage_put_func: partial, objname: str, bucket: str = "imagetemps"):
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from rag.svr.task_executor import minio_limiter
|
||||
from rag.svr.task_executor_limiter import minio_limiter
|
||||
|
||||
if "image" not in d:
|
||||
return
|
||||
|
||||
@@ -62,16 +62,28 @@ def _as_extra_dict(extra) -> dict:
|
||||
if isinstance(extra, dict):
|
||||
return extra
|
||||
if isinstance(extra, str) and extra:
|
||||
# Try standard JSON first (double quotes)
|
||||
try:
|
||||
parsed = json.loads(extra)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.warning(
|
||||
"Ignoring malformed RAPTOR extra payload while collecting chunk metadata: %s",
|
||||
extra[:200],
|
||||
exc_info=True,
|
||||
)
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
last_exc = True
|
||||
|
||||
# Fallback: try parsing Python dict literal (single quotes)
|
||||
try:
|
||||
import ast
|
||||
parsed = ast.literal_eval(extra)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (ValueError, SyntaxError):
|
||||
last_exc = True
|
||||
|
||||
logging.warning(
|
||||
"Ignoring malformed RAPTOR extra payload while collecting chunk metadata: %s",
|
||||
extra[:200],
|
||||
exc_info=last_exc,
|
||||
)
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
208
test/unit_test/common/test_settings_queue.py
Normal file
208
test/unit_test/common/test_settings_queue.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
"""Test cases for get_svr_queue_name and get_svr_queue_names functions in common.settings."""
|
||||
|
||||
from common.settings import get_svr_queue_name, get_svr_queue_names
|
||||
|
||||
|
||||
class TestGetSvrQueueName:
|
||||
"""Test cases for get_svr_queue_name function."""
|
||||
|
||||
def test_default_suffix(self):
|
||||
"""Test that default suffix is 'common'."""
|
||||
|
||||
result = get_svr_queue_name(0)
|
||||
assert result == "te.0.common"
|
||||
|
||||
def test_priority_zero(self):
|
||||
"""Test queue name with priority 0 (low)."""
|
||||
|
||||
result = get_svr_queue_name(0)
|
||||
assert result == "te.0.common"
|
||||
|
||||
def test_priority_one(self):
|
||||
"""Test queue name with priority 1 (high)."""
|
||||
|
||||
result = get_svr_queue_name(1)
|
||||
assert result == "te.1.common"
|
||||
|
||||
def test_explicit_suffix_common(self):
|
||||
"""Test with explicit 'common' suffix."""
|
||||
|
||||
result = get_svr_queue_name(0, "common")
|
||||
assert result == "te.0.common"
|
||||
|
||||
def test_suffix_parameter_ignored(self):
|
||||
"""Test that suffix parameter is currently ignored (hardcoded to 'common').
|
||||
|
||||
Note: The function signature accepts a suffix parameter but currently
|
||||
hardcodes 'common' in the return value. This test documents this behavior.
|
||||
"""
|
||||
|
||||
# Even with different suffix values, result should be the same
|
||||
result_default = get_svr_queue_name(0, "common")
|
||||
result_resume = get_svr_queue_name(0, "resume")
|
||||
result_graphrag = get_svr_queue_name(0, "graphrag")
|
||||
|
||||
# All should return the same value since suffix is hardcoded
|
||||
assert result_default == result_resume == result_graphrag == "te.0.common"
|
||||
|
||||
def test_format_structure(self):
|
||||
"""Test that queue name follows expected format: {SVR_QUEUE_NAME}.{priority}.common."""
|
||||
|
||||
for priority in [0, 1]:
|
||||
result = get_svr_queue_name(priority)
|
||||
parts = result.split(".")
|
||||
assert len(parts) == 3
|
||||
assert parts[0] == "te" # SVR_QUEUE_NAME
|
||||
assert parts[1] == str(priority)
|
||||
assert parts[2] == "common"
|
||||
|
||||
def test_different_priorities_produce_different_results(self):
|
||||
"""Test that different priorities produce different queue names."""
|
||||
|
||||
result_0 = get_svr_queue_name(0)
|
||||
result_1 = get_svr_queue_name(1)
|
||||
|
||||
assert result_0 != result_1
|
||||
assert result_0 == "te.0.common"
|
||||
assert result_1 == "te.1.common"
|
||||
|
||||
def test_with_various_priority_values(self):
|
||||
"""Test with various priority values beyond 0 and 1."""
|
||||
|
||||
# Test with other priority values to ensure format is correct
|
||||
for priority in [2, 5, 10, 100]:
|
||||
result = get_svr_queue_name(priority)
|
||||
expected = f"te.{priority}.common"
|
||||
assert result == expected
|
||||
|
||||
def test_returns_string_type(self):
|
||||
"""Test that function returns a string."""
|
||||
|
||||
result = get_svr_queue_name(0)
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_no_whitespace_issues(self):
|
||||
"""Test that queue name has no unexpected whitespace."""
|
||||
|
||||
for priority in [0, 1]:
|
||||
result = get_svr_queue_name(priority)
|
||||
assert " " not in result
|
||||
assert "\t" not in result
|
||||
assert "\n" not in result
|
||||
|
||||
|
||||
class TestGetSvrQueueNames:
|
||||
"""Test cases for get_svr_queue_names function."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Test that function returns a list."""
|
||||
|
||||
result = get_svr_queue_names("common")
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_two_queues(self):
|
||||
"""Test that function returns exactly two queue names."""
|
||||
|
||||
result = get_svr_queue_names("common")
|
||||
assert len(result) == 2
|
||||
|
||||
def test_sorted_high_to_low(self):
|
||||
"""Test that queue names are sorted from high priority to low priority."""
|
||||
|
||||
result = get_svr_queue_names("common")
|
||||
assert result[0] == "te.1.common" # High priority first
|
||||
assert result[1] == "te.0.common" # Low priority second
|
||||
|
||||
def test_expected_values(self):
|
||||
"""Test that returned values match expected queue names."""
|
||||
|
||||
result = get_svr_queue_names("common")
|
||||
expected = ["te.1.common", "te.0.common"]
|
||||
assert result == expected
|
||||
|
||||
def test_suffix_parameter_passed_through(self):
|
||||
"""Test that suffix parameter is passed to get_svr_queue_name.
|
||||
|
||||
Note: Since get_svr_queue_name currently hardcodes 'common' as the suffix,
|
||||
different suffix values will still produce the same result.
|
||||
"""
|
||||
|
||||
# All suffixes should produce same result due to hardcoded suffix in get_svr_queue_name
|
||||
result_common = get_svr_queue_names("common")
|
||||
result_resume = get_svr_queue_names("resume")
|
||||
result_graphrag = get_svr_queue_names("graphrag")
|
||||
|
||||
expected = ["te.1.common", "te.0.common"]
|
||||
assert result_common == expected
|
||||
assert result_resume == expected # suffix is currently ignored
|
||||
assert result_graphrag == expected # suffix is currently ignored
|
||||
|
||||
def test_all_elements_are_strings(self):
|
||||
"""Test that all elements in the returned list are strings."""
|
||||
|
||||
result = get_svr_queue_names("common")
|
||||
for item in result:
|
||||
assert isinstance(item, str)
|
||||
|
||||
def test_consistent_results(self):
|
||||
"""Test that multiple calls return consistent results."""
|
||||
|
||||
result1 = get_svr_queue_names("common")
|
||||
result2 = get_svr_queue_names("common")
|
||||
result3 = get_svr_queue_names("common")
|
||||
|
||||
assert result1 == result2 == result3
|
||||
|
||||
def test_with_empty_suffix(self):
|
||||
"""Test with empty string suffix."""
|
||||
|
||||
result = get_svr_queue_names("")
|
||||
# Should still work since suffix is ignored
|
||||
assert result == ["te.1.common", "te.0.common"]
|
||||
|
||||
|
||||
class TestGetSvrQueueNameWithMockedConstant:
|
||||
"""Test cases with mocked SVR_QUEUE_NAME constant."""
|
||||
|
||||
def test_with_custom_queue_name(self):
|
||||
"""Test with a custom SVR_QUEUE_NAME constant."""
|
||||
# Need to patch where the constant is imported in settings module
|
||||
import common.settings as settings_mod
|
||||
|
||||
original_value = settings_mod.SVR_QUEUE_NAME
|
||||
try:
|
||||
settings_mod.SVR_QUEUE_NAME = "custom_queue"
|
||||
result = settings_mod.get_svr_queue_name(0)
|
||||
assert result == "custom_queue.0.common"
|
||||
|
||||
result = settings_mod.get_svr_queue_name(1)
|
||||
assert result == "custom_queue.1.common"
|
||||
finally:
|
||||
settings_mod.SVR_QUEUE_NAME = original_value
|
||||
|
||||
def test_with_custom_queue_names(self):
|
||||
"""Test get_svr_queue_names with a custom SVR_QUEUE_NAME constant."""
|
||||
import common.settings as settings_mod
|
||||
|
||||
original_value = settings_mod.SVR_QUEUE_NAME
|
||||
try:
|
||||
settings_mod.SVR_QUEUE_NAME = "custom_queue"
|
||||
result = settings_mod.get_svr_queue_names("common")
|
||||
assert result == ["custom_queue.1.common", "custom_queue.0.common"]
|
||||
finally:
|
||||
settings_mod.SVR_QUEUE_NAME = original_value
|
||||
494
test/unit_test/rag/svr/task_executor_refactor/conftest.py
Normal file
494
test/unit_test/rag/svr/task_executor_refactor/conftest.py
Normal file
@@ -0,0 +1,494 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Shared pytest fixtures for task_executor_refactor integration tests.
|
||||
|
||||
This module provides reusable fixtures for integration tests that verify
|
||||
the complete orchestration flow of TaskHandler and its collaborating services.
|
||||
|
||||
Design principles:
|
||||
- Mock external system boundaries (LLM, ES, MinIO, MySQL)
|
||||
- Use real TaskContext, TaskHandler, and service instances
|
||||
- Verify RecordingContext for data flow assertions
|
||||
"""
|
||||
# =============================================================================
|
||||
# TensorFlow/UMAP Import Workaround
|
||||
# =============================================================================
|
||||
# Mock umap.parametric_umap before any other imports to prevent TensorFlow
|
||||
# dependency errors during test collection. This allows tests to run without
|
||||
# requiring TensorFlow to be installed.
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create a mock module for parametric_umap to satisfy umap's import check
|
||||
_mock_parametric_umap = MagicMock()
|
||||
sys.modules.setdefault("umap.parametric_umap", _mock_parametric_umap)
|
||||
sys.modules.setdefault("umap", MagicMock())
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext, TaskLimiters, TaskCallbacks
|
||||
from rag.svr.task_executor_refactor.recording_context import (
|
||||
RecordingContext,
|
||||
set_recording_context,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Async Limiter Fixtures
|
||||
# =============================================================================
|
||||
|
||||
class AsyncMockLimiter:
|
||||
"""Mock asyncio semaphore that does not actually limit."""
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_limiter():
|
||||
"""Provide a no-op async limiter."""
|
||||
return asyncio.Semaphore(5)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Dictionary Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def standard_task_dict() -> Dict[str, Any]:
|
||||
"""Provide a minimal but complete task dict for standard chunking."""
|
||||
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.fixture
|
||||
def dataflow_task_dict() -> Dict[str, Any]:
|
||||
"""Provide a task dict for dataflow tasks."""
|
||||
task = standard_task_dict()
|
||||
task["task_type"] = "dataflow"
|
||||
task["dataflow_id"] = "dataflow_test"
|
||||
return task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raptor_task_dict() -> Dict[str, Any]:
|
||||
"""Provide a task dict for RAPTOR tasks."""
|
||||
task = standard_task_dict()
|
||||
task["task_type"] = "raptor"
|
||||
task["doc_ids"] = ["doc_1", "doc_2"]
|
||||
return task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphrag_task_dict() -> Dict[str, Any]:
|
||||
"""Provide a task dict for GraphRAG tasks."""
|
||||
task = standard_task_dict()
|
||||
task["task_type"] = "graphrag"
|
||||
task["doc_ids"] = ["doc_1"]
|
||||
return task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_task_dict() -> Dict[str, Any]:
|
||||
"""Provide a task dict for memory tasks."""
|
||||
return {
|
||||
"id": f"task_{uuid.uuid4().hex[:8]}",
|
||||
"task_type": "memory",
|
||||
"memory_id": "mem_test",
|
||||
"source_id": "src_test",
|
||||
"message_dict": {"role": "user", "content": "test"},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TaskContext Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def task_context(standard_task_dict, mock_limiter, recording_context):
|
||||
"""Provide a real TaskContext instance with mocked limiters."""
|
||||
ctx = TaskContext(
|
||||
task=standard_task_dict,
|
||||
limiters=TaskLimiters(
|
||||
chat=mock_limiter,
|
||||
minio=mock_limiter,
|
||||
chunk=mock_limiter,
|
||||
embed=mock_limiter,
|
||||
kg=mock_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=MagicMock(),
|
||||
has_canceled=MagicMock(return_value=False),
|
||||
),
|
||||
recording_context=recording_context,
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canceled_task_context(standard_task_dict, mock_limiter, recording_context):
|
||||
"""Provide a TaskContext where the task is already canceled."""
|
||||
ctx = TaskContext(
|
||||
task=standard_task_dict,
|
||||
limiters=TaskLimiters(
|
||||
chat=mock_limiter,
|
||||
minio=mock_limiter,
|
||||
chunk=mock_limiter,
|
||||
embed=mock_limiter,
|
||||
kg=mock_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=MagicMock(),
|
||||
has_canceled=MagicMock(return_value=True),
|
||||
),
|
||||
recording_context=recording_context,
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RecordingContext Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def recording_context():
|
||||
"""Provide a fresh RecordingContext for each test.
|
||||
|
||||
This fixture is autouse=True to ensure every test has a clean
|
||||
recording context for assertions.
|
||||
"""
|
||||
ctx = RecordingContext()
|
||||
set_recording_context(ctx)
|
||||
yield ctx
|
||||
# Cleanup: reset the global context after test
|
||||
set_recording_context(RecordingContext())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_resources(request):
|
||||
"""Global resource cleanup fixture.
|
||||
|
||||
Runs after each test to clean up:
|
||||
- Unclosed event loops
|
||||
- Unclosed sockets (via garbage collection)
|
||||
- Unawaited coroutines
|
||||
- MagicMock objects that may hold unclosed resources
|
||||
|
||||
This prevents ResourceWarning and RuntimeWarning from failing
|
||||
tests when filterwarnings is set to "error".
|
||||
|
||||
Optimization: Uses minimal gc cycles and generation-2 collection
|
||||
for faster teardown.
|
||||
"""
|
||||
yield
|
||||
import warnings
|
||||
|
||||
# Suppress warnings during cleanup to avoid recursive warning issues
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
|
||||
# Close any unclosed event loops
|
||||
try:
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.get_event_loop()
|
||||
if not loop.is_closed():
|
||||
loop.close()
|
||||
except RuntimeError:
|
||||
# No event loop exists, which is fine
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# External System Mocks (Boundary Mocks)
|
||||
# =============================================================================
|
||||
|
||||
class MockEmbeddingModel:
|
||||
"""Mock embedding model that returns deterministic vectors."""
|
||||
|
||||
def __init__(self, vector_size: int = 128):
|
||||
self.vector_size = vector_size
|
||||
self.max_length = 512
|
||||
self.llm_name = "mock_embedding"
|
||||
|
||||
def encode(self, texts: List[str]):
|
||||
"""Return random vectors for the given texts."""
|
||||
vectors = np.random.rand(len(texts), self.vector_size).astype(np.float32)
|
||||
token_count = sum(len(t.split()) for t in texts)
|
||||
return vectors, token_count
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class MockChatModel:
|
||||
"""Mock chat model that returns canned responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.llm_name = "mock_chat"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_model():
|
||||
"""Provide a mock embedding model."""
|
||||
return MockEmbeddingModel(vector_size=128)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_model():
|
||||
"""Provide a mock chat model."""
|
||||
return MockChatModel()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Patching Helpers
|
||||
# =============================================================================
|
||||
|
||||
def create_patch_embedding_model(vectors=None, vector_size=128):
|
||||
"""Create a patcher for the embedding model binding.
|
||||
|
||||
This patches the entire _bind_embedding_model flow to return a mock model.
|
||||
"""
|
||||
if vectors is None:
|
||||
vectors = np.random.rand(1, vector_size).astype(np.float32)
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.encode.return_value = (vectors, 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)
|
||||
|
||||
return patch(
|
||||
"rag.svr.task_executor_refactor.task_handler.get_model_config_by_type_and_name",
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
def create_patch_docstore_insert():
|
||||
"""Create a patcher for docStoreConn.insert that always succeeds."""
|
||||
return patch(
|
||||
"common.settings.docStoreConn",
|
||||
new_callable=MagicMock,
|
||||
)
|
||||
|
||||
|
||||
def create_patch_storage_binary(binary_data=b"fake pdf content"):
|
||||
"""Create a patcher for storage retrieval."""
|
||||
mock_async = AsyncMock(return_value=binary_data)
|
||||
return patch(
|
||||
"rag.svr.task_executor_refactor.task_handler.File2DocumentService.get_storage_address",
|
||||
return_value=("bucket_test", "name_test"),
|
||||
), patch(
|
||||
"rag.svr.task_executor_refactor.task_handler.thread_pool_exec",
|
||||
new_callable=MagicMock,
|
||||
return_value=mock_async,
|
||||
)
|
||||
|
||||
|
||||
def create_patch_parser_chunking(chunks=None):
|
||||
"""Create a patcher for the parser chunking to return predefined chunks.
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dicts to return from the parser.
|
||||
If None, returns a default single chunk.
|
||||
"""
|
||||
if chunks is None:
|
||||
chunks = [{
|
||||
"content_with_weight": "This is a test chunk content.",
|
||||
"page_num_int": [0],
|
||||
"top_int": [0],
|
||||
"position_int": [0, 0, 0, 0],
|
||||
}]
|
||||
|
||||
mock_async = AsyncMock(return_value=chunks)
|
||||
return patch(
|
||||
"rag.svr.task_executor_refactor.chunk_service.thread_pool_exec",
|
||||
new_callable=MagicMock,
|
||||
return_value=mock_async,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared Helper Functions for Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_mock_embedding_model(vector_size: int = 128):
|
||||
"""Create a mock embedding model that returns deterministic vectors matching input size."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
def mock_encode(texts):
|
||||
n = len(texts) if isinstance(texts, list) else 1
|
||||
return (
|
||||
np.random.rand(n, vector_size).astype(np.float32),
|
||||
10 * n,
|
||||
)
|
||||
|
||||
mock_model.encode = mock_encode
|
||||
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)
|
||||
return mock_model
|
||||
|
||||
|
||||
def create_mock_chat_model():
|
||||
"""Create a mock chat model."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.llm_name = "mock_chat"
|
||||
mock_model.__enter__ = MagicMock(return_value=mock_model)
|
||||
mock_model.__exit__ = MagicMock(return_value=False)
|
||||
return mock_model
|
||||
|
||||
|
||||
def create_mock_settings():
|
||||
"""Create a mock settings object with STORAGE_IMPL and docStoreConn."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.STORAGE_IMPL = MagicMock()
|
||||
mock_settings.STORAGE_IMPL.get = MagicMock(return_value=b"fake binary content")
|
||||
mock_settings.docStoreConn = MagicMock()
|
||||
mock_settings.docStoreConn.create_idx = MagicMock(return_value=None)
|
||||
mock_settings.docStoreConn.insert = MagicMock(return_value=None)
|
||||
mock_settings.docStoreConn.delete = MagicMock(return_value=None)
|
||||
mock_settings.docStoreConn.index_exist = MagicMock(return_value=True)
|
||||
mock_settings.docStoreConn.search = MagicMock(return_value={"hits": []})
|
||||
mock_settings.DOC_MAXIMUM_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
mock_settings.DOC_BULK_SIZE = 100
|
||||
mock_settings.retriever = MagicMock()
|
||||
return mock_settings
|
||||
|
||||
|
||||
def create_default_chunks(count: int = 2) -> List[Dict[str, Any]]:
|
||||
"""Create default chunk dictionaries for testing."""
|
||||
chunks = []
|
||||
for i in range(count):
|
||||
chunks.append({
|
||||
"id": f"chunk_{i}_{uuid.uuid4().hex[:6]}",
|
||||
"content_with_weight": f"This is test chunk content number {i}.",
|
||||
"page_num_int": [i],
|
||||
"top_int": [i * 100],
|
||||
"position_int": [i, 0, i + 1, 0],
|
||||
"doc_id": "doc_test",
|
||||
"kb_id": "kb_test",
|
||||
"docnm_kwd": "test_document.pdf",
|
||||
})
|
||||
return chunks
|
||||
|
||||
|
||||
def create_mock_chunk_service(chunks=None):
|
||||
"""Create a mock ChunkService instance."""
|
||||
if chunks is None:
|
||||
chunks = create_default_chunks(count=3)
|
||||
mock_service = MagicMock()
|
||||
mock_service.build_chunks = AsyncMock(return_value=chunks)
|
||||
mock_service.insert_chunks = AsyncMock(return_value=True)
|
||||
return mock_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_model_factory():
|
||||
"""Provide a factory for mock embedding models."""
|
||||
return create_mock_embedding_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_model_factory():
|
||||
"""Provide a factory for mock chat models."""
|
||||
return create_mock_chat_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_factory():
|
||||
"""Provide a factory for mock settings."""
|
||||
return create_mock_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chunk_service_factory():
|
||||
"""Provide a factory for mock chunk services."""
|
||||
return create_mock_chunk_service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RaptorService Fixtures
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_raptor_context():
|
||||
"""Provide a mock TaskContext for RaptorService tests."""
|
||||
return create_mock_raptor_context()
|
||||
@@ -0,0 +1,219 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for ChunkBuilder module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rag.svr.task_executor_refactor.chunk_builder import (
|
||||
get_parser,
|
||||
run_chunking,
|
||||
extract_outline,
|
||||
)
|
||||
|
||||
|
||||
class TestGetParser:
|
||||
"""Tests for get_parser function."""
|
||||
|
||||
@pytest.mark.parametrize("parser_id", [
|
||||
"naive", "general", "table", "paper", "book",
|
||||
"picture", "audio", "email", "presentation", "manual",
|
||||
"laws", "qa", "resume", "one", "tag",
|
||||
])
|
||||
def test_get_parser_returns_non_none(self, parser_id):
|
||||
"""Test that get_parser returns non-None for all parser types."""
|
||||
parser = get_parser(parser_id)
|
||||
assert parser is not None
|
||||
|
||||
def test_get_parser_kg(self):
|
||||
"""Test getting kg parser (maps to naive)."""
|
||||
from common.constants import ParserType
|
||||
parser = get_parser(ParserType.KG.value)
|
||||
assert parser is not None
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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.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:
|
||||
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)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_chunking_exception(self):
|
||||
"""Test chunking handles exception."""
|
||||
ctx = self._create_mock_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()
|
||||
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()
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
@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
|
||||
|
||||
await extract_outline([], ctx)
|
||||
|
||||
mock_rec_ctx.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()
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_outline_persistence_exception(self):
|
||||
"""Test outline extraction handles persistence exception."""
|
||||
ctx = self._create_mock_context()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,460 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for ChunkPostProcessor module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rag.svr.task_executor_refactor.chunk_post_processor import (
|
||||
extract_keywords,
|
||||
generate_questions,
|
||||
generate_metadata,
|
||||
apply_tags,
|
||||
count_with_key,
|
||||
build_metadata_config,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractKeywords:
|
||||
"""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()
|
||||
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_by_type_and_name") 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]
|
||||
|
||||
@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)
|
||||
docs = [{"content_with_weight": "This is test content"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_keywords_empty_docs(self):
|
||||
"""Test keyword extraction with empty docs list."""
|
||||
ctx = self._create_mock_context()
|
||||
docs = []
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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()
|
||||
|
||||
|
||||
class TestGenerateQuestions:
|
||||
"""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"},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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]
|
||||
|
||||
@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)
|
||||
docs = [{"content_with_weight": "This is test content"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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]
|
||||
|
||||
|
||||
class TestGenerateMetadata:
|
||||
"""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"}},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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()
|
||||
|
||||
@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"}},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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"
|
||||
)
|
||||
|
||||
|
||||
class TestApplyTags:
|
||||
"""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"},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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
|
||||
|
||||
@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"},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_post_processor.get_model_config_by_type_and_name") 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}
|
||||
|
||||
await apply_tags(docs, ctx)
|
||||
|
||||
# Should return early due to cancellation
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def test_count_with_key_empty_docs(self):
|
||||
"""Test counting with empty docs list."""
|
||||
result = count_with_key([], "tag")
|
||||
assert result == 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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": {}}
|
||||
|
||||
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": [],
|
||||
}
|
||||
result = build_metadata_config(parser_config)
|
||||
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"}]
|
||||
|
||||
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"}]
|
||||
|
||||
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"}]
|
||||
|
||||
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
|
||||
|
||||
def test_missing_metadata_key(self):
|
||||
"""When parser_config has no 'metadata' key, built_in_metadata alone is returned."""
|
||||
parser_config = {"built_in_metadata": []}
|
||||
result = build_metadata_config(parser_config)
|
||||
assert result == []
|
||||
@@ -0,0 +1,453 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for ChunkService module.
|
||||
|
||||
Note: After refactoring, some functionality has been moved to:
|
||||
- chunk_builder.py: Parser factory, run_chunking, extract_outline
|
||||
- chunk_post_processor.py: Keyword extraction, question generation, metadata, tagging
|
||||
|
||||
This test file now focuses on ChunkService-specific functionality:
|
||||
- build_chunks orchestration
|
||||
- _prepare_docs_and_upload
|
||||
- insert_chunks and related methods
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rag.svr.task_executor_refactor.chunk_service import ChunkService
|
||||
|
||||
|
||||
class TestChunkServiceInit:
|
||||
"""Tests for ChunkService initialization."""
|
||||
|
||||
def test_init_stores_task_context(self):
|
||||
"""Test that task context is stored."""
|
||||
ctx = MagicMock()
|
||||
service = ChunkService(ctx=ctx)
|
||||
assert service._task_context is ctx
|
||||
|
||||
|
||||
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
|
||||
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.DOC_MAXIMUM_SIZE = 1000 # Small limit
|
||||
|
||||
mock_rec_ctx = MagicMock()
|
||||
ctx.recording_context = mock_rec_ctx
|
||||
|
||||
result = await service.build_chunks(b"test binary")
|
||||
|
||||
assert result == []
|
||||
mock_rec_ctx.record.assert_any_call("file_size_exceeded", True)
|
||||
|
||||
@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)
|
||||
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.DOC_MAXIMUM_SIZE = 10000000 # Large limit
|
||||
|
||||
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_parser = MagicMock()
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.run_chunking", new_callable=AsyncMock) as mock_run_chunking:
|
||||
mock_run_chunking.return_value = [{"content_with_weight": "test"}]
|
||||
|
||||
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"}]
|
||||
|
||||
await service.build_chunks(b"test binary")
|
||||
|
||||
mock_rec_ctx.record.assert_any_call("file_size_exceeded", False)
|
||||
mock_rec_ctx.record.assert_any_call("parser_id", "naive")
|
||||
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})
|
||||
|
||||
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.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()
|
||||
|
||||
|
||||
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()
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
cks = [{"content_with_weight": "test chunk"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.STORAGE_IMPL = MagicMock()
|
||||
mock_settings.STORAGE_IMPL.put = MagicMock()
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.image2id", new_callable=AsyncMock):
|
||||
|
||||
docs = await service._prepare_docs_and_upload(cks)
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0]["doc_id"] == "doc_1"
|
||||
assert docs[0]["kb_id"] == "kb_1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_docs_and_upload_with_pagerank(self):
|
||||
"""Test document preparation with pagerank."""
|
||||
ctx = self._create_mock_context()
|
||||
ctx.pagerank = 5
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
cks = [{"content_with_weight": "test chunk"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.STORAGE_IMPL = MagicMock()
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.image2id", new_callable=AsyncMock):
|
||||
|
||||
docs = await service._prepare_docs_and_upload(cks)
|
||||
|
||||
assert docs[0].get("pagerank_fea") == 5
|
||||
|
||||
|
||||
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()
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
chunks = [
|
||||
{"id": "chunk_1", "content_with_weight": "test1"},
|
||||
{"id": "chunk_2", "content_with_weight": "test2"},
|
||||
]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.DOC_BULK_SIZE = 100
|
||||
mock_settings.docStoreConn = MagicMock()
|
||||
mock_settings.docStoreConn.insert = MagicMock(return_value=None)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.search.index_name") as mock_index:
|
||||
mock_index.return_value = "test_index"
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = None
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.TaskService") as mock_task:
|
||||
mock_task.update_chunk_ids = MagicMock()
|
||||
|
||||
result = await service.insert_chunks("task_1", "tenant_1", "kb_1", chunks)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_chunks_canceled(self):
|
||||
"""Test chunk insertion when task is canceled."""
|
||||
ctx = self._create_mock_context()
|
||||
ctx.has_canceled_func = MagicMock(return_value=True)
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
chunks = [{"id": "chunk_1", "content_with_weight": "test1"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.DOC_BULK_SIZE = 100
|
||||
mock_settings.docStoreConn = MagicMock()
|
||||
mock_settings.docStoreConn.insert = MagicMock(return_value=None)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.search.index_name") as mock_index:
|
||||
mock_index.return_value = "test_index"
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = None
|
||||
|
||||
result = await service.insert_chunks("task_1", "tenant_1", "kb_1", chunks)
|
||||
|
||||
assert result is False
|
||||
ctx.progress_cb.assert_called_with(-1, msg="Task has been canceled.")
|
||||
|
||||
@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()
|
||||
service = ChunkService(ctx=ctx)
|
||||
|
||||
chunks = [{"id": "chunk_1", "content_with_weight": "test1"}]
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.settings") as mock_settings:
|
||||
mock_settings.DOC_BULK_SIZE = 100
|
||||
mock_settings.docStoreConn = MagicMock()
|
||||
mock_settings.docStoreConn.insert = MagicMock(return_value="Error message")
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.search.index_name") as mock_index:
|
||||
mock_index.return_value = "test_index"
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = "Error"
|
||||
|
||||
with pytest.raises(Exception, match="Insert chunk error"):
|
||||
await service.insert_chunks("task_1", "tenant_1", "kb_1", chunks)
|
||||
|
||||
|
||||
class TestChunkServiceCreateMotherChunks:
|
||||
"""Tests for _create_mother_chunks class method."""
|
||||
|
||||
def test_create_mother_chunks_with_mom_field(self):
|
||||
"""Test creating mother chunks from mom field."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "mom": "Summary text 1", "content_with_weight": "test1"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert len(mothers) == 1
|
||||
assert mothers[0]["content_with_weight"] == "Summary text 1"
|
||||
assert mothers[0]["available_int"] == 0
|
||||
|
||||
def test_create_mother_chunks_with_mom_with_weight_field(self):
|
||||
"""Test creating mother chunks from mom_with_weight field."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "mom_with_weight": "Summary text 2", "content_with_weight": "test1"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert len(mothers) == 1
|
||||
assert mothers[0]["content_with_weight"] == "Summary text 2"
|
||||
|
||||
def test_create_mother_chunks_no_mom_field(self):
|
||||
"""Test creating mother chunks when no mom field present."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "content_with_weight": "test1"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert len(mothers) == 0
|
||||
|
||||
def test_create_mother_chunks_empty_mom(self):
|
||||
"""Test creating mother chunks with empty mom field."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "mom": "", "content_with_weight": "test1"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert len(mothers) == 0
|
||||
|
||||
def test_create_mother_chunks_deduplicates_ids(self):
|
||||
"""Test that mother chunks deduplicate by ID."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "mom": "Same summary", "content_with_weight": "test1"},
|
||||
{"id": "chunk_2", "mom": "Same summary", "content_with_weight": "test2"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert len(mothers) == 1
|
||||
|
||||
def test_create_mother_chunks_filters_fields(self):
|
||||
"""Test that mother chunks only keep allowed fields."""
|
||||
chunks = [
|
||||
{"id": "chunk_1", "mom": "Summary", "extra_field": "should be removed", "content_with_weight": "test1"},
|
||||
]
|
||||
|
||||
mothers = ChunkService._create_mother_chunks(chunks)
|
||||
|
||||
assert "extra_field" not in mothers[0]
|
||||
assert "id" in mothers[0]
|
||||
assert "content_with_weight" in mothers[0]
|
||||
598
test/unit_test/rag/svr/task_executor_refactor/test_comparator.py
Normal file
598
test/unit_test/rag/svr/task_executor_refactor/test_comparator.py
Normal file
@@ -0,0 +1,598 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for Comparator module.
|
||||
"""
|
||||
|
||||
from rag.svr.task_executor_refactor.report_generator import (
|
||||
ComparisonResult,
|
||||
ComparisonReport,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.comparator import (
|
||||
ContextComparator,
|
||||
)
|
||||
from rag.svr.task_executor_refactor.recording_context import RecordingContext
|
||||
|
||||
|
||||
class TestComparisonResult:
|
||||
"""Tests for ComparisonResult dataclass."""
|
||||
|
||||
def test_init_with_required_fields(self):
|
||||
"""Test initialization with required fields."""
|
||||
result = ComparisonResult(key="test_key", match=True)
|
||||
assert result.key == "test_key"
|
||||
assert result.match is True
|
||||
assert result.production_value is None
|
||||
assert result.dry_run_value is None
|
||||
assert result.diff_details is None
|
||||
|
||||
def test_init_with_all_fields(self):
|
||||
"""Test initialization with all fields."""
|
||||
result = ComparisonResult(
|
||||
key="test_key",
|
||||
match=False,
|
||||
production_value=100,
|
||||
dry_run_value=200,
|
||||
diff_details="Values differ"
|
||||
)
|
||||
assert result.key == "test_key"
|
||||
assert result.match is False
|
||||
assert result.production_value == 100
|
||||
assert result.dry_run_value == 200
|
||||
assert result.diff_details == "Values differ"
|
||||
|
||||
def test_to_dict_match(self):
|
||||
"""Test to_dict for matching result."""
|
||||
result = ComparisonResult(key="key", match=True)
|
||||
d = result.to_dict()
|
||||
assert d == {"key": "key", "match": True, "diff_details": None}
|
||||
|
||||
def test_to_dict_mismatch(self):
|
||||
"""Test to_dict for mismatching result."""
|
||||
result = ComparisonResult(
|
||||
key="key",
|
||||
match=False,
|
||||
diff_details="Difference"
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d == {"key": "key", "match": False, "diff_details": "Difference"}
|
||||
|
||||
|
||||
class TestComparisonReport:
|
||||
"""Tests for ComparisonReport dataclass."""
|
||||
|
||||
def test_init_with_required_fields(self):
|
||||
"""Test initialization with required fields."""
|
||||
report = ComparisonReport(task_id="task_123")
|
||||
assert report.task_id == "task_123"
|
||||
assert report.total_keys == 0
|
||||
assert report.matched_keys == 0
|
||||
assert report.mismatched_keys == 0
|
||||
assert report.missing_in_production == []
|
||||
assert report.missing_in_dry_run == []
|
||||
assert report.details == []
|
||||
|
||||
def test_summary_no_keys(self):
|
||||
"""Test summary when no keys to compare."""
|
||||
report = ComparisonReport(task_id="task_123")
|
||||
assert "No keys to compare" in report.summary()
|
||||
|
||||
def test_summary_with_keys(self):
|
||||
"""Test summary with keys."""
|
||||
report = ComparisonReport(
|
||||
task_id="task_123",
|
||||
total_keys=10,
|
||||
matched_keys=8,
|
||||
mismatched_keys=2
|
||||
)
|
||||
summary = report.summary()
|
||||
assert "8/10" in summary
|
||||
assert "80.0%" in summary
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test to_dict serialization."""
|
||||
report = ComparisonReport(
|
||||
task_id="task_123",
|
||||
total_keys=1,
|
||||
matched_keys=1,
|
||||
details=[ComparisonResult(key="k", match=True)]
|
||||
)
|
||||
d = report.to_dict()
|
||||
assert d["task_id"] == "task_123"
|
||||
assert d["total_keys"] == 1
|
||||
assert len(d["details"]) == 1
|
||||
|
||||
def test_to_markdown(self):
|
||||
"""Test to_markdown serialization."""
|
||||
report = ComparisonReport(
|
||||
task_id="task_123",
|
||||
total_keys=1,
|
||||
matched_keys=1,
|
||||
mismatched_keys=0,
|
||||
missing_in_production=[],
|
||||
missing_in_dry_run=[],
|
||||
details=[ComparisonResult(key="k", match=True)]
|
||||
)
|
||||
md = report.to_markdown()
|
||||
assert "# Comparison Report: task_123" in md
|
||||
assert "## Summary" in md
|
||||
assert "## Details" in md
|
||||
|
||||
def test_to_markdown_empty_details(self):
|
||||
"""Test to_markdown with no details."""
|
||||
report = ComparisonReport(task_id="task_123")
|
||||
md = report.to_markdown()
|
||||
assert "No comparison details" in md
|
||||
|
||||
|
||||
class TestContextComparatorInit:
|
||||
"""Tests for ContextComparator initialization."""
|
||||
|
||||
def test_init_default_tolerance(self):
|
||||
"""Test initialization with default tolerance."""
|
||||
comparator = ContextComparator()
|
||||
assert comparator.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."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.comparator = ContextComparator()
|
||||
|
||||
def test_compare_none_values(self):
|
||||
"""Test comparing None values."""
|
||||
result = self.comparator.compare_value("key", None, None)
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_one_none(self):
|
||||
"""Test comparing when one value is None."""
|
||||
result = self.comparator.compare_value("key", 1, None)
|
||||
assert result.match is False
|
||||
assert "None" in result.diff_details
|
||||
|
||||
def test_compare_equal_strings(self):
|
||||
"""Test comparing equal strings."""
|
||||
result = self.comparator.compare_value("key", "hello", "hello")
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_different_strings(self):
|
||||
"""Test comparing different strings."""
|
||||
result = self.comparator.compare_value("key", "hello", "world")
|
||||
assert result.match is False
|
||||
|
||||
def test_compare_equal_booleans(self):
|
||||
"""Test comparing equal booleans."""
|
||||
result = self.comparator.compare_value("key", True, True)
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_different_booleans(self):
|
||||
"""Test comparing different booleans."""
|
||||
result = self.comparator.compare_value("key", True, False)
|
||||
assert result.match is False
|
||||
|
||||
def test_compare_equal_integers(self):
|
||||
"""Test comparing equal integers."""
|
||||
result = self.comparator.compare_value("key", 42, 42)
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_equal_floats_within_tolerance(self):
|
||||
"""Test comparing equal floats within tolerance."""
|
||||
result = self.comparator.compare_value("key", 1.0000001, 1.0000002)
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_different_floats_exceeding_tolerance(self):
|
||||
"""Test comparing floats exceeding tolerance."""
|
||||
result = self.comparator.compare_value("key", 1.0, 2.0)
|
||||
assert result.match is False
|
||||
assert "exceeds tolerance" in result.diff_details
|
||||
|
||||
def test_compare_equal_lists(self):
|
||||
"""Test comparing equal lists."""
|
||||
result = self.comparator.compare_value("key", [1, 2, 3], [1, 2, 3])
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_different_length_lists(self):
|
||||
"""Test comparing lists with different lengths."""
|
||||
result = self.comparator.compare_value("key", [1, 2], [1, 2, 3])
|
||||
assert result.match is False
|
||||
assert "Length differs" in result.diff_details
|
||||
|
||||
def test_compare_equal_dicts(self):
|
||||
"""Test comparing equal dicts."""
|
||||
result = self.comparator.compare_value("key", {"a": 1}, {"a": 1})
|
||||
assert result.match is True
|
||||
|
||||
def test_compare_different_dicts(self):
|
||||
"""Test comparing different dicts."""
|
||||
result = self.comparator.compare_value("key", {"a": 1}, {"a": 2})
|
||||
assert result.match is False
|
||||
|
||||
def test_compare_chunks_key_uses_chunk_comparison(self):
|
||||
"""Test that chunk keys use chunk comparison strategy."""
|
||||
result = self.comparator.compare_value(
|
||||
"raw_chunks",
|
||||
[{"id": "1", "content_with_weight": "a"}],
|
||||
[{"id": "1", "content_with_weight": "a"}]
|
||||
)
|
||||
assert result.match is True
|
||||
|
||||
|
||||
class TestContextComparatorCompareLists:
|
||||
"""Tests for _compare_lists method."""
|
||||
|
||||
def test_equal_lists(self):
|
||||
"""Test comparing equal lists."""
|
||||
result = ContextComparator._compare_lists("key", [1, 2], [1, 2])
|
||||
assert result.match is True
|
||||
|
||||
def test_different_length_lists(self):
|
||||
"""Test comparing lists with different lengths."""
|
||||
result = ContextComparator._compare_lists("key", [1], [1, 2])
|
||||
assert result.match is False
|
||||
|
||||
def test_different_elements(self):
|
||||
"""Test comparing lists with different elements."""
|
||||
result = ContextComparator._compare_lists("key", [1, 2], [1, 3])
|
||||
assert result.match is False
|
||||
|
||||
|
||||
class TestContextComparatorCompareDicts:
|
||||
"""Tests for _compare_dicts method."""
|
||||
|
||||
def test_equal_dicts(self):
|
||||
"""Test comparing equal dicts."""
|
||||
result = ContextComparator._compare_dicts("key", {"a": 1}, {"a": 1})
|
||||
assert result.match is True
|
||||
|
||||
def test_dicts_different_keys(self):
|
||||
"""Test comparing dicts with different keys."""
|
||||
result = ContextComparator._compare_dicts("key", {"a": 1}, {"b": 1})
|
||||
assert result.match is False
|
||||
assert "Keys differ" in result.diff_details
|
||||
|
||||
def test_dicts_same_keys_different_values(self):
|
||||
"""Test comparing dicts with same keys but different values."""
|
||||
result = ContextComparator._compare_dicts("key", {"a": 1}, {"a": 2})
|
||||
assert result.match is False
|
||||
|
||||
|
||||
class TestContextComparatorCompareNumbers:
|
||||
"""Tests for _compare_numbers method."""
|
||||
|
||||
def test_equal_numbers(self):
|
||||
"""Test comparing equal numbers."""
|
||||
comparator = ContextComparator()
|
||||
result = comparator._compare_numbers("key", 1.0, 1.0)
|
||||
assert result.match is True
|
||||
|
||||
def test_numbers_within_tolerance(self):
|
||||
"""Test comparing numbers within tolerance."""
|
||||
comparator = ContextComparator(float_tolerance=0.1)
|
||||
result = comparator._compare_numbers("key", 1.0, 1.05)
|
||||
assert result.match is True
|
||||
|
||||
def test_numbers_exceeding_tolerance(self):
|
||||
"""Test comparing numbers exceeding tolerance."""
|
||||
comparator = ContextComparator(float_tolerance=0.01)
|
||||
result = comparator._compare_numbers("key", 1.0, 1.1)
|
||||
assert result.match is False
|
||||
|
||||
|
||||
class TestContextComparatorCompareChunks:
|
||||
"""Tests for _compare_chunks method."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.comparator = ContextComparator()
|
||||
|
||||
def test_equal_chunks(self):
|
||||
"""Test comparing equal chunk lists."""
|
||||
prod = [{"id": "1", "content_with_weight": "a"}]
|
||||
dry = [{"id": "1", "content_with_weight": "a"}]
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is True
|
||||
|
||||
def test_different_count_chunks(self):
|
||||
"""Test comparing chunks with different counts."""
|
||||
prod = [{"id": "1"}]
|
||||
dry = [{"id": "1"}, {"id": "2"}]
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
assert "Chunk count differs" in result.diff_details
|
||||
|
||||
def test_different_ids_chunks(self):
|
||||
"""Test comparing chunks with different IDs."""
|
||||
prod = [{"id": "1"}]
|
||||
dry = [{"id": "2"}]
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
assert "Chunk IDs differ" in result.diff_details
|
||||
|
||||
def test_empty_chunks_lists(self):
|
||||
"""Test comparing empty chunk lists."""
|
||||
result = self.comparator._compare_chunks("raw_chunks", [], [])
|
||||
assert result.match is True
|
||||
|
||||
def test_all_chunks_compared_not_sampled(self):
|
||||
"""Test that ALL chunks are compared, not just samples.
|
||||
|
||||
This test creates 10 chunks where only the middle one (index 5) differs.
|
||||
With the old sampling strategy, this difference might be missed.
|
||||
With full comparison, the difference should always be detected.
|
||||
"""
|
||||
prod = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(10)]
|
||||
dry = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(10)]
|
||||
# Only modify chunk at index 5 (which might not be sampled in old strategy)
|
||||
dry[5]["content_with_weight"] = "different_content"
|
||||
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
assert "Content differs" in result.diff_details
|
||||
|
||||
def test_all_chunks_detect_first_difference(self):
|
||||
"""Test that first chunk difference is detected."""
|
||||
prod = [{"id": "1", "content_with_weight": "a"}, {"id": "2", "content_with_weight": "b"}]
|
||||
dry = [{"id": "1", "content_with_weight": "different"}, {"id": "2", "content_with_weight": "b"}]
|
||||
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
|
||||
def test_all_chunks_detect_last_difference(self):
|
||||
"""Test that last chunk difference is detected."""
|
||||
prod = [{"id": "1", "content_with_weight": "a"}, {"id": "2", "content_with_weight": "b"}]
|
||||
dry = [{"id": "1", "content_with_weight": "a"}, {"id": "2", "content_with_weight": "different"}]
|
||||
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
|
||||
def test_all_chunks_large_list_all_match(self):
|
||||
"""Test that large list of chunks all match."""
|
||||
prod = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(100)]
|
||||
dry = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(100)]
|
||||
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is True
|
||||
|
||||
def test_all_chunks_large_list_one_mismatch(self):
|
||||
"""Test that a single mismatch in a large list is detected."""
|
||||
prod = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(100)]
|
||||
dry = [{"id": str(i), "content_with_weight": f"content_{i}"} for i in range(100)]
|
||||
# Modify only the last chunk
|
||||
dry[99]["content_with_weight"] = "different"
|
||||
|
||||
result = self.comparator._compare_chunks("raw_chunks", prod, dry)
|
||||
assert result.match is False
|
||||
|
||||
|
||||
class TestContextComparatorExtractChunkIds:
|
||||
"""Tests for _extract_chunk_ids method."""
|
||||
|
||||
def test_extract_ids_from_valid_chunks(self):
|
||||
"""Test extracting IDs from valid chunks."""
|
||||
chunks = [{"id": "1"}, {"id": "2"}, {"id": "3"}]
|
||||
ids = ContextComparator._extract_chunk_ids(chunks)
|
||||
assert ids == {"1", "2", "3"}
|
||||
|
||||
def test_extract_ids_from_empty_chunks(self):
|
||||
"""Test extracting IDs from empty list."""
|
||||
ids = ContextComparator._extract_chunk_ids([])
|
||||
assert ids == set()
|
||||
|
||||
def test_extract_ids_from_chunks_without_id(self):
|
||||
"""Test extracting IDs from chunks without id field."""
|
||||
chunks = [{"content": "a"}, {"id": "1"}]
|
||||
ids = ContextComparator._extract_chunk_ids(chunks)
|
||||
assert ids == {"1"}
|
||||
|
||||
|
||||
class TestContextComparatorGetChunkId:
|
||||
"""Tests for _get_chunk_id method."""
|
||||
|
||||
def test_get_id_from_valid_chunk(self):
|
||||
"""Test getting ID from valid chunk."""
|
||||
chunk = {"id": "123"}
|
||||
assert ContextComparator._get_chunk_id(chunk) == "123"
|
||||
|
||||
def test_get_id_from_chunk_without_id(self):
|
||||
"""Test getting ID from chunk without id."""
|
||||
chunk = {"content": "a"}
|
||||
assert ContextComparator._get_chunk_id(chunk) == ""
|
||||
|
||||
def test_get_id_from_non_dict(self):
|
||||
"""Test getting ID from non-dict."""
|
||||
assert ContextComparator._get_chunk_id("not a dict") == ""
|
||||
|
||||
|
||||
class TestContextComparatorCompare:
|
||||
"""Tests for compare method."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.comparator = ContextComparator()
|
||||
|
||||
def test_compare_empty_contexts(self):
|
||||
"""Test comparing empty contexts."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
assert report.total_keys == 0
|
||||
|
||||
def test_compare_matching_values(self):
|
||||
"""Test comparing contexts with matching values."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("key", "value")
|
||||
ctx2.record("key", "value")
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
assert report.matched_keys == 1
|
||||
assert report.mismatched_keys == 0
|
||||
|
||||
def test_compare_mismatching_values(self):
|
||||
"""Test comparing contexts with mismatching values."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("key1", "value1")
|
||||
ctx2.record("key1", "value2")
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
assert report.mismatched_keys == 1
|
||||
|
||||
def test_compare_missing_key_in_one_context(self):
|
||||
"""Test comparing when key is missing in one context."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("key1", "value1")
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
assert "key1" in report.missing_in_dry_run
|
||||
|
||||
def test_compare_with_specific_keys(self):
|
||||
"""Test comparing with specific keys list."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("key1", "value1")
|
||||
ctx1.record("key2", "value2")
|
||||
ctx2.record("key1", "value1")
|
||||
ctx2.record("key2", "value2")
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2, comparison_keys=["key1"])
|
||||
assert report.total_keys == 1
|
||||
|
||||
def test_compare_filters_out_time_keys(self):
|
||||
"""Test that _time keys are filtered out."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("operation_time", 1.0)
|
||||
ctx2.record("operation_time", 1.0)
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
assert report.total_keys == 0
|
||||
|
||||
|
||||
class TestContextComparatorStripNonDeterministicFields:
|
||||
"""Tests for _strip_non_deterministic_fields method."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.comparator = ContextComparator()
|
||||
|
||||
def test_strip_seconds_from_dict_value(self):
|
||||
"""Test that 'seconds' key is removed from dict values."""
|
||||
data = {
|
||||
"graphrag_result": {"seconds": 45.48, "status": "done"},
|
||||
"other_key": "value"
|
||||
}
|
||||
result = self.comparator._strip_non_deterministic_fields(data)
|
||||
assert "seconds" not in result["graphrag_result"]
|
||||
assert result["graphrag_result"] == {"status": "done"}
|
||||
assert result["other_key"] == "value"
|
||||
|
||||
def test_strip_seconds_from_multiple_dict_values(self):
|
||||
"""Test that 'seconds' is removed from multiple dict values."""
|
||||
data = {
|
||||
"result1": {"seconds": 10.0, "count": 5},
|
||||
"result2": {"seconds": 20.0, "name": "test"},
|
||||
"simple_key": 123
|
||||
}
|
||||
result = self.comparator._strip_non_deterministic_fields(data)
|
||||
assert result["result1"] == {"count": 5}
|
||||
assert result["result2"] == {"name": "test"}
|
||||
assert result["simple_key"] == 123
|
||||
|
||||
def test_strip_does_not_modify_original_dict(self):
|
||||
"""Test that the original dict is not modified in place."""
|
||||
data = {
|
||||
"result": {"seconds": 1.0, "value": "test"}
|
||||
}
|
||||
_ = data["result"].copy()
|
||||
self.comparator._strip_non_deterministic_fields(data)
|
||||
# The original nested dict should still have seconds since we only do shallow copy
|
||||
assert "seconds" in data["result"]
|
||||
|
||||
def test_strip_with_empty_dict_values(self):
|
||||
"""Test handling of empty dict values."""
|
||||
data = {
|
||||
"empty_dict": {},
|
||||
"normal_key": "value"
|
||||
}
|
||||
result = self.comparator._strip_non_deterministic_fields(data)
|
||||
assert result["empty_dict"] == {}
|
||||
assert result["normal_key"] == "value"
|
||||
|
||||
def test_strip_with_non_dict_values(self):
|
||||
"""Test that non-dict values are not affected."""
|
||||
data = {
|
||||
"string_val": "test",
|
||||
"int_val": 42,
|
||||
"list_val": [1, 2, 3],
|
||||
"dict_val": {"seconds": 1.0, "name": "test"}
|
||||
}
|
||||
result = self.comparator._strip_non_deterministic_fields(data)
|
||||
assert result["string_val"] == "test"
|
||||
assert result["int_val"] == 42
|
||||
assert result["list_val"] == [1, 2, 3]
|
||||
assert result["dict_val"] == {"name": "test"}
|
||||
|
||||
def test_strip_seconds_from_graphrag_result(self):
|
||||
"""Test the specific case from the bug report: graphrag_result with seconds."""
|
||||
prod_data = {
|
||||
"graphrag_result": {
|
||||
"seconds": 45.48,
|
||||
"status": "success",
|
||||
"entity_count": 100
|
||||
}
|
||||
}
|
||||
dry_run_data = {
|
||||
"graphrag_result": {
|
||||
"seconds": 0.99,
|
||||
"status": "success",
|
||||
"entity_count": 100
|
||||
}
|
||||
}
|
||||
prod_stripped = self.comparator._strip_non_deterministic_fields(prod_data)
|
||||
dry_run_stripped = self.comparator._strip_non_deterministic_fields(dry_run_data)
|
||||
|
||||
# After stripping, both should be equal (except for seconds)
|
||||
assert prod_stripped["graphrag_result"] == {"status": "success", "entity_count": 100}
|
||||
assert dry_run_stripped["graphrag_result"] == {"status": "success", "entity_count": 100}
|
||||
assert prod_stripped["graphrag_result"] == dry_run_stripped["graphrag_result"]
|
||||
|
||||
def test_compare_with_seconds_in_dict_values(self):
|
||||
"""Test that compare correctly handles dict values with 'seconds' field."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("graphrag_result", {"seconds": 45.48, "status": "success"})
|
||||
ctx2.record("graphrag_result", {"seconds": 0.99, "status": "success"})
|
||||
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
# Should match because seconds is stripped
|
||||
assert report.matched_keys == 1
|
||||
assert report.mismatched_keys == 0
|
||||
|
||||
def test_compare_with_different_dict_values_excluding_seconds(self):
|
||||
"""Test that compare correctly detects differences in dict values (excluding seconds)."""
|
||||
ctx1 = RecordingContext()
|
||||
ctx2 = RecordingContext()
|
||||
ctx1.record("graphrag_result", {"seconds": 45.48, "status": "success", "count": 100})
|
||||
ctx2.record("graphrag_result", {"seconds": 0.99, "status": "failed", "count": 50})
|
||||
|
||||
report = self.comparator.compare("task_1", ctx1, ctx2)
|
||||
# Should mismatch because status and count differ
|
||||
assert report.mismatched_keys == 1
|
||||
assert report.matched_keys == 0
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for constants module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for constants module."""
|
||||
|
||||
def test_canvas_debug_doc_id_exists(self):
|
||||
"""Test that CANVAS_DEBUG_DOC_ID constant exists."""
|
||||
assert CANVAS_DEBUG_DOC_ID is not None
|
||||
|
||||
@pytest.mark.parametrize("expected_type", [str])
|
||||
def test_canvas_debug_doc_id_type(self, expected_type):
|
||||
"""Test that CANVAS_DEBUG_DOC_ID is a string."""
|
||||
assert isinstance(CANVAS_DEBUG_DOC_ID, expected_type)
|
||||
|
||||
@pytest.mark.parametrize("expected_value", ["dataflow_x"])
|
||||
def test_canvas_debug_doc_id_value(self, expected_value):
|
||||
"""Test that CANVAS_DEBUG_DOC_ID has expected value."""
|
||||
assert CANVAS_DEBUG_DOC_ID == expected_value
|
||||
|
||||
def test_canvas_debug_doc_id_not_empty(self):
|
||||
"""Test that CANVAS_DEBUG_DOC_ID is not empty."""
|
||||
assert len(CANVAS_DEBUG_DOC_ID) > 0
|
||||
@@ -0,0 +1,381 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for DataflowService module.
|
||||
|
||||
Tests validate behavior through the public run_dataflow() entry point.
|
||||
Private orchestration helpers (_process_chunks, _encode_batch, _normalize_chunks,
|
||||
_get_output_type, _embed_chunks, _load_dsl, etc.) are exercised implicitly; no test
|
||||
reaches directly into those internals.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from rag.svr.task_executor_refactor.dataflow_service import DataflowService
|
||||
|
||||
|
||||
class TestDataflowServiceRunDataflow:
|
||||
"""Tests for the public run_dataflow() method.
|
||||
|
||||
Internal helpers (_load_dsl, _normalize_chunks, _get_output_type, _process_chunks,
|
||||
_embed_chunks, _encode_batch) are exercised through this single entry point so
|
||||
the suite stays resilient when internal method boundaries change.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("rag.svr.task_executor_refactor.dataflow_service.UserCanvasService")
|
||||
@patch("rag.svr.task_executor_refactor.dataflow_service.PipelineOperationLogService")
|
||||
async def test_run_dataflow_dsl_not_found(self, mock_pipeline_log, mock_canvas, task_context):
|
||||
"""Test run_dataflow returns early when DSL is not found."""
|
||||
task_context._task["task_type"] = "dataflow"
|
||||
task_context._task["dataflow_id"] = "dataflow_test"
|
||||
mock_canvas.get_by_id.return_value = (False, None)
|
||||
|
||||
service = DataflowService(ctx=task_context)
|
||||
with pytest.raises(AssertionError, match="User pipeline not found"):
|
||||
await service.run_dataflow()
|
||||
|
||||
@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_empty_chunks(self, mock_canvas, mock_pipeline_class, task_context):
|
||||
"""Test run_dataflow handles empty pipeline output."""
|
||||
task_context._task["task_type"] = "dataflow"
|
||||
task_context._task["dataflow_id"] = "dataflow_test"
|
||||
mock_canvas.get_by_id.return_value = (True, MagicMock(dsl='{"id": "test"}'))
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.run = AsyncMock(return_value={})
|
||||
mock_pipeline_class.return_value = mock_pipeline
|
||||
|
||||
with patch.object(DataflowService, '_record_pipeline_log'):
|
||||
service = DataflowService(ctx=task_context)
|
||||
await service.run_dataflow()
|
||||
|
||||
@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_chunks_output(self, mock_canvas, mock_pipeline_class, task_context):
|
||||
"""Test run_dataflow processes 'chunks' output type end-to-end."""
|
||||
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 = {
|
||||
"chunks": [
|
||||
{"text": "Hello world", "content_with_weight": "Hello world"},
|
||||
],
|
||||
"embedding_token_consumption": 5,
|
||||
}
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.run = AsyncMock(return_value=chunks)
|
||||
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()
|
||||
|
||||
# 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()
|
||||
|
||||
@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_embedding_failure(self, mock_canvas, mock_pipeline_class, task_context):
|
||||
"""Test run_dataflow handles embedding failure gracefully."""
|
||||
task_context._task["task_type"] = "dataflow"
|
||||
task_context._task["dataflow_id"] = "dataflow_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 = {
|
||||
"chunks": [
|
||||
{"text": "Hello"},
|
||||
],
|
||||
"embedding_token_consumption": 1,
|
||||
}
|
||||
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=(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()
|
||||
|
||||
@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_billing_hook_success(self, mock_canvas, mock_pipeline_class, task_context):
|
||||
"""Test run_dataflow calls billing hook on success."""
|
||||
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 = {
|
||||
"chunks": [
|
||||
{"text": "Hello"},
|
||||
],
|
||||
"embedding_token_consumption": 1,
|
||||
}
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.run = AsyncMock(return_value=chunks)
|
||||
mock_pipeline_class.return_value = mock_pipeline
|
||||
|
||||
billing_hook = MagicMock()
|
||||
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()
|
||||
|
||||
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")
|
||||
@patch("rag.svr.task_executor_refactor.dataflow_service.UserCanvasService")
|
||||
async def test_run_dataflow_with_billing_hook_error(self, mock_canvas, mock_pipeline_class, task_context):
|
||||
"""Test run_dataflow calls billing hook on error."""
|
||||
task_context._task["task_type"] = "dataflow"
|
||||
task_context._task["dataflow_id"] = "dataflow_test"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
task_context._write_interceptor = None
|
||||
|
||||
mock_canvas.get_by_id.return_value = (True, MagicMock(dsl='{"id": "test"}'))
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.run = AsyncMock(side_effect=Exception("Pipeline failure"))
|
||||
mock_pipeline_class.return_value = mock_pipeline
|
||||
|
||||
billing_hook = MagicMock()
|
||||
billing_hook.on_pipeline_success = AsyncMock()
|
||||
billing_hook.on_pipeline_error = AsyncMock()
|
||||
|
||||
service = DataflowService(ctx=task_context, billing_hook=billing_hook)
|
||||
with pytest.raises(Exception, match="Pipeline failure"):
|
||||
await service.run_dataflow()
|
||||
|
||||
billing_hook.on_pipeline_error.assert_called_once()
|
||||
billing_hook.on_pipeline_success.assert_not_called()
|
||||
|
||||
|
||||
class TestDataflowServiceNormalizeChunks:
|
||||
"""Tests for _normalize_chunks — stable pure helper for output-format normalization."""
|
||||
|
||||
def test_normalize_chunks_from_chunks_key(self):
|
||||
"""Test normalization from 'chunks' key."""
|
||||
result = DataflowService._normalize_chunks({"chunks": [{"a": 1}]})
|
||||
assert result == [{"a": 1}]
|
||||
|
||||
def test_normalize_chunks_from_json_key(self):
|
||||
"""Test normalization from 'json' key."""
|
||||
result = DataflowService._normalize_chunks({"json": [{"a": 1}]})
|
||||
assert result == [{"a": 1}]
|
||||
|
||||
def test_normalize_chunks_from_markdown_key(self):
|
||||
"""Test normalization from 'markdown' key."""
|
||||
result = DataflowService._normalize_chunks({"markdown": "# Title"})
|
||||
assert result == [{"text": ["# Title"]}]
|
||||
|
||||
def test_normalize_chunks_from_text_key(self):
|
||||
"""Test normalization from 'text' key."""
|
||||
result = DataflowService._normalize_chunks({"text": "plain text"})
|
||||
assert result == [{"text": ["plain text"]}]
|
||||
|
||||
def test_normalize_chunks_from_html_key(self):
|
||||
"""Test normalization from 'html' key."""
|
||||
result = DataflowService._normalize_chunks({"html": "<p>content</p>"})
|
||||
assert result == [{"text": ["<p>content</p>"]}]
|
||||
|
||||
def test_normalize_chunks_unknown_key(self):
|
||||
"""Test normalization with unknown key returns empty."""
|
||||
result = DataflowService._normalize_chunks({"unknown": "data"})
|
||||
assert result == []
|
||||
|
||||
def test_normalize_chunks_empty_markdown(self):
|
||||
"""Test normalization with empty markdown value returns empty."""
|
||||
result = DataflowService._normalize_chunks({"markdown": ""})
|
||||
assert result == []
|
||||
|
||||
def test_normalize_chunks_preserves_deepcopy(self):
|
||||
"""Test normalization returns a deepcopy so mutations don't leak."""
|
||||
input_data = {"chunks": [{"key": "value"}]}
|
||||
result = DataflowService._normalize_chunks(input_data)
|
||||
result[0]["key"] = "modified"
|
||||
assert input_data["chunks"][0]["key"] == "value"
|
||||
|
||||
|
||||
class TestDataflowServiceGetOutputType:
|
||||
"""Tests for _get_output_type — stable pure helper for output-type detection."""
|
||||
|
||||
def test_get_output_type_chunks(self):
|
||||
assert DataflowService._get_output_type({"chunks": []}) == "chunks"
|
||||
|
||||
def test_get_output_type_json(self):
|
||||
assert DataflowService._get_output_type({"json": []}) == "json"
|
||||
|
||||
def test_get_output_type_markdown(self):
|
||||
assert DataflowService._get_output_type({"markdown": ""}) == "markdown"
|
||||
|
||||
def test_get_output_type_text(self):
|
||||
assert DataflowService._get_output_type({"text": ""}) == "text"
|
||||
|
||||
def test_get_output_type_html(self):
|
||||
assert DataflowService._get_output_type({"html": ""}) == "html"
|
||||
|
||||
def test_get_output_type_empty(self):
|
||||
assert DataflowService._get_output_type({}) == "empty"
|
||||
|
||||
|
||||
class TestDataflowServiceProcessChunks:
|
||||
"""Tests for _process_chunks — stable pure helper for chunk metadata processing."""
|
||||
|
||||
def test_process_chunks_adds_doc_id_and_kb_id(self, task_context):
|
||||
"""Test _process_chunks adds doc_id, kb_id, and metadata."""
|
||||
task_context._task["doc_id"] = "doc_123"
|
||||
task_context._task["kb_id"] = "kb_456"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
chunks = [{"text": "content"}]
|
||||
DataflowService._process_chunks(DataflowService(ctx=task_context), chunks)
|
||||
assert chunks[0]["doc_id"] == "doc_123"
|
||||
assert "kb_id" in chunks[0]
|
||||
assert "content_with_weight" in chunks[0]
|
||||
assert "text" not in chunks[0]
|
||||
|
||||
def test_process_chunks_generates_id(self, task_context):
|
||||
"""Test _process_chunks auto-generates id."""
|
||||
task_context._task["doc_id"] = "doc_123"
|
||||
task_context._task["kb_id"] = "kb_456"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
chunks = [{"text": "content"}]
|
||||
DataflowService._process_chunks(DataflowService(ctx=task_context), chunks)
|
||||
assert "id" in chunks[0]
|
||||
|
||||
def test_process_chunks_questions_field(self, task_context):
|
||||
"""Test _process_chunks processes questions field."""
|
||||
task_context._task["doc_id"] = "doc_123"
|
||||
task_context._task["kb_id"] = "kb_456"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
chunks = [{"text": "content", "questions": "Q1\nQ2"}]
|
||||
DataflowService._process_chunks(DataflowService(ctx=task_context), chunks)
|
||||
assert "questions" not in chunks[0]
|
||||
assert "question_kwd" in chunks[0]
|
||||
|
||||
def test_process_chunks_summary_field(self, task_context):
|
||||
"""Test _process_chunks processes summary field."""
|
||||
task_context._task["doc_id"] = "doc_123"
|
||||
task_context._task["kb_id"] = "kb_456"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
chunks = [{"text": "content", "summary": "summary text"}]
|
||||
DataflowService._process_chunks(DataflowService(ctx=task_context), chunks)
|
||||
assert "summary" not in chunks[0]
|
||||
assert "content_ltks" in chunks[0]
|
||||
|
||||
def test_process_chunks_metadata_field(self, task_context):
|
||||
"""Test _process_chunks extracts metadata."""
|
||||
task_context._task["doc_id"] = "doc_123"
|
||||
task_context._task["kb_id"] = "kb_456"
|
||||
task_context._task["name"] = "test.pdf"
|
||||
chunks = [{"text": "content", "metadata": {"key": "val"}}]
|
||||
metadata = DataflowService._process_chunks(DataflowService(ctx=task_context), chunks)
|
||||
assert "metadata" not in chunks[0]
|
||||
assert "key" in metadata
|
||||
|
||||
|
||||
class TestDataflowServiceInit:
|
||||
"""Tests for DataflowService initialization."""
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.dataflow_service.settings")
|
||||
def test_init_with_custom_batch_sizes(self, mock_settings):
|
||||
"""Test initialization with custom batch sizes."""
|
||||
ctx = MagicMock()
|
||||
service = DataflowService(ctx=ctx, embedding_batch_size=64, doc_bulk_size=50)
|
||||
assert service._embedding_batch_size == 64
|
||||
assert service._doc_bulk_size == 50
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.dataflow_service.settings")
|
||||
def test_init_with_default_sizes(self, mock_settings):
|
||||
"""Test initialization with default batch sizes."""
|
||||
mock_settings.EMBEDDING_BATCH_SIZE = 32
|
||||
mock_settings.DOC_BULK_SIZE = 100
|
||||
ctx = MagicMock()
|
||||
service = DataflowService(ctx=ctx)
|
||||
assert service._embedding_batch_size == 32
|
||||
assert service._doc_bulk_size == 100
|
||||
|
||||
def test_init_stores_context_and_hook(self):
|
||||
"""Test initialization stores context and billing hook."""
|
||||
ctx = MagicMock()
|
||||
hook = MagicMock()
|
||||
service = DataflowService(ctx=ctx, billing_hook=hook)
|
||||
assert service._task_context is ctx
|
||||
assert service._billing_hook is hook
|
||||
@@ -0,0 +1,145 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from rag.svr.task_executor_refactor.embedding_service import EmbeddingService
|
||||
|
||||
|
||||
class TestEmbeddingServiceInit:
|
||||
"""Tests for EmbeddingService initialization."""
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.embedding_service.settings")
|
||||
def test_init_with_default_batch_size(self, mock_settings):
|
||||
"""Test initialization with default batch size."""
|
||||
mock_settings.EMBEDDING_BATCH_SIZE = 32
|
||||
ctx = MagicMock()
|
||||
service = EmbeddingService(ctx=ctx)
|
||||
assert service._embedding_batch_size == 32
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.embedding_service.settings")
|
||||
def test_init_with_custom_batch_size(self, mock_settings):
|
||||
"""Test initialization with custom batch size."""
|
||||
ctx = MagicMock()
|
||||
service = EmbeddingService(ctx=ctx, embedding_batch_size=64)
|
||||
assert service._embedding_batch_size == 64
|
||||
|
||||
def test_init_stores_task_context(self):
|
||||
"""Test that task context is stored."""
|
||||
ctx = MagicMock()
|
||||
service = EmbeddingService(ctx=ctx)
|
||||
assert service._task_context is ctx
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@patch.object(EmbeddingService, '_run_encode')
|
||||
def test_embed_chunks_basic(self, mock_run_encode):
|
||||
"""Test basic chunk embedding."""
|
||||
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
|
||||
ctx = MagicMock()
|
||||
ctx.progress_cb = None
|
||||
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
|
||||
model = MagicMock()
|
||||
model.max_length = 100
|
||||
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
|
||||
]
|
||||
tk_count, vector_size = 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)
|
||||
ctx = MagicMock()
|
||||
ctx.progress_cb = None
|
||||
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
|
||||
model = MagicMock()
|
||||
model.max_length = 100
|
||||
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
|
||||
]
|
||||
service.embed_chunks(docs, model)
|
||||
|
||||
mock_run_encode.assert_called()
|
||||
|
||||
@patch.object(EmbeddingService, '_run_encode')
|
||||
def test_embed_chunks_with_title_content_combination(self, mock_run_encode):
|
||||
"""Test that title and content vectors are combined."""
|
||||
mock_run_encode.return_value = (np.array([[1.0, 2.0]]), 10)
|
||||
ctx = MagicMock()
|
||||
ctx.progress_cb = None
|
||||
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
|
||||
model = MagicMock()
|
||||
model.max_length = 100
|
||||
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
|
||||
]
|
||||
_, vector_size = 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):
|
||||
"""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)
|
||||
ctx = MagicMock()
|
||||
ctx.progress_cb = None
|
||||
service = EmbeddingService(ctx=ctx, embedding_batch_size=10)
|
||||
model = MagicMock()
|
||||
model.max_length = 100
|
||||
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "a" * 200},
|
||||
]
|
||||
tk_count, vector_size = 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]
|
||||
@@ -0,0 +1,331 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for EmbeddingUtils module.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from unittest.mock import patch
|
||||
from rag.svr.task_executor_refactor.embedding_utils import EmbeddingUtils
|
||||
|
||||
|
||||
class TestEmbeddingUtilsPrepareTexts:
|
||||
"""Tests for prepare_texts_for_embedding class method."""
|
||||
|
||||
def test_prepare_texts_basic(self):
|
||||
"""Test basic text preparation."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
|
||||
{"docnm_kwd": "Title2", "content_with_weight": "Content2"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert titles == ["Title1", "Title2"]
|
||||
assert contents == ["Content1", "Content2"]
|
||||
|
||||
def test_prepare_texts_with_question_kwd(self):
|
||||
"""Test text preparation with question_kwd."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "question_kwd": ["Q1", "Q2"], "content_with_weight": "Content1"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert titles == ["Title1"]
|
||||
assert contents == ["Q1\nQ2"]
|
||||
|
||||
def test_prepare_texts_with_empty_question_kwd(self):
|
||||
"""Test text preparation with empty question_kwd falls back to content."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "question_kwd": [], "content_with_weight": "Content1"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert contents == ["Content1"]
|
||||
|
||||
def test_prepare_texts_with_missing_question_kwd(self):
|
||||
"""Test text preparation without question_kwd uses content."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "Content1"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert contents == ["Content1"]
|
||||
|
||||
def test_prepare_texts_normalizes_table_html(self):
|
||||
"""Test that table HTML tags are normalized."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": "<table><tr><td>Cell</td></tr></table>"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
# Table tags should be replaced with spaces
|
||||
assert "<table>" not in contents[0]
|
||||
|
||||
def test_prepare_texts_whitespace_only_becomes_none(self):
|
||||
"""Test that whitespace-only content becomes 'None'."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "content_with_weight": " \n\n "},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert contents == ["None"]
|
||||
|
||||
def test_prepare_texts_default_title(self):
|
||||
"""Test that missing docnm_kwd uses 'Title' as default."""
|
||||
docs = [
|
||||
{"content_with_weight": "Content1"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs)
|
||||
assert titles == ["Title"]
|
||||
|
||||
def test_prepare_texts_without_question_kwd(self):
|
||||
"""Test text preparation with use_question_kwd=False."""
|
||||
docs = [
|
||||
{"docnm_kwd": "Title1", "question_kwd": ["Q1"], "content_with_weight": "Content1"},
|
||||
]
|
||||
titles, contents = EmbeddingUtils.prepare_texts_for_embedding(docs, use_question_kwd=False)
|
||||
assert contents == ["Content1"]
|
||||
|
||||
|
||||
class TestEmbeddingUtilsPrepareDataflowTexts:
|
||||
"""Tests for prepare_texts_for_dataflow_embedding class method."""
|
||||
|
||||
def test_prepare_dataflow_texts_with_questions(self):
|
||||
"""Test dataflow text preparation with questions field."""
|
||||
chunks = [
|
||||
{"questions": "Q1\nQ2"},
|
||||
{"questions": "Q3"},
|
||||
]
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
assert texts == ["Q1\nQ2", "Q3"]
|
||||
|
||||
def test_prepare_dataflow_texts_with_summary(self):
|
||||
"""Test dataflow text preparation with summary field (no questions)."""
|
||||
chunks = [
|
||||
{"summary": "Summary1"},
|
||||
]
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
assert texts == ["Summary1"]
|
||||
|
||||
def test_prepare_dataflow_texts_with_text(self):
|
||||
"""Test dataflow text preparation with text field (no questions/summary)."""
|
||||
chunks = [
|
||||
{"text": "Text content"},
|
||||
]
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
assert texts == ["Text content"]
|
||||
|
||||
def test_prepare_dataflow_texts_priority(self):
|
||||
"""Test field priority: questions > summary > text."""
|
||||
chunks = [
|
||||
{"questions": "Q", "summary": "S", "text": "T"},
|
||||
]
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
assert texts == ["Q"]
|
||||
|
||||
chunks = [
|
||||
{"summary": "S", "text": "T"},
|
||||
]
|
||||
texts = EmbeddingUtils.prepare_texts_for_dataflow_embedding(chunks)
|
||||
assert texts == ["S"]
|
||||
|
||||
|
||||
class TestEmbeddingUtilsTruncateTexts:
|
||||
"""Tests for truncate_texts class method."""
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.embedding_utils.truncate")
|
||||
def test_truncate_texts_calls_truncate(self, mock_truncate):
|
||||
"""Test truncate_texts calls truncate with correct max_length."""
|
||||
mock_truncate.return_value = "truncated"
|
||||
texts = ["long text 1", "long text 2"]
|
||||
max_length = 100
|
||||
|
||||
_ = EmbeddingUtils.truncate_texts(texts, max_length)
|
||||
|
||||
assert mock_truncate.call_count == 2
|
||||
# Should subtract 10 for safety margin
|
||||
mock_truncate.assert_called_with("long text 2", 90)
|
||||
|
||||
@patch("rag.svr.task_executor_refactor.embedding_utils.truncate")
|
||||
def test_truncate_texts_returns_list(self, mock_truncate):
|
||||
"""Test truncate_texts returns a list of same length."""
|
||||
mock_truncate.return_value = "truncated"
|
||||
texts = ["text1", "text2", "text3"]
|
||||
result = EmbeddingUtils.truncate_texts(texts, 50)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestEmbeddingUtilsStackVectors:
|
||||
"""Tests for stack_vectors class method."""
|
||||
|
||||
def test_stack_vectors_with_multiple_batches(self):
|
||||
"""Test stacking multiple vector batches."""
|
||||
batch1 = np.array([[1.0, 2.0], [3.0, 4.0]])
|
||||
batch2 = np.array([[5.0, 6.0]])
|
||||
result = EmbeddingUtils.stack_vectors([batch1, batch2])
|
||||
expected = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
|
||||
def test_stack_vectors_with_empty_batches(self):
|
||||
"""Test stacking empty batches returns empty array."""
|
||||
result = EmbeddingUtils.stack_vectors([])
|
||||
assert result.size == 0
|
||||
|
||||
def test_stack_vectors_with_single_batch(self):
|
||||
"""Test stacking a single batch."""
|
||||
batch = np.array([[1.0, 2.0]])
|
||||
result = EmbeddingUtils.stack_vectors([batch])
|
||||
np.testing.assert_array_equal(result, batch)
|
||||
|
||||
|
||||
class TestEmbeddingUtilsAttachVectors:
|
||||
"""Tests for attach_vectors class method."""
|
||||
|
||||
def test_attach_vectors_basic(self):
|
||||
"""Test attaching vectors to docs."""
|
||||
docs = [{"id": 1}, {"id": 2}]
|
||||
vectors = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
||||
|
||||
vector_size = EmbeddingUtils.attach_vectors(docs, vectors)
|
||||
|
||||
assert vector_size == 3
|
||||
assert "q_3_vec" in docs[0]
|
||||
assert "q_3_vec" in docs[1]
|
||||
assert docs[0]["q_3_vec"] == [1.0, 2.0, 3.0]
|
||||
assert docs[1]["q_3_vec"] == [4.0, 5.0, 6.0]
|
||||
|
||||
def test_attach_vectors_custom_key_template(self):
|
||||
"""Test attaching vectors with custom key template."""
|
||||
docs = [{"id": 1}]
|
||||
vectors = np.array([[1.0, 2.0]])
|
||||
|
||||
EmbeddingUtils.attach_vectors(docs, vectors, vector_key_template="vec_%d")
|
||||
|
||||
assert "vec_2" in docs[0]
|
||||
|
||||
def test_attach_vectors_modifies_in_place(self):
|
||||
"""Test that attach_vectors modifies docs in place."""
|
||||
docs = [{"id": 1}]
|
||||
vectors = np.array([[1.0, 2.0]])
|
||||
original_id = id(docs)
|
||||
|
||||
EmbeddingUtils.attach_vectors(docs, vectors)
|
||||
|
||||
assert id(docs) == original_id
|
||||
|
||||
|
||||
class TestEmbeddingUtilsCombineVectors:
|
||||
"""Tests for combine_title_content_vectors class method."""
|
||||
|
||||
def test_combine_vectors_with_title_and_content(self):
|
||||
"""Test combining title and content vectors with weight."""
|
||||
title_vecs = np.array([[1.0, 2.0], [3.0, 4.0]])
|
||||
content_vecs = np.array([[5.0, 6.0], [7.0, 8.0]])
|
||||
|
||||
result = EmbeddingUtils.combine_title_content_vectors(title_vecs, content_vecs, title_weight=0.3)
|
||||
|
||||
# Expected: 0.3 * title + 0.7 * content
|
||||
expected = 0.3 * title_vecs + 0.7 * content_vecs
|
||||
np.testing.assert_array_almost_equal(result, expected)
|
||||
|
||||
def test_combine_vectors_with_default_weight(self):
|
||||
"""Test combining with default weight when not specified."""
|
||||
title_vecs = np.array([[1.0, 2.0]])
|
||||
content_vecs = np.array([[5.0, 6.0]])
|
||||
|
||||
result = EmbeddingUtils.combine_title_content_vectors(title_vecs, content_vecs)
|
||||
|
||||
# Expected: 0.1 * title + 0.9 * content (default weight is 0.1)
|
||||
expected = 0.1 * title_vecs + 0.9 * content_vecs
|
||||
np.testing.assert_array_almost_equal(result, expected)
|
||||
|
||||
def test_combine_vectors_with_none_title(self):
|
||||
"""Test combining when title vectors is None returns content."""
|
||||
content_vecs = np.array([[5.0, 6.0]])
|
||||
|
||||
result = EmbeddingUtils.combine_title_content_vectors(None, content_vecs, title_weight=0.3)
|
||||
|
||||
np.testing.assert_array_equal(result, content_vecs)
|
||||
|
||||
def test_combine_vectors_with_mismatched_shapes(self):
|
||||
"""Test combining when shapes don't match returns content."""
|
||||
title_vecs = np.array([[1.0, 2.0]])
|
||||
content_vecs = np.array([[5.0, 6.0], [7.0, 8.0]])
|
||||
|
||||
result = EmbeddingUtils.combine_title_content_vectors(title_vecs, content_vecs, title_weight=0.3)
|
||||
|
||||
# Should return content_vecs when shapes don't match
|
||||
np.testing.assert_array_equal(result, content_vecs)
|
||||
|
||||
def test_combine_vectors_with_zero_weight(self):
|
||||
"""Test combining when weight is 0 uses default 0.1."""
|
||||
title_vecs = np.array([[1.0, 2.0]])
|
||||
content_vecs = np.array([[5.0, 6.0]])
|
||||
|
||||
result = EmbeddingUtils.combine_title_content_vectors(title_vecs, content_vecs, title_weight=0)
|
||||
|
||||
# Should use default weight of 0.1
|
||||
expected = 0.1 * title_vecs + 0.9 * content_vecs
|
||||
np.testing.assert_array_almost_equal(result, expected)
|
||||
|
||||
|
||||
class TestEmbeddingUtilsInternals:
|
||||
"""Tests for internal helper methods."""
|
||||
|
||||
def test_extract_content_with_question_kwd(self):
|
||||
"""Test _extract_content with question_kwd."""
|
||||
doc = {"question_kwd": ["Q1", "Q2"], "content_with_weight": "Content"}
|
||||
result = EmbeddingUtils._extract_content(doc, use_question_kwd=True)
|
||||
assert result == "Q1\nQ2"
|
||||
|
||||
def test_extract_content_without_question_kwd(self):
|
||||
"""Test _extract_content without question_kwd."""
|
||||
doc = {"content_with_weight": "Content"}
|
||||
result = EmbeddingUtils._extract_content(doc, use_question_kwd=True)
|
||||
assert result == "Content"
|
||||
|
||||
def test_extract_content_with_use_question_false(self):
|
||||
"""Test _extract_content with use_question_kwd=False."""
|
||||
doc = {"question_kwd": ["Q1"], "content_with_weight": "Content"}
|
||||
result = EmbeddingUtils._extract_content(doc, use_question_kwd=False)
|
||||
assert result == "Content"
|
||||
|
||||
def test_normalize_table_html(self):
|
||||
"""Test _normalize_table_html removes table tags."""
|
||||
html = "<table><tr><td>Cell</td></tr></table>"
|
||||
result = EmbeddingUtils._normalize_table_html(html)
|
||||
assert "<table>" not in result
|
||||
assert "<tr>" not in result
|
||||
assert "<td>" not in result
|
||||
|
||||
def test_handle_whitespace(self):
|
||||
"""Test _handle_whitespace replaces whitespace-only with placeholder."""
|
||||
assert EmbeddingUtils._handle_whitespace(" \n ") == "None"
|
||||
assert EmbeddingUtils._handle_whitespace(" text ") == " text "
|
||||
|
||||
def test_handle_whitespace_with_empty_string(self):
|
||||
"""Test _handle_whitespace with empty string."""
|
||||
assert EmbeddingUtils._handle_whitespace("") == "None"
|
||||
|
||||
|
||||
class TestEmbeddingUtilsConstants:
|
||||
"""Tests for class constants."""
|
||||
|
||||
def test_default_title_weight(self):
|
||||
"""Test DEFAULT_TITLE_WEIGHT value."""
|
||||
assert EmbeddingUtils.DEFAULT_TITLE_WEIGHT == 0.1
|
||||
|
||||
def test_default_title_placeholder(self):
|
||||
"""Test DEFAULT_TITLE_PLACEHOLDER value."""
|
||||
assert EmbeddingUtils.DEFAULT_TITLE_PLACEHOLDER == "Title"
|
||||
|
||||
def test_content_placeholder_for_whitespace(self):
|
||||
"""Test CONTENT_PLACEHOLDER_FOR_WHITESPACE value."""
|
||||
assert EmbeddingUtils.CONTENT_PLACEHOLDER_FOR_WHITESPACE == "None"
|
||||
@@ -0,0 +1,130 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for PostProcessor module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rag.svr.task_executor_refactor.post_processor import PostProcessor
|
||||
|
||||
|
||||
class TestPostProcessorInit:
|
||||
"""Tests for PostProcessor initialization."""
|
||||
|
||||
def test_init_stores_task_context(self):
|
||||
"""Test that task context is stored."""
|
||||
ctx = MagicMock()
|
||||
service = PostProcessor(ctx=ctx)
|
||||
assert service._task_context is ctx
|
||||
|
||||
|
||||
class TestPostProcessorProcessTableParserMetadata:
|
||||
"""Tests for process_table_parser_metadata method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_non_table_parser(self):
|
||||
"""Test that processing is skipped for non-table parser."""
|
||||
ctx = MagicMock()
|
||||
ctx.parser_id = "naive"
|
||||
service = PostProcessor(ctx=ctx)
|
||||
|
||||
await service.process_table_parser_metadata("doc_1", [])
|
||||
|
||||
# Should return early without any further processing
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_not_manual_column_mode(self):
|
||||
"""Test that processing is skipped when not in manual column mode."""
|
||||
ctx = MagicMock()
|
||||
ctx.parser_id = "table"
|
||||
ctx.raw_task = {}
|
||||
service = PostProcessor(ctx=ctx)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.post_processor.merge_table_parser_config_from_kb") as mock_merge:
|
||||
mock_merge.return_value = {"table_column_mode": "auto"}
|
||||
await service.process_table_parser_metadata("doc_1", [])
|
||||
|
||||
mock_merge.assert_called_once()
|
||||
|
||||
|
||||
class TestPostProcessorInsertTocChunk:
|
||||
"""Tests for insert_toc_chunk method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_for_none_chunk(self):
|
||||
"""Test that method returns False when chunk is None."""
|
||||
ctx = MagicMock()
|
||||
service = PostProcessor(ctx=ctx)
|
||||
chunk_service = MagicMock()
|
||||
|
||||
result = await service.insert_toc_chunk(None, chunk_service)
|
||||
|
||||
assert result is False
|
||||
chunk_service.insert_chunks.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checks_cancellation(self):
|
||||
"""Test that cancellation is checked."""
|
||||
ctx = MagicMock()
|
||||
ctx.id = "task_1"
|
||||
ctx.has_canceled_func = MagicMock(return_value=True)
|
||||
ctx.progress_cb = MagicMock()
|
||||
service = PostProcessor(ctx=ctx)
|
||||
chunk_service = MagicMock()
|
||||
toc_chunk = {"id": "toc_1"}
|
||||
|
||||
result = await service.insert_toc_chunk(toc_chunk, chunk_service)
|
||||
|
||||
assert result is False
|
||||
ctx.progress_cb.assert_called_with(-1, msg="Task has been canceled.")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inserts_toc_chunk_successfully(self):
|
||||
"""Test successful TOC chunk insertion."""
|
||||
ctx = MagicMock()
|
||||
ctx.id = "task_1"
|
||||
ctx.tenant_id = "tenant_1"
|
||||
ctx.kb_id = "kb_1"
|
||||
ctx.has_canceled_func = MagicMock(return_value=False)
|
||||
service = PostProcessor(ctx=ctx)
|
||||
chunk_service = AsyncMock()
|
||||
chunk_service.insert_chunks = AsyncMock(return_value=True)
|
||||
toc_chunk = {"id": "toc_1"}
|
||||
|
||||
result = await service.insert_toc_chunk(toc_chunk, chunk_service)
|
||||
|
||||
assert result is True
|
||||
chunk_service.insert_chunks.assert_called_once_with(
|
||||
"task_1", "tenant_1", "kb_1", [toc_chunk]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_insert_failure(self):
|
||||
"""Test handling of insert failure."""
|
||||
ctx = MagicMock()
|
||||
ctx.id = "task_1"
|
||||
ctx.tenant_id = "tenant_1"
|
||||
ctx.kb_id = "kb_1"
|
||||
ctx.has_canceled_func = MagicMock(return_value=False)
|
||||
service = PostProcessor(ctx=ctx)
|
||||
chunk_service = AsyncMock()
|
||||
chunk_service.insert_chunks = AsyncMock(return_value=False)
|
||||
toc_chunk = {"id": "toc_1"}
|
||||
|
||||
result = await service.insert_toc_chunk(toc_chunk, chunk_service)
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,452 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Tests for RaptorService.
|
||||
|
||||
Coverage is driven through the public entry point `run_raptor_for_kb()`.
|
||||
|
||||
Design principles:
|
||||
- All orchestration behavior is validated through the public API.
|
||||
- Only stable pure helpers (`_collect_doc_info`, `_schedule_raptor_cleanup`)
|
||||
are tested directly.
|
||||
- Internal methods (`_run_file_level_raptor`, `_run_dataset_level_raptor`,
|
||||
`_should_skip_raptor`, `_load_doc_chunks`, `_load_all_doc_chunks`,
|
||||
`_generate_raptor`, `_get_raptor_chunk_methods`) are NOT tested directly —
|
||||
their behavior is covered by exercising `run_raptor_for_kb()` with
|
||||
appropriate mocks.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from rag.svr.task_executor_refactor.raptor_service import RaptorService
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stable Pure Helpers (tested directly)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRaptorServiceInit:
|
||||
"""Tests for RaptorService initialization."""
|
||||
|
||||
def test_init_stores_task_context(self, mock_raptor_context):
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
assert svc._task_context is mock_raptor_context
|
||||
|
||||
def test_init_uses_provided_kb_id(self, mock_raptor_context):
|
||||
mock_raptor_context.kb_id = "custom_kb"
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
assert svc._task_context.kb_id == "custom_kb"
|
||||
|
||||
|
||||
class TestRaptorServiceCollectDocInfo:
|
||||
"""Tests for _collect_doc_info — stable pure data aggregation (classmethod)."""
|
||||
|
||||
def _make_mock_doc(self, name, type, parser_id, parser_config):
|
||||
"""Create a mock document with accessible attributes."""
|
||||
mock_doc = MagicMock()
|
||||
mock_doc.name = name
|
||||
mock_doc.type = type
|
||||
mock_doc.parser_id = parser_id
|
||||
mock_doc.parser_config = parser_config
|
||||
return mock_doc
|
||||
|
||||
def test_collect_doc_info_success(self):
|
||||
doc_ids = ["doc_1", "doc_2"]
|
||||
|
||||
mock_doc_1 = self._make_mock_doc(name="", type="pdf", parser_id="naive", parser_config={})
|
||||
mock_doc_2 = self._make_mock_doc(name="doc2.txt", type="txt", parser_id="manual", parser_config={"chunk_token_num": 512})
|
||||
|
||||
def get_by_id_side_effect(doc_id):
|
||||
if doc_id == "doc_1":
|
||||
return True, mock_doc_1
|
||||
if doc_id == "doc_2":
|
||||
return True, mock_doc_2
|
||||
return False, None
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_service.DocumentService") as mock_ds:
|
||||
mock_ds.get_by_id = MagicMock(side_effect=get_by_id_side_effect)
|
||||
result = RaptorService._collect_doc_info(doc_ids)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result["doc_1"]["name"] == ""
|
||||
assert result["doc_1"]["type"] == "pdf"
|
||||
assert result["doc_1"]["parser_id"] == "naive"
|
||||
assert result["doc_2"]["name"] == "doc2.txt"
|
||||
assert result["doc_2"]["type"] == "txt"
|
||||
assert result["doc_2"]["parser_id"] == "manual"
|
||||
assert result["doc_2"]["parser_config"] == {"chunk_token_num": 512}
|
||||
|
||||
def test_collect_doc_info_empty_input(self):
|
||||
result = RaptorService._collect_doc_info([])
|
||||
assert result == {}
|
||||
|
||||
def test_collect_doc_info_deduplicates_doc_ids(self):
|
||||
"""Duplicate doc_ids should be deduplicated."""
|
||||
doc_ids = ["doc_1", "doc_1", "doc_2"]
|
||||
|
||||
mock_doc = self._make_mock_doc(name="test.pdf", type="pdf", parser_id="naive", parser_config={})
|
||||
|
||||
called_ids = []
|
||||
|
||||
def get_by_id_side_effect(doc_id):
|
||||
called_ids.append(doc_id)
|
||||
return True, mock_doc
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_service.DocumentService") as mock_ds:
|
||||
mock_ds.get_by_id = MagicMock(side_effect=get_by_id_side_effect)
|
||||
result = RaptorService._collect_doc_info(doc_ids)
|
||||
|
||||
assert sorted(called_ids) == ["doc_1", "doc_2"]
|
||||
assert len(result) == 2
|
||||
|
||||
def test_collect_doc_info_missing_document(self):
|
||||
doc_ids = ["doc_1", "missing_doc"]
|
||||
|
||||
mock_doc = self._make_mock_doc(name="test.pdf", type="pdf", parser_id="naive", parser_config={})
|
||||
|
||||
def get_by_id_side_effect(doc_id):
|
||||
if doc_id == "doc_1":
|
||||
return True, mock_doc
|
||||
return False, None
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_service.DocumentService") as mock_ds:
|
||||
mock_ds.get_by_id = MagicMock(side_effect=get_by_id_side_effect)
|
||||
result = RaptorService._collect_doc_info(doc_ids)
|
||||
|
||||
assert len(result) == 1
|
||||
assert "doc_1" in result
|
||||
assert "missing_doc" not in result
|
||||
|
||||
|
||||
class TestRaptorServiceScheduleRaptorCleanup:
|
||||
"""Tests for _schedule_raptor_cleanup — stable pure data operation (classmethod)."""
|
||||
|
||||
def test_schedule_cleanup_adds_entry(self):
|
||||
cleanup_list = []
|
||||
RaptorService._schedule_raptor_cleanup("doc_1", "tree_builder_a", cleanup_list)
|
||||
assert cleanup_list == [("doc_1", "tree_builder_a")]
|
||||
|
||||
def test_schedule_cleanup_deduplicates(self):
|
||||
cleanup_list = [("doc_1", "tree_builder_a")]
|
||||
RaptorService._schedule_raptor_cleanup("doc_1", "tree_builder_a", cleanup_list)
|
||||
assert len(cleanup_list) == 1
|
||||
|
||||
def test_schedule_cleanup_keep_method_none(self):
|
||||
cleanup_list = []
|
||||
RaptorService._schedule_raptor_cleanup("doc_1", None, cleanup_list)
|
||||
assert cleanup_list == [("doc_1", None)]
|
||||
|
||||
def test_schedule_cleanup_multiple_docs(self):
|
||||
cleanup_list = []
|
||||
RaptorService._schedule_raptor_cleanup("doc_1", "t1", cleanup_list)
|
||||
RaptorService._schedule_raptor_cleanup("doc_2", "t2", cleanup_list)
|
||||
RaptorService._schedule_raptor_cleanup("doc_3", None, cleanup_list)
|
||||
assert len(cleanup_list) == 3
|
||||
assert ("doc_1", "t1") in cleanup_list
|
||||
assert ("doc_2", "t2") in cleanup_list
|
||||
assert ("doc_3", None) in cleanup_list
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Entry Point Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRaptorServiceRunRaptorForKb:
|
||||
"""Tests for run_raptor_for_kb() — the public entry point.
|
||||
|
||||
All orchestration behavior (file-level vs dataset-level dispatch,
|
||||
chunk loading, skip logic, cleanup scheduling) is validated through
|
||||
this method by mocking internal helpers and observing:
|
||||
- Return values (chunks, token_count, cleanup_raptor_chunks)
|
||||
- Mock call patterns (which internal method was invoked, with what args)
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_chunks(self):
|
||||
"""Sample RAPTOR summary chunks returned by internal methods."""
|
||||
return [{"id": "chunk_1", "content_with_weight": "Summary 1"}]
|
||||
|
||||
@pytest.fixture
|
||||
def raptor_config_file_scope(self):
|
||||
"""RAPTOR config with file-level scope."""
|
||||
return {
|
||||
"raptor": {
|
||||
"tree_builder": "raptor",
|
||||
"clustering_method": "gmm",
|
||||
"scope": "file",
|
||||
"prompt": "summarize",
|
||||
"max_token": 512,
|
||||
"threshold": 0.5,
|
||||
"max_cluster": 64,
|
||||
"random_seed": 42,
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def raptor_config_dataset_scope(self):
|
||||
"""RAPTOR config with dataset-level scope."""
|
||||
return {
|
||||
"raptor": {
|
||||
"tree_builder": "raptor",
|
||||
"clustering_method": "gmm",
|
||||
"scope": "dataset",
|
||||
"prompt": "summarize",
|
||||
"max_token": 512,
|
||||
"threshold": 0.5,
|
||||
"max_cluster": 64,
|
||||
"random_seed": 42,
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Basic dispatch (file-level scope) ----
|
||||
|
||||
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."""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_1", "doc_2"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
vector_size = 128
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={
|
||||
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
"doc_2": {"name": "b.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
}), patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file, \
|
||||
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()
|
||||
|
||||
mock_file.assert_called_once()
|
||||
mock_dataset.assert_not_called()
|
||||
assert chunks == sample_chunks
|
||||
assert tk_count == 42
|
||||
|
||||
# ---- Basic dispatch (dataset-level scope) ----
|
||||
|
||||
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."""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_1"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
vector_size = 128
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={
|
||||
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
}), patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file, \
|
||||
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()
|
||||
|
||||
mock_dataset.assert_called_once()
|
||||
mock_file.assert_not_called()
|
||||
assert chunks == sample_chunks
|
||||
assert tk_count == 99
|
||||
|
||||
# ---- Empty / no documents ----
|
||||
|
||||
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()
|
||||
embd_mdl = MagicMock()
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={}), \
|
||||
patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file, \
|
||||
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()
|
||||
|
||||
assert chunks == []
|
||||
assert tk_count == 0
|
||||
assert cleanup == []
|
||||
|
||||
# ---- Cleanup scheduling through the public API ----
|
||||
|
||||
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.
|
||||
"""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_1"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
|
||||
expected_cleanup = [("doc_1", "tree_builder_a")]
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={
|
||||
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
}), 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()
|
||||
|
||||
assert cleanup == expected_cleanup
|
||||
|
||||
# ---- Dispatch with missing raptor config key ----
|
||||
|
||||
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."""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_1"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
config = {} # No raptor key at all
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={
|
||||
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
}), patch.object(svc, "_run_file_level_raptor", new_callable=AsyncMock) as mock_file, \
|
||||
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()
|
||||
|
||||
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(
|
||||
self, mock_raptor_context, sample_chunks, raptor_config_file_scope
|
||||
):
|
||||
"""Vector size is used to construct vctr_nm and passed to internal method."""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_1"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
vector_size = 256
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value={
|
||||
"doc_1": {"name": "a.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}},
|
||||
}), 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()
|
||||
|
||||
# Verify _run_file_level_raptor received vctr_nm with the correct vector size
|
||||
# Positional args: 0=raptor_config, 1=tree_builder, 2=clustering_method,
|
||||
# 3=chat_mdl, 4=embd_mdl, 5=vctr_nm
|
||||
positional_args = mock_file.call_args[0]
|
||||
assert positional_args[5] == "q_256_vec"
|
||||
|
||||
# ---- Document info collection through public API ----
|
||||
|
||||
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."""
|
||||
svc = RaptorService(mock_raptor_context)
|
||||
doc_ids = ["doc_a"]
|
||||
chat_mdl = MagicMock()
|
||||
embd_mdl = MagicMock()
|
||||
|
||||
expected_info = {"doc_a": {"name": "file.pdf", "type": "pdf", "parser_id": "naive", "parser_config": {}}}
|
||||
|
||||
with patch.object(svc, "_collect_doc_info", return_value=expected_info) as mock_collect, \
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -0,0 +1,357 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for RecordingContext module.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from rag.svr.task_executor_refactor.recording_context import (
|
||||
RecordingContext,
|
||||
get_recording_context,
|
||||
set_recording_context,
|
||||
recording_context_manager,
|
||||
timed_with_recording,
|
||||
)
|
||||
|
||||
|
||||
class TestRecordingContextInit:
|
||||
"""Tests for RecordingContext initialization."""
|
||||
|
||||
def test_init_creates_empty_data(self):
|
||||
"""Test that __init__ creates empty _data dict."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx._data == {}
|
||||
|
||||
def test_init_creates_empty_records(self):
|
||||
"""Test that __init__ creates empty records list."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.records == []
|
||||
|
||||
|
||||
class TestRecordingContextRecord:
|
||||
"""Tests for RecordingContext.record method."""
|
||||
|
||||
def test_record_single_value(self):
|
||||
"""Test recording a single value."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("chunk_count", 100)
|
||||
assert ctx.get("chunk_count") == 100
|
||||
|
||||
def test_record_overwrites_existing_value(self):
|
||||
"""Test that recording with same key overwrites previous value."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value1")
|
||||
ctx.record("key", "value2")
|
||||
assert ctx.get("key") == "value2"
|
||||
|
||||
def test_record_none_value(self):
|
||||
"""Test recording None value."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", None)
|
||||
assert ctx.get("key") is None
|
||||
|
||||
def test_record_complex_object(self):
|
||||
"""Test recording a complex object like list or dict."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("chunks", [{"id": 1}, {"id": 2}])
|
||||
assert ctx.get("chunks") == [{"id": 1}, {"id": 2}]
|
||||
|
||||
|
||||
class TestRecordingContextFuncReturnValues:
|
||||
"""Tests for function return value recording."""
|
||||
|
||||
def test_save_func_return_value_first_call(self):
|
||||
"""Test saving first return value for a function."""
|
||||
ctx = RecordingContext()
|
||||
ctx.save_func_return_value("test_func", 42)
|
||||
assert ctx.get_func_return_values("test_func") == [42]
|
||||
|
||||
def test_save_func_return_value_multiple_calls(self):
|
||||
"""Test saving multiple return values for same function."""
|
||||
ctx = RecordingContext()
|
||||
ctx.save_func_return_value("test_func", 1)
|
||||
ctx.save_func_return_value("test_func", 2)
|
||||
ctx.save_func_return_value("test_func", 3)
|
||||
assert ctx.get_func_return_values("test_func") == [1, 2, 3]
|
||||
|
||||
def test_get_func_return_values_nonexistent_function(self):
|
||||
"""Test getting return values for nonexistent function returns empty list."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.get_func_return_values("nonexistent") == []
|
||||
|
||||
def test_get_func_return_values_multiple_functions(self):
|
||||
"""Test getting return values for different functions."""
|
||||
ctx = RecordingContext()
|
||||
ctx.save_func_return_value("func_a", "a1")
|
||||
ctx.save_func_return_value("func_b", "b1")
|
||||
ctx.save_func_return_value("func_a", "a2")
|
||||
assert ctx.get_func_return_values("func_a") == ["a1", "a2"]
|
||||
assert ctx.get_func_return_values("func_b") == ["b1"]
|
||||
|
||||
|
||||
class TestRecordingContextGet:
|
||||
"""Tests for RecordingContext.get method."""
|
||||
|
||||
def test_get_existing_key(self):
|
||||
"""Test getting an existing key."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value")
|
||||
assert ctx.get("key") == "value"
|
||||
|
||||
def test_get_nonexistent_key_returns_none(self):
|
||||
"""Test getting nonexistent key returns None."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.get("missing") is None
|
||||
|
||||
def test_get_nonexistent_key_returns_default(self):
|
||||
"""Test getting nonexistent key returns provided default."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.get("missing", "default") == "default"
|
||||
|
||||
def test_get_with_none_default(self):
|
||||
"""Test getting with None as default."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.get("missing", None) is None
|
||||
|
||||
|
||||
class TestRecordingContextGetAllFuncReturnValues:
|
||||
"""Tests for get_all_func_return_values method."""
|
||||
|
||||
def test_get_all_func_return_values_empty(self):
|
||||
"""Test getting all values when none recorded."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.get_all_func_return_values() == {}
|
||||
|
||||
def test_get_all_func_return_values_with_data(self):
|
||||
"""Test getting all values with some data."""
|
||||
ctx = RecordingContext()
|
||||
ctx.save_func_return_value("func_a", 1)
|
||||
ctx.save_func_return_value("func_b", 2)
|
||||
result = ctx.get_all_func_return_values()
|
||||
assert result == {"func_a": [1], "func_b": [2]}
|
||||
|
||||
def test_get_all_func_return_values_returns_copy(self):
|
||||
"""Test that returned dict is a copy, not the original."""
|
||||
ctx = RecordingContext()
|
||||
ctx.save_func_return_value("func", 1)
|
||||
result = ctx.get_all_func_return_values()
|
||||
result["func"] = []
|
||||
# Original should be unchanged
|
||||
assert ctx.get_func_return_values("func") == [1]
|
||||
|
||||
|
||||
class TestRecordingContextHas:
|
||||
"""Tests for RecordingContext.has method."""
|
||||
|
||||
def test_has_existing_key(self):
|
||||
"""Test has returns True for existing key."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value")
|
||||
assert ctx.has("key") is True
|
||||
|
||||
def test_has_nonexistent_key(self):
|
||||
"""Test has returns False for nonexistent key."""
|
||||
ctx = RecordingContext()
|
||||
assert ctx.has("missing") is False
|
||||
|
||||
def test_has_after_clear(self):
|
||||
"""Test has returns False after clear."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value")
|
||||
ctx.clear()
|
||||
assert ctx.has("key") is False
|
||||
|
||||
|
||||
class TestRecordingContextClear:
|
||||
"""Tests for RecordingContext.clear method."""
|
||||
|
||||
def test_clear_removes_all_data(self):
|
||||
"""Test that clear removes all recorded data."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key1", "value1")
|
||||
ctx.record("key2", "value2")
|
||||
ctx.clear()
|
||||
assert ctx._data == {}
|
||||
|
||||
def test_clear_removes_all_records(self):
|
||||
"""Test that clear removes all timing records."""
|
||||
ctx = RecordingContext()
|
||||
with ctx.measure("op1"):
|
||||
pass
|
||||
ctx.clear()
|
||||
assert ctx.records == []
|
||||
|
||||
|
||||
class TestRecordingContextMeasure:
|
||||
"""Tests for RecordingContext.measure context manager."""
|
||||
|
||||
def test_measure_records_elapsed_time(self):
|
||||
"""Test that measure records elapsed time."""
|
||||
ctx = RecordingContext()
|
||||
with ctx.measure("test_op"):
|
||||
time.sleep(0.01)
|
||||
assert len(ctx.records) == 1
|
||||
assert ctx.records[0][0] == "test_op"
|
||||
assert ctx.records[0][1] >= 0.01
|
||||
|
||||
def test_measure_multiple_operations(self):
|
||||
"""Test measuring multiple operations."""
|
||||
ctx = RecordingContext()
|
||||
with ctx.measure("op1"):
|
||||
time.sleep(0.01)
|
||||
with ctx.measure("op2"):
|
||||
time.sleep(0.02)
|
||||
assert len(ctx.records) == 2
|
||||
assert ctx.records[0][0] == "op1"
|
||||
assert ctx.records[1][0] == "op2"
|
||||
|
||||
def test_measure_preserves_context_on_exception(self):
|
||||
"""Test that measure still records time on exception."""
|
||||
ctx = RecordingContext()
|
||||
with pytest.raises(ValueError):
|
||||
with ctx.measure("failing_op"):
|
||||
raise ValueError("test error")
|
||||
assert len(ctx.records) == 1
|
||||
assert ctx.records[0][0] == "failing_op"
|
||||
|
||||
|
||||
class TestRecordingContextReset:
|
||||
"""Tests for RecordingContext.reset method."""
|
||||
|
||||
def test_reset_clears_data(self):
|
||||
"""Test that reset clears all data."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value")
|
||||
ctx.reset()
|
||||
assert ctx._data == {}
|
||||
|
||||
def test_reset_clears_records(self):
|
||||
"""Test that reset clears all records."""
|
||||
ctx = RecordingContext()
|
||||
with ctx.measure("op"):
|
||||
pass
|
||||
ctx.reset()
|
||||
assert ctx.records == []
|
||||
|
||||
|
||||
class TestRecordingContextRepr:
|
||||
"""Tests for RecordingContext.__repr__ method."""
|
||||
|
||||
def test_repr_empty_context(self):
|
||||
"""Test repr of empty context."""
|
||||
ctx = RecordingContext()
|
||||
assert "RecordingContext" in repr(ctx)
|
||||
|
||||
def test_repr_with_data(self):
|
||||
"""Test repr with some data."""
|
||||
ctx = RecordingContext()
|
||||
ctx.record("key", "value")
|
||||
r = repr(ctx)
|
||||
assert "RecordingContext" in r
|
||||
assert "key" in r
|
||||
|
||||
|
||||
class TestContextVariableFunctions:
|
||||
"""Tests for context variable functions."""
|
||||
|
||||
def test_set_and_get_recording_context(self):
|
||||
"""Test set and get recording context."""
|
||||
ctx = RecordingContext()
|
||||
set_recording_context(ctx)
|
||||
assert get_recording_context() is ctx
|
||||
|
||||
def test_set_recording_context_none_unbinds(self):
|
||||
"""Test setting None unbinds the context."""
|
||||
ctx = RecordingContext()
|
||||
set_recording_context(ctx)
|
||||
set_recording_context(None)
|
||||
# After unbinding, get should raise RuntimeError
|
||||
with pytest.raises(RuntimeError, match="no context"):
|
||||
get_recording_context()
|
||||
|
||||
|
||||
class TestRecordingContextManager:
|
||||
"""Tests for recording_context_manager context manager."""
|
||||
|
||||
def test_context_manager_with_provided_context(self):
|
||||
"""Test context manager with provided context."""
|
||||
ctx = RecordingContext()
|
||||
with recording_context_manager(ctx) as mgr:
|
||||
assert mgr is ctx
|
||||
mgr.record("key", "value")
|
||||
assert ctx.get("key") == "value"
|
||||
|
||||
def test_context_manager_creates_new_context(self):
|
||||
"""Test context manager creates new context when none provided."""
|
||||
with recording_context_manager() as ctx:
|
||||
assert isinstance(ctx, RecordingContext)
|
||||
ctx.record("key", "value")
|
||||
assert ctx.get("key") == "value"
|
||||
|
||||
def test_context_manager_restores_previous_context(self):
|
||||
"""Test context manager restores previous context after exit."""
|
||||
outer_ctx = RecordingContext()
|
||||
set_recording_context(outer_ctx)
|
||||
|
||||
inner_ctx = RecordingContext()
|
||||
with recording_context_manager(inner_ctx):
|
||||
assert get_recording_context() is inner_ctx
|
||||
|
||||
# After exiting, should restore outer_ctx
|
||||
assert get_recording_context() is outer_ctx
|
||||
|
||||
|
||||
class TestTimedWithRecordingDecorator:
|
||||
"""Tests for timed_with_recording decorator."""
|
||||
|
||||
def test_decorator_without_parentheses(self):
|
||||
"""Test decorator used without parentheses."""
|
||||
ctx = RecordingContext()
|
||||
set_recording_context(ctx)
|
||||
|
||||
@timed_with_recording
|
||||
def test_func():
|
||||
time.sleep(0.01)
|
||||
return 42
|
||||
|
||||
result = test_func()
|
||||
assert result == 42
|
||||
|
||||
def test_decorator_with_parentheses_and_context(self):
|
||||
"""Test decorator with explicit context."""
|
||||
ctx = RecordingContext()
|
||||
|
||||
@timed_with_recording(recording_context=ctx)
|
||||
def test_func():
|
||||
time.sleep(0.01)
|
||||
return "hello"
|
||||
|
||||
result = test_func()
|
||||
assert result == "hello"
|
||||
|
||||
def test_decorator_without_context_raises_error(self):
|
||||
"""Test decorator raises RuntimeError when no context is available."""
|
||||
# Ensure no context is set
|
||||
set_recording_context(None)
|
||||
|
||||
@timed_with_recording
|
||||
def test_func():
|
||||
return 123
|
||||
|
||||
# Should raise RuntimeError because no context is available
|
||||
with pytest.raises(RuntimeError, match="no context"):
|
||||
test_func()
|
||||
@@ -0,0 +1,417 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for TaskContext module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext, TaskLimiters, TaskCallbacks
|
||||
|
||||
|
||||
def _make_ctx(task, **kwargs):
|
||||
"""Helper to create TaskContext with default limiters and callbacks."""
|
||||
return TaskContext(
|
||||
task=task,
|
||||
limiters=kwargs.get("limiters", TaskLimiters()),
|
||||
callbacks=kwargs.get("callbacks", TaskCallbacks()),
|
||||
write_interceptor=kwargs.get("write_interceptor", None),
|
||||
)
|
||||
|
||||
|
||||
class TestTaskContextInit:
|
||||
"""Tests for TaskContext initialization."""
|
||||
|
||||
def test_init_with_minimal_task(self):
|
||||
"""Test initialization with minimal task dict."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.id == "task_1"
|
||||
|
||||
def test_init_with_all_parameters(self):
|
||||
"""Test initialization with all parameters."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
chat_limiter = MagicMock()
|
||||
minio_limiter = MagicMock()
|
||||
chunk_limiter = MagicMock()
|
||||
embed_limiter = MagicMock()
|
||||
kg_limiter = MagicMock()
|
||||
write_interceptor = MagicMock()
|
||||
progress_callback = MagicMock()
|
||||
has_canceled_func = MagicMock()
|
||||
|
||||
ctx = TaskContext(
|
||||
task=task,
|
||||
limiters=TaskLimiters(
|
||||
chat=chat_limiter,
|
||||
minio=minio_limiter,
|
||||
chunk=chunk_limiter,
|
||||
embed=embed_limiter,
|
||||
kg=kg_limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=progress_callback,
|
||||
has_canceled=has_canceled_func,
|
||||
),
|
||||
write_interceptor=write_interceptor,
|
||||
)
|
||||
|
||||
assert ctx.chat_limiter is chat_limiter
|
||||
assert ctx.minio_limiter is minio_limiter
|
||||
assert ctx.chunk_limiter is chunk_limiter
|
||||
assert ctx.embed_limiter is embed_limiter
|
||||
assert ctx.kg_limiter is kg_limiter
|
||||
assert ctx.write_interceptor is write_interceptor
|
||||
assert ctx.callbacks.progress is progress_callback
|
||||
assert ctx.has_canceled_func is has_canceled_func
|
||||
|
||||
def test_init_defaults_for_callbacks(self):
|
||||
"""Test that callbacks default to no-op functions."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
# Should not raise
|
||||
ctx.callbacks.progress()
|
||||
assert ctx.has_canceled_func("task_1") is False
|
||||
|
||||
|
||||
class TestTaskContextIdentityProperties:
|
||||
"""Tests for task identity properties."""
|
||||
|
||||
def test_id(self):
|
||||
"""Test id property."""
|
||||
task = {"id": "task_123", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.id == "task_123"
|
||||
|
||||
def test_tenant_id(self):
|
||||
"""Test tenant_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.tenant_id == "tenant_1"
|
||||
|
||||
def test_kb_id_default(self):
|
||||
"""Test kb_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.kb_id == ""
|
||||
|
||||
def test_kb_id(self):
|
||||
"""Test kb_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "kb_id": "kb_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.kb_id == "kb_1"
|
||||
|
||||
def test_doc_id_default(self):
|
||||
"""Test doc_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.doc_id == ""
|
||||
|
||||
def test_doc_id(self):
|
||||
"""Test doc_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "doc_id": "doc_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.doc_id == "doc_1"
|
||||
|
||||
def test_doc_ids_default(self):
|
||||
"""Test doc_ids property defaults to empty list."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.doc_ids == []
|
||||
|
||||
def test_doc_ids(self):
|
||||
"""Test doc_ids property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "doc_ids": ["doc_1", "doc_2"]}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.doc_ids == ["doc_1", "doc_2"]
|
||||
|
||||
|
||||
class TestTaskContextDocumentMetadataProperties:
|
||||
"""Tests for document metadata properties."""
|
||||
|
||||
def test_name_default(self):
|
||||
"""Test name property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.name == ""
|
||||
|
||||
def test_name(self):
|
||||
"""Test name property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "name": "test.pdf"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.name == "test.pdf"
|
||||
|
||||
def test_location_default(self):
|
||||
"""Test location property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.location == ""
|
||||
|
||||
def test_size_default(self):
|
||||
"""Test size property defaults to 0."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.size == 0
|
||||
|
||||
def test_size(self):
|
||||
"""Test size property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "size": 1024}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.size == 1024
|
||||
|
||||
|
||||
class TestTaskContextParserProperties:
|
||||
"""Tests for parser configuration properties."""
|
||||
|
||||
def test_parser_id_default(self):
|
||||
"""Test parser_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.parser_id == ""
|
||||
|
||||
def test_parser_id(self):
|
||||
"""Test parser_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "parser_id": "naive"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.parser_id == "naive"
|
||||
|
||||
def test_parser_config_default(self):
|
||||
"""Test parser_config property defaults to empty dict."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.parser_config == {}
|
||||
|
||||
def test_parser_config(self):
|
||||
"""Test parser_config property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "parser_config": {"chunk_size": 512}}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.parser_config == {"chunk_size": 512}
|
||||
|
||||
def test_kb_parser_config_default(self):
|
||||
"""Test kb_parser_config property defaults to empty dict."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.kb_parser_config == {}
|
||||
|
||||
def test_kb_parser_config(self):
|
||||
"""Test kb_parser_config property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "kb_parser_config": {"language": "en"}}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.kb_parser_config == {"language": "en"}
|
||||
|
||||
|
||||
class TestTaskContextLanguageAndModelProperties:
|
||||
"""Tests for language and model properties."""
|
||||
|
||||
def test_language_default(self):
|
||||
"""Test language property defaults to 'en'."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.language == "en"
|
||||
|
||||
def test_language(self):
|
||||
"""Test language property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "language": "zh"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.language == "zh"
|
||||
|
||||
def test_llm_id_default(self):
|
||||
"""Test llm_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.llm_id == ""
|
||||
|
||||
def test_llm_id(self):
|
||||
"""Test llm_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "llm_id": "gpt-4"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.llm_id == "gpt-4"
|
||||
|
||||
def test_embd_id_default(self):
|
||||
"""Test embd_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.embd_id == ""
|
||||
|
||||
def test_embd_id(self):
|
||||
"""Test embd_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "embd_id": "text-embedding-ada-002"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.embd_id == "text-embedding-ada-002"
|
||||
|
||||
|
||||
class TestTaskContextPageRangeProperties:
|
||||
"""Tests for page range properties."""
|
||||
|
||||
def test_from_page_default(self):
|
||||
"""Test from_page property defaults to 0."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.from_page == 0
|
||||
|
||||
def test_from_page(self):
|
||||
"""Test from_page property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "from_page": 10}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.from_page == 10
|
||||
|
||||
def test_to_page_default(self):
|
||||
"""Test to_page property defaults to -1."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.to_page == -1
|
||||
|
||||
def test_to_page(self):
|
||||
"""Test to_page property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "to_page": 100}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.to_page == 100
|
||||
|
||||
|
||||
class TestTaskContextTaskTypeAndRoutingProperties:
|
||||
"""Tests for task type and routing properties."""
|
||||
|
||||
def test_task_type_default(self):
|
||||
"""Test task_type property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.task_type == ""
|
||||
|
||||
def test_task_type(self):
|
||||
"""Test task_type property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "task_type": "raptor"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.task_type == "raptor"
|
||||
|
||||
def test_dataflow_id_default(self):
|
||||
"""Test dataflow_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.dataflow_id == ""
|
||||
|
||||
def test_dataflow_id(self):
|
||||
"""Test dataflow_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "dataflow_id": "flow_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.dataflow_id == "flow_1"
|
||||
|
||||
|
||||
class TestTaskContextAdditionalProperties:
|
||||
"""Tests for additional properties."""
|
||||
|
||||
def test_pagerank_default(self):
|
||||
"""Test pagerank property defaults to 0."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.pagerank == 0
|
||||
|
||||
def test_pagerank(self):
|
||||
"""Test pagerank property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "pagerank": 10}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.pagerank == 10
|
||||
|
||||
def test_file_default(self):
|
||||
"""Test file property defaults to None."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.file is None
|
||||
|
||||
def test_file(self):
|
||||
"""Test file property."""
|
||||
file_obj = MagicMock()
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "file": file_obj}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.file is file_obj
|
||||
|
||||
|
||||
class TestTaskContextMemoryProperties:
|
||||
"""Tests for memory task properties."""
|
||||
|
||||
def test_memory_id_default(self):
|
||||
"""Test memory_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.memory_id == ""
|
||||
|
||||
def test_memory_id(self):
|
||||
"""Test memory_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "memory_id": "mem_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.memory_id == "mem_1"
|
||||
|
||||
def test_source_id_default(self):
|
||||
"""Test source_id property defaults to empty string."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.source_id == ""
|
||||
|
||||
def test_source_id(self):
|
||||
"""Test source_id property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "source_id": "src_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.source_id == "src_1"
|
||||
|
||||
def test_message_dict_default(self):
|
||||
"""Test message_dict property defaults to empty dict."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.message_dict == {}
|
||||
|
||||
def test_message_dict(self):
|
||||
"""Test message_dict property."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "message_dict": {"key": "value"}}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.message_dict == {"key": "value"}
|
||||
|
||||
|
||||
class TestTaskContextRawTask:
|
||||
"""Tests for raw_task property and get method."""
|
||||
|
||||
def test_raw_task_returns_original_dict(self):
|
||||
"""Test raw_task returns the original task dict."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "custom_key": "value"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.raw_task is task
|
||||
|
||||
def test_get_existing_key(self):
|
||||
"""Test get method with existing key."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1", "custom_key": "value"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.get("custom_key") == "value"
|
||||
|
||||
def test_get_nonexistent_key_returns_none(self):
|
||||
"""Test get method with nonexistent key returns None."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.get("missing") is None
|
||||
|
||||
def test_get_with_default(self):
|
||||
"""Test get method with default value."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
assert ctx.get("missing", "default") == "default"
|
||||
|
||||
|
||||
class TestTaskContextProgressCallback:
|
||||
"""Tests for progress callback functionality."""
|
||||
|
||||
def test_progress_cb_is_set_in_init(self):
|
||||
"""Test that _progress_cb is set during initialization."""
|
||||
task = {"id": "task_1", "tenant_id": "tenant_1"}
|
||||
ctx = _make_ctx(task=task)
|
||||
# _progress_cb should be set in __init__
|
||||
assert hasattr(ctx, '_progress_cb')
|
||||
assert ctx._progress_cb is not None
|
||||
@@ -0,0 +1,300 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
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.
|
||||
|
||||
Stable pure helpers (_build_toc, _get_vector_size) are tested directly since they
|
||||
are side-effect-free data transformations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from rag.svr.task_executor_refactor.task_handler import TaskHandler
|
||||
|
||||
|
||||
class TestTaskHandlerHandleTask:
|
||||
"""Tests for the public handle_task() entry point."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_task_calls_handle(self):
|
||||
"""Test handle_task delegates to handle()."""
|
||||
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=False)
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
handler.handle = AsyncMock()
|
||||
await handler.handle_task()
|
||||
handler.handle.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_task_cleanup_on_cancel(self):
|
||||
"""Test handle_task cleans up docStore when canceled."""
|
||||
from common import settings
|
||||
mock_doc_store = MagicMock()
|
||||
mock_doc_store.index_exist = MagicMock(return_value=True)
|
||||
mock_doc_store.delete = MagicMock(return_value=None)
|
||||
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"))
|
||||
# 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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@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)
|
||||
|
||||
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()
|
||||
await handler.handle()
|
||||
mock_handle.assert_called_once_with(ctx.raw_task)
|
||||
|
||||
@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)
|
||||
|
||||
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()
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
ctx.progress_cb.assert_called_once_with(-1, msg="Task has been canceled.")
|
||||
|
||||
@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
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
handler._bind_embedding_model = AsyncMock(return_value=None)
|
||||
|
||||
await handler.handle()
|
||||
# Should not call _run_standard_chunking when model is None
|
||||
assert not hasattr(handler, '_run_standard_chunking_called')
|
||||
|
||||
|
||||
class TestTaskHandlerGetVectorSize:
|
||||
"""Tests for _get_vector_size — stable pure helper."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestTaskHandlerBuildToc:
|
||||
"""Tests for _build_toc — stable pure helper (requires LLM mocking)."""
|
||||
|
||||
def test_build_toc_with_empty_docs(self):
|
||||
"""Test _build_toc returns None when run_toc_from_text returns empty."""
|
||||
ctx = MagicMock()
|
||||
ctx.tenant_id = "tenant_1"
|
||||
ctx.llm_id = "llm_1"
|
||||
ctx.language = "en"
|
||||
|
||||
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_by_type_and_name") as mock_cfg:
|
||||
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
|
||||
|
||||
def test_build_toc_with_results(self):
|
||||
"""Test _build_toc builds TOC chunk when results exist."""
|
||||
ctx = MagicMock()
|
||||
ctx.tenant_id = "tenant_1"
|
||||
ctx.llm_id = "llm_1"
|
||||
ctx.language = "en"
|
||||
|
||||
docs = [{"id": "chunk_0", "content_with_weight": "text", "doc_id": "doc_1", "page_num_int": [1], "top_int": [0]}]
|
||||
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_by_type_and_name") as mock_cfg:
|
||||
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
|
||||
|
||||
|
||||
class TestTaskHandlerInit:
|
||||
"""Tests for TaskHandler initialization."""
|
||||
|
||||
def test_init_stores_context_and_hook(self):
|
||||
ctx = MagicMock()
|
||||
hook = MagicMock()
|
||||
handler = TaskHandler(ctx=ctx, billing_hook=hook)
|
||||
assert handler._task_context is ctx
|
||||
assert handler._billing_hook is hook
|
||||
|
||||
def test_init_default_hook_none(self):
|
||||
ctx = MagicMock()
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
assert handler._billing_hook is None
|
||||
@@ -0,0 +1,993 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Integration tests for TaskHandler orchestration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rag.svr.task_executor_refactor.task_handler import TaskHandler
|
||||
from rag.svr.task_executor_refactor.task_context import TaskContext, TaskLimiters, TaskCallbacks
|
||||
from rag.svr.task_executor_refactor.recording_context import BaseRecordingContext, RecordingContext
|
||||
from rag.svr.task_executor_refactor.constants import CANVAS_DEBUG_DOC_ID, GRAPH_RAPTOR_FAKE_DOC_ID
|
||||
|
||||
# Import shared helpers from conftest
|
||||
from test.unit_test.rag.svr.task_executor_refactor.conftest import (
|
||||
AsyncMockLimiter,
|
||||
create_mock_embedding_model,
|
||||
create_default_chunks,
|
||||
create_mock_settings,
|
||||
create_mock_chunk_service,
|
||||
)
|
||||
|
||||
|
||||
def create_task_context(
|
||||
task_dict: Dict[str, Any],
|
||||
is_canceled: bool = False,
|
||||
recording_context: BaseRecordingContext | None = None,
|
||||
) -> TaskContext:
|
||||
"""Create a real TaskContext with mocked limiters and callbacks.
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary with all task attributes.
|
||||
is_canceled: If True, has_canceled_func returns True.
|
||||
recording_context: RecordingContext to inject. If None, a new one
|
||||
is created automatically so that recording_context access works.
|
||||
|
||||
Returns:
|
||||
TaskContext with all required dependencies injected.
|
||||
"""
|
||||
if recording_context is None:
|
||||
recording_context = RecordingContext()
|
||||
limiter = AsyncMockLimiter()
|
||||
progress_callback = MagicMock()
|
||||
ctx = TaskContext(
|
||||
task=task_dict,
|
||||
limiters=TaskLimiters(
|
||||
chat=limiter,
|
||||
minio=limiter,
|
||||
chunk=limiter,
|
||||
embed=limiter,
|
||||
kg=limiter,
|
||||
),
|
||||
callbacks=TaskCallbacks(
|
||||
progress=progress_callback,
|
||||
has_canceled=MagicMock(return_value=is_canceled),
|
||||
),
|
||||
recording_context=recording_context,
|
||||
)
|
||||
# Add progress_callback property for task_handler compatibility
|
||||
ctx.progress_callback = progress_callback
|
||||
# Add set_progress_cb method for task_handler compatibility
|
||||
ctx.set_progress_cb = lambda cb: setattr(ctx.callbacks, 'progress_cb', cb)
|
||||
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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
task_status = recording_ctx.get("task_status")
|
||||
assert task_status == "completed", f"Expected task_status='completed', got {task_status}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
insertion_result = recording_ctx.get("insertion_result")
|
||||
assert insertion_result == "success", f"Expected insertion_result='success', got {insertion_result}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunks = create_default_chunks(count=3)
|
||||
mock_chunk_service = create_mock_chunk_service(chunks=mock_chunks)
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.run_toc_from_text", new_callable=AsyncMock) as mock_run_toc, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
mock_doc_service.update_document_metadata = MagicMock()
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
chunk_ids_count = recording_ctx.get("chunk_ids_count")
|
||||
assert chunk_ids_count is not None, "chunk_ids_count should be recorded"
|
||||
assert chunk_ids_count == 3, f"Expected chunk_ids_count=3, got {chunk_ids_count}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
token_count = recording_ctx.get("token_count")
|
||||
vector_size = recording_ctx.get("vector_size")
|
||||
|
||||
assert token_count is not None, "token_count should be recorded"
|
||||
assert vector_size is not None, "vector_size should be recorded"
|
||||
assert vector_size == 128, f"Expected vector_size=128, got {vector_size}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
ctx.progress_callback.assert_called()
|
||||
call_count = ctx.progress_callback.call_count
|
||||
assert call_count > 0, "progress_callback should have been invoked at least once"
|
||||
|
||||
|
||||
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()
|
||||
ctx = create_task_context(task_dict, is_canceled=True)
|
||||
mock_settings = create_mock_settings()
|
||||
|
||||
call_log = []
|
||||
|
||||
def mock_thread_impl(func, *args, **kwargs):
|
||||
# Get the actual method name from the mock
|
||||
func_repr = repr(func)
|
||||
call_log.append(func_repr)
|
||||
if 'index_exist' in func_repr:
|
||||
return True
|
||||
if 'delete' in func_repr:
|
||||
return {"result": "deleted"}
|
||||
return {"result": "deleted"}
|
||||
|
||||
with patch_task_handler_settings(mock_settings), \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_index"), \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec", side_effect=mock_thread_impl):
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle_task()
|
||||
|
||||
# Verify delete was called by checking the call log
|
||||
delete_calls = [c for c in call_log if 'delete' in c]
|
||||
assert len(delete_calls) >= 1, f"Expected at least one delete call, got: {call_log}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict, is_canceled=True)
|
||||
mock_settings = create_mock_settings()
|
||||
|
||||
def mock_thread_impl(func, *args, **kwargs):
|
||||
func_repr = repr(func)
|
||||
if 'index_exist' in func_repr:
|
||||
return True
|
||||
if 'delete' in func_repr:
|
||||
return {"result": "deleted"}
|
||||
return {"result": "deleted"}
|
||||
|
||||
with patch_task_handler_settings(mock_settings), \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name", return_value="test_index"), \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec", side_effect=mock_thread_impl):
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle_task()
|
||||
|
||||
ctx.progress_callback.assert_called()
|
||||
call_args_list = ctx.progress_callback.call_args_list
|
||||
# Check for -1 in any position of the call arguments
|
||||
has_negative_progress = False
|
||||
for call in call_args_list:
|
||||
# Check positional args
|
||||
for arg in call[0]:
|
||||
if arg == -1:
|
||||
has_negative_progress = True
|
||||
break
|
||||
# Check keyword args
|
||||
if call[1].get("prog") == -1:
|
||||
has_negative_progress = True
|
||||
if has_negative_progress:
|
||||
break
|
||||
assert has_negative_progress, f"progress_callback should have been called with -1 progress. Calls: {call_args_list}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict, is_canceled=True)
|
||||
mock_settings = create_mock_settings()
|
||||
|
||||
with patch_task_handler_settings(mock_settings), \
|
||||
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.get_model_config_by_type_and_name") 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:
|
||||
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_settings.docStoreConn.index_exist.return_value = True
|
||||
mock_settings.docStoreConn.delete.return_value = {"result": "deleted"}
|
||||
|
||||
async def mock_thread_impl(func, *args, **kwargs):
|
||||
return {"result": "deleted"}
|
||||
|
||||
mock_thread_exec.side_effect = mock_thread_impl
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle_task()
|
||||
|
||||
mock_bundle.assert_not_called()
|
||||
|
||||
|
||||
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()
|
||||
ctx = create_task_context(task_dict)
|
||||
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 = {"raptor": {"use_raptor": False}}
|
||||
|
||||
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_by_type_and_name") 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.RaptorService") as mock_raptor_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service:
|
||||
|
||||
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_raptor_service.return_value.run_raptor_for_kb = AsyncMock(return_value=([], 0, []))
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
task_status = recording_ctx.get("task_status")
|
||||
assert task_status == "completed", f"Expected task_status='completed', got {task_status}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
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 = {"raptor": {"use_raptor": False}}
|
||||
|
||||
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_by_type_and_name") 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.RaptorService") as mock_raptor_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service:
|
||||
|
||||
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_raptor_service.return_value.run_raptor_for_kb = AsyncMock(return_value=([], 0, []))
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
# Check that the kb parser_config was updated
|
||||
mock_kb_service.update_by_id.assert_called_once()
|
||||
call_args = mock_kb_service.update_by_id.call_args
|
||||
update_dict = call_args[0][1]
|
||||
assert update_dict.get("parser_config", {}).get("raptor", {}).get("use_raptor") is True, \
|
||||
"RAPTOR should be enabled in parser_config after running"
|
||||
|
||||
|
||||
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()
|
||||
ctx = create_task_context(task_dict)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_by_type_and_name") as mock_get_config, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type") as mock_get_default:
|
||||
|
||||
mock_get_config.side_effect = Exception("Model not found")
|
||||
mock_get_default.side_effect = Exception("Model not found")
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
|
||||
with pytest.raises(Exception, match="Model not found"):
|
||||
await handler.handle()
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.task_handler.get_model_config_by_type_and_name") as mock_get_config, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.get_tenant_default_model_by_type") as mock_get_default:
|
||||
|
||||
mock_get_config.side_effect = Exception("Model not found")
|
||||
mock_get_default.side_effect = Exception("Model not found")
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await handler.handle()
|
||||
|
||||
ctx.progress_callback.assert_called()
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
mock_dataflow_service.assert_called_once()
|
||||
mock_instance.run_dataflow.assert_called_once()
|
||||
|
||||
|
||||
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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.run_toc_from_text", new_callable=AsyncMock) as mock_run_toc, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls, \
|
||||
patch("rag.svr.task_executor_refactor.post_processor.DocumentService") as mock_post_doc_service:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
mock_doc_service.update_document_metadata = MagicMock()
|
||||
mock_chunk_service_cls.return_value = mock_chunk_service
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
mock_run_toc.assert_called()
|
||||
|
||||
# Explicit cleanup to prevent resource leaks
|
||||
del mock_embedding, mock_settings, mock_chunk_service
|
||||
del mock_get_config, mock_get_default, mock_bundle, mock_file_service
|
||||
del mock_index_name, mock_doc_service, mock_chunk_service_cls, mock_run_toc, mock_post_doc_service
|
||||
del mock_thread_exec, mock_chunk_thread_exec
|
||||
# Allow pending callbacks to execute
|
||||
await asyncio.sleep(0)
|
||||
gc.collect()
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||||
async def test_toc_async_flow_does_not_create_thread_when_disabled(self):
|
||||
"""Verify that TOC async flow does not create a thread when disabled.
|
||||
|
||||
Note: This test has a known issue with resource leaks (unclosed sockets and
|
||||
event loops) when run as part of the full test suite. The warning filter
|
||||
above suppresses these warnings temporarily. The root cause is related to
|
||||
asyncio.to_thread creating new event loops that are not properly cleaned up
|
||||
by pytest-asyncio.
|
||||
"""
|
||||
|
||||
task_dict = self._create_toc_enabled_task_dict()
|
||||
task_dict["parser_config"]["toc_extraction"] = False
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.run_toc_from_text", new_callable=AsyncMock) as mock_run_toc, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
mock_run_toc.assert_not_called()
|
||||
|
||||
# Explicit cleanup to prevent resource leaks
|
||||
del mock_embedding, mock_settings, mock_chunk_service
|
||||
del mock_get_config, mock_get_default, mock_bundle, mock_file_service
|
||||
del mock_index_name, mock_doc_service, mock_chunk_service_cls, mock_run_toc
|
||||
del mock_thread_exec, mock_chunk_thread_exec
|
||||
# Allow pending callbacks to execute and close event loop
|
||||
await asyncio.sleep(0)
|
||||
# Cancel all pending tasks
|
||||
current_task = asyncio.current_task()
|
||||
pending = [t for t in asyncio.all_tasks() if t is not current_task and not t.done()]
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
gc.collect()
|
||||
|
||||
|
||||
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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
file_size_exceeded = recording_ctx.get("file_size_exceeded")
|
||||
assert file_size_exceeded is None or file_size_exceeded is False, \
|
||||
f"Expected file_size_exceeded to be False/None for small file, got {file_size_exceeded}"
|
||||
|
||||
@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()
|
||||
ctx = create_task_context(task_dict)
|
||||
mock_embedding = create_mock_embedding_model(vector_size=128)
|
||||
mock_settings = create_mock_settings()
|
||||
mock_chunk_service = create_mock_chunk_service()
|
||||
|
||||
with patch_get_storage_binary(), \
|
||||
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_by_type_and_name") 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.File2DocumentService") as mock_file_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.thread_pool_exec") as mock_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.chunk_service.thread_pool_exec") as mock_chunk_thread_exec, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.DocumentService") as mock_doc_service, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.search.index_name") as mock_index_name, \
|
||||
patch("rag.svr.task_executor_refactor.task_handler.ChunkService") as mock_chunk_service_cls:
|
||||
|
||||
mock_get_config.return_value = MagicMock()
|
||||
mock_get_default.return_value = MagicMock()
|
||||
mock_bundle.return_value = mock_embedding
|
||||
mock_file_service.get_storage_address.return_value = ("bucket_test", "name_test")
|
||||
mock_index_name.return_value = "test_index"
|
||||
mock_doc_service.increment_chunk_num = MagicMock()
|
||||
mock_doc_service.get_document_metadata.return_value = {}
|
||||
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
|
||||
|
||||
handler = TaskHandler(ctx=ctx)
|
||||
await handler.handle()
|
||||
|
||||
recording_ctx = ctx.recording_context
|
||||
# parser_id is available in the task context, verify task completion
|
||||
task_status = recording_ctx.get("task_status")
|
||||
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}"
|
||||
@@ -0,0 +1,219 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for rag/svr/task_executor_refactor/raptor_utils.py module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from rag.svr.task_executor_refactor.raptor_utils import (
|
||||
get_raptor_chunk_field_map,
|
||||
delete_raptor_chunks,
|
||||
)
|
||||
|
||||
|
||||
class TestGetRaptorChunkFieldMap:
|
||||
"""Tests for get_raptor_chunk_field_map function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_primary_result_when_raptor_chunks_exist(self):
|
||||
"""Test that primary result is returned when RAPTOR chunks exist."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
mock_doc_store.search.return_value = {"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}}}
|
||||
mock_doc_store.get_fields.return_value = {"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}}}
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
async def mock_exec(*args, **kwargs):
|
||||
return {"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}}}
|
||||
mock_thread.side_effect = mock_exec
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = {"chunk_1"}
|
||||
|
||||
result = await get_raptor_chunk_field_map("doc_1", "tenant_1", "kb_1")
|
||||
|
||||
assert "chunk_1" in result
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_secondary_search_when_no_raptor_chunks(self):
|
||||
"""Test that fallback search is used when no RAPTOR chunks found."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
call_count = 0
|
||||
async def mock_exec(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return {} # Primary returns empty
|
||||
else:
|
||||
return {"chunk_1": {"raptor_kwd": "raptor"}} # Fallback
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
mock_thread.side_effect = mock_exec
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = set() # Primary has no RAPTOR chunks
|
||||
|
||||
_ = await get_raptor_chunk_field_map("doc_1", "tenant_1", "kb_1")
|
||||
|
||||
# Should have called thread_pool_exec twice (primary + fallback)
|
||||
assert mock_thread.call_count == 2
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_fallback_search_exception(self):
|
||||
"""Test that exception in fallback search is handled gracefully."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
mock_doc_store.get_fields.return_value = {}
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
call_count = 0
|
||||
async def mock_exec(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return {} # Primary returns empty
|
||||
else:
|
||||
raise Exception("Fallback search failed") # Fallback will raise exception
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
mock_thread.side_effect = mock_exec
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = set() # Primary has no RAPTOR chunks
|
||||
|
||||
# Fallback will raise exception, but it should be caught
|
||||
result = await get_raptor_chunk_field_map("doc_1", "tenant_1", "kb_1")
|
||||
|
||||
# Should return primary result (empty)
|
||||
assert result == {}
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
|
||||
|
||||
class TestDeleteRaptorChunks:
|
||||
"""Tests for delete_raptor_chunks function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_all_chunks_when_keep_method_is_none(self):
|
||||
"""Test that all RAPTOR chunks are deleted when keep_method is None."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = 0
|
||||
|
||||
_ = await delete_raptor_chunks("doc_1", "tenant_1", "kb_1", keep_method=None)
|
||||
|
||||
mock_thread.assert_called_once()
|
||||
# Verify delete was called with correct condition
|
||||
call_args = mock_thread.call_args
|
||||
assert call_args[0][0] == settings.docStoreConn.delete
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_0_when_no_stale_chunks(self):
|
||||
"""Test that 0 is returned when no stale chunks to delete."""
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.get_raptor_chunk_field_map") as mock_get_map:
|
||||
mock_get_map.return_value = {}
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = set() # No stale chunks
|
||||
|
||||
result = await delete_raptor_chunks("doc_1", "tenant_1", "kb_1", keep_method="raptor")
|
||||
|
||||
assert result == 0
|
||||
mock_collect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_stale_chunks_when_keep_method_specified(self):
|
||||
"""Test that stale chunks are deleted when keep_method is specified."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.get_raptor_chunk_field_map") as mock_get_map:
|
||||
mock_get_map.return_value = {
|
||||
"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}},
|
||||
"chunk_2": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}}
|
||||
}
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = {"chunk_1"} # Only chunk_1 is stale (psi, not raptor)
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = 0
|
||||
|
||||
_ = await delete_raptor_chunks("doc_1", "tenant_1", "kb_1", keep_method="raptor")
|
||||
|
||||
# Should have called delete for stale chunks
|
||||
mock_thread.assert_called_once()
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_info_when_removing_stale_chunks(self):
|
||||
"""Test that info is logged when removing stale chunks."""
|
||||
from common import settings
|
||||
original_retriever = settings.docStoreConn
|
||||
|
||||
mock_doc_store = MagicMock()
|
||||
settings.docStoreConn = mock_doc_store
|
||||
|
||||
try:
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.get_raptor_chunk_field_map") as mock_get_map:
|
||||
mock_get_map.return_value = {
|
||||
"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}}
|
||||
}
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.collect_raptor_chunk_ids") as mock_collect:
|
||||
mock_collect.return_value = {"chunk_1"}
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.thread_pool_exec") as mock_thread:
|
||||
mock_thread.return_value = 0
|
||||
|
||||
with patch("rag.svr.task_executor_refactor.raptor_utils.logging.info") as mock_log:
|
||||
await delete_raptor_chunks("doc_1", "tenant_1", "kb_1", keep_method="raptor")
|
||||
|
||||
# Should have logged the removal
|
||||
mock_log.assert_called()
|
||||
finally:
|
||||
settings.docStoreConn = original_retriever
|
||||
@@ -0,0 +1,228 @@
|
||||
#
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Unit tests for WriteOperationInterceptor module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rag.svr.task_executor_refactor.write_operation_interceptor import (
|
||||
WriteOperationInterceptor,
|
||||
ALLOWED_METHOD_NAMES,
|
||||
)
|
||||
|
||||
|
||||
def _create_valid_recorded_values():
|
||||
"""Helper to create valid recorded_values dict."""
|
||||
return {method: [] for method in ALLOWED_METHOD_NAMES}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_recorded_values():
|
||||
"""Provide a valid recorded_values dict for testing."""
|
||||
return _create_valid_recorded_values()
|
||||
|
||||
|
||||
class TestAllowedMethodNames:
|
||||
"""Tests for ALLOWED_METHOD_NAMES constant."""
|
||||
|
||||
def test_allowed_method_names_count(self):
|
||||
"""Test that ALLOWED_METHOD_NAMES contains exactly 8 methods."""
|
||||
assert len(ALLOWED_METHOD_NAMES) == 10
|
||||
|
||||
def test_allowed_method_names_contains_expected_methods(self):
|
||||
"""Test that ALLOWED_METHOD_NAMES contains all expected methods."""
|
||||
expected_methods = {
|
||||
"KnowledgebaseService.update_by_id",
|
||||
"TaskService.update_chunk_ids",
|
||||
"DocumentService.increment_chunk_num",
|
||||
"DocMetadataService.update_document_metadata",
|
||||
"PipelineOperationLogService.record_pipeline_operation",
|
||||
"handle_save_to_memory_task",
|
||||
"PipelineOperationLogService.create",
|
||||
"delete_raptor_chunks",
|
||||
"docStoreConn.insert",
|
||||
"docStoreConn.delete"
|
||||
}
|
||||
assert ALLOWED_METHOD_NAMES == expected_methods
|
||||
|
||||
|
||||
class TestWriteOperationInterceptorInit:
|
||||
"""Tests for WriteOperationInterceptor.__init__."""
|
||||
|
||||
def test_init_with_valid_empty_values(self, valid_recorded_values):
|
||||
"""Test initialization with valid but empty values for all methods."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor is not None
|
||||
|
||||
def test_init_with_valid_values(self, valid_recorded_values):
|
||||
"""Test initialization with valid recorded values."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [1, 0]
|
||||
valid_recorded_values["handle_save_to_memory_task"] = [None]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor is not None
|
||||
|
||||
def test_init_with_extra_keys_ignored(self, valid_recorded_values):
|
||||
"""Test that extra keys in recorded_values are ignored."""
|
||||
valid_recorded_values["invalid_method_name"] = [1, 2, 3]
|
||||
# Should not raise an error, extra keys are simply ignored
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor is not None
|
||||
# The extra key should not be accessible
|
||||
assert "invalid_method_name" not in interceptor._recorded_values
|
||||
|
||||
|
||||
class TestWriteOperationInterceptorIntercept:
|
||||
"""Tests for WriteOperationInterceptor.intercept."""
|
||||
|
||||
def test_intercept_returns_first_value(self, valid_recorded_values):
|
||||
"""Test that intercept returns the first value in the list."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [1, 0, 2]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
result = interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
assert result == 1
|
||||
|
||||
def test_intercept_returns_subsequent_values(self, valid_recorded_values):
|
||||
"""Test that intercept returns subsequent values on each call."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [1, 0, 2]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor.intercept("KnowledgebaseService.update_by_id") == 1
|
||||
assert interceptor.intercept("KnowledgebaseService.update_by_id") == 0
|
||||
assert interceptor.intercept("KnowledgebaseService.update_by_id") == 2
|
||||
|
||||
def test_intercept_invalid_method_raises_value_error(self, valid_recorded_values):
|
||||
"""Test that intercepting an invalid method raises ValueError."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
with pytest.raises(ValueError, match="Cannot intercept method"):
|
||||
interceptor.intercept("invalid_method_name")
|
||||
|
||||
def test_intercept_empty_list_raises_index_error(self, valid_recorded_values):
|
||||
"""Test that intercepting when list is empty raises IndexError."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
with pytest.raises(IndexError, match="No more recorded values"):
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
|
||||
def test_intercept_pops_value(self, valid_recorded_values):
|
||||
"""Test that intercept pops the value from the internal list."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [42]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
# Check internal state, not the original input list (which is copied)
|
||||
assert len(interceptor._recorded_values["KnowledgebaseService.update_by_id"]) == 0
|
||||
|
||||
def test_intercept_with_none_value(self, valid_recorded_values):
|
||||
"""Test that intercept can return None values."""
|
||||
valid_recorded_values["handle_save_to_memory_task"] = [None]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
result = interceptor.intercept("handle_save_to_memory_task")
|
||||
assert result is None
|
||||
|
||||
def test_intercept_with_default_value_when_empty(self, valid_recorded_values):
|
||||
"""Test that intercept returns default_value when list is empty."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
result = interceptor.intercept("KnowledgebaseService.update_by_id", default_value=42)
|
||||
assert result == 42
|
||||
|
||||
def test_intercept_with_default_value_none_when_empty(self, valid_recorded_values):
|
||||
"""Test that intercept returns None when default_value is None and list is empty."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
# When default_value is None, it should return None (not raise IndexError)
|
||||
# because None is a valid default value (different from _NO_DEFAULT sentinel)
|
||||
result = interceptor.intercept("KnowledgebaseService.update_by_id", default_value=None)
|
||||
assert result is None
|
||||
|
||||
def test_intercept_default_value_does_not_affect_existing_values(self, valid_recorded_values):
|
||||
"""Test that default_value is only used when list is empty."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [100]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
# Should return the recorded value, not the default_value
|
||||
result = interceptor.intercept("KnowledgebaseService.update_by_id", default_value=999)
|
||||
assert result == 100
|
||||
|
||||
@pytest.mark.parametrize("default_value", [
|
||||
"default_string",
|
||||
{"status": "success", "data": [1, 2, 3]},
|
||||
[1, 2, 3, 4, 5],
|
||||
(1, "two", 3.0),
|
||||
True,
|
||||
False,
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
{},
|
||||
])
|
||||
def test_intercept_with_various_default_values(self, valid_recorded_values, default_value):
|
||||
"""Test that intercept returns various default_value types when list is empty."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
result = interceptor.intercept("KnowledgebaseService.update_by_id", default_value=default_value)
|
||||
assert result == default_value
|
||||
|
||||
def test_intercept_with_complex_values(self, valid_recorded_values):
|
||||
"""Test that intercept can return complex values like dicts and tuples."""
|
||||
complex_value = {"key": "value", "nested": [1, 2, 3]}
|
||||
valid_recorded_values["DocMetadataService.update_document_metadata"] = [complex_value]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
result = interceptor.intercept("DocMetadataService.update_document_metadata")
|
||||
assert result == complex_value
|
||||
|
||||
class TestWriteOperationInterceptorRemainingCount:
|
||||
"""Tests for WriteOperationInterceptor.remaining_count."""
|
||||
|
||||
def test_remaining_count_with_values(self, valid_recorded_values):
|
||||
"""Test remaining_count returns correct count."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [1, 2, 3]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 3
|
||||
|
||||
def test_remaining_count_empty_list(self, valid_recorded_values):
|
||||
"""Test remaining_count returns 0 for empty list."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 0
|
||||
|
||||
with pytest.raises(IndexError):
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
|
||||
def test_remaining_count_after_intercept(self, valid_recorded_values):
|
||||
"""Test remaining_count decreases after intercept calls."""
|
||||
valid_recorded_values["KnowledgebaseService.update_by_id"] = [1, 2, 3]
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 3
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 2
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 1
|
||||
interceptor.intercept("KnowledgebaseService.update_by_id")
|
||||
assert interceptor.remaining_count("KnowledgebaseService.update_by_id") == 0
|
||||
|
||||
def test_remaining_count_invalid_method(self, valid_recorded_values):
|
||||
"""Test remaining_count returns 0 for invalid method names."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
assert interceptor.remaining_count("invalid_method") == 0
|
||||
|
||||
|
||||
class TestWriteOperationInterceptorRepr:
|
||||
"""Tests for WriteOperationInterceptor.__repr__."""
|
||||
|
||||
def test_repr_contains_class_name(self, valid_recorded_values):
|
||||
"""Test that repr contains the class name."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
repr_str = repr(interceptor)
|
||||
assert "WriteOperationInterceptor" in repr_str
|
||||
|
||||
def test_repr_contains_total_recorded(self, valid_recorded_values):
|
||||
"""Test that repr contains total_recorded."""
|
||||
interceptor = WriteOperationInterceptor(valid_recorded_values)
|
||||
repr_str = repr(interceptor)
|
||||
assert "total_recorded=" in repr_str
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -12,395 +12,441 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
Unit tests for Raptor utility functions.
|
||||
Unit tests for rag/utils/raptor_utils.py module.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from rag.utils.raptor_utils import (
|
||||
CSV_EXTENSIONS,
|
||||
EXCEL_EXTENSIONS,
|
||||
STRUCTURED_EXTENSIONS,
|
||||
collect_raptor_chunk_ids,
|
||||
collect_raptor_methods,
|
||||
get_raptor_clustering_method,
|
||||
RAPTOR_TREE_BUILDER,
|
||||
PSI_TREE_BUILDER,
|
||||
GMM_CLUSTERING_METHOD,
|
||||
AHC_CLUSTERING_METHOD,
|
||||
get_raptor_tree_builder,
|
||||
get_skip_reason,
|
||||
get_raptor_clustering_method,
|
||||
_as_extra_dict,
|
||||
_has_raptor_marker,
|
||||
_raptor_methods_from_fields,
|
||||
collect_raptor_methods,
|
||||
collect_raptor_chunk_ids,
|
||||
make_raptor_summary_chunk_id,
|
||||
is_structured_file_type,
|
||||
is_tabular_pdf,
|
||||
make_raptor_summary_chunk_id,
|
||||
should_skip_raptor,
|
||||
get_skip_reason,
|
||||
)
|
||||
|
||||
|
||||
class TestGetRaptorTreeBuilder:
|
||||
"""Tests for get_raptor_tree_builder function."""
|
||||
|
||||
def test_returns_default_raptor_tree_builder(self):
|
||||
"""Test that default tree builder is 'raptor'."""
|
||||
result = get_raptor_tree_builder(None)
|
||||
assert result == RAPTOR_TREE_BUILDER
|
||||
|
||||
def test_returns_default_with_empty_config(self):
|
||||
"""Test that empty config returns default."""
|
||||
result = get_raptor_tree_builder({})
|
||||
assert result == RAPTOR_TREE_BUILDER
|
||||
|
||||
def test_returns_configured_tree_builder(self):
|
||||
"""Test that configured tree builder is returned."""
|
||||
config = {"tree_builder": PSI_TREE_BUILDER}
|
||||
result = get_raptor_tree_builder(config)
|
||||
assert result == PSI_TREE_BUILDER
|
||||
|
||||
def test_returns_ext_tree_builder(self):
|
||||
"""Test that ext.tree_builder takes precedence."""
|
||||
config = {"tree_builder": "old", "ext": {"tree_builder": PSI_TREE_BUILDER}}
|
||||
result = get_raptor_tree_builder(config)
|
||||
assert result == PSI_TREE_BUILDER
|
||||
|
||||
def test_raises_error_for_unsupported_tree_builder(self):
|
||||
"""Test that unsupported tree builder raises ValueError."""
|
||||
config = {"tree_builder": "unknown"}
|
||||
with pytest.raises(ValueError, match="Unsupported RAPTOR tree builder"):
|
||||
get_raptor_tree_builder(config)
|
||||
|
||||
|
||||
class TestGetRaptorClusteringMethod:
|
||||
"""Tests for get_raptor_clustering_method function."""
|
||||
|
||||
def test_returns_default_gmm(self):
|
||||
"""Test that default clustering method is 'gmm'."""
|
||||
result = get_raptor_clustering_method(None)
|
||||
assert result == GMM_CLUSTERING_METHOD
|
||||
|
||||
def test_returns_configured_clustering_method(self):
|
||||
"""Test that configured clustering method is returned."""
|
||||
config = {"clustering_method": AHC_CLUSTERING_METHOD}
|
||||
result = get_raptor_clustering_method(config)
|
||||
assert result == AHC_CLUSTERING_METHOD
|
||||
|
||||
def test_returns_ext_clustering_method(self):
|
||||
"""Test that ext.clustering_method takes precedence."""
|
||||
config = {"clustering_method": "old", "ext": {"clustering_method": AHC_CLUSTERING_METHOD}}
|
||||
result = get_raptor_clustering_method(config)
|
||||
assert result == AHC_CLUSTERING_METHOD
|
||||
|
||||
def test_raises_error_for_unsupported_clustering_method(self):
|
||||
"""Test that unsupported clustering method raises ValueError."""
|
||||
config = {"clustering_method": "unknown"}
|
||||
with pytest.raises(ValueError, match="Unsupported RAPTOR clustering method"):
|
||||
get_raptor_clustering_method(config)
|
||||
|
||||
|
||||
class TestAsExtraDict:
|
||||
"""Tests for _as_extra_dict function."""
|
||||
|
||||
def test_returns_dict_as_is(self):
|
||||
"""Test that dict input is returned as-is."""
|
||||
input_dict = {"key": "value"}
|
||||
result = _as_extra_dict(input_dict)
|
||||
assert result == input_dict
|
||||
|
||||
def test_returns_empty_dict_for_none(self):
|
||||
"""Test that None input returns empty dict."""
|
||||
result = _as_extra_dict(None)
|
||||
assert result == {}
|
||||
|
||||
def test_returns_empty_dict_for_empty_string(self):
|
||||
"""Test that empty string input returns empty dict."""
|
||||
result = _as_extra_dict("")
|
||||
assert result == {}
|
||||
|
||||
def test_parses_valid_json_string(self):
|
||||
"""Test that valid JSON string is parsed correctly."""
|
||||
input_str = '{"key": "value"}'
|
||||
result = _as_extra_dict(input_str)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_returns_empty_dict_for_non_dict_json(self):
|
||||
"""Test that non-dict JSON returns empty dict."""
|
||||
input_str = '[1, 2, 3]'
|
||||
result = _as_extra_dict(input_str)
|
||||
assert result == {}
|
||||
|
||||
def test_parses_python_dict_literal(self):
|
||||
"""Test that Python dict literal is parsed."""
|
||||
input_str = "{'key': 'value'}"
|
||||
result = _as_extra_dict(input_str)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_returns_empty_dict_for_malformed_string(self):
|
||||
"""Test that malformed string returns empty dict."""
|
||||
input_str = "{invalid json}"
|
||||
result = _as_extra_dict(input_str)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestHasRaptorMarker:
|
||||
"""Tests for _has_raptor_marker function."""
|
||||
|
||||
def test_returns_true_for_raptor_string(self):
|
||||
"""Test that 'raptor' string returns True."""
|
||||
assert _has_raptor_marker("raptor") is True
|
||||
|
||||
def test_returns_true_for_raptor_in_list(self):
|
||||
"""Test that 'raptor' in list returns True."""
|
||||
assert _has_raptor_marker(["raptor", "other"]) is True
|
||||
|
||||
def test_returns_false_for_other_string(self):
|
||||
"""Test that other string returns False."""
|
||||
assert _has_raptor_marker("other") is False
|
||||
|
||||
def test_returns_false_for_empty_list(self):
|
||||
"""Test that empty list returns False."""
|
||||
assert _has_raptor_marker([]) is False
|
||||
|
||||
def test_returns_false_for_list_without_raptor(self):
|
||||
"""Test that list without 'raptor' returns False."""
|
||||
assert _has_raptor_marker(["psi", "other"]) is False
|
||||
|
||||
|
||||
class TestRaptorMethodsFromFields:
|
||||
"""Tests for _raptor_methods_from_fields function."""
|
||||
|
||||
def test_returns_default_raptor_method(self):
|
||||
"""Test that default method is 'raptor'."""
|
||||
result = _raptor_methods_from_fields({})
|
||||
assert result == {RAPTOR_TREE_BUILDER}
|
||||
|
||||
def test_returns_method_from_extra_dict(self):
|
||||
"""Test that method is extracted from extra dict."""
|
||||
fields = {"extra": {"raptor_method": PSI_TREE_BUILDER}}
|
||||
result = _raptor_methods_from_fields(fields)
|
||||
assert result == {PSI_TREE_BUILDER}
|
||||
|
||||
def test_returns_method_from_extra_field(self):
|
||||
"""Test that method is extracted from extra field directly."""
|
||||
fields = {"extra": "{'raptor_method': 'psi'}"}
|
||||
result = _raptor_methods_from_fields(fields)
|
||||
assert result == {PSI_TREE_BUILDER}
|
||||
|
||||
def test_handles_list_method(self):
|
||||
"""Test that list method is converted to set."""
|
||||
fields = {"extra": {"raptor_method": ["raptor", "psi"]}}
|
||||
result = _raptor_methods_from_fields(fields)
|
||||
assert result == {RAPTOR_TREE_BUILDER, PSI_TREE_BUILDER}
|
||||
|
||||
def test_handles_empty_method(self):
|
||||
"""Test that empty method returns default."""
|
||||
fields = {"extra": {"raptor_method": ""}}
|
||||
result = _raptor_methods_from_fields(fields)
|
||||
assert result == {RAPTOR_TREE_BUILDER}
|
||||
|
||||
|
||||
class TestCollectRaptorMethods:
|
||||
"""Tests for collect_raptor_methods function."""
|
||||
|
||||
def test_returns_empty_set_for_empty_map(self):
|
||||
"""Test that empty field map returns empty set."""
|
||||
result = collect_raptor_methods({})
|
||||
assert result == set()
|
||||
|
||||
def test_collects_methods_from_raptor_chunks(self):
|
||||
"""Test that methods are collected from RAPTOR chunks."""
|
||||
field_map = {
|
||||
"chunk_1": {
|
||||
"raptor_kwd": "raptor",
|
||||
"extra": {"raptor_method": PSI_TREE_BUILDER}
|
||||
}
|
||||
}
|
||||
result = collect_raptor_methods(field_map)
|
||||
assert result == {PSI_TREE_BUILDER}
|
||||
|
||||
def test_skips_non_raptor_chunks(self):
|
||||
"""Test that non-RAPTOR chunks are skipped."""
|
||||
field_map = {
|
||||
"chunk_1": {
|
||||
"raptor_kwd": "other",
|
||||
"extra": {"raptor_method": PSI_TREE_BUILDER}
|
||||
}
|
||||
}
|
||||
result = collect_raptor_methods(field_map)
|
||||
assert result == set()
|
||||
|
||||
def test_collects_multiple_methods(self):
|
||||
"""Test that multiple methods are collected."""
|
||||
field_map = {
|
||||
"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}},
|
||||
"chunk_2": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}}
|
||||
}
|
||||
result = collect_raptor_methods(field_map)
|
||||
assert result == {RAPTOR_TREE_BUILDER, PSI_TREE_BUILDER}
|
||||
|
||||
|
||||
class TestCollectRaptorChunkIds:
|
||||
"""Tests for collect_raptor_chunk_ids function."""
|
||||
|
||||
def test_returns_empty_set_for_empty_map(self):
|
||||
"""Test that empty field map returns empty set."""
|
||||
result = collect_raptor_chunk_ids({})
|
||||
assert result == set()
|
||||
|
||||
def test_collects_ids_of_raptor_chunks(self):
|
||||
"""Test that IDs of RAPTOR chunks are collected."""
|
||||
field_map = {
|
||||
"chunk_1": {"raptor_kwd": "raptor"},
|
||||
"chunk_2": {"raptor_kwd": "raptor"}
|
||||
}
|
||||
result = collect_raptor_chunk_ids(field_map)
|
||||
assert result == {"chunk_1", "chunk_2"}
|
||||
|
||||
def test_excludes_specified_methods(self):
|
||||
"""Test that specified methods are excluded."""
|
||||
field_map = {
|
||||
"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}},
|
||||
"chunk_2": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}}
|
||||
}
|
||||
result = collect_raptor_chunk_ids(field_map, exclude_methods={"raptor"})
|
||||
assert result == {"chunk_2"}
|
||||
|
||||
def test_skips_non_raptor_chunks(self):
|
||||
"""Test that non-RAPTOR chunks are skipped."""
|
||||
field_map = {
|
||||
"chunk_1": {"raptor_kwd": "raptor"},
|
||||
"chunk_2": {"raptor_kwd": "other"}
|
||||
}
|
||||
result = collect_raptor_chunk_ids(field_map)
|
||||
assert result == {"chunk_1"}
|
||||
|
||||
|
||||
class TestMakeRaptorSummaryChunkId:
|
||||
"""Tests for make_raptor_summary_chunk_id function."""
|
||||
|
||||
def test_generates_consistent_id(self):
|
||||
"""Test that same input generates same ID."""
|
||||
id1 = make_raptor_summary_chunk_id("content", "doc_1")
|
||||
id2 = make_raptor_summary_chunk_id("content", "doc_1")
|
||||
assert id1 == id2
|
||||
|
||||
def test_generates_different_ids_for_different_content(self):
|
||||
"""Test that different content generates different ID."""
|
||||
id1 = make_raptor_summary_chunk_id("content1", "doc_1")
|
||||
id2 = make_raptor_summary_chunk_id("content2", "doc_1")
|
||||
assert id1 != id2
|
||||
|
||||
def test_generates_different_ids_for_different_doc(self):
|
||||
"""Test that different doc_id generates different ID."""
|
||||
id1 = make_raptor_summary_chunk_id("content", "doc_1")
|
||||
id2 = make_raptor_summary_chunk_id("content", "doc_2")
|
||||
assert id1 != id2
|
||||
|
||||
def test_returns_string(self):
|
||||
"""Test that result is a string."""
|
||||
result = make_raptor_summary_chunk_id("content", "doc_1")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestIsStructuredFileType:
|
||||
"""Test file type detection for structured data"""
|
||||
"""Tests for is_structured_file_type function."""
|
||||
|
||||
@pytest.mark.parametrize("file_type,expected", [
|
||||
(".xlsx", True),
|
||||
(".xls", True),
|
||||
(".xlsm", True),
|
||||
(".xlsb", True),
|
||||
(".csv", True),
|
||||
(".tsv", True),
|
||||
("xlsx", True), # Without leading dot
|
||||
("XLSX", True), # Uppercase
|
||||
(".pdf", False),
|
||||
(".docx", False),
|
||||
(".txt", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
])
|
||||
def test_file_type_detection(self, file_type, expected):
|
||||
"""Test detection of various file types"""
|
||||
assert is_structured_file_type(file_type) == expected
|
||||
def test_returns_true_for_xlsx(self):
|
||||
"""Test that .xlsx is recognized as structured."""
|
||||
assert is_structured_file_type(".xlsx") is True
|
||||
|
||||
def test_excel_extensions_defined(self):
|
||||
"""Test that Excel extensions are properly defined"""
|
||||
assert ".xlsx" in EXCEL_EXTENSIONS
|
||||
assert ".xls" in EXCEL_EXTENSIONS
|
||||
assert len(EXCEL_EXTENSIONS) >= 4
|
||||
def test_returns_true_for_xls(self):
|
||||
"""Test that .xls is recognized as structured."""
|
||||
assert is_structured_file_type(".xls") is True
|
||||
|
||||
def test_csv_extensions_defined(self):
|
||||
"""Test that CSV extensions are properly defined"""
|
||||
assert ".csv" in CSV_EXTENSIONS
|
||||
assert ".tsv" in CSV_EXTENSIONS
|
||||
def test_returns_true_for_csv(self):
|
||||
"""Test that .csv is recognized as structured."""
|
||||
assert is_structured_file_type(".csv") is True
|
||||
|
||||
def test_structured_extensions_combined(self):
|
||||
"""Test that structured extensions include both Excel and CSV"""
|
||||
assert EXCEL_EXTENSIONS.issubset(STRUCTURED_EXTENSIONS)
|
||||
assert CSV_EXTENSIONS.issubset(STRUCTURED_EXTENSIONS)
|
||||
def test_returns_true_for_tsv(self):
|
||||
"""Test that .tsv is recognized as structured."""
|
||||
assert is_structured_file_type(".tsv") is True
|
||||
|
||||
def test_returns_false_for_pdf(self):
|
||||
"""Test that .pdf is not structured."""
|
||||
assert is_structured_file_type(".pdf") is False
|
||||
|
||||
def test_returns_false_for_txt(self):
|
||||
"""Test that .txt is not structured."""
|
||||
assert is_structured_file_type(".txt") is False
|
||||
|
||||
def test_returns_false_for_none(self):
|
||||
"""Test that None is not structured."""
|
||||
assert is_structured_file_type(None) is False
|
||||
|
||||
def test_returns_false_for_empty_string(self):
|
||||
"""Test that empty string is not structured."""
|
||||
assert is_structured_file_type("") is False
|
||||
|
||||
def test_handles_case_insensitive(self):
|
||||
"""Test that case is handled insensitively."""
|
||||
assert is_structured_file_type(".XLSX") is True
|
||||
assert is_structured_file_type("xlsx") is True
|
||||
|
||||
def test_handles_missing_dot(self):
|
||||
"""Test that missing dot is handled."""
|
||||
assert is_structured_file_type("xlsx") is True
|
||||
|
||||
|
||||
class TestIsTabularPDF:
|
||||
"""Test tabular PDF detection"""
|
||||
class TestIsTabularPdf:
|
||||
"""Tests for is_tabular_pdf function."""
|
||||
|
||||
def test_table_parser_detected(self):
|
||||
"""Test that table parser is detected as tabular"""
|
||||
def test_returns_true_for_table_parser(self):
|
||||
"""Test that table parser returns True."""
|
||||
assert is_tabular_pdf("table", {}) is True
|
||||
assert is_tabular_pdf("TABLE", {}) is True
|
||||
|
||||
def test_html4excel_detected(self):
|
||||
"""Test that html4excel config is detected as tabular"""
|
||||
def test_returns_true_for_html4excel(self):
|
||||
"""Test that html4excel enabled returns True."""
|
||||
assert is_tabular_pdf("naive", {"html4excel": True}) is True
|
||||
assert is_tabular_pdf("", {"html4excel": True}) is True
|
||||
|
||||
def test_non_tabular_pdf(self):
|
||||
"""Test that non-tabular PDFs are not detected"""
|
||||
def test_returns_false_for_naive_parser(self):
|
||||
"""Test that naive parser returns False."""
|
||||
assert is_tabular_pdf("naive", {}) is False
|
||||
assert is_tabular_pdf("naive", {"html4excel": False}) is False
|
||||
|
||||
def test_returns_false_for_empty_parser_id(self):
|
||||
"""Test that empty parser_id returns False."""
|
||||
assert is_tabular_pdf("", {}) is False
|
||||
|
||||
def test_combined_conditions(self):
|
||||
"""Test combined table parser and html4excel"""
|
||||
assert is_tabular_pdf("table", {"html4excel": True}) is True
|
||||
assert is_tabular_pdf("table", {"html4excel": False}) is True
|
||||
def test_returns_false_for_html4excel_false(self):
|
||||
"""Test that html4excel=False returns False."""
|
||||
assert is_tabular_pdf("naive", {"html4excel": False}) is False
|
||||
|
||||
def test_handles_case_insensitive_parser_id(self):
|
||||
"""Test that parser_id case is handled."""
|
||||
assert is_tabular_pdf("TABLE", {}) is True
|
||||
assert is_tabular_pdf("Table", {}) is True
|
||||
|
||||
|
||||
class TestShouldSkipRaptor:
|
||||
"""Test Raptor skip logic"""
|
||||
"""Tests for should_skip_raptor function."""
|
||||
|
||||
def test_skip_excel_files(self):
|
||||
"""Test that Excel files skip Raptor"""
|
||||
assert should_skip_raptor(".xlsx") is True
|
||||
assert should_skip_raptor(".xls") is True
|
||||
assert should_skip_raptor(".xlsm") is True
|
||||
def test_skips_for_xlsx_file(self):
|
||||
"""Test that .xlsx file skips Raptor."""
|
||||
assert should_skip_raptor(file_type=".xlsx") is True
|
||||
|
||||
def test_skip_csv_files(self):
|
||||
"""Test that CSV files skip Raptor"""
|
||||
assert should_skip_raptor(".csv") is True
|
||||
assert should_skip_raptor(".tsv") is True
|
||||
def test_skips_for_csv_file(self):
|
||||
"""Test that .csv file skips Raptor."""
|
||||
assert should_skip_raptor(file_type=".csv") is True
|
||||
|
||||
def test_skip_tabular_pdf_with_table_parser(self):
|
||||
"""Test that tabular PDFs skip Raptor"""
|
||||
assert should_skip_raptor(".pdf", parser_id="table") is True
|
||||
assert should_skip_raptor("pdf", parser_id="TABLE") is True
|
||||
def test_skips_for_tabular_pdf(self):
|
||||
"""Test that tabular PDF skips Raptor."""
|
||||
assert should_skip_raptor(file_type=".pdf", parser_id="table") is True
|
||||
|
||||
def test_skip_tabular_pdf_with_html4excel(self):
|
||||
"""Test that PDFs with html4excel skip Raptor"""
|
||||
assert should_skip_raptor(".pdf", parser_config={"html4excel": True}) is True
|
||||
def test_does_not_skip_for_normal_pdf(self):
|
||||
"""Test that normal PDF does not skip Raptor."""
|
||||
assert should_skip_raptor(file_type=".pdf", parser_id="naive") is False
|
||||
|
||||
def test_dont_skip_regular_pdf(self):
|
||||
"""Test that regular PDFs don't skip Raptor"""
|
||||
assert should_skip_raptor(".pdf", parser_id="naive") is False
|
||||
assert should_skip_raptor(".pdf", parser_config={}) is False
|
||||
def test_does_not_skip_for_txt_file(self):
|
||||
"""Test that .txt file does not skip Raptor."""
|
||||
assert should_skip_raptor(file_type=".txt") is False
|
||||
|
||||
def test_dont_skip_text_files(self):
|
||||
"""Test that text files don't skip Raptor"""
|
||||
assert should_skip_raptor(".txt") is False
|
||||
assert should_skip_raptor(".docx") is False
|
||||
assert should_skip_raptor(".md") is False
|
||||
def test_respects_auto_disable_config_false(self):
|
||||
"""Test that auto_disable_for_structured_data=False disables skipping."""
|
||||
assert should_skip_raptor(
|
||||
file_type=".xlsx",
|
||||
raptor_config={"auto_disable_for_structured_data": False}
|
||||
) is False
|
||||
|
||||
def test_override_with_config(self):
|
||||
"""Test that auto-disable can be overridden"""
|
||||
raptor_config = {"auto_disable_for_structured_data": False}
|
||||
|
||||
# Should not skip even for Excel files
|
||||
assert should_skip_raptor(".xlsx", raptor_config=raptor_config) is False
|
||||
assert should_skip_raptor(".csv", raptor_config=raptor_config) is False
|
||||
assert should_skip_raptor(".pdf", parser_id="table", raptor_config=raptor_config) is False
|
||||
def test_respects_auto_disable_config_true(self):
|
||||
"""Test that auto_disable_for_structured_data=True enables skipping."""
|
||||
assert should_skip_raptor(
|
||||
file_type=".xlsx",
|
||||
raptor_config={"auto_disable_for_structured_data": True}
|
||||
) is True
|
||||
|
||||
def test_default_auto_disable_enabled(self):
|
||||
"""Test that auto-disable is enabled by default"""
|
||||
# Empty raptor_config should default to auto_disable=True
|
||||
assert should_skip_raptor(".xlsx", raptor_config={}) is True
|
||||
assert should_skip_raptor(".xlsx", raptor_config=None) is True
|
||||
def test_default_auto_disable_is_true(self):
|
||||
"""Test that default auto_disable is True."""
|
||||
assert should_skip_raptor(file_type=".xlsx") is True
|
||||
|
||||
def test_explicit_auto_disable_enabled(self):
|
||||
"""Test explicit auto-disable enabled"""
|
||||
raptor_config = {"auto_disable_for_structured_data": True}
|
||||
assert should_skip_raptor(".xlsx", raptor_config=raptor_config) is True
|
||||
def test_returns_false_for_none_file_type(self):
|
||||
"""Test that None file_type does not skip."""
|
||||
assert should_skip_raptor(file_type=None) is False
|
||||
|
||||
|
||||
class TestGetSkipReason:
|
||||
"""Test skip reason generation"""
|
||||
"""Tests for get_skip_reason function."""
|
||||
|
||||
def test_excel_skip_reason(self):
|
||||
"""Test skip reason for Excel files"""
|
||||
reason = get_skip_reason(".xlsx")
|
||||
def test_returns_reason_for_structured_file(self):
|
||||
"""Test that reason is returned for structured file."""
|
||||
reason = get_skip_reason(file_type=".xlsx")
|
||||
assert "Structured data file" in reason
|
||||
assert ".xlsx" in reason
|
||||
assert "auto-disabled" in reason.lower()
|
||||
|
||||
def test_csv_skip_reason(self):
|
||||
"""Test skip reason for CSV files"""
|
||||
reason = get_skip_reason(".csv")
|
||||
assert "Structured data file" in reason
|
||||
assert ".csv" in reason
|
||||
|
||||
def test_tabular_pdf_skip_reason(self):
|
||||
"""Test skip reason for tabular PDFs"""
|
||||
reason = get_skip_reason(".pdf", parser_id="table")
|
||||
def test_returns_reason_for_tabular_pdf(self):
|
||||
"""Test that reason is returned for tabular PDF."""
|
||||
reason = get_skip_reason(file_type=".pdf", parser_id="table")
|
||||
assert "Tabular PDF" in reason
|
||||
assert "table" in reason.lower()
|
||||
assert "auto-disabled" in reason.lower()
|
||||
assert "table" in reason
|
||||
|
||||
def test_html4excel_skip_reason(self):
|
||||
"""Test skip reason for html4excel PDFs"""
|
||||
reason = get_skip_reason(".pdf", parser_config={"html4excel": True})
|
||||
assert "Tabular PDF" in reason
|
||||
|
||||
def test_no_skip_reason_for_regular_files(self):
|
||||
"""Test that regular files have no skip reason"""
|
||||
assert get_skip_reason(".txt") == ""
|
||||
assert get_skip_reason(".docx") == ""
|
||||
assert get_skip_reason(".pdf", parser_id="naive") == ""
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling"""
|
||||
|
||||
def test_none_values(self):
|
||||
"""Test handling of None values"""
|
||||
assert should_skip_raptor(None) is False
|
||||
assert should_skip_raptor("") is False
|
||||
assert get_skip_reason(None) == ""
|
||||
|
||||
def test_empty_strings(self):
|
||||
"""Test handling of empty strings"""
|
||||
assert should_skip_raptor("") is False
|
||||
assert get_skip_reason("") == ""
|
||||
|
||||
def test_case_insensitivity(self):
|
||||
"""Test case insensitive handling"""
|
||||
assert is_structured_file_type("XLSX") is True
|
||||
assert is_structured_file_type("XlSx") is True
|
||||
assert is_tabular_pdf("TABLE", {}) is True
|
||||
assert is_tabular_pdf("TaBlE", {}) is True
|
||||
|
||||
def test_with_and_without_dot(self):
|
||||
"""Test file extensions with and without leading dot"""
|
||||
assert should_skip_raptor(".xlsx") is True
|
||||
assert should_skip_raptor("xlsx") is True
|
||||
assert should_skip_raptor(".CSV") is True
|
||||
assert should_skip_raptor("csv") is True
|
||||
|
||||
|
||||
class TestIntegrationScenarios:
|
||||
"""Test real-world integration scenarios"""
|
||||
|
||||
def test_financial_excel_report(self):
|
||||
"""Test scenario: Financial quarterly Excel report"""
|
||||
file_type = ".xlsx"
|
||||
parser_id = "naive"
|
||||
parser_config = {}
|
||||
raptor_config = {"use_raptor": True}
|
||||
|
||||
# Should skip Raptor
|
||||
assert should_skip_raptor(file_type, parser_id, parser_config, raptor_config) is True
|
||||
reason = get_skip_reason(file_type, parser_id, parser_config)
|
||||
assert "Structured data file" in reason
|
||||
|
||||
def test_scientific_csv_data(self):
|
||||
"""Test scenario: Scientific experimental CSV results"""
|
||||
file_type = ".csv"
|
||||
|
||||
# Should skip Raptor
|
||||
assert should_skip_raptor(file_type) is True
|
||||
reason = get_skip_reason(file_type)
|
||||
assert ".csv" in reason
|
||||
|
||||
def test_legal_contract_with_tables(self):
|
||||
"""Test scenario: Legal contract PDF with tables"""
|
||||
file_type = ".pdf"
|
||||
parser_id = "table"
|
||||
parser_config = {}
|
||||
|
||||
# Should skip Raptor
|
||||
assert should_skip_raptor(file_type, parser_id, parser_config) is True
|
||||
reason = get_skip_reason(file_type, parser_id, parser_config)
|
||||
assert "Tabular PDF" in reason
|
||||
|
||||
def test_text_heavy_pdf_document(self):
|
||||
"""Test scenario: Text-heavy PDF document"""
|
||||
file_type = ".pdf"
|
||||
parser_id = "naive"
|
||||
parser_config = {}
|
||||
|
||||
# Should NOT skip Raptor
|
||||
assert should_skip_raptor(file_type, parser_id, parser_config) is False
|
||||
reason = get_skip_reason(file_type, parser_id, parser_config)
|
||||
def test_returns_empty_for_normal_pdf(self):
|
||||
"""Test that empty reason is returned for normal PDF."""
|
||||
reason = get_skip_reason(file_type=".pdf", parser_id="naive")
|
||||
assert reason == ""
|
||||
|
||||
def test_mixed_dataset_processing(self):
|
||||
"""Test scenario: Mixed dataset with various file types"""
|
||||
files = [
|
||||
(".xlsx", "naive", {}, True), # Excel - skip
|
||||
(".csv", "naive", {}, True), # CSV - skip
|
||||
(".pdf", "table", {}, True), # Tabular PDF - skip
|
||||
(".pdf", "naive", {}, False), # Regular PDF - don't skip
|
||||
(".docx", "naive", {}, False), # Word doc - don't skip
|
||||
(".txt", "naive", {}, False), # Text file - don't skip
|
||||
]
|
||||
|
||||
for file_type, parser_id, parser_config, expected_skip in files:
|
||||
result = should_skip_raptor(file_type, parser_id, parser_config)
|
||||
assert result == expected_skip, f"Failed for {file_type}"
|
||||
def test_returns_empty_for_txt_file(self):
|
||||
"""Test that empty reason is returned for .txt file."""
|
||||
reason = get_skip_reason(file_type=".txt")
|
||||
assert reason == ""
|
||||
|
||||
def test_override_for_special_excel(self):
|
||||
"""Test scenario: Override auto-disable for special Excel processing"""
|
||||
file_type = ".xlsx"
|
||||
raptor_config = {"auto_disable_for_structured_data": False}
|
||||
|
||||
# Should NOT skip when explicitly disabled
|
||||
assert should_skip_raptor(file_type, raptor_config=raptor_config) is False
|
||||
|
||||
|
||||
class TestRaptorTreeBuilderConfig:
|
||||
"""Test RAPTOR tree builder config resolution"""
|
||||
|
||||
def test_defaults_to_original_raptor_builder(self):
|
||||
assert get_raptor_tree_builder({}) == "raptor"
|
||||
assert get_raptor_tree_builder(None) == "raptor"
|
||||
|
||||
def test_reads_top_level_tree_builder(self):
|
||||
assert get_raptor_tree_builder({"tree_builder": "psi"}) == "psi"
|
||||
|
||||
def test_reads_legacy_ext_tree_builder(self):
|
||||
assert get_raptor_tree_builder({"ext": {"tree_builder": "psi"}}) == "psi"
|
||||
|
||||
def test_ext_tree_builder_overrides_stale_top_level_value(self):
|
||||
assert get_raptor_tree_builder({"tree_builder": "psi", "ext": {"tree_builder": "raptor"}}) == "raptor"
|
||||
|
||||
def test_rejects_unknown_tree_builder(self):
|
||||
with pytest.raises(ValueError, match="Unsupported RAPTOR tree builder"):
|
||||
get_raptor_tree_builder({"tree_builder": "ahc"})
|
||||
|
||||
|
||||
class TestRaptorClusteringMethodConfig:
|
||||
"""Test RAPTOR clustering method config resolution"""
|
||||
|
||||
def test_defaults_to_gmm(self):
|
||||
assert get_raptor_clustering_method({}) == "gmm"
|
||||
assert get_raptor_clustering_method(None) == "gmm"
|
||||
|
||||
def test_reads_top_level_clustering_method(self):
|
||||
assert get_raptor_clustering_method({"clustering_method": "gmm"}) == "gmm"
|
||||
assert get_raptor_clustering_method({"clustering_method": "ahc"}) == "ahc"
|
||||
|
||||
def test_reads_legacy_ext_clustering_method(self):
|
||||
assert get_raptor_clustering_method({"ext": {"clustering_method": "ahc"}}) == "ahc"
|
||||
|
||||
def test_ext_clustering_method_overrides_stale_top_level_value(self):
|
||||
assert get_raptor_clustering_method({"clustering_method": "gmm", "ext": {"clustering_method": "ahc"}}) == "ahc"
|
||||
|
||||
def test_rejects_unknown_clustering_method(self):
|
||||
with pytest.raises(ValueError, match="Unsupported RAPTOR clustering method"):
|
||||
get_raptor_clustering_method({"clustering_method": "unknown"})
|
||||
|
||||
|
||||
class TestRaptorMethodCollection:
|
||||
"""Test RAPTOR summary method extraction from doc-store fields"""
|
||||
|
||||
def test_legacy_summary_without_method_is_original_raptor(self):
|
||||
field_map = {"chunk_1": {"raptor_kwd": "raptor"}}
|
||||
|
||||
assert collect_raptor_methods(field_map) == {"raptor"}
|
||||
assert collect_raptor_chunk_ids(field_map) == {"chunk_1"}
|
||||
|
||||
def test_extra_method_is_preserved(self):
|
||||
field_map = {"chunk_1": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}}}
|
||||
|
||||
assert collect_raptor_methods(field_map) == {"psi"}
|
||||
assert collect_raptor_chunk_ids(field_map) == {"chunk_1"}
|
||||
|
||||
def test_extra_field_supports_oceanbase_legacy_rows(self):
|
||||
field_map = {
|
||||
"chunk_1": {
|
||||
"extra": {
|
||||
"raptor_kwd": "raptor",
|
||||
"raptor_method": "psi",
|
||||
}
|
||||
},
|
||||
"chunk_2": {
|
||||
"extra": "{\"raptor_kwd\": \"raptor\"}",
|
||||
},
|
||||
"chunk_3": {
|
||||
"extra": {"raptor_kwd": ""},
|
||||
},
|
||||
}
|
||||
|
||||
assert collect_raptor_methods(field_map) == {"psi", "raptor"}
|
||||
assert collect_raptor_chunk_ids(field_map) == {"chunk_1", "chunk_2"}
|
||||
|
||||
def test_non_raptor_rows_are_ignored(self):
|
||||
field_map = {
|
||||
"chunk_1": {"raptor_kwd": ""},
|
||||
"chunk_2": {"extra": {"raptor_kwd": "graph"}},
|
||||
"chunk_3": {},
|
||||
}
|
||||
|
||||
assert collect_raptor_methods(field_map) == set()
|
||||
assert collect_raptor_chunk_ids(field_map) == set()
|
||||
|
||||
def test_malformed_extra_payload_is_logged_and_ignored(self, caplog):
|
||||
field_map = {"chunk_1": {"extra": "{bad json"}}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert collect_raptor_methods(field_map) == set()
|
||||
assert collect_raptor_chunk_ids(field_map) == set()
|
||||
|
||||
assert "Ignoring malformed RAPTOR extra payload" in caplog.text
|
||||
|
||||
def test_chunk_id_collection_can_preserve_current_method(self):
|
||||
field_map = {
|
||||
"legacy": {"raptor_kwd": "raptor"},
|
||||
"old": {"raptor_kwd": "raptor", "extra": {"raptor_method": "raptor"}},
|
||||
"current": {"raptor_kwd": "raptor", "extra": {"raptor_method": "psi"}},
|
||||
}
|
||||
|
||||
assert collect_raptor_chunk_ids(field_map, exclude_methods={"psi"}) == {"legacy", "old"}
|
||||
assert collect_raptor_chunk_ids(field_map, exclude_methods={"raptor"}) == {"current"}
|
||||
|
||||
def test_summary_chunk_ids_include_real_document_id(self):
|
||||
content = "same generated summary"
|
||||
|
||||
assert make_raptor_summary_chunk_id(content, "doc-a") != make_raptor_summary_chunk_id(content, "doc-b")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
def test_returns_empty_for_none_file_type(self):
|
||||
"""Test that empty reason is returned for None file_type."""
|
||||
reason = get_skip_reason(file_type=None)
|
||||
assert reason == ""
|
||||
|
||||
Reference in New Issue
Block a user