feat(data-process): 支持任务重新生成
This commit is contained in:
@@ -8,9 +8,12 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from app.modules.data_process.store import (
|
||||
ConflictError,
|
||||
DataProcessStore,
|
||||
DataProcessStoreError,
|
||||
InvalidStateError,
|
||||
_decode_row,
|
||||
_preview_config_changed,
|
||||
_source_storage_descriptor,
|
||||
)
|
||||
|
||||
@@ -114,6 +117,72 @@ class _PublishStore(DataProcessStore):
|
||||
return []
|
||||
|
||||
|
||||
class _RegenerationConnection:
|
||||
def __init__(self, task: dict[str, Any]) -> None:
|
||||
self.task = task
|
||||
self.datasets = [
|
||||
{"id": "dataset_train"},
|
||||
{"id": "dataset_validation"},
|
||||
{"id": "dataset_test"},
|
||||
]
|
||||
self.sources = [{"id": "source_1"}]
|
||||
self.previews = [{"id": "preview_1"}]
|
||||
self.results = [{"id": "result_1"}]
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> _Result:
|
||||
normalized = " ".join(sql.split())
|
||||
if params is not None:
|
||||
assert normalized.count("%s") == len(params)
|
||||
if normalized.startswith("SELECT EXISTS("):
|
||||
return _Result(row={"exists": bool(self.datasets)})
|
||||
if normalized.startswith("DELETE FROM data_process_results"):
|
||||
self.results.clear()
|
||||
return _Result()
|
||||
if normalized.startswith("DELETE FROM data_process_preview_items"):
|
||||
self.previews.clear()
|
||||
return _Result()
|
||||
if normalized.startswith("SELECT COUNT(*) AS count FROM data_process_preview_items"):
|
||||
return _Result(row={"count": len(self.previews)})
|
||||
if normalized.startswith("UPDATE data_process_tasks SET name="):
|
||||
self.task.update(
|
||||
{
|
||||
"name": params[0],
|
||||
"description": params[1],
|
||||
"config": params[2],
|
||||
"status": "pending",
|
||||
"progress": params[3],
|
||||
"output_dataset_id": None,
|
||||
"output_count": 0,
|
||||
"filtered_count": 0,
|
||||
"duplicate_count": 0,
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"updated_at": params[4],
|
||||
}
|
||||
)
|
||||
return _Result(row=dict(self.task))
|
||||
raise AssertionError(f"unexpected SQL: {normalized}")
|
||||
|
||||
|
||||
class _RegenerationStore(DataProcessStore):
|
||||
def __init__(self, conn: _RegenerationConnection):
|
||||
self._conn = conn
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[_RegenerationConnection]:
|
||||
yield self._conn
|
||||
|
||||
def _task_in_connection(
|
||||
self, conn: Any, task_id: str, *, for_update: bool = False
|
||||
) -> dict[str, Any]:
|
||||
assert for_update is True
|
||||
assert task_id == self._conn.task["id"]
|
||||
return dict(self._conn.task)
|
||||
|
||||
|
||||
def test_decode_row_serializes_postgres_numeric_values_as_json_numbers() -> None:
|
||||
decoded = _decode_row(
|
||||
{
|
||||
@@ -125,6 +194,225 @@ def test_decode_row_serializes_postgres_numeric_values_as_json_numbers() -> None
|
||||
assert decoded == {"progress": 100.0, "duration_seconds": 389.0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("process_type", "current", "next_config", "expected"),
|
||||
[
|
||||
(
|
||||
"structured",
|
||||
{"preprocess_options": ["deduplicate"]},
|
||||
{"preprocess_options": ["deduplicate"], "temperature": 0.2},
|
||||
False,
|
||||
),
|
||||
(
|
||||
"structured",
|
||||
{"preprocess_options": ["deduplicate"]},
|
||||
{"preprocess_options": ["clean_invalid"]},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"structured",
|
||||
{"preprocess_options": ["a", "b"]},
|
||||
{"preprocessOptions": ["b", "a", "a"], "chunk_size": 2048},
|
||||
False,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"chunk_method": "fixed"},
|
||||
{"chunk_method": "fixed", "generation_prompt": "new"},
|
||||
False,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"chunk_method": "fixed"},
|
||||
{"chunk_method": "semantic"},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"chunk_size": 800, "chunk_overlap": 100},
|
||||
{"chunk_size": 900, "chunk_overlap": 100},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"min_chunk_size": 100},
|
||||
{"min_chunk_size": 120},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"semantic_breakpoint_percentile": 95},
|
||||
{"semantic_breakpoint_percentile": 90},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{},
|
||||
{
|
||||
"preserve_tables": True,
|
||||
"preserve_code_blocks": True,
|
||||
"preserve_lists": True,
|
||||
},
|
||||
False,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"preserve_tables": False},
|
||||
{"preserve_tables": True},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"preserve_code_blocks": False},
|
||||
{"preserve_code_blocks": True},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"preserve_lists": False},
|
||||
{"preserve_lists": True},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"unstructured",
|
||||
{"preprocess_options": ["deduplicate"]},
|
||||
{"preprocess_options": ["clean_invalid"]},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_regeneration_preview_invalidation_matrix(
|
||||
process_type: str,
|
||||
current: dict[str, Any],
|
||||
next_config: dict[str, Any],
|
||||
expected: bool,
|
||||
) -> None:
|
||||
assert _preview_config_changed(process_type, current, next_config) is expected
|
||||
|
||||
|
||||
def _regeneration_task(**updates: Any) -> dict[str, Any]:
|
||||
task = {
|
||||
"id": "task-1",
|
||||
"name": "原任务",
|
||||
"description": "",
|
||||
"process_type": "unstructured",
|
||||
"config": {"chunk_method": "fixed", "temperature": 0.7},
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"output_dataset_id": "dataset_train",
|
||||
"output_count": 28,
|
||||
"filtered_count": 1,
|
||||
"duplicate_count": 1,
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"started_at": "2026-07-25T18:00:00Z",
|
||||
"completed_at": "2026-07-25T18:05:00Z",
|
||||
"updated_at": "2026-07-25T18:05:00Z",
|
||||
}
|
||||
task.update(updates)
|
||||
return task
|
||||
|
||||
|
||||
def test_prepare_regeneration_preserves_outputs_sources_and_generation_only_preview() -> None:
|
||||
conn = _RegenerationConnection(_regeneration_task())
|
||||
original_datasets = list(conn.datasets)
|
||||
original_sources = list(conn.sources)
|
||||
|
||||
result = _RegenerationStore(conn).prepare_regeneration(
|
||||
"task-1",
|
||||
{
|
||||
"name": "新任务名",
|
||||
"description": "更换生成参数",
|
||||
"process_type": "unstructured",
|
||||
"config": {"chunk_method": "fixed", "temperature": 0.2},
|
||||
"expected_updated_at": "2026-07-25T18:05:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["preview_invalidated"] is False
|
||||
assert result["published_outputs_preserved"] is True
|
||||
assert result["task"]["output_dataset_id"] is None
|
||||
assert result["task"]["status"] == "pending"
|
||||
assert result["task"]["progress"] == 20
|
||||
assert result["task"]["output_count"] == 0
|
||||
assert result["task"]["started_at"] is None
|
||||
assert result["task"]["completed_at"] is None
|
||||
assert conn.results == []
|
||||
assert conn.previews == [{"id": "preview_1"}]
|
||||
assert conn.datasets == original_datasets
|
||||
assert conn.sources == original_sources
|
||||
|
||||
|
||||
def test_prepare_regeneration_deletes_preview_when_chunk_configuration_changes() -> None:
|
||||
conn = _RegenerationConnection(_regeneration_task(output_dataset_id=None))
|
||||
|
||||
result = _RegenerationStore(conn).prepare_regeneration(
|
||||
"task-1",
|
||||
{
|
||||
"name": "原任务",
|
||||
"description": "",
|
||||
"process_type": "unstructured",
|
||||
"config": {"chunk_method": "semantic", "temperature": 0.7},
|
||||
"expected_updated_at": "2026-07-25T18:05:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["preview_invalidated"] is True
|
||||
assert result["published_outputs_preserved"] is True
|
||||
assert result["task"]["progress"] == 0
|
||||
assert conn.previews == []
|
||||
assert conn.results == []
|
||||
assert len(conn.datasets) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("task_updates", "payload_updates", "error_type", "message"),
|
||||
[
|
||||
(
|
||||
{"status": "running"},
|
||||
{},
|
||||
ConflictError,
|
||||
"running task cannot be prepared",
|
||||
),
|
||||
(
|
||||
{},
|
||||
{"expected_updated_at": "2026-07-25T17:00:00Z"},
|
||||
ConflictError,
|
||||
"modified by another request",
|
||||
),
|
||||
(
|
||||
{},
|
||||
{"process_type": "structured"},
|
||||
InvalidStateError,
|
||||
"process_type cannot be changed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_prepare_regeneration_rejects_running_stale_and_type_change_without_mutation(
|
||||
task_updates: dict[str, Any],
|
||||
payload_updates: dict[str, Any],
|
||||
error_type: type[Exception],
|
||||
message: str,
|
||||
) -> None:
|
||||
conn = _RegenerationConnection(_regeneration_task(**task_updates))
|
||||
payload = {
|
||||
"name": "原任务",
|
||||
"description": "",
|
||||
"process_type": "unstructured",
|
||||
"config": {"chunk_method": "fixed"},
|
||||
"expected_updated_at": "2026-07-25T18:05:00Z",
|
||||
**payload_updates,
|
||||
}
|
||||
|
||||
with pytest.raises(error_type, match=message):
|
||||
_RegenerationStore(conn).prepare_regeneration("task-1", payload)
|
||||
|
||||
assert conn.results == [{"id": "result_1"}]
|
||||
assert conn.previews == [{"id": "preview_1"}]
|
||||
assert conn.task["output_dataset_id"] == "dataset_train"
|
||||
|
||||
|
||||
def test_publish_creates_three_independent_datasets_with_exact_counts() -> None:
|
||||
results = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user