Files
YG_FT/backend/tests/test_data_process_store.py
2026-07-27 13:08:44 +08:00

1188 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
from decimal import Decimal
from typing import Any
import pytest
from app.modules.data_process.store import (
ConflictError,
DataProcessStore,
DataProcessStoreError,
InvalidStateError,
_decode_row,
_preview_config_changed,
_reasoning_output_is_valid,
_source_storage_descriptor,
)
class _Result:
def __init__(self, *, row: dict[str, Any] | None = None, rows: list[dict[str, Any]] | None = None):
self.row = row
self.rows = rows or []
def fetchone(self) -> dict[str, Any] | None:
return self.row
def fetchall(self) -> list[dict[str, Any]]:
return self.rows
class _PublishConnection:
def __init__(
self,
results: list[dict[str, Any]],
datasets: list[dict[str, Any]] | None = None,
):
self.results = results
self.datasets: list[dict[str, Any]] = datasets or []
self.files: list[dict[str, Any]] = []
self.records: list[dict[str, Any]] = []
def execute(self, sql: str, params: Any = None) -> _Result:
normalized = " ".join(sql.split())
if params is not None:
placeholder_count = normalized.count("%s")
assert placeholder_count == len(params), (
f"SQL placeholder count {placeholder_count} does not match "
f"parameter count {len(params)}"
)
if normalized.startswith("SELECT * FROM data_process_results"):
return _Result(rows=self.results)
if normalized.startswith("SELECT * FROM datasets WHERE source='task'"):
assert "source_task_id=%s" in normalized
assert "source_task_id IS NULL AND task_id=%s" in normalized
assert "deleted_at IS NULL" in normalized
source_task_id, legacy_task_id = params
return _Result(
rows=[
item
for item in self.datasets
if item.get("source") == "task"
and item.get("deleted_at") is None
and (
item.get("source_task_id") == source_task_id
or (
item.get("source_task_id") is None
and item.get("task_id") == legacy_task_id
)
)
]
)
if normalized.startswith("INSERT INTO datasets"):
dataset = {
"id": params[0],
"name": params[1],
"type": params[2],
"source": "task",
"task_id": params[4],
"source_task_id": params[5],
"count": params[8],
"record_count": params[9],
"metadata": params[11],
"deleted_at": None,
}
self.datasets.append(dataset)
return _Result(row=dataset)
if normalized.startswith("UPDATE datasets SET name="):
dataset = next(item for item in self.datasets if item["id"] == params[10])
dataset.update(
{
"name": params[0],
"type": params[1],
"count": params[5],
"record_count": params[6],
"metadata": params[8],
}
)
return _Result(row=dataset)
if normalized.startswith("DELETE FROM dataset_records WHERE dataset_id"):
self.records = [item for item in self.records if item["dataset_id"] != params[0]]
if normalized.startswith("DELETE FROM dataset_files WHERE dataset_id"):
self.files = [item for item in self.files if item["dataset_id"] != params[0]]
if normalized.startswith("INSERT INTO dataset_files"):
self.files.append(
{
"id": params[0],
"dataset_id": params[1],
"name": params[2],
"record_count": params[11],
}
)
if normalized.startswith("INSERT INTO dataset_records"):
self.records.append(
{
"dataset_id": params[1],
"line_no": params[4],
"split": params[5],
"output": params[8],
"raw": json.loads(params[9]),
}
)
return _Result()
class _PublishStore(DataProcessStore):
def __init__(
self,
conn: _PublishConnection,
task_config: dict[str, Any] | None = None,
):
self._conn = conn
self._task_config = task_config or {}
@contextmanager
def connect(self) -> Iterator[_PublishConnection]:
yield self._conn
def _task_in_connection(self, conn: Any, task_id: str, *, for_update: bool = False) -> dict[str, Any]:
train_dataset = next(
(item for item in self._conn.datasets if item["type"] == "train"), None
)
return {
"id": task_id,
"status": "completed",
"description": "",
"config": self._task_config,
"output_dataset_id": train_dataset and train_dataset["id"],
}
@staticmethod
def _source_ids(conn: Any, task_id: str) -> list[dict[str, Any]]:
return []
class _RegenerationConnection:
def __init__(
self,
task: dict[str, Any],
datasets: list[dict[str, Any]] | None = None,
) -> None:
self.task = task
self.datasets = datasets or [
{
"id": "dataset_train",
"source": "task",
"task_id": task["id"],
"source_task_id": task["id"],
"deleted_at": None,
},
{
"id": "dataset_validation",
"source": "task",
"task_id": task["id"],
"source_task_id": task["id"],
"deleted_at": None,
},
{
"id": "dataset_test",
"source": "task",
"task_id": task["id"],
"source_task_id": task["id"],
"deleted_at": None,
},
]
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("UPDATE datasets SET source_task_id="):
source_task_id, _, legacy_task_id = params
for dataset in self.datasets:
if (
dataset.get("source") == "task"
and dataset.get("source_task_id") is None
and dataset.get("task_id") == legacy_task_id
and dataset.get("deleted_at") is None
):
dataset["source_task_id"] = source_task_id
return _Result()
if normalized.startswith("SELECT EXISTS("):
assert "source_task_id=%s" in normalized
assert "source_task_id IS NULL AND task_id=%s" in normalized
assert "deleted_at IS NULL" in normalized
source_task_id, legacy_task_id = params
exists = any(
dataset.get("source") == "task"
and dataset.get("deleted_at") is None
and (
dataset.get("source_task_id") == source_task_id
or (
dataset.get("source_task_id") is None
and dataset.get("task_id") == legacy_task_id
)
)
for dataset in self.datasets
)
return _Result(row={"exists": exists})
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],
"updated_at": params[3],
}
)
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)
class _TaskDetailConnection:
def __init__(
self,
task: dict[str, Any],
datasets: list[dict[str, Any]],
) -> None:
self.task = task
self.datasets = datasets
def execute(self, sql: str, params: Any = None) -> _Result:
normalized = " ".join(sql.split())
assert normalized.startswith("SELECT task.*")
assert "dataset.source_task_id=task.id" in normalized
assert "dataset.source_task_id IS NULL AND dataset.task_id=task.id" in normalized
assert "dataset.deleted_at IS NULL" in normalized
assert "dataset.metadata::jsonb" not in normalized
assert "WHEN 'train' THEN 'train'" in normalized
assert "WHEN 'val' THEN 'validation'" in normalized
assert "WHEN 'test' THEN 'test'" in normalized
task_id = params[0]
visible = [
dataset
for dataset in self.datasets
if dataset.get("source") == "task"
and dataset.get("deleted_at") is None
and (
dataset.get("source_task_id") == task_id
or (
dataset.get("source_task_id") is None
and dataset.get("task_id") == task_id
)
)
]
return _Result(
row={
**self.task,
"output_datasets": json.dumps(
[
{
"id": item["id"],
"name": item["name"],
"type": item["type"],
"count": item["count"],
"dataset_split": item["dataset_split"],
}
for item in visible
]
),
}
)
class _TaskDetailStore(DataProcessStore):
def __init__(self, conn: _TaskDetailConnection) -> None:
self._conn = conn
@contextmanager
def connect(self) -> Iterator[_TaskDetailConnection]:
yield self._conn
class _TaskListConnection:
def __init__(self) -> None:
self.task = {
**_regeneration_task(
status="pending",
output_dataset_id=None,
output_count=17,
),
"created_at": "2026-07-25T18:00:00Z",
"deleted_at": None,
}
self.sources = [
{"task_id": "task-1", "deleted_at": None},
{"task_id": "task-1", "deleted_at": None},
{"task_id": "task-1", "deleted_at": "2026-07-26T00:00:00Z"},
]
def execute(self, sql: str, params: Any = None) -> _Result:
normalized = " ".join(sql.split())
if normalized.startswith("SELECT COUNT(*) AS count FROM data_process_tasks task"):
assert "task.deleted_at IS NULL" in normalized
return _Result(row={"count": 1})
assert normalized.startswith("SELECT task.*")
assert "source_file.task_id=task.id" in normalized
assert "source_file.deleted_at IS NULL" in normalized
source_file_count = sum(
item["task_id"] == self.task["id"] and item["deleted_at"] is None
for item in self.sources
)
return _Result(
rows=[
{
**self.task,
"source_file_count": source_file_count,
}
]
)
class _TaskListStore(DataProcessStore):
def __init__(self, conn: _TaskListConnection) -> None:
self._conn = conn
@contextmanager
def connect(self) -> Iterator[_TaskListConnection]:
yield self._conn
class _LegacyRecoveryConnection:
def __init__(self) -> None:
self.task = {
**_regeneration_task(
status="pending",
progress=20,
output_dataset_id=None,
output_count=0,
generation_run_id=None,
started_at=None,
completed_at=None,
),
}
self.datasets = [
{"id": "dataset_train", "type": "train", "count": 1, "created_at": "2026-07-25T18:12:00Z"},
{"id": "dataset_test", "type": "test", "count": 1, "created_at": "2026-07-25T18:12:00Z"},
]
self.records = [
{
"id": "record_train",
"dataset_id": "dataset_train",
"line_no": 1,
"split": "train",
"instruction": "训练问题",
"input": "",
"output": "训练答案",
"raw": json.dumps(
{
"source_result_id": "result_train",
"preview_item_id": "preview_1",
}
),
"status": "valid",
"source_result_id": None,
"preview_item_id": None,
"created_at": "2026-07-25T18:12:00Z",
},
{
"id": "record_test",
"dataset_id": "dataset_test",
"line_no": 1,
"split": "test",
"instruction": "测试问题",
"input": "输入",
"output": "测试答案",
"raw": json.dumps({"source_result_id": "result_test"}),
"status": "modified",
"source_result_id": None,
"preview_item_id": None,
"created_at": "2026-07-25T18:12:00Z",
},
]
self.previews = [{"id": "preview_1"}]
self.results: list[dict[str, Any]] = []
def execute(self, sql: str, params: Any = None) -> _Result:
normalized = " ".join(sql.split())
if normalized.startswith("SELECT COUNT(*) AS count FROM data_process_results"):
return _Result(row={"count": len(self.results)})
if normalized.startswith("SELECT id, type, count, created_at FROM datasets"):
return _Result(rows=list(self.datasets))
if normalized.startswith("SELECT id, dataset_id, line_no, split"):
return _Result(rows=list(self.records))
if normalized.startswith("SELECT id FROM data_process_preview_items"):
return _Result(rows=list(self.previews))
if normalized.startswith("INSERT INTO data_process_results"):
self.results.append(
{
"id": params[0],
"task_id": params[1],
"preview_item_id": params[2],
"instruction": params[3],
"input": params[4],
"output": params[5],
"status": params[9],
"split": params[10],
}
)
return _Result()
if normalized.startswith("UPDATE dataset_records SET source_result_id="):
record = next(item for item in self.records if item["id"] == params[2])
record["source_result_id"] = params[0]
record["preview_item_id"] = params[1]
return _Result()
if normalized.startswith("UPDATE data_process_tasks SET status='completed'"):
self.task.update(
{
"status": "completed",
"progress": 100,
"output_dataset_id": params[0],
"output_count": params[1],
}
)
return _Result()
raise AssertionError(f"unexpected SQL: {normalized}")
class _LegacyRecoveryStore(DataProcessStore):
def __init__(self, conn: _LegacyRecoveryConnection) -> None:
self._conn = conn
@contextmanager
def connect(self) -> Iterator[_LegacyRecoveryConnection]:
yield self._conn
def _task_in_connection(
self, conn: Any, task_id: str, *, for_update: bool = False
) -> dict[str, Any]:
assert task_id == "task-1"
assert for_update is True
return dict(self._conn.task)
class _StartGenerationConnection:
def __init__(self, *, published_prepared: bool = False, preview_count: int = 1) -> None:
config = {"chunk_method": "fixed", "temperature": 0.7}
if published_prepared:
config["_regeneration_prepared"] = {
"prepared": True,
"preview_invalidated": False,
}
self.task = {
**_regeneration_task(
status="completed" if published_prepared else "pending",
config=config,
output_dataset_id="dataset_train" if published_prepared else None,
output_count=28,
),
"generation_run_id": None,
}
self.preview_count = preview_count
self.results = [{"id": "old-result"}]
def execute(self, sql: str, params: Any = None) -> _Result:
normalized = " ".join(sql.split())
if normalized.startswith("SELECT COUNT(*) AS count FROM data_process_preview_items"):
return _Result(row={"count": self.preview_count})
if normalized.startswith("DELETE FROM data_process_results"):
self.results.clear()
return _Result()
assert normalized.startswith("UPDATE data_process_tasks SET config=%s, status='running'")
assert "output_dataset_id=NULL" in normalized
assert "output_count=0" in normalized
self.task.update(
{
"config": params[0],
"status": "running",
"progress": 30,
"output_count": 0,
"output_dataset_id": None,
"failure_reason": None,
"started_at": params[1],
"completed_at": None,
"filtered_count": 0,
"duplicate_count": 0,
"error_count": 0,
"generation_run_id": params[2],
"updated_at": params[3],
}
)
return _Result(row=dict(self.task))
class _StartGenerationStore(DataProcessStore):
def __init__(self, conn: _StartGenerationConnection) -> None:
self._conn = conn
@contextmanager
def connect(self) -> Iterator[_StartGenerationConnection]:
yield self._conn
def _task_in_connection(
self, conn: Any, task_id: str, *, for_update: bool = False
) -> dict[str, Any]:
assert task_id == "task-1"
assert for_update is True
return dict(self._conn.task)
def test_decode_row_serializes_postgres_numeric_values_as_json_numbers() -> None:
decoded = _decode_row(
{
"progress": Decimal("100.00"),
"duration_seconds": Decimal("389.000000"),
}
)
assert decoded == {"progress": 100.0, "duration_seconds": 389.0}
def test_decode_row_decodes_aggregated_output_datasets_json() -> None:
decoded = _decode_row(
{
"id": "task-1",
"output_datasets": '[{"id":"dataset_train","type":"train"}]',
}
)
assert decoded == {
"id": "task-1",
"output_datasets": [{"id": "dataset_train", "type": "train"}],
}
@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_list_tasks_exposes_source_and_generation_counts() -> None:
page = _TaskListStore(_TaskListConnection()).list_tasks(page=1, page_size=20)
assert page["total"] == 1
assert page["page"] == 1
assert page["page_size"] == 20
item = page["items"][0]
assert item["status"] == "pending"
assert item["source_file_count"] == 2
assert item["output_count"] == 17
assert item["output_dataset_id"] is None
def test_start_generation_clears_previous_output_count() -> None:
conn = _StartGenerationConnection()
task = _StartGenerationStore(conn).start_generation("task-1")
assert task["status"] == "running"
assert task["output_count"] == 0
assert conn.results == []
def test_prepared_published_task_is_only_cleared_when_generation_starts() -> None:
conn = _StartGenerationConnection(published_prepared=True)
task = _StartGenerationStore(conn).start_generation("task-1")
assert task["status"] == "running"
assert task["output_dataset_id"] is None
assert task["output_count"] == 0
assert "_regeneration_prepared" not in task["config"]
assert conn.results == []
def test_prepared_published_task_survives_generation_preflight_failure() -> None:
conn = _StartGenerationConnection(published_prepared=True, preview_count=0)
with pytest.raises(InvalidStateError, match="preview must be built"):
_StartGenerationStore(conn).start_generation("task-1")
assert conn.task["status"] == "completed"
assert conn.task["output_dataset_id"] == "dataset_train"
assert conn.task["output_count"] == 28
assert "_regeneration_prepared" in conn.task["config"]
assert conn.results == [{"id": "old-result"}]
def test_legacy_aborted_regeneration_recovers_results_and_published_state() -> None:
conn = _LegacyRecoveryConnection()
store = _LegacyRecoveryStore(conn)
recovered = store.recover_legacy_aborted_regeneration("task-1")
assert recovered == {"recovered": True, "result_count": 2}
assert conn.task["status"] == "completed"
assert conn.task["progress"] == 100
assert conn.task["output_dataset_id"] == "dataset_train"
assert conn.task["output_count"] == 2
assert conn.task["started_at"] is None
assert conn.task["completed_at"] is None
assert [item["id"] for item in conn.results] == ["result_train", "result_test"]
assert conn.results[0]["preview_item_id"] == "preview_1"
assert conn.results[1]["preview_item_id"] is None
assert conn.records[0]["source_result_id"] == "result_train"
assert conn.records[0]["preview_item_id"] == "preview_1"
repeated = store.recover_legacy_aborted_regeneration("task-1")
assert repeated == {"recovered": False, "result_count": 0}
assert len(conn.results) == 2
def test_normal_pending_task_is_not_mistaken_for_legacy_regeneration() -> None:
conn = _LegacyRecoveryConnection()
conn.datasets = []
conn.records = []
result = _LegacyRecoveryStore(conn).recover_legacy_aborted_regeneration("task-1")
assert result == {"recovered": False, "result_count": 0}
assert conn.task["status"] == "pending"
assert conn.results == []
def _legacy_published_datasets(task_id: str = "task-1") -> list[dict[str, Any]]:
specs = (
("dataset_train", "制度问答-训练集", "train", "train", 22),
("dataset_validation", "制度问答-验证集", "val", "validation", 3),
("dataset_test", "制度问答-测试集", "test", "test", 3),
)
dataset_ids = {split: dataset_id for dataset_id, _, _, split, _ in specs}
return [
{
"id": dataset_id,
"name": name,
"type": dataset_type,
"source": "task",
"task_id": task_id,
"source_task_id": None,
"count": count,
"record_count": count,
"dataset_split": split,
"metadata": json.dumps(
{
"base_dataset_name": "制度问答",
"dataset_split": split,
"split_dataset_ids": dataset_ids,
}
),
"deleted_at": None,
}
for dataset_id, name, dataset_type, split, count in specs
]
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"] == "dataset_train"
assert result["task"]["status"] == "completed"
assert result["task"]["progress"] == 100
assert result["task"]["output_count"] == 28
assert result["task"]["started_at"] == "2026-07-25T18:00:00Z"
assert result["task"]["completed_at"] == "2026-07-25T18:05:00Z"
assert "_regeneration_prepared" not in result["task"]["config"]
stored_config = json.loads(conn.task["config"])
assert stored_config["_regeneration_prepared"]["prepared"] is True
assert conn.results == [{"id": "result_1"}]
assert conn.previews == [{"id": "preview_1"}]
assert conn.datasets == original_datasets
assert conn.sources == original_sources
def test_prepare_regeneration_backfills_and_keeps_legacy_task_datasets_visible() -> None:
legacy_datasets = _legacy_published_datasets()
# 历史 metadata 可能不是合法 JSON详情查询不能再依赖 metadata::jsonb。
legacy_datasets[0]["metadata"] = "{legacy-invalid-json"
deleted_dataset = {
**legacy_datasets[0],
"id": "dataset_deleted",
"name": "已删除训练集",
"deleted_at": "2026-07-25T20:00:00Z",
}
unrelated_dataset = {
**legacy_datasets[0],
"id": "dataset_unrelated",
"name": "其他任务训练集",
"task_id": "task-other",
}
conn = _RegenerationConnection(
_regeneration_task(),
[*legacy_datasets, deleted_dataset, unrelated_dataset],
)
before = _TaskDetailStore(_TaskDetailConnection(conn.task, conn.datasets)).get_task(
"task-1"
)
assert [item["id"] for item in before["output_datasets"]] == [
"dataset_train",
"dataset_validation",
"dataset_test",
]
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["published_outputs_preserved"] is True
assert result["task"]["output_dataset_id"] == "dataset_train"
assert len(conn.datasets) == 5
assert all(
item["source_task_id"] == "task-1" for item in conn.datasets[:3]
)
assert deleted_dataset["source_task_id"] is None
assert unrelated_dataset["source_task_id"] is None
after = _TaskDetailStore(_TaskDetailConnection(conn.task, conn.datasets)).get_task(
"task-1"
)
assert [item["id"] for item in after["output_datasets"]] == [
"dataset_train",
"dataset_validation",
"dataset_test",
]
def test_prepare_regeneration_defers_preview_deletion_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"]["status"] == "completed"
assert result["task"]["progress"] == 100
assert result["task"]["output_count"] == 28
assert conn.previews == [{"id": "preview_1"}]
assert conn.results == [{"id": "result_1"}]
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 = [
{
"id": f"result-{index}",
"status": "valid",
"instruction": f"问题 {index}",
"input": "",
"output": (
"<think>\n先读取制度条款。\n</think>\n答案 0"
if index == 0
else f"答案 {index}"
),
"preview_item_id": f"preview-{index}",
}
for index in range(28)
]
conn = _PublishConnection(results)
published = _PublishStore(conn).publish(
"task-1",
{
"dataset_name": "制度问答",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
assert [(item["name"], item["type"], item["count"]) for item in conn.datasets] == [
("制度问答-训练集", "train", 22),
("制度问答-验证集", "val", 3),
("制度问答-测试集", "test", 3),
]
assert len(conn.files) == 3
assert {item["dataset_id"] for item in conn.files} == {
item["id"] for item in conn.datasets
}
assert len(conn.records) == 28
reasoning_record = next(
item for item in conn.records if item["output"].startswith("<think>")
)
assert reasoning_record["raw"]["output"] == reasoning_record["output"]
assert published["dataset"]["type"] == "train"
assert len(published["datasets"]) == 3
assert published["split_counts"] == {"train": 22, "validation": 3, "test": 3}
original_ids = [item["id"] for item in conn.datasets]
republished = _PublishStore(conn).publish(
"task-1",
{
"dataset_name": "制度问答-训练集",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
assert [item["id"] for item in conn.datasets] == original_ids
assert len(conn.datasets) == 3
assert len(conn.files) == 3
assert len(conn.records) == 28
assert republished["created"] is False
def test_publish_reuses_legacy_task_id_only_split_datasets() -> None:
results = [
{
"id": f"result-{index}",
"status": "valid",
"instruction": f"问题 {index}",
"input": "",
"output": f"答案 {index}",
"preview_item_id": f"preview-{index}",
}
for index in range(10)
]
legacy_datasets = _legacy_published_datasets()
original_ids = [item["id"] for item in legacy_datasets]
conn = _PublishConnection(results, legacy_datasets)
published = _PublishStore(conn).publish(
"task-1",
{
"dataset_name": "不会创建新数据集",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
assert published["created"] is False
assert [item["id"] for item in published["datasets"]] == original_ids
assert [item["id"] for item in conn.datasets] == original_ids
def test_publish_keeps_all_three_datasets_when_a_small_split_is_empty() -> None:
conn = _PublishConnection(
[
{
"id": "result-only",
"status": "valid",
"instruction": "唯一问题",
"input": "",
"output": "唯一答案",
"preview_item_id": "preview-only",
}
]
)
published = _PublishStore(conn).publish(
"task-small",
{
"dataset_name": "小样本",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
assert [(item["type"], item["count"]) for item in conn.datasets] == [
("train", 1),
("val", 0),
("test", 0),
]
assert len(published["datasets"]) == 3
assert len(conn.files) == 3
@pytest.mark.parametrize(
("output", "expected"),
[
("<think>\n推理步骤\n</think>\n最终答案", True),
("<think></think>\n最终答案", False),
("<think>只有推理</think>", False),
("没有标签的最终答案", False),
("<think>外层<think>嵌套</think></think>答案", False),
],
)
def test_reasoning_output_validator_requires_one_complete_pair(
output: str,
expected: bool,
) -> None:
assert _reasoning_output_is_valid(output) is expected
def test_publish_rejects_invalid_reasoning_output_format() -> None:
conn = _PublishConnection(
[
{
"id": "result-reasoning-invalid",
"status": "valid",
"instruction": "需要推理的问题",
"input": "",
"output": "只有最终答案",
"preview_item_id": "preview-reasoning-invalid",
}
]
)
with pytest.raises(InvalidStateError, match="1 invalid results"):
_PublishStore(conn, {"output_type": "reasoning"}).publish(
"task-reasoning-invalid",
{
"dataset_name": "无效思维链",
"storage_type": "local",
"format": "alpaca_jsonl",
"split": {"train": 80, "validation": 10, "test": 10},
},
)
def test_source_storage_descriptor_accepts_owned_local_and_legacy_db_references() -> None:
task_id = "dpt_task"
source_file_id = "dpsf_source"
local_reference = (
f"local://data-process/{task_id}/{source_file_id}/v1/source%20100%25.csv"
)
reference, metadata = _source_storage_descriptor(
{
"storage_object_id": local_reference,
"metadata": {"storage_backend": "spoofed", "content_type": "text/csv"},
},
task_id,
source_file_id,
)
assert reference == local_reference
assert metadata == {"storage_backend": "local", "content_type": "text/csv"}
legacy_reference, legacy_metadata = _source_storage_descriptor(
{"metadata": {"legacy": True}},
task_id,
source_file_id,
)
assert legacy_reference == f"db://data-process/{task_id}/{source_file_id}/v1"
assert legacy_metadata == {"storage_backend": "database", "legacy": True}
@pytest.mark.parametrize(
"reference",
[
"local://data-process/dpt_other/dpsf_source/v1/source.txt",
"db://data-process/dpt_task/dpsf_other/v1",
"/var/tmp/source.txt",
],
)
def test_source_storage_descriptor_rejects_unowned_or_unsupported_references(
reference: str,
) -> None:
with pytest.raises(DataProcessStoreError):
_source_storage_descriptor(
{"storage_object_id": reference},
"dpt_task",
"dpsf_source",
)