feat(data-process): 完善后台生成与失败重试
This commit is contained in:
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
from copy import deepcopy
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from threading import Barrier, Lock
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from docx import Document as WordDocument
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import Workbook
|
||||
|
||||
@@ -31,6 +33,7 @@ class FakeDataProcessStore:
|
||||
self.previews: dict[str, list[dict[str, Any]]] = {}
|
||||
self.results: dict[str, list[dict[str, Any]]] = {}
|
||||
self.datasets: dict[str, dict[str, Any]] = {}
|
||||
self.models: dict[str, dict[str, Any]] = {}
|
||||
self.regeneration_prepared: set[str] = set()
|
||||
self.sequence = 0
|
||||
|
||||
@@ -73,6 +76,14 @@ class FakeDataProcessStore:
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"output_dataset_id": None,
|
||||
"results_confirmed": False,
|
||||
"workflow_step": "create",
|
||||
"preview_status": "idle",
|
||||
"preview_progress": 0,
|
||||
"preview_run_id": None,
|
||||
"preview_failure_reason": None,
|
||||
"preview_total_files": 0,
|
||||
"preview_completed_files": 0,
|
||||
}
|
||||
self.tasks[task_id] = task
|
||||
self.sources[task_id] = []
|
||||
@@ -105,6 +116,11 @@ class FakeDataProcessStore:
|
||||
self.tasks[task_id].update(deepcopy(payload))
|
||||
return self.get_task(task_id)
|
||||
|
||||
def update_workflow_step(self, task_id: str, workflow_step: str) -> dict[str, Any]:
|
||||
self.get_task(task_id)
|
||||
self.tasks[task_id]["workflow_step"] = workflow_step
|
||||
return self.get_task(task_id)
|
||||
|
||||
def prepare_regeneration(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
@@ -136,8 +152,6 @@ class FakeDataProcessStore:
|
||||
|
||||
def delete_task(self, task_id: str, **_: Any) -> None:
|
||||
self.get_task(task_id)
|
||||
if self.tasks[task_id]["status"] == "running":
|
||||
raise InvalidStateError("running task must be stopped before deletion")
|
||||
del self.tasks[task_id]
|
||||
|
||||
def list_source_files(self, task_id: str) -> list[dict[str, Any]]:
|
||||
@@ -197,6 +211,13 @@ class FakeDataProcessStore:
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"workflow_step": "upload",
|
||||
"preview_status": "idle",
|
||||
"preview_progress": 0,
|
||||
"preview_run_id": None,
|
||||
"preview_failure_reason": None,
|
||||
"preview_total_files": 0,
|
||||
"preview_completed_files": 0,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
}
|
||||
@@ -254,6 +275,15 @@ class FakeDataProcessStore:
|
||||
item for item in self.previews[task_id] if item["source_file_id"] != file_id
|
||||
]
|
||||
self.results[task_id] = []
|
||||
self.tasks[task_id].update(
|
||||
workflow_step="upload",
|
||||
preview_status="idle",
|
||||
preview_progress=0,
|
||||
preview_run_id=None,
|
||||
preview_failure_reason=None,
|
||||
preview_total_files=0,
|
||||
preview_completed_files=0,
|
||||
)
|
||||
|
||||
def replace_preview_items(
|
||||
self,
|
||||
@@ -261,7 +291,10 @@ class FakeDataProcessStore:
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
source_file_ids: list[str] | None = None,
|
||||
preview_run_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if preview_run_id is not None and not self.preview_is_running(task_id, preview_run_id):
|
||||
raise InvalidStateError("preview run is no longer active")
|
||||
created = [
|
||||
{"id": self._id("dpp"), "task_id": task_id, **deepcopy(item)} for item in items
|
||||
]
|
||||
@@ -276,8 +309,121 @@ class FakeDataProcessStore:
|
||||
] + created
|
||||
self.results[task_id] = []
|
||||
self.tasks[task_id]["progress"] = 20
|
||||
if preview_run_id is None:
|
||||
file_count = len(source_file_ids or {item["source_file_id"] for item in created})
|
||||
self.tasks[task_id].update(
|
||||
workflow_step="preview",
|
||||
preview_status="completed",
|
||||
preview_progress=100,
|
||||
preview_run_id=None,
|
||||
preview_failure_reason=None,
|
||||
preview_total_files=file_count,
|
||||
preview_completed_files=file_count,
|
||||
)
|
||||
return deepcopy(created)
|
||||
|
||||
def start_preview(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
source_file_ids: list[str] | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
task = self.tasks[task_id]
|
||||
if task.get("preview_status") in {"queued", "running"}:
|
||||
raise InvalidStateError("task cannot be edited while preview is running")
|
||||
selected_ids = source_file_ids or [str(item["id"]) for item in self.sources[task_id]]
|
||||
found = {str(item["id"]) for item in self.sources[task_id]}
|
||||
missing = set(selected_ids) - found
|
||||
if missing:
|
||||
raise NotFoundError(f"source files not found: {', '.join(sorted(missing))}")
|
||||
if not selected_ids:
|
||||
raise InvalidStateError("at least one source file is required")
|
||||
run_id = self._id("dpprun")
|
||||
self.results[task_id] = []
|
||||
task.update(
|
||||
status="pending",
|
||||
workflow_step="upload",
|
||||
preview_status="queued",
|
||||
preview_progress=0,
|
||||
preview_run_id=run_id,
|
||||
preview_failure_reason=None,
|
||||
preview_total_files=len(selected_ids),
|
||||
preview_completed_files=0,
|
||||
results_confirmed=False,
|
||||
)
|
||||
return self.get_task(task_id), list(selected_ids)
|
||||
|
||||
def mark_preview_running(self, task_id: str, preview_run_id: str) -> bool:
|
||||
task = self.tasks.get(task_id)
|
||||
if not task or task.get("preview_status") != "queued" or task.get("preview_run_id") != preview_run_id:
|
||||
return False
|
||||
task["preview_status"] = "running"
|
||||
return True
|
||||
|
||||
def preview_is_running(self, task_id: str, preview_run_id: str) -> bool:
|
||||
task = self.tasks.get(task_id)
|
||||
return bool(
|
||||
task
|
||||
and task.get("preview_status") in {"queued", "running"}
|
||||
and task.get("preview_run_id") == preview_run_id
|
||||
)
|
||||
|
||||
def update_preview_progress(
|
||||
self,
|
||||
task_id: str,
|
||||
preview_run_id: str,
|
||||
completed_files: int,
|
||||
total_files: int,
|
||||
) -> bool:
|
||||
if not self.preview_is_running(task_id, preview_run_id):
|
||||
return False
|
||||
self.tasks[task_id]["preview_completed_files"] = completed_files
|
||||
self.tasks[task_id]["preview_progress"] = completed_files / max(1, total_files) * 100
|
||||
return True
|
||||
|
||||
def complete_preview(self, task_id: str, preview_run_id: str) -> bool:
|
||||
if not self.preview_is_running(task_id, preview_run_id):
|
||||
return False
|
||||
task = self.tasks[task_id]
|
||||
task.update(
|
||||
workflow_step="preview",
|
||||
preview_status="completed",
|
||||
preview_progress=100,
|
||||
preview_run_id=None,
|
||||
preview_failure_reason=None,
|
||||
preview_completed_files=task["preview_total_files"],
|
||||
)
|
||||
return True
|
||||
|
||||
def mark_preview_failed(
|
||||
self,
|
||||
task_id: str,
|
||||
reason: str,
|
||||
*,
|
||||
preview_run_id: str,
|
||||
) -> bool:
|
||||
if not self.preview_is_running(task_id, preview_run_id):
|
||||
return False
|
||||
self.tasks[task_id].update(
|
||||
preview_status="failed",
|
||||
preview_run_id=None,
|
||||
preview_failure_reason=reason,
|
||||
)
|
||||
return True
|
||||
|
||||
def preview_progress(self, task_id: str) -> dict[str, Any]:
|
||||
task = self.get_task(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"workflow_step": task["workflow_step"],
|
||||
"preview_status": task["preview_status"],
|
||||
"preview_progress": task["preview_progress"],
|
||||
"preview_run_id": task["preview_run_id"],
|
||||
"preview_failure_reason": task["preview_failure_reason"],
|
||||
"preview_total_files": task["preview_total_files"],
|
||||
"preview_completed_files": task["preview_completed_files"],
|
||||
}
|
||||
|
||||
def list_preview_items(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -348,15 +494,19 @@ class FakeDataProcessStore:
|
||||
progress=30,
|
||||
output_dataset_id=None,
|
||||
output_count=0,
|
||||
results_confirmed=False,
|
||||
workflow_step="generate",
|
||||
generation_run_id=self._id("dprun"),
|
||||
)
|
||||
self.regeneration_prepared.discard(task_id)
|
||||
return self.get_task(task_id)
|
||||
|
||||
def generation_is_running(self, task_id: str, generation_run_id: str) -> bool:
|
||||
task = self.tasks.get(task_id)
|
||||
return (
|
||||
self.tasks[task_id]["status"] == "running"
|
||||
and self.tasks[task_id].get("generation_run_id") == generation_run_id
|
||||
bool(task)
|
||||
and task["status"] == "running"
|
||||
and task.get("generation_run_id") == generation_run_id
|
||||
)
|
||||
|
||||
def update_generation_progress(
|
||||
@@ -389,6 +539,7 @@ class FakeDataProcessStore:
|
||||
status="completed",
|
||||
progress=100,
|
||||
output_count=len(results),
|
||||
results_confirmed=False,
|
||||
generation_run_id=None,
|
||||
**counts,
|
||||
)
|
||||
@@ -416,6 +567,7 @@ class FakeDataProcessStore:
|
||||
result = {key: task.get(key) for key in (
|
||||
"status", "progress", "input_count", "output_count",
|
||||
"filtered_count", "duplicate_count", "error_count", "failure_reason",
|
||||
"results_confirmed",
|
||||
)}
|
||||
result["task_id"] = task["id"]
|
||||
return result
|
||||
@@ -488,6 +640,66 @@ class FakeDataProcessStore:
|
||||
item["status"] = "valid"
|
||||
return deepcopy(item)
|
||||
|
||||
def get_generation_model(self, model_id: str) -> dict[str, Any]:
|
||||
model = self.models.get(model_id)
|
||||
if not model:
|
||||
raise NotFoundError("generation model not found")
|
||||
return deepcopy(model)
|
||||
|
||||
def replace_generated_result(
|
||||
self,
|
||||
task_id: str,
|
||||
result_id: str,
|
||||
replacement: dict[str, Any],
|
||||
*,
|
||||
expected_updated_at: str,
|
||||
) -> dict[str, Any]:
|
||||
task = self.tasks[task_id]
|
||||
if task["status"] != "completed" or task.get("workflow_step") != "results":
|
||||
raise InvalidStateError("task is not editing generation results")
|
||||
if task.get("results_confirmed") or task.get("output_dataset_id"):
|
||||
raise InvalidStateError("confirmed or published results cannot be regenerated")
|
||||
item = next((item for item in self.results[task_id] if item["id"] == result_id), None)
|
||||
if not item:
|
||||
raise NotFoundError("data process result not found")
|
||||
if item["status"] != "invalid":
|
||||
raise InvalidStateError("only an invalid result can be regenerated")
|
||||
if expected_updated_at != item.get("updated_at"):
|
||||
raise InvalidStateError("data process result was modified by another request")
|
||||
for field in ("instruction", "input", "output"):
|
||||
item[field] = replacement[field]
|
||||
item[f"original_{field}"] = replacement[field]
|
||||
item.update(
|
||||
status=replacement["status"],
|
||||
error=replacement.get("error"),
|
||||
quality_score=deepcopy(replacement["quality_score"]),
|
||||
updated_at="2026-07-27T22:00:00Z",
|
||||
)
|
||||
task["error_count"] = sum(
|
||||
result["status"] == "invalid" for result in self.results[task_id]
|
||||
)
|
||||
return deepcopy(item)
|
||||
|
||||
def confirm_results(self, task_id: str) -> dict[str, Any]:
|
||||
task = self.tasks[task_id]
|
||||
if task["status"] != "completed":
|
||||
raise InvalidStateError("only a completed task can confirm results")
|
||||
if task.get("workflow_step") != "results":
|
||||
raise InvalidStateError("workflow must be on results before confirmation")
|
||||
invalid_count = sum(
|
||||
1
|
||||
for item in self.results[task_id]
|
||||
if item["status"] == "invalid"
|
||||
or not item["instruction"].strip()
|
||||
or not item["output"].strip()
|
||||
)
|
||||
if invalid_count:
|
||||
raise InvalidStateError(f"task contains {invalid_count} invalid results")
|
||||
if not self.results[task_id]:
|
||||
raise InvalidStateError("task has no results to confirm")
|
||||
task["results_confirmed"] = True
|
||||
return self.get_task(task_id)
|
||||
|
||||
def publish(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.tasks[task_id]
|
||||
if task_id in self.regeneration_prepared:
|
||||
@@ -508,6 +720,8 @@ class FakeDataProcessStore:
|
||||
}
|
||||
if task["status"] != "completed":
|
||||
raise InvalidStateError("only a completed task can be published")
|
||||
if not task.get("results_confirmed"):
|
||||
raise InvalidStateError("results must be confirmed before publishing")
|
||||
split_specs = (
|
||||
("train", "训练集"),
|
||||
("val", "验证集"),
|
||||
@@ -625,6 +839,14 @@ def _minimal_pdf(text: str = "Hello PDF") -> bytes:
|
||||
return _minimal_pdf_pages(text)
|
||||
|
||||
|
||||
def test_outdated_data_process_schema_returns_actionable_503() -> None:
|
||||
with pytest.raises(HTTPException) as captured, data_process_endpoint.api_errors():
|
||||
raise psycopg.errors.UndefinedColumn("missing runtime column")
|
||||
|
||||
assert captured.value.status_code == 503
|
||||
assert "schema is missing or out of date" in captured.value.detail["message"]
|
||||
|
||||
|
||||
def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
||||
client, store, _ = make_client(tmp_path)
|
||||
created = client.post(
|
||||
@@ -636,6 +858,7 @@ def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["data"]["results_confirmed"] is False
|
||||
task_id = created.json()["data"]["id"]
|
||||
|
||||
source_content = (
|
||||
@@ -687,8 +910,10 @@ def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
||||
|
||||
generated = client.post(f"/modelTF/data-process/{task_id}/generate")
|
||||
assert generated.status_code == 200
|
||||
assert generated.json()["data"]["results_confirmed"] is False
|
||||
progress = client.get(f"/modelTF/data-process/{task_id}/progress")
|
||||
assert progress.json()["data"]["status"] == "completed"
|
||||
assert progress.json()["data"]["results_confirmed"] is False
|
||||
result_page = client.get(f"/modelTF/data-process/{task_id}/results").json()["data"]
|
||||
assert result_page["total"] == 2
|
||||
keyword_page = client.get(
|
||||
@@ -719,6 +944,17 @@ def test_data_process_full_contract_without_database(tmp_path: Path) -> None:
|
||||
assert restored.json()["data"]["status"] == "valid"
|
||||
assert store.tasks[task_id]["error_count"] == 0
|
||||
|
||||
workflow = client.put(
|
||||
f"/modelTF/data-process/{task_id}/workflow-step",
|
||||
json={"workflow_step": "results"},
|
||||
)
|
||||
assert workflow.status_code == 200
|
||||
confirmed = client.post(
|
||||
f"/modelTF/data-process/{task_id}/confirm-results"
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["data"]["results_confirmed"] is True
|
||||
|
||||
publish_payload = {"dataset_name": "客服问答清洗集"}
|
||||
first_publish = client.post(
|
||||
f"/modelTF/data-process/{task_id}/publish", json=publish_payload
|
||||
@@ -761,6 +997,120 @@ def test_task_list_exposes_document_and_generation_counts(
|
||||
assert item["output_dataset_id"] is None
|
||||
|
||||
|
||||
def test_workflow_step_update_is_independent_and_validated(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.previews[task_id] = [{"id": "preview-1"}]
|
||||
store.results[task_id] = [{"id": "result-1"}]
|
||||
store.tasks[task_id].update(status="running", generation_run_id="run-1")
|
||||
|
||||
updated = client.put(
|
||||
f"/modelTF/data-process/{task_id}/workflow-step",
|
||||
json={"workflow_step": "generate"},
|
||||
)
|
||||
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["data"]["workflow_step"] == "generate"
|
||||
assert store.previews[task_id] == [{"id": "preview-1"}]
|
||||
assert store.results[task_id] == [{"id": "result-1"}]
|
||||
assert store.tasks[task_id]["status"] == "running"
|
||||
assert client.put(
|
||||
f"/modelTF/data-process/{task_id}/workflow-step",
|
||||
json={"workflow_step": "unknown"},
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_background_preview_persists_progress_and_items(tmp_path: Path) -> None:
|
||||
client, _, _ = make_client(tmp_path)
|
||||
task_id = client.post(
|
||||
"/modelTF/data-process",
|
||||
json={"name": "后台切分", "process_type": "structured", "config": {}},
|
||||
).json()["data"]["id"]
|
||||
source = client.post(
|
||||
f"/modelTF/data-process/{task_id}/source-files",
|
||||
files={
|
||||
"files": (
|
||||
"one.jsonl",
|
||||
b'{"question":"What is one?","answer":"One."}\n',
|
||||
"application/jsonl",
|
||||
)
|
||||
},
|
||||
).json()["data"]["files"][0]
|
||||
|
||||
started = client.post(
|
||||
f"/modelTF/data-process/{task_id}/preview/start",
|
||||
json={"source_file_ids": [source["id"]]},
|
||||
)
|
||||
|
||||
assert started.status_code == 202
|
||||
assert started.json()["data"]["preview_status"] == "queued"
|
||||
assert started.json()["data"]["preview_run_id"]
|
||||
progress = client.get(
|
||||
f"/modelTF/data-process/{task_id}/preview/progress"
|
||||
).json()["data"]
|
||||
assert progress == {
|
||||
"task_id": task_id,
|
||||
"workflow_step": "preview",
|
||||
"preview_status": "completed",
|
||||
"preview_progress": 100.0,
|
||||
"preview_run_id": None,
|
||||
"preview_failure_reason": None,
|
||||
"preview_total_files": 1,
|
||||
"preview_completed_files": 1,
|
||||
}
|
||||
assert client.get(
|
||||
f"/modelTF/data-process/{task_id}/preview"
|
||||
).json()["data"]["total"] == 1
|
||||
|
||||
|
||||
def test_stale_preview_worker_cannot_replace_new_run(tmp_path: Path) -> None:
|
||||
_, store, storage = make_client(tmp_path)
|
||||
task = store.create_task(
|
||||
{"name": "切分代次", "process_type": "structured", "config": {}}
|
||||
)
|
||||
task_id = str(task["id"])
|
||||
store.sources[task_id] = [{"id": "source-1"}]
|
||||
first, source_ids = store.start_preview(task_id, source_file_ids=["source-1"])
|
||||
first_run_id = str(first["preview_run_id"])
|
||||
store.tasks[task_id].update(preview_status="cancelled", preview_run_id=None)
|
||||
second, _ = store.start_preview(task_id, source_file_ids=["source-1"])
|
||||
|
||||
data_process_endpoint._run_preview(
|
||||
store,
|
||||
storage,
|
||||
task_id,
|
||||
first_run_id,
|
||||
source_ids,
|
||||
)
|
||||
|
||||
assert store.tasks[task_id]["preview_status"] == "queued"
|
||||
assert store.tasks[task_id]["preview_run_id"] == second["preview_run_id"]
|
||||
assert store.previews[task_id] == []
|
||||
|
||||
|
||||
def test_delete_invalidates_active_generation_and_preview(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="running",
|
||||
generation_run_id="generation-1",
|
||||
preview_status="running",
|
||||
preview_run_id="preview-1",
|
||||
)
|
||||
|
||||
deleted = client.delete(f"/modelTF/data-process/{task_id}")
|
||||
|
||||
assert deleted.status_code == 200
|
||||
assert store.generation_is_running(task_id, "generation-1") is False
|
||||
assert store.preview_is_running(task_id, "preview-1") is False
|
||||
|
||||
|
||||
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(
|
||||
@@ -811,6 +1161,404 @@ def test_generation_start_response_clears_previous_output_count(
|
||||
assert store.tasks[task_id]["output_count"] == 0
|
||||
|
||||
|
||||
def test_results_must_be_generated_and_valid_before_confirmation(
|
||||
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"]
|
||||
|
||||
pending = client.post(f"/modelTF/data-process/{task_id}/confirm-results")
|
||||
assert pending.status_code == 409
|
||||
|
||||
store.tasks[task_id].update(status="completed", progress=100, workflow_step="generate")
|
||||
store.results[task_id] = [
|
||||
{
|
||||
"id": "result_valid",
|
||||
"status": "valid",
|
||||
"instruction": "问题",
|
||||
"input": "",
|
||||
"output": "答案",
|
||||
}
|
||||
]
|
||||
wrong_step = client.post(f"/modelTF/data-process/{task_id}/confirm-results")
|
||||
assert wrong_step.status_code == 409
|
||||
|
||||
store.tasks[task_id]["workflow_step"] = "results"
|
||||
store.results[task_id] = [
|
||||
{
|
||||
"id": "result_invalid",
|
||||
"status": "invalid",
|
||||
"instruction": "问题",
|
||||
"input": "",
|
||||
"output": "",
|
||||
}
|
||||
]
|
||||
invalid = client.post(f"/modelTF/data-process/{task_id}/confirm-results")
|
||||
assert invalid.status_code == 409
|
||||
|
||||
store.results[task_id][0].update(status="valid", output="答案")
|
||||
confirmed = client.post(f"/modelTF/data-process/{task_id}/confirm-results")
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["data"]["results_confirmed"] is True
|
||||
assert client.post(
|
||||
f"/modelTF/data-process/{task_id}/confirm-results"
|
||||
).status_code == 200
|
||||
|
||||
|
||||
def test_invalid_result_can_be_regenerated_in_place(
|
||||
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": {
|
||||
"generation_model_id": "model-1",
|
||||
"output_type": "standard",
|
||||
"min_output_length": 5,
|
||||
"generation_retries": 5,
|
||||
"request_timeout_seconds": 120,
|
||||
},
|
||||
},
|
||||
).json()["data"]["id"]
|
||||
store.tasks[task_id].update(
|
||||
status="completed",
|
||||
progress=100,
|
||||
workflow_step="results",
|
||||
results_confirmed=False,
|
||||
error_count=1,
|
||||
)
|
||||
store.models["model-1"] = {
|
||||
"id": "model-1",
|
||||
"name": "测试模型",
|
||||
"online_model_name": "test-model",
|
||||
"api_url": "https://model.example/v1",
|
||||
"api_key": "secret",
|
||||
}
|
||||
store.previews[task_id] = [{
|
||||
"id": "preview-1",
|
||||
"status": "original",
|
||||
"original_content": "申请编号字段用于标识报销申请。",
|
||||
"edited_content": "申请编号字段用于标识报销申请。",
|
||||
}]
|
||||
store.results[task_id] = [{
|
||||
"id": "result-1",
|
||||
"preview_item_id": "preview-1",
|
||||
"instruction": "模型生成失败,请人工补充",
|
||||
"input": "申请编号字段用于标识报销申请。",
|
||||
"output": "",
|
||||
"original_instruction": "模型生成失败,请人工补充",
|
||||
"original_input": "申请编号字段用于标识报销申请。",
|
||||
"original_output": "",
|
||||
"status": "invalid",
|
||||
"error": "model response is not valid JSON",
|
||||
"split": "train",
|
||||
"quality_score": {},
|
||||
"updated_at": "2026-07-27T21:00:00Z",
|
||||
}]
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_generate(preview_items: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
captured["preview_items"] = list(preview_items)
|
||||
captured["qa_pairs_per_item"] = kwargs["qa_pairs_per_item"]
|
||||
captured["config"] = kwargs["config"]
|
||||
return [{
|
||||
"id": "temporary-result",
|
||||
"preview_item_id": "preview-1",
|
||||
"instruction": "申请编号字段有什么作用?",
|
||||
"input": "申请编号字段用于标识报销申请。",
|
||||
"output": "申请编号字段用于唯一标识一笔报销申请。",
|
||||
"original_instruction": "申请编号字段有什么作用?",
|
||||
"original_input": "申请编号字段用于标识报销申请。",
|
||||
"original_output": "申请编号字段用于唯一标识一笔报销申请。",
|
||||
"status": "valid",
|
||||
"error": None,
|
||||
"split": "train",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(data_process_endpoint, "generate_model_records", fake_generate)
|
||||
response = client.post(
|
||||
f"/modelTF/data-process/{task_id}/results/result-1/regenerate",
|
||||
json={"expected_updated_at": "2026-07-27T21:00:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
regenerated = response.json()["data"]
|
||||
assert regenerated["id"] == "result-1"
|
||||
assert regenerated["status"] == "valid"
|
||||
assert regenerated["instruction"] == regenerated["original_instruction"]
|
||||
assert regenerated["output"] == regenerated["original_output"]
|
||||
assert store.tasks[task_id]["error_count"] == 0
|
||||
assert captured["qa_pairs_per_item"] == 1
|
||||
assert [item["id"] for item in captured["preview_items"]] == ["preview-1"]
|
||||
assert captured["config"]["generation_retries"] == 0
|
||||
assert captured["config"]["request_timeout_seconds"] == 60
|
||||
|
||||
|
||||
def test_failed_result_regeneration_keeps_the_original_error(
|
||||
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": {
|
||||
"generation_model_id": "model-1",
|
||||
"output_type": "standard",
|
||||
},
|
||||
},
|
||||
).json()["data"]["id"]
|
||||
store.tasks[task_id].update(
|
||||
status="completed",
|
||||
progress=100,
|
||||
workflow_step="results",
|
||||
results_confirmed=False,
|
||||
error_count=1,
|
||||
)
|
||||
store.models["model-1"] = {
|
||||
"id": "model-1",
|
||||
"online_model_name": "test-model",
|
||||
"api_url": "https://model.example/v1",
|
||||
"api_key": "secret",
|
||||
}
|
||||
store.previews[task_id] = [{
|
||||
"id": "preview-1",
|
||||
"status": "original",
|
||||
"original_content": "原始内容",
|
||||
"edited_content": "原始内容",
|
||||
}]
|
||||
original_result = {
|
||||
"id": "result-1",
|
||||
"preview_item_id": "preview-1",
|
||||
"instruction": "模型生成失败,请人工补充",
|
||||
"input": "原始内容",
|
||||
"output": "",
|
||||
"status": "invalid",
|
||||
"error": "first failure",
|
||||
"updated_at": "2026-07-27T21:30:00Z",
|
||||
}
|
||||
store.results[task_id] = [original_result.copy()]
|
||||
|
||||
monkeypatch.setattr(
|
||||
data_process_endpoint,
|
||||
"generate_model_records",
|
||||
lambda *args, **kwargs: [{
|
||||
"status": "invalid",
|
||||
"error": "second failure",
|
||||
}],
|
||||
)
|
||||
response = client.post(
|
||||
f"/modelTF/data-process/{task_id}/results/result-1/regenerate",
|
||||
json={"expected_updated_at": "2026-07-27T21:30:00Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert store.results[task_id] == [original_result]
|
||||
assert store.tasks[task_id]["error_count"] == 1
|
||||
|
||||
|
||||
def test_failed_results_can_be_regenerated_in_parallel_with_partial_success(
|
||||
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": {
|
||||
"generation_model_id": "model-1",
|
||||
"output_type": "standard",
|
||||
"min_output_length": 5,
|
||||
},
|
||||
},
|
||||
).json()["data"]["id"]
|
||||
store.tasks[task_id].update(
|
||||
status="completed",
|
||||
progress=100,
|
||||
workflow_step="results",
|
||||
results_confirmed=False,
|
||||
error_count=2,
|
||||
)
|
||||
store.models["model-1"] = {
|
||||
"id": "model-1",
|
||||
"online_model_name": "test-model",
|
||||
"api_url": "https://model.example/v1",
|
||||
"api_key": "secret",
|
||||
}
|
||||
store.previews[task_id] = [
|
||||
{
|
||||
"id": "preview-1",
|
||||
"status": "original",
|
||||
"original_content": "申请编号用于唯一标识一笔报销申请。",
|
||||
"edited_content": "申请编号用于唯一标识一笔报销申请。",
|
||||
},
|
||||
{
|
||||
"id": "preview-2",
|
||||
"status": "original",
|
||||
"original_content": "联系电话用于联系申请人。",
|
||||
"edited_content": "联系电话用于联系申请人。",
|
||||
},
|
||||
]
|
||||
original_results = [
|
||||
{
|
||||
"id": "result-1",
|
||||
"preview_item_id": "preview-1",
|
||||
"instruction": "模型生成失败,请人工补充",
|
||||
"input": "申请编号用于唯一标识一笔报销申请。",
|
||||
"output": "",
|
||||
"original_instruction": "模型生成失败,请人工补充",
|
||||
"original_input": "申请编号用于唯一标识一笔报销申请。",
|
||||
"original_output": "",
|
||||
"status": "invalid",
|
||||
"error": "first failure",
|
||||
"split": "train",
|
||||
"quality_score": {},
|
||||
"updated_at": "2026-07-28T09:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "result-2",
|
||||
"preview_item_id": "preview-2",
|
||||
"instruction": "模型生成失败,请人工补充",
|
||||
"input": "联系电话用于联系申请人。",
|
||||
"output": "",
|
||||
"original_instruction": "模型生成失败,请人工补充",
|
||||
"original_input": "联系电话用于联系申请人。",
|
||||
"original_output": "",
|
||||
"status": "invalid",
|
||||
"error": "first failure",
|
||||
"split": "train",
|
||||
"quality_score": {},
|
||||
"updated_at": "2026-07-28T09:00:01Z",
|
||||
},
|
||||
]
|
||||
store.results[task_id] = deepcopy(original_results)
|
||||
barrier = Barrier(2, timeout=2)
|
||||
activity_lock = Lock()
|
||||
active_calls = 0
|
||||
max_active_calls = 0
|
||||
model_clients: list[Any] = []
|
||||
|
||||
def fake_generate(preview_items: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
nonlocal active_calls, max_active_calls
|
||||
preview = next(iter(preview_items))
|
||||
with activity_lock:
|
||||
active_calls += 1
|
||||
max_active_calls = max(max_active_calls, active_calls)
|
||||
model_clients.append(kwargs.get("client"))
|
||||
try:
|
||||
barrier.wait()
|
||||
if preview["id"] == "preview-2":
|
||||
return [{"status": "invalid", "error": "second failure"}]
|
||||
return [{
|
||||
"id": "temporary-result",
|
||||
"preview_item_id": preview["id"],
|
||||
"instruction": "申请编号有什么作用?",
|
||||
"input": preview["edited_content"],
|
||||
"output": "申请编号用于唯一标识一笔报销申请。",
|
||||
"status": "valid",
|
||||
"error": None,
|
||||
"split": "train",
|
||||
}]
|
||||
finally:
|
||||
with activity_lock:
|
||||
active_calls -= 1
|
||||
|
||||
monkeypatch.setattr(data_process_endpoint, "generate_model_records", fake_generate)
|
||||
response = client.post(
|
||||
f"/modelTF/data-process/{task_id}/results/regenerate-batch",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"result_id": "result-1",
|
||||
"expected_updated_at": "2026-07-28T09:00:00Z",
|
||||
},
|
||||
{
|
||||
"result_id": "result-2",
|
||||
"expected_updated_at": "2026-07-28T09:00:01Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == 2
|
||||
assert data["succeeded"] == 1
|
||||
assert data["failed"] == 1
|
||||
assert data["remaining_invalid_count"] == 1
|
||||
assert [item["id"] for item in data["items"]] == ["result-1"]
|
||||
assert data["failures"][0]["result_id"] == "result-2"
|
||||
assert store.results[task_id][0]["status"] == "valid"
|
||||
assert store.results[task_id][0]["id"] == "result-1"
|
||||
assert store.results[task_id][1] == original_results[1]
|
||||
assert store.tasks[task_id]["error_count"] == 1
|
||||
assert max_active_calls == 2
|
||||
assert all(client is not None for client in model_clients)
|
||||
assert len({id(client) for client in model_clients}) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"locked_state",
|
||||
[
|
||||
{"results_confirmed": True},
|
||||
{"output_dataset_id": "dataset-published"},
|
||||
],
|
||||
ids=["confirmed", "published"],
|
||||
)
|
||||
def test_batch_result_regeneration_rejects_locked_tasks_before_model_call(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
locked_state: dict[str, Any],
|
||||
) -> None:
|
||||
client, store, _ = make_client(tmp_path)
|
||||
task_id = client.post(
|
||||
"/modelTF/data-process",
|
||||
json={
|
||||
"name": "批量重生成门禁",
|
||||
"process_type": "structured",
|
||||
"config": {"generation_model_id": "model-1"},
|
||||
},
|
||||
).json()["data"]["id"]
|
||||
store.tasks[task_id].update(
|
||||
status="completed",
|
||||
workflow_step="results",
|
||||
results_confirmed=False,
|
||||
)
|
||||
store.tasks[task_id].update(locked_state)
|
||||
model_calls = 0
|
||||
|
||||
def fake_generate(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
nonlocal model_calls
|
||||
model_calls += 1
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(data_process_endpoint, "generate_model_records", fake_generate)
|
||||
response = client.post(
|
||||
f"/modelTF/data-process/{task_id}/results/regenerate-batch",
|
||||
json={
|
||||
"items": [{
|
||||
"result_id": "result-1",
|
||||
"expected_updated_at": "2026-07-28T09:00:00Z",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert model_calls == 0
|
||||
|
||||
|
||||
def test_preview_build_replaces_only_selected_files_and_reports_file_counts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -1026,6 +1774,7 @@ def test_published_split_datasets_remain_in_detail_after_regeneration(
|
||||
"status": "completed",
|
||||
"updated_at": "2026-07-27T09:00:00Z",
|
||||
"output_count": 1,
|
||||
"results_confirmed": True,
|
||||
}
|
||||
)
|
||||
store.results[task_id] = [
|
||||
@@ -1440,6 +2189,41 @@ def test_reasoning_output_requires_generation_model() -> None:
|
||||
assert failed["failure_reason"] == "思维链输出必须配置可用的数据生成模型"
|
||||
|
||||
|
||||
def test_generation_failure_is_written_to_structured_log(caplog: pytest.LogCaptureFixture) -> None:
|
||||
store = FakeDataProcessStore()
|
||||
task = store.create_task(
|
||||
{
|
||||
"name": "生成失败日志",
|
||||
"process_type": "structured",
|
||||
"config": {"output_type": "reasoning"},
|
||||
}
|
||||
)
|
||||
task_id = task["id"]
|
||||
store.replace_preview_items(
|
||||
task_id,
|
||||
[
|
||||
{
|
||||
"source_file_id": None,
|
||||
"original_content": "需要推理的来源内容",
|
||||
"edited_content": "需要推理的来源内容",
|
||||
"status": "manual",
|
||||
}
|
||||
],
|
||||
)
|
||||
started = store.start_generation(task_id, replace_existing=True)
|
||||
|
||||
with caplog.at_level("INFO", logger=data_process_endpoint.__name__):
|
||||
data_process_endpoint._run_generation(
|
||||
store,
|
||||
task_id,
|
||||
started["generation_run_id"],
|
||||
)
|
||||
|
||||
messages = [record.getMessage() for record in caplog.records]
|
||||
assert any("generation worker started" in message for message in messages)
|
||||
assert any("generation failed" in message for message in messages)
|
||||
|
||||
|
||||
def test_result_status_cannot_be_forged_by_client(tmp_path: Path) -> None:
|
||||
client, _, _ = make_client(tmp_path)
|
||||
task_id = client.post(
|
||||
|
||||
Reference in New Issue
Block a user