feat(data-process): 完善后台生成与失败重试

This commit is contained in:
caoxiaozhu
2026-07-28 10:56:05 +08:00
parent 8a6a6574bb
commit 01d2e6c76a
178 changed files with 4472 additions and 595 deletions

View File

@@ -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(