fix(data-process): 修正任务详情数据契约
This commit is contained in:
@@ -716,7 +716,9 @@ def task_detail(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with api_errors():
|
with api_errors():
|
||||||
task = store.get_task(task_id)
|
task = store.get_task(task_id)
|
||||||
task["source_files"] = store.list_source_files(task_id)
|
source_files = store.list_source_files(task_id)
|
||||||
|
task["source_files"] = source_files
|
||||||
|
task["source_file_count"] = len(source_files)
|
||||||
return ok(task)
|
return ok(task)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,12 @@ class DataProcessStore:
|
|||||||
'name', dataset.name,
|
'name', dataset.name,
|
||||||
'type', dataset.type,
|
'type', dataset.type,
|
||||||
'count', dataset.count,
|
'count', dataset.count,
|
||||||
'dataset_split', dataset.metadata::jsonb->>'dataset_split'
|
'dataset_split', CASE dataset.type
|
||||||
|
WHEN 'train' THEN 'train'
|
||||||
|
WHEN 'val' THEN 'validation'
|
||||||
|
WHEN 'test' THEN 'test'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
) ORDER BY CASE dataset.type
|
) ORDER BY CASE dataset.type
|
||||||
WHEN 'train' THEN 1 WHEN 'val' THEN 2 WHEN 'test' THEN 3 ELSE 4 END), '[]'::json)
|
WHEN 'train' THEN 1 WHEN 'val' THEN 2 WHEN 'test' THEN 3 ELSE 4 END), '[]'::json)
|
||||||
FROM datasets dataset
|
FROM datasets dataset
|
||||||
@@ -1077,8 +1082,8 @@ class DataProcessStore:
|
|||||||
"""
|
"""
|
||||||
UPDATE data_process_tasks
|
UPDATE data_process_tasks
|
||||||
SET status='running', progress=30, failure_reason=NULL, started_at=%s,
|
SET status='running', progress=30, failure_reason=NULL, started_at=%s,
|
||||||
completed_at=NULL, filtered_count=0, duplicate_count=0, error_count=0,
|
completed_at=NULL, output_count=0, filtered_count=0,
|
||||||
generation_run_id=%s, updated_at=%s
|
duplicate_count=0, error_count=0, generation_run_id=%s, updated_at=%s
|
||||||
WHERE id=%s
|
WHERE id=%s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -81,7 +81,18 @@ class FakeDataProcessStore:
|
|||||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||||
if task_id not in self.tasks:
|
if task_id not in self.tasks:
|
||||||
raise NotFoundError("data process task not found")
|
raise NotFoundError("data process task not found")
|
||||||
return deepcopy(self.tasks[task_id])
|
task = deepcopy(self.tasks[task_id])
|
||||||
|
split_order = {"train": 0, "val": 1, "test": 2}
|
||||||
|
task["output_datasets"] = sorted(
|
||||||
|
(
|
||||||
|
deepcopy(dataset)
|
||||||
|
for dataset in self.datasets.values()
|
||||||
|
if dataset.get("source_task_id") == task_id
|
||||||
|
and dataset.get("deleted_at") is None
|
||||||
|
),
|
||||||
|
key=lambda dataset: split_order.get(str(dataset.get("type")), 3),
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
def update_task(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def update_task(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
self.get_task(task_id)
|
self.get_task(task_id)
|
||||||
@@ -334,6 +345,7 @@ class FakeDataProcessStore:
|
|||||||
self.tasks[task_id].update(
|
self.tasks[task_id].update(
|
||||||
status="running",
|
status="running",
|
||||||
progress=30,
|
progress=30,
|
||||||
|
output_count=0,
|
||||||
generation_run_id=self._id("dprun"),
|
generation_run_id=self._id("dprun"),
|
||||||
)
|
)
|
||||||
return self.get_task(task_id)
|
return self.get_task(task_id)
|
||||||
@@ -475,15 +487,53 @@ class FakeDataProcessStore:
|
|||||||
|
|
||||||
def publish(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def publish(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
task = self.tasks[task_id]
|
task = self.tasks[task_id]
|
||||||
|
published = [
|
||||||
|
dataset
|
||||||
|
for dataset in self.datasets.values()
|
||||||
|
if dataset.get("source_task_id") == task_id
|
||||||
|
and dataset.get("deleted_at") is None
|
||||||
|
]
|
||||||
if task.get("output_dataset_id"):
|
if task.get("output_dataset_id"):
|
||||||
return {"dataset": deepcopy(self.datasets[task["output_dataset_id"]]), "created": False}
|
train_dataset = self.datasets[task["output_dataset_id"]]
|
||||||
|
return {
|
||||||
|
"dataset": deepcopy(train_dataset),
|
||||||
|
"datasets": deepcopy(published),
|
||||||
|
"output_datasets": deepcopy(published),
|
||||||
|
"created": False,
|
||||||
|
}
|
||||||
if task["status"] != "completed":
|
if task["status"] != "completed":
|
||||||
raise InvalidStateError("only a completed task can be published")
|
raise InvalidStateError("only a completed task can be published")
|
||||||
dataset_id = self._id("dataset")
|
split_specs = (
|
||||||
dataset = {"id": dataset_id, "name": payload["dataset_name"], "source_task_id": task_id}
|
("train", "训练集"),
|
||||||
self.datasets[dataset_id] = dataset
|
("val", "验证集"),
|
||||||
task["output_dataset_id"] = dataset_id
|
("test", "测试集"),
|
||||||
return {"dataset": deepcopy(dataset), "created": True}
|
)
|
||||||
|
for dataset_type, label in split_specs:
|
||||||
|
dataset_id = self._id("dataset")
|
||||||
|
self.datasets[dataset_id] = {
|
||||||
|
"id": dataset_id,
|
||||||
|
"name": f"{payload['dataset_name']}-{label}",
|
||||||
|
"type": dataset_type,
|
||||||
|
"count": len(self.results[task_id]),
|
||||||
|
"source": "task",
|
||||||
|
"task_id": task_id,
|
||||||
|
"source_task_id": task_id,
|
||||||
|
"deleted_at": None,
|
||||||
|
}
|
||||||
|
published = [
|
||||||
|
dataset
|
||||||
|
for dataset in self.datasets.values()
|
||||||
|
if dataset.get("source_task_id") == task_id
|
||||||
|
and dataset.get("deleted_at") is None
|
||||||
|
]
|
||||||
|
train_dataset = next(dataset for dataset in published if dataset["type"] == "train")
|
||||||
|
task["output_dataset_id"] = train_dataset["id"]
|
||||||
|
return {
|
||||||
|
"dataset": deepcopy(train_dataset),
|
||||||
|
"datasets": deepcopy(published),
|
||||||
|
"output_datasets": deepcopy(published),
|
||||||
|
"created": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_client(
|
def make_client(
|
||||||
@@ -706,6 +756,56 @@ def test_task_list_exposes_document_and_generation_counts(
|
|||||||
assert item["output_dataset_id"] is None
|
assert item["output_dataset_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_detail_uses_returned_source_files_as_document_count(tmp_path: Path) -> None:
|
||||||
|
client, store, _ = make_client(tmp_path)
|
||||||
|
task_id = client.post(
|
||||||
|
"/modelTF/data-process",
|
||||||
|
json={"name": "详情文档数", "process_type": "unstructured", "config": {}},
|
||||||
|
).json()["data"]["id"]
|
||||||
|
store.tasks[task_id]["source_file_count"] = 99
|
||||||
|
store.sources[task_id] = [
|
||||||
|
{"id": "source-1", "name": "一.pdf", "content": "正文一"},
|
||||||
|
{"id": "source-2", "name": "二.pdf", "content": "正文二"},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/modelTF/data-process/{task_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
detail = response.json()["data"]
|
||||||
|
assert len(detail["source_files"]) == 2
|
||||||
|
assert detail["source_file_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_start_response_clears_previous_output_count(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
client, store, _ = make_client(tmp_path)
|
||||||
|
task_id = client.post(
|
||||||
|
"/modelTF/data-process",
|
||||||
|
json={"name": "新一轮生成", "process_type": "structured", "config": {}},
|
||||||
|
).json()["data"]["id"]
|
||||||
|
store.tasks[task_id]["output_count"] = 28
|
||||||
|
store.previews[task_id] = [
|
||||||
|
{
|
||||||
|
"id": "preview-1",
|
||||||
|
"source_file_id": None,
|
||||||
|
"original_content": '{"question":"新问题","answer":"新答案"}',
|
||||||
|
"edited_content": '{"question":"新问题","answer":"新答案"}',
|
||||||
|
"status": "original",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(data_process_endpoint, "_run_generation", lambda *args: None)
|
||||||
|
|
||||||
|
response = client.post(f"/modelTF/data-process/{task_id}/generate")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
progress = response.json()["data"]
|
||||||
|
assert progress["status"] == "running"
|
||||||
|
assert progress["output_count"] == 0
|
||||||
|
assert store.tasks[task_id]["output_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_preview_build_replaces_only_selected_files_and_reports_file_counts(
|
def test_preview_build_replaces_only_selected_files_and_reports_file_counts(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -893,6 +993,64 @@ def test_regenerate_endpoint_prepares_an_existing_published_task(tmp_path: Path)
|
|||||||
assert store.previews[task_id][0]["id"] == "preview_1"
|
assert store.previews[task_id][0]["id"] == "preview_1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_split_datasets_remain_in_detail_after_regeneration(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
client, store, _ = make_client(tmp_path)
|
||||||
|
task_id = client.post(
|
||||||
|
"/modelTF/data-process",
|
||||||
|
json={"name": "保留旧发布数据", "process_type": "structured", "config": {}},
|
||||||
|
).json()["data"]["id"]
|
||||||
|
store.tasks[task_id].update(
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"updated_at": "2026-07-27T09:00:00Z",
|
||||||
|
"output_count": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.results[task_id] = [
|
||||||
|
{
|
||||||
|
"id": "result_1",
|
||||||
|
"status": "valid",
|
||||||
|
"instruction": "问题",
|
||||||
|
"input": "",
|
||||||
|
"output": "答案",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
published = client.post(
|
||||||
|
f"/modelTF/data-process/{task_id}/publish",
|
||||||
|
json={"dataset_name": "保留旧发布数据集"},
|
||||||
|
)
|
||||||
|
assert published.status_code == 200
|
||||||
|
published_datasets = published.json()["data"]["datasets"]
|
||||||
|
assert len(published_datasets) == 3
|
||||||
|
published_ids = {item["id"] for item in published_datasets}
|
||||||
|
assert store.tasks[task_id]["output_dataset_id"] in published_ids
|
||||||
|
|
||||||
|
regenerated = client.post(
|
||||||
|
f"/modelTF/data-process/{task_id}/regenerate",
|
||||||
|
json={
|
||||||
|
"name": "保留旧发布数据",
|
||||||
|
"description": "更换生成配置后退出",
|
||||||
|
"process_type": "structured",
|
||||||
|
"config": {"generation_model_id": "model_2"},
|
||||||
|
"expected_updated_at": "2026-07-27T09:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert regenerated.status_code == 200
|
||||||
|
assert regenerated.json()["data"]["task"]["output_dataset_id"] is None
|
||||||
|
|
||||||
|
detail = client.get(f"/modelTF/data-process/{task_id}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
detail_data = detail.json()["data"]
|
||||||
|
assert detail_data["status"] == "pending"
|
||||||
|
assert detail_data["output_dataset_id"] is None
|
||||||
|
assert len(detail_data["output_datasets"]) == 3
|
||||||
|
assert {item["id"] for item in detail_data["output_datasets"]} == published_ids
|
||||||
|
assert set(store.datasets) == published_ids
|
||||||
|
|
||||||
|
|
||||||
def test_regenerate_endpoint_validates_snapshot_and_locked_process_type(
|
def test_regenerate_endpoint_validates_snapshot_and_locked_process_type(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -274,6 +274,10 @@ class _TaskDetailConnection:
|
|||||||
assert "dataset.source_task_id=task.id" in normalized
|
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.source_task_id IS NULL AND dataset.task_id=task.id" in normalized
|
||||||
assert "dataset.deleted_at IS NULL" 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]
|
task_id = params[0]
|
||||||
visible = [
|
visible = [
|
||||||
dataset
|
dataset
|
||||||
@@ -364,6 +368,61 @@ class _TaskListStore(DataProcessStore):
|
|||||||
yield self._conn
|
yield self._conn
|
||||||
|
|
||||||
|
|
||||||
|
class _StartGenerationConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.task = {
|
||||||
|
**_regeneration_task(
|
||||||
|
status="pending",
|
||||||
|
output_dataset_id=None,
|
||||||
|
output_count=28,
|
||||||
|
),
|
||||||
|
"generation_run_id": None,
|
||||||
|
}
|
||||||
|
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": 1})
|
||||||
|
if normalized.startswith("DELETE FROM data_process_results"):
|
||||||
|
self.results.clear()
|
||||||
|
return _Result()
|
||||||
|
assert normalized.startswith("UPDATE data_process_tasks SET status='running'")
|
||||||
|
assert "output_count=0" in normalized
|
||||||
|
self.task.update(
|
||||||
|
{
|
||||||
|
"status": "running",
|
||||||
|
"progress": 30,
|
||||||
|
"output_count": 0,
|
||||||
|
"failure_reason": None,
|
||||||
|
"started_at": params[0],
|
||||||
|
"completed_at": None,
|
||||||
|
"filtered_count": 0,
|
||||||
|
"duplicate_count": 0,
|
||||||
|
"error_count": 0,
|
||||||
|
"generation_run_id": params[1],
|
||||||
|
"updated_at": params[2],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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:
|
def test_decode_row_serializes_postgres_numeric_values_as_json_numbers() -> None:
|
||||||
decoded = _decode_row(
|
decoded = _decode_row(
|
||||||
{
|
{
|
||||||
@@ -522,6 +581,16 @@ def test_list_tasks_exposes_source_and_generation_counts() -> None:
|
|||||||
assert item["output_dataset_id"] is None
|
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 _legacy_published_datasets(task_id: str = "task-1") -> list[dict[str, Any]]:
|
def _legacy_published_datasets(task_id: str = "task-1") -> list[dict[str, Any]]:
|
||||||
specs = (
|
specs = (
|
||||||
("dataset_train", "制度问答-训练集", "train", "train", 22),
|
("dataset_train", "制度问答-训练集", "train", "train", 22),
|
||||||
@@ -585,6 +654,8 @@ def test_prepare_regeneration_preserves_outputs_sources_and_generation_only_prev
|
|||||||
|
|
||||||
def test_prepare_regeneration_backfills_and_keeps_legacy_task_datasets_visible() -> None:
|
def test_prepare_regeneration_backfills_and_keeps_legacy_task_datasets_visible() -> None:
|
||||||
legacy_datasets = _legacy_published_datasets()
|
legacy_datasets = _legacy_published_datasets()
|
||||||
|
# 历史 metadata 可能不是合法 JSON,详情查询不能再依赖 metadata::jsonb。
|
||||||
|
legacy_datasets[0]["metadata"] = "{legacy-invalid-json"
|
||||||
deleted_dataset = {
|
deleted_dataset = {
|
||||||
**legacy_datasets[0],
|
**legacy_datasets[0],
|
||||||
"id": "dataset_deleted",
|
"id": "dataset_deleted",
|
||||||
|
|||||||
Reference in New Issue
Block a user