feat(data-process): 完善后台生成与失败重试
This commit is contained in:
@@ -7,12 +7,16 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from threading import BoundedSemaphore, Lock
|
||||
from typing import Any, Iterator, Literal
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
import psycopg
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
@@ -77,6 +81,7 @@ from app.schemas.data_process import (
|
||||
DataProcessStatus,
|
||||
DataProcessTaskCreate,
|
||||
DataProcessTaskUpdate,
|
||||
DataProcessWorkflowStepUpdate,
|
||||
ExternalPullRequest,
|
||||
ExternalSourceRequest,
|
||||
GenerateRequest,
|
||||
@@ -85,6 +90,8 @@ from app.schemas.data_process import (
|
||||
PreviewItemUpdate,
|
||||
ProcessType,
|
||||
PublishRequest,
|
||||
ResultBatchRegenerateRequest,
|
||||
ResultRegenerateRequest,
|
||||
ResultUpdate,
|
||||
)
|
||||
|
||||
@@ -122,6 +129,12 @@ RAW_INLINE_PREVIEW_MEDIA_TYPES = {
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
RESULT_REGENERATION_CONCURRENCY = 4
|
||||
RESULT_REGENERATION_RETRIES = 0
|
||||
RESULT_REGENERATION_TIMEOUT_SECONDS = 60.0
|
||||
_result_regeneration_slots = BoundedSemaphore(RESULT_REGENERATION_CONCURRENCY)
|
||||
_result_regeneration_claims_lock = Lock()
|
||||
_active_result_regenerations: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
||||
@@ -147,8 +160,11 @@ def api_errors() -> Iterator[None]:
|
||||
raise fail(409, str(exc)) from exc
|
||||
except (DataProcessStoreError, ValueError) as exc:
|
||||
raise fail(400, str(exc)) from exc
|
||||
except psycopg.errors.UndefinedTable as exc:
|
||||
raise fail(503, "data process schema is not installed; run schema_cli --check") from exc
|
||||
except (psycopg.errors.UndefinedTable, psycopg.errors.UndefinedColumn) as exc:
|
||||
raise fail(
|
||||
503,
|
||||
"data process schema is missing or out of date; run schema_cli --check",
|
||||
) from exc
|
||||
except psycopg.OperationalError as exc:
|
||||
raise fail(503, "data process database is unavailable") from exc
|
||||
|
||||
@@ -205,7 +221,10 @@ def _commit_source_batch(
|
||||
storage.delete(item.reference)
|
||||
except Exception:
|
||||
# 文件系统回滚失败不能覆盖数据库抛出的根因,并继续清理其余对象。
|
||||
pass
|
||||
logger.exception(
|
||||
"failed to roll back data process source object task_id=%s",
|
||||
task_id,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -533,9 +552,21 @@ def _all_preview_items(store: DataProcessStore, task_id: str) -> list[dict[str,
|
||||
def _run_generation(
|
||||
store: DataProcessStore, task_id: str, generation_run_id: str
|
||||
) -> None:
|
||||
started_at = time.perf_counter()
|
||||
logger.info(
|
||||
"data process generation worker started task_id=%s generation_run_id=%s",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
)
|
||||
try:
|
||||
task = store.get_task(task_id)
|
||||
if not store.generation_is_running(task_id, generation_run_id):
|
||||
logger.info(
|
||||
"data process generation worker skipped inactive run task_id=%s "
|
||||
"generation_run_id=%s",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
)
|
||||
return
|
||||
all_preview_items = _all_preview_items(store, task_id)
|
||||
preview_items = [
|
||||
@@ -619,6 +650,12 @@ def _run_generation(
|
||||
len(preview_items),
|
||||
len(preview_items),
|
||||
):
|
||||
logger.info(
|
||||
"data process generation stopped before completion task_id=%s "
|
||||
"generation_run_id=%s",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
)
|
||||
return
|
||||
|
||||
known_fingerprints: set[str] = set()
|
||||
@@ -678,7 +715,7 @@ def _run_generation(
|
||||
|
||||
# stop 请求可能在纯函数计算期间到达,最终写入前再次检查状态。
|
||||
if store.generation_is_running(task_id, generation_run_id):
|
||||
store.complete_generation(
|
||||
completed = store.complete_generation(
|
||||
task_id,
|
||||
accepted,
|
||||
generation_run_id=generation_run_id,
|
||||
@@ -686,7 +723,32 @@ def _run_generation(
|
||||
duplicate_count=duplicate_count,
|
||||
error_count=error_count,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - background failures must be persisted
|
||||
logger.info(
|
||||
"data process generation completed task_id=%s generation_run_id=%s "
|
||||
"output_count=%s filtered_count=%s duplicate_count=%s error_count=%s "
|
||||
"duration_ms=%.2f",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
completed.get("output_count", len(accepted)),
|
||||
completed.get("filtered_count", filtered_count),
|
||||
completed.get("duplicate_count", duplicate_count),
|
||||
completed.get("error_count", error_count),
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"data process generation stopped before result persistence task_id=%s "
|
||||
"generation_run_id=%s",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"data process generation failed task_id=%s generation_run_id=%s duration_ms=%.2f",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
try:
|
||||
if store.generation_is_running(task_id, generation_run_id):
|
||||
store.mark_failed(
|
||||
@@ -695,7 +757,12 @@ def _run_generation(
|
||||
generation_run_id=generation_run_id,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
logger.exception(
|
||||
"failed to persist data process generation failure task_id=%s "
|
||||
"generation_run_id=%s",
|
||||
task_id,
|
||||
generation_run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -757,6 +824,19 @@ def update_task(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{task_id}/workflow-step")
|
||||
def update_workflow_step(
|
||||
task_id: str,
|
||||
payload: DataProcessWorkflowStepUpdate,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
return ok(
|
||||
store.update_workflow_step(task_id, payload.workflow_step.value),
|
||||
"data process workflow step updated",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/regenerate")
|
||||
def prepare_regeneration(
|
||||
task_id: str,
|
||||
@@ -1338,6 +1418,150 @@ def _prepare_preview_items(
|
||||
return items
|
||||
|
||||
|
||||
def _run_preview(
|
||||
store: DataProcessStore,
|
||||
storage: LocalDataProcessStorage,
|
||||
task_id: str,
|
||||
preview_run_id: str,
|
||||
source_file_ids: list[str],
|
||||
) -> None:
|
||||
"""后台逐文件切分;所有写入均由 preview_run_id 保护。"""
|
||||
|
||||
started_at = time.perf_counter()
|
||||
logger.info(
|
||||
"data process preview started task_id=%s preview_run_id=%s total_files=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
len(source_file_ids),
|
||||
)
|
||||
try:
|
||||
if not store.mark_preview_running(task_id, preview_run_id):
|
||||
logger.info(
|
||||
"data process preview skipped inactive run task_id=%s preview_run_id=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
)
|
||||
return
|
||||
total_files = len(source_file_ids)
|
||||
total_items = 0
|
||||
for completed_files, source_file_id in enumerate(source_file_ids, start=1):
|
||||
if not store.preview_is_running(task_id, preview_run_id):
|
||||
logger.info(
|
||||
"data process preview cancelled task_id=%s preview_run_id=%s "
|
||||
"completed_files=%s total_files=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
completed_files - 1,
|
||||
total_files,
|
||||
)
|
||||
return
|
||||
items = _prepare_preview_items(
|
||||
task_id,
|
||||
store,
|
||||
storage,
|
||||
[source_file_id],
|
||||
)
|
||||
if not items:
|
||||
raise InvalidStateError(
|
||||
f"source file did not produce preview items: {source_file_id}"
|
||||
)
|
||||
created = store.replace_preview_items(
|
||||
task_id,
|
||||
items,
|
||||
source_file_ids=[source_file_id],
|
||||
preview_run_id=preview_run_id,
|
||||
)
|
||||
total_items += len(created)
|
||||
if not store.update_preview_progress(
|
||||
task_id,
|
||||
preview_run_id,
|
||||
completed_files,
|
||||
total_files,
|
||||
):
|
||||
logger.info(
|
||||
"data process preview stopped before progress update task_id=%s "
|
||||
"preview_run_id=%s completed_files=%s total_files=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
completed_files,
|
||||
total_files,
|
||||
)
|
||||
return
|
||||
if store.complete_preview(task_id, preview_run_id):
|
||||
logger.info(
|
||||
"data process preview completed task_id=%s preview_run_id=%s "
|
||||
"total_files=%s total_items=%s duration_ms=%.2f",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
total_files,
|
||||
total_items,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"data process preview completion ignored for inactive run task_id=%s "
|
||||
"preview_run_id=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"data process preview failed task_id=%s preview_run_id=%s duration_ms=%.2f",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
try:
|
||||
if store.preview_is_running(task_id, preview_run_id):
|
||||
store.mark_preview_failed(
|
||||
task_id,
|
||||
str(exc),
|
||||
preview_run_id=preview_run_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"failed to persist data process preview failure task_id=%s "
|
||||
"preview_run_id=%s",
|
||||
task_id,
|
||||
preview_run_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/preview/start", status_code=202)
|
||||
def start_preview(
|
||||
task_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: PreviewBuildRequest = Body(default_factory=PreviewBuildRequest),
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
task, selected_ids = store.start_preview(
|
||||
task_id,
|
||||
source_file_ids=payload.source_file_ids,
|
||||
)
|
||||
preview_run_id = str(task["preview_run_id"])
|
||||
progress = store.preview_progress(task_id)
|
||||
background_tasks.add_task(
|
||||
_run_preview,
|
||||
store,
|
||||
storage,
|
||||
task_id,
|
||||
preview_run_id,
|
||||
selected_ids,
|
||||
)
|
||||
return ok(progress, "data process preview started")
|
||||
|
||||
|
||||
@router.get("/{task_id}/preview/progress")
|
||||
def preview_progress(
|
||||
task_id: str,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
return ok(store.preview_progress(task_id))
|
||||
|
||||
|
||||
@router.post("/{task_id}/preview/build")
|
||||
def build_preview(
|
||||
task_id: str,
|
||||
@@ -1537,6 +1761,15 @@ def results(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/confirm-results")
|
||||
def confirm_results(
|
||||
task_id: str,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
return ok(store.confirm_results(task_id), "data process results confirmed")
|
||||
|
||||
|
||||
@router.put("/{task_id}/results/{result_id}")
|
||||
def update_result(
|
||||
task_id: str,
|
||||
@@ -1635,6 +1868,387 @@ def restore_result(
|
||||
return ok(restored, "data process result restored")
|
||||
|
||||
|
||||
class _ResultRegenerationFailed(InvalidStateError):
|
||||
"""模型返回或质量校验失败,原失败结果必须保持不变。"""
|
||||
|
||||
|
||||
def _assert_result_regeneration_allowed(task: dict[str, Any]) -> None:
|
||||
if task.get("status") != "completed" or task.get("workflow_step") != "results":
|
||||
raise InvalidStateError("task is not editing generation results")
|
||||
if task.get("results_confirmed"):
|
||||
raise InvalidStateError("confirmed results cannot be regenerated")
|
||||
if task.get("output_dataset_id"):
|
||||
raise InvalidStateError("published results cannot be regenerated")
|
||||
|
||||
|
||||
def _result_regeneration_model(
|
||||
task: dict[str, Any],
|
||||
store: DataProcessStore,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
config = task.get("config") or {}
|
||||
model_id = _value(config, "generation_model_id", "generationModelId", None)
|
||||
if not model_id:
|
||||
raise InvalidStateError("task does not have a generation model")
|
||||
return config, store.get_generation_model(str(model_id))
|
||||
|
||||
|
||||
def _result_regeneration_timeout(config: dict[str, Any]) -> float:
|
||||
configured = float(
|
||||
_value(config, "request_timeout_seconds", "requestTimeoutSeconds", 60)
|
||||
)
|
||||
return max(1.0, min(RESULT_REGENERATION_TIMEOUT_SECONDS, configured))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _claim_result_regeneration(task_id: str, result_id: str) -> Iterator[None]:
|
||||
key = (task_id, result_id)
|
||||
with _result_regeneration_claims_lock:
|
||||
if key in _active_result_regenerations:
|
||||
raise ConflictError("data process result regeneration is already running")
|
||||
_active_result_regenerations.add(key)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _result_regeneration_claims_lock:
|
||||
_active_result_regenerations.discard(key)
|
||||
|
||||
|
||||
def _generate_result_replacement(
|
||||
task_id: str,
|
||||
current: dict[str, Any],
|
||||
preview: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
generation_model: dict[str, Any],
|
||||
model_client: httpx.Client | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source_content = str(
|
||||
preview.get("edited_content") or preview.get("original_content") or ""
|
||||
).strip()
|
||||
if not source_content or preview.get("status") == "invalid":
|
||||
raise InvalidStateError("result source preview item is invalid or empty")
|
||||
|
||||
output_type = str(
|
||||
_value(config, "output_type", "outputType", "standard")
|
||||
).strip().lower()
|
||||
previous_instruction = str(current.get("instruction") or "")[:1000]
|
||||
previous_output = str(current.get("output") or "")[:1000]
|
||||
base_prompt = str(
|
||||
_value(config, "generation_prompt", "generationPrompt", "") or ""
|
||||
)
|
||||
regeneration_instruction = (
|
||||
"这是一次失败结果的重新生成。请使用新的提问角度和表达,"
|
||||
"不要复述旧结果。旧问题:"
|
||||
f"{previous_instruction or '无'};旧答案:{previous_output or '无'}。"
|
||||
)
|
||||
runtime_config = {
|
||||
**config,
|
||||
"generation_prompt": f"{base_prompt}\n{regeneration_instruction}".strip(),
|
||||
"output_type": output_type,
|
||||
"reasoning_detail": _value(
|
||||
config, "reasoning_detail", "reasoningDetail", "normal"
|
||||
),
|
||||
"max_tokens": _value(config, "max_tokens", "maxTokens", 1024),
|
||||
"json_mode": _value(config, "json_mode", "jsonMode", False),
|
||||
# 交互式重新生成只做一次新尝试,避免继承整任务的重试配置后长时间等待。
|
||||
"generation_retries": RESULT_REGENERATION_RETRIES,
|
||||
"request_timeout_seconds": _result_regeneration_timeout(config),
|
||||
}
|
||||
with _result_regeneration_slots:
|
||||
generated = generate_model_records(
|
||||
[preview],
|
||||
model=generation_model,
|
||||
config=runtime_config,
|
||||
task_id=task_id,
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=model_client,
|
||||
)
|
||||
if not generated or generated[0].get("status") == "invalid":
|
||||
reason = str(
|
||||
(generated[0] if generated else {}).get("error")
|
||||
or "model generation failed"
|
||||
)
|
||||
raise _ResultRegenerationFailed(f"重新生成结果仍无效:{reason}")
|
||||
|
||||
minimum = max(
|
||||
1,
|
||||
int(_value(config, "min_output_length", "minOutputLength", 20) or 20),
|
||||
)
|
||||
replacement = generated[0]
|
||||
quality = score_quality(
|
||||
replacement,
|
||||
min_output_length=minimum,
|
||||
source_content=source_content,
|
||||
)
|
||||
if not quality.is_valid:
|
||||
reason = ", ".join(quality.flags) or "quality validation failed"
|
||||
raise _ResultRegenerationFailed(f"重新生成结果未通过质量校验:{reason}")
|
||||
replacement["quality_score"] = asdict(quality)
|
||||
replacement["status"] = "valid"
|
||||
replacement["error"] = None
|
||||
return replacement
|
||||
|
||||
|
||||
def _regenerate_result_in_place(
|
||||
task_id: str,
|
||||
current: dict[str, Any],
|
||||
preview: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
generation_model: dict[str, Any],
|
||||
store: DataProcessStore,
|
||||
*,
|
||||
expected_updated_at: str,
|
||||
model_client: httpx.Client | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result_id = str(current["id"])
|
||||
with _claim_result_regeneration(task_id, result_id):
|
||||
replacement = _generate_result_replacement(
|
||||
task_id,
|
||||
current,
|
||||
preview,
|
||||
config,
|
||||
generation_model,
|
||||
model_client,
|
||||
)
|
||||
return store.replace_generated_result(
|
||||
task_id,
|
||||
result_id,
|
||||
replacement,
|
||||
expected_updated_at=expected_updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _safe_regeneration_error(exc: Exception) -> str:
|
||||
return re.sub(r"\s+", " ", str(exc)).strip()[:500] or "result regeneration failed"
|
||||
|
||||
|
||||
@router.post("/{task_id}/results/regenerate-batch")
|
||||
def regenerate_results_batch(
|
||||
task_id: str,
|
||||
payload: ResultBatchRegenerateRequest,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
"""并发重新生成一批失败结果;每条独立提交并允许部分成功。"""
|
||||
|
||||
started_at = time.perf_counter()
|
||||
batch_id = new_id("dprb")
|
||||
with api_errors():
|
||||
task = store.get_task(task_id)
|
||||
_assert_result_regeneration_allowed(task)
|
||||
config, generation_model = _result_regeneration_model(task, store)
|
||||
prepared: list[tuple[int, dict[str, Any], dict[str, Any], str]] = []
|
||||
failures: list[tuple[int, dict[str, str]]] = []
|
||||
|
||||
for index, requested in enumerate(payload.items):
|
||||
try:
|
||||
current = store.get_result(task_id, requested.result_id)
|
||||
if current.get("status") != "invalid":
|
||||
raise InvalidStateError("only an invalid result can be regenerated")
|
||||
if requested.expected_updated_at != str(current.get("updated_at") or ""):
|
||||
raise ConflictError("data process result was modified by another request")
|
||||
preview_id = current.get("preview_item_id")
|
||||
if not preview_id:
|
||||
raise InvalidStateError(
|
||||
"result is not associated with a source preview item"
|
||||
)
|
||||
preview = store.get_preview_item(task_id, str(preview_id))
|
||||
prepared.append(
|
||||
(index, current, preview, requested.expected_updated_at)
|
||||
)
|
||||
except ConflictError as exc:
|
||||
failures.append((index, {
|
||||
"result_id": requested.result_id,
|
||||
"code": "conflict",
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
except (NotFoundError, InvalidStateError) as exc:
|
||||
failures.append((index, {
|
||||
"result_id": requested.result_id,
|
||||
"code": "skipped",
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
|
||||
logger.info(
|
||||
"data process result batch regeneration started batch_id=%s task_id=%s "
|
||||
"requested=%s prepared=%s concurrency=%s",
|
||||
batch_id,
|
||||
task_id,
|
||||
len(payload.items),
|
||||
len(prepared),
|
||||
min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
||||
)
|
||||
successes: list[tuple[int, dict[str, Any]]] = []
|
||||
if prepared:
|
||||
request_timeout = _result_regeneration_timeout(config)
|
||||
model_timeout = httpx.Timeout(
|
||||
request_timeout,
|
||||
connect=min(10.0, request_timeout),
|
||||
)
|
||||
model_limits = httpx.Limits(
|
||||
max_connections=RESULT_REGENERATION_CONCURRENCY,
|
||||
max_keepalive_connections=RESULT_REGENERATION_CONCURRENCY,
|
||||
)
|
||||
# httpx.Client 支持跨线程复用,批次内共享连接池可减少重复建连开销。
|
||||
with (
|
||||
httpx.Client(timeout=model_timeout, limits=model_limits) as model_client,
|
||||
ThreadPoolExecutor(
|
||||
max_workers=min(RESULT_REGENERATION_CONCURRENCY, len(prepared)),
|
||||
thread_name_prefix="data-result-regeneration",
|
||||
) as executor,
|
||||
):
|
||||
futures = {
|
||||
executor.submit(
|
||||
_regenerate_result_in_place,
|
||||
task_id,
|
||||
current,
|
||||
preview,
|
||||
config,
|
||||
generation_model,
|
||||
store,
|
||||
expected_updated_at=expected_updated_at,
|
||||
model_client=model_client,
|
||||
): (index, str(current["id"]), time.perf_counter())
|
||||
for index, current, preview, expected_updated_at in prepared
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
index, result_id, item_started_at = futures[future]
|
||||
try:
|
||||
regenerated = future.result()
|
||||
successes.append((index, regenerated))
|
||||
outcome = "succeeded"
|
||||
except ConflictError as exc:
|
||||
outcome = "conflict"
|
||||
failures.append((index, {
|
||||
"result_id": result_id,
|
||||
"code": outcome,
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
except _ResultRegenerationFailed as exc:
|
||||
outcome = "generation_failed"
|
||||
failures.append((index, {
|
||||
"result_id": result_id,
|
||||
"code": outcome,
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
except (NotFoundError, InvalidStateError) as exc:
|
||||
outcome = "skipped"
|
||||
failures.append((index, {
|
||||
"result_id": result_id,
|
||||
"code": outcome,
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
except Exception as exc: # pragma: no cover - defensive boundary
|
||||
outcome = "internal_error"
|
||||
logger.exception(
|
||||
"data process result batch regeneration crashed "
|
||||
"batch_id=%s task_id=%s result_id=%s",
|
||||
batch_id,
|
||||
task_id,
|
||||
result_id,
|
||||
)
|
||||
failures.append((index, {
|
||||
"result_id": result_id,
|
||||
"code": outcome,
|
||||
"message": _safe_regeneration_error(exc),
|
||||
}))
|
||||
logger.info(
|
||||
"data process result batch item finished batch_id=%s task_id=%s "
|
||||
"result_id=%s outcome=%s duration_ms=%.2f",
|
||||
batch_id,
|
||||
task_id,
|
||||
result_id,
|
||||
outcome,
|
||||
(time.perf_counter() - item_started_at) * 1000,
|
||||
)
|
||||
|
||||
success_items = [item for _, item in sorted(successes, key=lambda pair: pair[0])]
|
||||
failure_items = [item for _, item in sorted(failures, key=lambda pair: pair[0])]
|
||||
remaining_invalid_count = int(
|
||||
store.list_results(
|
||||
task_id,
|
||||
page=1,
|
||||
page_size=1,
|
||||
status="invalid",
|
||||
)["total"]
|
||||
)
|
||||
duration_ms = (time.perf_counter() - started_at) * 1000
|
||||
logger.info(
|
||||
"data process result batch regeneration completed batch_id=%s task_id=%s "
|
||||
"succeeded=%s failed=%s remaining_invalid=%s duration_ms=%.2f",
|
||||
batch_id,
|
||||
task_id,
|
||||
len(success_items),
|
||||
len(failure_items),
|
||||
remaining_invalid_count,
|
||||
duration_ms,
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"batch_id": batch_id,
|
||||
"total": len(payload.items),
|
||||
"succeeded": len(success_items),
|
||||
"failed": len(failure_items),
|
||||
"remaining_invalid_count": remaining_invalid_count,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
"items": success_items,
|
||||
"failures": failure_items,
|
||||
},
|
||||
"data process results regenerated",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/results/{result_id}/regenerate")
|
||||
def regenerate_result(
|
||||
task_id: str,
|
||||
result_id: str,
|
||||
payload: ResultRegenerateRequest,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
"""只重新生成一个失败结果,成功后原位替换且不影响其他结果。"""
|
||||
|
||||
started_at = time.perf_counter()
|
||||
with api_errors():
|
||||
task = store.get_task(task_id)
|
||||
_assert_result_regeneration_allowed(task)
|
||||
current = store.get_result(task_id, result_id)
|
||||
if current.get("status") != "invalid":
|
||||
raise InvalidStateError("only an invalid result can be regenerated")
|
||||
if payload.expected_updated_at != str(current.get("updated_at") or ""):
|
||||
raise ConflictError("data process result was modified by another request")
|
||||
preview_id = current.get("preview_item_id")
|
||||
if not preview_id:
|
||||
raise InvalidStateError("result is not associated with a source preview item")
|
||||
preview = store.get_preview_item(task_id, str(preview_id))
|
||||
config, generation_model = _result_regeneration_model(task, store)
|
||||
try:
|
||||
result = _regenerate_result_in_place(
|
||||
task_id,
|
||||
current,
|
||||
preview,
|
||||
config,
|
||||
generation_model,
|
||||
store,
|
||||
expected_updated_at=payload.expected_updated_at,
|
||||
)
|
||||
except _ResultRegenerationFailed as exc:
|
||||
logger.warning(
|
||||
"data process result regeneration failed task_id=%s result_id=%s "
|
||||
"duration_ms=%.2f reason=%s",
|
||||
task_id,
|
||||
result_id,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
_safe_regeneration_error(exc),
|
||||
)
|
||||
raise
|
||||
logger.info(
|
||||
"data process result regenerated task_id=%s result_id=%s duration_ms=%.2f",
|
||||
task_id,
|
||||
result_id,
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
return ok(result, "data process result regenerated")
|
||||
|
||||
|
||||
@router.post("/{task_id}/publish")
|
||||
def publish(
|
||||
task_id: str,
|
||||
|
||||
@@ -80,6 +80,17 @@ CREATE TABLE IF NOT EXISTS data_process_tasks (
|
||||
error_count BIGINT NOT NULL DEFAULT 0 CHECK (error_count >= 0),
|
||||
failure_reason TEXT,
|
||||
generation_run_id TEXT,
|
||||
results_confirmed BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
workflow_step VARCHAR(20) NOT NULL DEFAULT 'create'
|
||||
CHECK (workflow_step IN ('create', 'model', 'upload', 'preview', 'generate', 'results')),
|
||||
preview_status VARCHAR(20) NOT NULL DEFAULT 'idle'
|
||||
CHECK (preview_status IN ('idle', 'queued', 'running', 'completed', 'failed', 'cancelled')),
|
||||
preview_progress NUMERIC(5,2) NOT NULL DEFAULT 0
|
||||
CHECK (preview_progress >= 0 AND preview_progress <= 100),
|
||||
preview_run_id TEXT,
|
||||
preview_failure_reason TEXT,
|
||||
preview_total_files INTEGER NOT NULL DEFAULT 0 CHECK (preview_total_files >= 0),
|
||||
preview_completed_files INTEGER NOT NULL DEFAULT 0 CHECK (preview_completed_files >= 0),
|
||||
tenant_id TEXT,
|
||||
project_id TEXT,
|
||||
owner_id TEXT,
|
||||
@@ -95,6 +106,87 @@ CREATE TABLE IF NOT EXISTS data_process_tasks (
|
||||
);
|
||||
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS generation_run_id TEXT;
|
||||
-- 历史任务在引入六步确认流程前已经完成审核,默认保留为已确认;
|
||||
-- 新任务由创建接口显式写入 FALSE,并在第六步确认后转为 TRUE。
|
||||
ALTER TABLE data_process_tasks
|
||||
ADD COLUMN IF NOT EXISTS results_confirmed BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
UPDATE data_process_tasks
|
||||
SET results_confirmed=FALSE
|
||||
WHERE status <> 'completed' AND results_confirmed=TRUE;
|
||||
|
||||
-- 先以可空列接入旧库,才能只回填历史行;随后再收紧默认值与约束。
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS workflow_step VARCHAR(20);
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_status VARCHAR(20);
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_progress NUMERIC(5,2);
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_run_id TEXT;
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_failure_reason TEXT;
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_total_files INTEGER;
|
||||
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS preview_completed_files INTEGER;
|
||||
|
||||
CREATE TEMP TABLE data_process_workflow_backfill_ids ON COMMIT DROP AS
|
||||
SELECT id FROM data_process_tasks WHERE workflow_step IS NULL;
|
||||
|
||||
UPDATE data_process_tasks task
|
||||
SET workflow_step = CASE
|
||||
WHEN task.status IN ('running', 'failed', 'stopped') THEN 'generate'
|
||||
WHEN task.status = 'completed' AND task.results_confirmed=FALSE THEN 'generate'
|
||||
WHEN task.status = 'completed' THEN 'results'
|
||||
ELSE 'create'
|
||||
END
|
||||
WHERE task.workflow_step IS NULL;
|
||||
UPDATE data_process_tasks
|
||||
SET preview_status='idle', preview_progress=0,
|
||||
preview_total_files=0, preview_completed_files=0
|
||||
WHERE preview_status IS NULL OR preview_progress IS NULL
|
||||
OR preview_total_files IS NULL OR preview_completed_files IS NULL;
|
||||
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN workflow_step SET DEFAULT 'create';
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN workflow_step SET NOT NULL;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_status SET DEFAULT 'idle';
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_status SET NOT NULL;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_progress SET DEFAULT 0;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_progress SET NOT NULL;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_total_files SET DEFAULT 0;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_total_files SET NOT NULL;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_completed_files SET DEFAULT 0;
|
||||
ALTER TABLE data_process_tasks ALTER COLUMN preview_completed_files SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid='data_process_tasks'::regclass
|
||||
AND conname='ck_data_process_tasks_workflow_step'
|
||||
) THEN
|
||||
ALTER TABLE data_process_tasks ADD CONSTRAINT ck_data_process_tasks_workflow_step
|
||||
CHECK (workflow_step IN ('create', 'model', 'upload', 'preview', 'generate', 'results'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid='data_process_tasks'::regclass
|
||||
AND conname='ck_data_process_tasks_preview_status'
|
||||
) THEN
|
||||
ALTER TABLE data_process_tasks ADD CONSTRAINT ck_data_process_tasks_preview_status
|
||||
CHECK (preview_status IN ('idle', 'queued', 'running', 'completed', 'failed', 'cancelled'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid='data_process_tasks'::regclass
|
||||
AND conname='ck_data_process_tasks_preview_progress'
|
||||
) THEN
|
||||
ALTER TABLE data_process_tasks ADD CONSTRAINT ck_data_process_tasks_preview_progress
|
||||
CHECK (preview_progress >= 0 AND preview_progress <= 100);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conrelid='data_process_tasks'::regclass
|
||||
AND conname='ck_data_process_tasks_preview_file_counts'
|
||||
) THEN
|
||||
ALTER TABLE data_process_tasks ADD CONSTRAINT ck_data_process_tasks_preview_file_counts
|
||||
CHECK (preview_total_files >= 0 AND preview_completed_files >= 0
|
||||
AND preview_completed_files <= preview_total_files);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_data_process_tasks_name_alive
|
||||
ON data_process_tasks(name) WHERE deleted_at IS NULL;
|
||||
@@ -153,6 +245,22 @@ CREATE TABLE IF NOT EXISTS data_process_preview_items (
|
||||
CREATE INDEX IF NOT EXISTS idx_data_process_preview_task_file
|
||||
ON data_process_preview_items(task_id, source_file_id, created_at);
|
||||
|
||||
-- 子表在新库中到这里才存在;只修复本次新增 workflow_step 前的历史任务。
|
||||
UPDATE data_process_tasks task
|
||||
SET workflow_step = CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM data_process_preview_items preview
|
||||
WHERE preview.task_id=task.id
|
||||
) THEN 'preview'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM data_process_source_files source_file
|
||||
WHERE source_file.task_id=task.id AND source_file.deleted_at IS NULL
|
||||
) THEN 'upload'
|
||||
ELSE task.workflow_step
|
||||
END
|
||||
WHERE task.id IN (SELECT id FROM data_process_workflow_backfill_ids)
|
||||
AND task.status='pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_process_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any
|
||||
@@ -22,6 +23,10 @@ class ModelGenerationError(ValueError):
|
||||
"""模型配置、响应或调用失败。"""
|
||||
|
||||
|
||||
class _TerminalModelGenerationError(ModelGenerationError):
|
||||
"""使用相同参数重试也无法恢复的模型响应错误。"""
|
||||
|
||||
|
||||
OUTPUT_TYPE_STANDARD = "standard"
|
||||
OUTPUT_TYPE_REASONING = "reasoning"
|
||||
SUPPORTED_OUTPUT_TYPES = {OUTPUT_TYPE_STANDARD, OUTPUT_TYPE_REASONING}
|
||||
@@ -31,6 +36,25 @@ SUPPORTED_REASONING_DETAILS = {
|
||||
REASONING_DETAIL_NORMAL,
|
||||
REASONING_DETAIL_DETAILED,
|
||||
}
|
||||
MINIMAX_M3_API_HOSTS = {"api.minimax.io", "api.minimaxi.com"}
|
||||
MINIMAX_M3_MIN_COMPLETION_TOKENS = 4096
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_retryable_generation_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, _TerminalModelGenerationError):
|
||||
return False
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
status_code = exc.response.status_code
|
||||
return status_code in {408, 425, 429} or status_code >= 500
|
||||
if isinstance(exc, httpx.RequestError):
|
||||
return True
|
||||
return isinstance(exc, (json.JSONDecodeError, ModelGenerationError))
|
||||
|
||||
|
||||
def _is_official_minimax_m3(endpoint: str, model_name: str) -> bool:
|
||||
host = (urlsplit(endpoint).hostname or "").casefold()
|
||||
return host in MINIMAX_M3_API_HOSTS and model_name.casefold() == "minimax-m3"
|
||||
|
||||
|
||||
def chat_completions_url(value: str) -> str:
|
||||
@@ -59,32 +83,139 @@ def chat_completions_url(value: str) -> str:
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, target_path, "", ""))
|
||||
|
||||
|
||||
def _message_content(payload: Mapping[str, Any]) -> str:
|
||||
def _response_choice(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
try:
|
||||
content = payload["choices"][0]["message"]["content"]
|
||||
choice = payload["choices"][0]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise ModelGenerationError(
|
||||
"model response does not contain choices[0].message.content"
|
||||
) from exc
|
||||
raise ModelGenerationError("模型响应缺少 choices[0]") from exc
|
||||
if not isinstance(choice, Mapping):
|
||||
raise ModelGenerationError("模型响应 choices[0] 不是对象")
|
||||
return choice
|
||||
|
||||
|
||||
def _response_finish_reason(payload: Mapping[str, Any]) -> str:
|
||||
try:
|
||||
return str(_response_choice(payload).get("finish_reason") or "").strip().lower()
|
||||
except ModelGenerationError:
|
||||
return ""
|
||||
|
||||
|
||||
def _response_content_length(payload: Mapping[str, Any]) -> int:
|
||||
try:
|
||||
message = _response_choice(payload).get("message")
|
||||
if not isinstance(message, Mapping):
|
||||
return 0
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return len(content)
|
||||
if isinstance(content, list):
|
||||
return sum(
|
||||
len(str(item.get("text") or ""))
|
||||
for item in content
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
except ModelGenerationError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _raise_for_terminal_response(payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
choice = _response_choice(payload)
|
||||
base_response = payload.get("base_resp")
|
||||
status_code: Any = None
|
||||
status_message = ""
|
||||
if isinstance(base_response, Mapping):
|
||||
status_code = base_response.get("status_code")
|
||||
status_message = re.sub(
|
||||
r"\s+", " ", str(base_response.get("status_msg") or "")
|
||||
).strip()[:200]
|
||||
|
||||
if bool(payload.get("input_sensitive")) or status_code in {1026, "1026"}:
|
||||
raise _TerminalModelGenerationError(
|
||||
f"模型输入触发内容安全拦截(code={status_code or 1026})"
|
||||
)
|
||||
if bool(payload.get("output_sensitive")) or status_code in {1027, "1027"}:
|
||||
raise _TerminalModelGenerationError(
|
||||
f"模型输出触发内容安全拦截(code={status_code or 1027})"
|
||||
)
|
||||
|
||||
finish_reason = str(choice.get("finish_reason") or "").strip().lower()
|
||||
if finish_reason == "length":
|
||||
raise _TerminalModelGenerationError(
|
||||
"模型输出因达到 Token 上限被截断(finish_reason=length),"
|
||||
"请提高最大输出长度后重试"
|
||||
)
|
||||
if finish_reason == "content_filter":
|
||||
raise _TerminalModelGenerationError(
|
||||
"模型输出被内容安全策略拦截(finish_reason=content_filter)"
|
||||
)
|
||||
if finish_reason in {"tool_calls", "function_call"}:
|
||||
raise _TerminalModelGenerationError(
|
||||
f"模型返回了当前生成任务不支持的工具调用(finish_reason={finish_reason})"
|
||||
)
|
||||
if status_code not in {None, "", 0, "0"}:
|
||||
detail = f":{status_message}" if status_message else ""
|
||||
raise _TerminalModelGenerationError(
|
||||
f"模型服务返回业务错误(code={status_code}){detail}"
|
||||
)
|
||||
return choice
|
||||
|
||||
|
||||
def _message_content(payload: Mapping[str, Any]) -> str:
|
||||
choice = _raise_for_terminal_response(payload)
|
||||
message = choice.get("message")
|
||||
if not isinstance(message, Mapping):
|
||||
raise ModelGenerationError("模型响应缺少 choices[0].message")
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
result = content
|
||||
elif isinstance(content, list):
|
||||
parts = [
|
||||
str(item.get("text") or "")
|
||||
for item in content
|
||||
if isinstance(item, Mapping) and item.get("type") in {None, "text", "output_text"}
|
||||
]
|
||||
if parts:
|
||||
return "".join(parts)
|
||||
raise ModelGenerationError("model response content must be text")
|
||||
result = "".join(parts)
|
||||
elif content is None:
|
||||
result = ""
|
||||
else:
|
||||
raise ModelGenerationError("模型响应 content 必须是文本")
|
||||
if not result.strip():
|
||||
raise ModelGenerationError("模型返回的最终内容为空,未生成可解析的 JSON")
|
||||
return result
|
||||
|
||||
|
||||
def _json_documents(content: str) -> list[Any]:
|
||||
decoder = json.JSONDecoder()
|
||||
documents: list[Any] = []
|
||||
cursor = 0
|
||||
while cursor < len(content):
|
||||
match = re.search(r"[\[{]", content[cursor:])
|
||||
if not match:
|
||||
break
|
||||
start = cursor + match.start()
|
||||
try:
|
||||
value, end = decoder.raw_decode(content[start:])
|
||||
except json.JSONDecodeError:
|
||||
cursor = start + 1
|
||||
continue
|
||||
if isinstance(value, (Mapping, list)):
|
||||
documents.append(value)
|
||||
cursor = start + max(end, 1)
|
||||
return documents
|
||||
|
||||
|
||||
def _json_payload(content: str) -> Any:
|
||||
# 只移除模型在 JSON 之前自行输出的思考过程,不能破坏 JSON 字段中的训练内容。
|
||||
cleaned = content.strip()
|
||||
if re.match(r"^\s*<think>", cleaned, flags=re.IGNORECASE) and not re.match(
|
||||
r"^\s*<think>[\s\S]*?</think>", cleaned, flags=re.IGNORECASE
|
||||
):
|
||||
raise ModelGenerationError("模型思考内容未闭合,响应可能已被截断")
|
||||
cleaned = re.sub(
|
||||
r"^\s*<think>[\s\S]*?</think>\s*",
|
||||
r"^\s*(?:<think>[\s\S]*?</think>\s*)+",
|
||||
"",
|
||||
content,
|
||||
cleaned,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
@@ -93,10 +224,16 @@ def _json_payload(content: str) -> Any:
|
||||
cleaned = fenced.group(1).strip()
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError as exc:
|
||||
except json.JSONDecodeError as direct_error:
|
||||
documents = _json_documents(cleaned)
|
||||
if len(documents) == 1:
|
||||
return documents[0]
|
||||
if len(documents) > 1:
|
||||
raise ModelGenerationError("模型响应包含多个 JSON 对象,无法确定应使用哪一个")
|
||||
raise ModelGenerationError(
|
||||
f"model response is not valid JSON at line {exc.lineno}, column {exc.colno}"
|
||||
) from exc
|
||||
"模型响应中没有找到唯一且完整的 JSON 对象"
|
||||
f"(第 {direct_error.lineno} 行,第 {direct_error.colno} 列)"
|
||||
) from direct_error
|
||||
|
||||
|
||||
def _result_items(payload: Any) -> list[Mapping[str, Any]]:
|
||||
@@ -206,6 +343,7 @@ def generate_model_records(
|
||||
model_name = str(model.get("online_model_name") or model.get("name") or "").strip()
|
||||
if not model_name:
|
||||
raise ModelGenerationError("generation model name is required")
|
||||
is_minimax_m3 = _is_official_minimax_m3(endpoint, model_name)
|
||||
|
||||
temperature = float(config.get("temperature", 0.7))
|
||||
max_tokens = int(config.get("max_tokens", 1024))
|
||||
@@ -246,9 +384,18 @@ def generate_model_records(
|
||||
reasoning_detail=reasoning_detail,
|
||||
),
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if bool(config.get("json_mode", False)):
|
||||
if is_minimax_m3:
|
||||
request_payload.update(
|
||||
reasoning_split=True,
|
||||
max_completion_tokens=max(
|
||||
max_tokens,
|
||||
MINIMAX_M3_MIN_COMPLETION_TOKENS,
|
||||
),
|
||||
)
|
||||
else:
|
||||
request_payload["max_tokens"] = max_tokens
|
||||
if bool(config.get("json_mode", False)) and not is_minimax_m3:
|
||||
request_payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
last_error: Exception | None = None
|
||||
@@ -264,7 +411,24 @@ def generate_model_records(
|
||||
body = response.json()
|
||||
if not isinstance(body, Mapping):
|
||||
raise ModelGenerationError("model response body must be a JSON object")
|
||||
candidate_items = _result_items(_json_payload(_message_content(body)))
|
||||
try:
|
||||
candidate_items = _result_items(
|
||||
_json_payload(_message_content(body))
|
||||
)
|
||||
except ModelGenerationError as exc:
|
||||
logger.warning(
|
||||
"data process model response rejected task_id=%s model=%s "
|
||||
"finish_reason=%s response_chars=%s input_sensitive=%s "
|
||||
"output_sensitive=%s reason=%s",
|
||||
task_id,
|
||||
model_name,
|
||||
_response_finish_reason(body) or "missing",
|
||||
_response_content_length(body),
|
||||
bool(body.get("input_sensitive")),
|
||||
bool(body.get("output_sensitive")),
|
||||
str(exc),
|
||||
)
|
||||
raise
|
||||
if len(candidate_items) < batch_count:
|
||||
raise ModelGenerationError(
|
||||
"model response contains fewer result objects than requested: "
|
||||
@@ -278,6 +442,8 @@ def generate_model_records(
|
||||
ModelGenerationError,
|
||||
) as exc:
|
||||
last_error = exc
|
||||
if not _is_retryable_generation_error(exc):
|
||||
break
|
||||
|
||||
if generated_items is None:
|
||||
error_message = str(last_error or "model generation failed")[:2000]
|
||||
|
||||
@@ -7,6 +7,18 @@ from urllib.parse import urlsplit
|
||||
|
||||
from app.modules.data_process.store import DataProcessStore
|
||||
|
||||
REQUIRED_TASK_COLUMNS = (
|
||||
"generation_run_id",
|
||||
"results_confirmed",
|
||||
"workflow_step",
|
||||
"preview_status",
|
||||
"preview_progress",
|
||||
"preview_run_id",
|
||||
"preview_failure_reason",
|
||||
"preview_total_files",
|
||||
"preview_completed_files",
|
||||
)
|
||||
|
||||
|
||||
def _target_label(database_url: str) -> str:
|
||||
parsed = urlsplit(database_url)
|
||||
@@ -18,14 +30,13 @@ def _schema_ready(store: DataProcessStore) -> bool:
|
||||
with store.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=current_schema()
|
||||
AND table_name='data_process_tasks'
|
||||
AND column_name='generation_run_id'
|
||||
) AS ready
|
||||
"""
|
||||
SELECT COUNT(*) = %s AS ready
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=current_schema()
|
||||
AND table_name='data_process_tasks'
|
||||
AND column_name = ANY(%s)
|
||||
""",
|
||||
(len(REQUIRED_TASK_COLUMNS), list(REQUIRED_TASK_COLUMNS)),
|
||||
).fetchone()
|
||||
return bool(row and row["ready"])
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.modules.data_process.algorithms import estimate_token_count, stable_spl
|
||||
|
||||
TASK_STATUSES = {"pending", "running", "completed", "failed", "stopped"}
|
||||
EDITABLE_STATUSES = {"pending", "failed", "stopped", "completed"}
|
||||
ACTIVE_PREVIEW_STATUSES = {"queued", "running"}
|
||||
WORKFLOW_STEPS = {"create", "model", "upload", "preview", "generate", "results"}
|
||||
|
||||
_PREVIEW_CONFIG_ALIASES = {
|
||||
"preprocess_options": "preprocessOptions",
|
||||
@@ -339,9 +341,10 @@ class DataProcessStore:
|
||||
"""
|
||||
INSERT INTO data_process_tasks
|
||||
(id, name, description, status, process_type, source_dataset_id, config,
|
||||
progress, tenant_id, project_id, owner_id, created_by, updated_by,
|
||||
progress, results_confirmed, tenant_id, project_id, owner_id, created_by, updated_by,
|
||||
created_at, updated_at)
|
||||
VALUES (%s, %s, %s, 'pending', %s, %s, %s, 0, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, 'pending', %s, %s, %s, 0, FALSE,
|
||||
%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
@@ -442,9 +445,30 @@ class DataProcessStore:
|
||||
def _ensure_editable(task: dict[str, Any]) -> None:
|
||||
if task["status"] not in EDITABLE_STATUSES:
|
||||
raise InvalidStateError(f"task cannot be edited while status is {task['status']}")
|
||||
if task.get("preview_status") in ACTIVE_PREVIEW_STATUSES:
|
||||
raise InvalidStateError("task cannot be edited while preview is running")
|
||||
if task.get("output_dataset_id") and not _is_regeneration_prepared(task):
|
||||
raise InvalidStateError("published task cannot be edited")
|
||||
|
||||
def update_workflow_step(self, task_id: str, workflow_step: str) -> dict[str, Any]:
|
||||
"""独立保存向导位置,不触发配置或结果失效逻辑。"""
|
||||
|
||||
if workflow_step not in WORKFLOW_STEPS:
|
||||
raise ValueError("invalid data process workflow step")
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET workflow_step=%s, updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
RETURNING *
|
||||
""",
|
||||
(workflow_step, utcnow(), task_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise NotFoundError("data process task not found")
|
||||
return _public_task(_decode_row(row)) or {}
|
||||
|
||||
def recover_legacy_aborted_regeneration(self, task_id: str) -> dict[str, Any]:
|
||||
"""恢复旧版在真正开始生成前误删的上一轮结果。
|
||||
|
||||
@@ -593,7 +617,8 @@ class DataProcessStore:
|
||||
output_count=%s, filtered_count=0, duplicate_count=0,
|
||||
error_count=(SELECT COUNT(*) FROM data_process_results
|
||||
WHERE task_id=%s AND status='invalid'),
|
||||
failure_reason=NULL, updated_at=%s
|
||||
failure_reason=NULL, results_confirmed=TRUE,
|
||||
workflow_step='results', updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(
|
||||
@@ -660,6 +685,13 @@ class DataProcessStore:
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"results_confirmed": False,
|
||||
"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,
|
||||
}
|
||||
@@ -709,6 +741,8 @@ class DataProcessStore:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
if task["status"] == "running":
|
||||
raise ConflictError("running task cannot be prepared for regeneration")
|
||||
if task.get("preview_status") in ACTIVE_PREVIEW_STATUSES:
|
||||
raise ConflictError("running preview cannot be prepared for regeneration")
|
||||
|
||||
current_updated_at = _serialize_value(task.get("updated_at"))
|
||||
if payload["expected_updated_at"] != current_updated_at:
|
||||
@@ -785,14 +819,19 @@ class DataProcessStore:
|
||||
|
||||
def delete_task(self, task_id: str, *, deleted_by: str | None = None) -> None:
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
if task["status"] == "running":
|
||||
raise InvalidStateError("running task must be stopped before deletion")
|
||||
self._task_in_connection(conn, task_id, for_update=True)
|
||||
now = utcnow()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET deleted_at=%s, deleted_by=%s, updated_at=%s
|
||||
SET status=CASE WHEN status='running' THEN 'stopped' ELSE status END,
|
||||
generation_run_id=NULL,
|
||||
preview_status=CASE
|
||||
WHEN preview_status IN ('queued', 'running') THEN 'cancelled'
|
||||
ELSE preview_status
|
||||
END,
|
||||
preview_run_id=NULL,
|
||||
deleted_at=%s, deleted_by=%s, updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(now, deleted_by, now, task_id),
|
||||
@@ -918,7 +957,10 @@ class DataProcessStore:
|
||||
SELECT COALESCE(SUM(record_count), 0)
|
||||
FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL
|
||||
), updated_at=%s
|
||||
), workflow_step='upload', preview_status='idle',
|
||||
preview_progress=0, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL, preview_total_files=0,
|
||||
preview_completed_files=0, updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(task_id, now, task_id),
|
||||
@@ -934,7 +976,12 @@ class DataProcessStore:
|
||||
UPDATE data_process_tasks
|
||||
SET status='pending', progress=%s, output_count=0, filtered_count=0,
|
||||
duplicate_count=0, error_count=0, failure_reason=NULL,
|
||||
generation_run_id=NULL, started_at=NULL, completed_at=NULL,
|
||||
generation_run_id=NULL, results_confirmed=FALSE,
|
||||
workflow_step='upload', preview_status='idle',
|
||||
preview_progress=0, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL, preview_total_files=0,
|
||||
preview_completed_files=0,
|
||||
started_at=NULL, completed_at=NULL,
|
||||
input_count=(
|
||||
SELECT COALESCE(SUM(record_count), 0)
|
||||
FROM data_process_source_files
|
||||
@@ -1035,7 +1082,10 @@ class DataProcessStore:
|
||||
SET input_count=(SELECT COALESCE(SUM(record_count), 0)
|
||||
FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL),
|
||||
updated_at=%s
|
||||
workflow_step='upload', preview_status='idle',
|
||||
preview_progress=0, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL, preview_total_files=0,
|
||||
preview_completed_files=0, updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(task_id, now, task_id),
|
||||
@@ -1049,6 +1099,11 @@ class DataProcessStore:
|
||||
UPDATE data_process_tasks
|
||||
SET status='pending', progress=0, output_count=0, filtered_count=0,
|
||||
duplicate_count=0, error_count=0, failure_reason=NULL,
|
||||
results_confirmed=FALSE,
|
||||
workflow_step='upload', preview_status='idle',
|
||||
preview_progress=0, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL, preview_total_files=0,
|
||||
preview_completed_files=0,
|
||||
input_count=(SELECT COALESCE(SUM(record_count), 0)
|
||||
FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL),
|
||||
@@ -1064,6 +1119,7 @@ class DataProcessStore:
|
||||
items: Sequence[dict[str, Any]],
|
||||
*,
|
||||
source_file_ids: Sequence[str] | None = None,
|
||||
preview_run_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
selected_ids = (
|
||||
list(dict.fromkeys(str(file_id) for file_id in source_file_ids))
|
||||
@@ -1081,11 +1137,21 @@ class DataProcessStore:
|
||||
}
|
||||
if unexpected:
|
||||
raise ValueError("preview items contain an unselected source file")
|
||||
preview_file_count = len(selected_ids) if selected_ids is not None else len(
|
||||
{str(item.get("source_file_id") or "") for item in items}
|
||||
)
|
||||
is_direct_build = preview_run_id is None
|
||||
|
||||
now = utcnow()
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
self._ensure_editable(task)
|
||||
if is_direct_build:
|
||||
self._ensure_editable(task)
|
||||
elif (
|
||||
task.get("preview_run_id") != preview_run_id
|
||||
or task.get("preview_status") != "running"
|
||||
):
|
||||
raise InvalidStateError("preview run is no longer active")
|
||||
regeneration_prepared = _is_regeneration_prepared(task)
|
||||
if not regeneration_prepared:
|
||||
conn.execute("DELETE FROM data_process_results WHERE task_id=%s", (task_id,))
|
||||
@@ -1144,22 +1210,249 @@ class DataProcessStore:
|
||||
).fetchone()
|
||||
created.append(_decode_row(row) or {})
|
||||
if regeneration_prepared:
|
||||
conn.execute(
|
||||
"UPDATE data_process_tasks SET updated_at=%s WHERE id=%s",
|
||||
(now, task_id),
|
||||
)
|
||||
if is_direct_build:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET workflow_step='preview', preview_status='completed',
|
||||
preview_progress=100, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL, preview_total_files=%s,
|
||||
preview_completed_files=%s, updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(preview_file_count, preview_file_count, now, task_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE data_process_tasks SET updated_at=%s WHERE id=%s",
|
||||
(now, task_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET status='pending', progress=20, output_count=0, filtered_count=0,
|
||||
duplicate_count=0, error_count=0, failure_reason=NULL, updated_at=%s
|
||||
duplicate_count=0, error_count=0, failure_reason=NULL,
|
||||
results_confirmed=FALSE,
|
||||
workflow_step=CASE WHEN %s THEN 'preview' ELSE workflow_step END,
|
||||
preview_status=CASE WHEN %s THEN 'completed' ELSE preview_status END,
|
||||
preview_progress=CASE WHEN %s THEN 100 ELSE preview_progress END,
|
||||
preview_run_id=CASE WHEN %s THEN NULL ELSE preview_run_id END,
|
||||
preview_failure_reason=CASE WHEN %s THEN NULL ELSE preview_failure_reason END,
|
||||
preview_total_files=CASE WHEN %s THEN %s ELSE preview_total_files END,
|
||||
preview_completed_files=CASE WHEN %s THEN %s ELSE preview_completed_files END,
|
||||
updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(now, task_id),
|
||||
(
|
||||
is_direct_build,
|
||||
is_direct_build,
|
||||
is_direct_build,
|
||||
is_direct_build,
|
||||
is_direct_build,
|
||||
is_direct_build,
|
||||
preview_file_count,
|
||||
is_direct_build,
|
||||
preview_file_count,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
return created
|
||||
|
||||
def start_preview(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
source_file_ids: Sequence[str] | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""创建一轮持久化切分任务,并返回本轮固定的源文件集合。"""
|
||||
|
||||
requested_ids = (
|
||||
list(dict.fromkeys(str(file_id) for file_id in source_file_ids))
|
||||
if source_file_ids is not None
|
||||
else None
|
||||
)
|
||||
if requested_ids is not None and (
|
||||
not requested_ids or any(not file_id for file_id in requested_ids)
|
||||
):
|
||||
raise ValueError("source_file_ids must contain non-empty ids")
|
||||
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
self._ensure_editable(task)
|
||||
if requested_ids is None:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL
|
||||
ORDER BY created_at, id
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL AND id=ANY(%s)
|
||||
ORDER BY created_at, id
|
||||
""",
|
||||
(task_id, requested_ids),
|
||||
).fetchall()
|
||||
selected_ids = [str(row["id"]) for row in rows]
|
||||
if not selected_ids:
|
||||
raise InvalidStateError("at least one source file is required")
|
||||
if requested_ids is not None:
|
||||
missing = set(requested_ids) - set(selected_ids)
|
||||
if missing:
|
||||
raise NotFoundError(
|
||||
f"source files not found: {', '.join(sorted(missing))}"
|
||||
)
|
||||
|
||||
regeneration_prepared = _is_regeneration_prepared(task)
|
||||
if not regeneration_prepared:
|
||||
conn.execute("DELETE FROM data_process_results WHERE task_id=%s", (task_id,))
|
||||
preview_run_id = new_id("dpprun")
|
||||
now = utcnow()
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET status=CASE WHEN %s THEN status ELSE 'pending' END,
|
||||
progress=CASE WHEN %s THEN progress ELSE 0 END,
|
||||
output_count=CASE WHEN %s THEN output_count ELSE 0 END,
|
||||
filtered_count=CASE WHEN %s THEN filtered_count ELSE 0 END,
|
||||
duplicate_count=CASE WHEN %s THEN duplicate_count ELSE 0 END,
|
||||
error_count=CASE WHEN %s THEN error_count ELSE 0 END,
|
||||
failure_reason=CASE WHEN %s THEN failure_reason ELSE NULL END,
|
||||
results_confirmed=CASE WHEN %s THEN results_confirmed ELSE FALSE END,
|
||||
workflow_step='upload', preview_status='queued', preview_progress=0,
|
||||
preview_run_id=%s, preview_failure_reason=NULL,
|
||||
preview_total_files=%s, preview_completed_files=0,
|
||||
updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
regeneration_prepared,
|
||||
preview_run_id,
|
||||
len(selected_ids),
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
).fetchone()
|
||||
return _public_task(_decode_row(row)) or {}, selected_ids
|
||||
|
||||
def mark_preview_running(self, task_id: str, preview_run_id: str) -> bool:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET preview_status='running', updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
AND preview_status='queued' AND preview_run_id=%s
|
||||
RETURNING id
|
||||
""",
|
||||
(utcnow(), task_id, preview_run_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def preview_is_running(self, task_id: str, preview_run_id: str) -> bool:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT preview_status, preview_run_id
|
||||
FROM data_process_tasks
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return bool(
|
||||
row
|
||||
and row.get("preview_status") in ACTIVE_PREVIEW_STATUSES
|
||||
and row.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:
|
||||
total = max(1, total_files)
|
||||
completed = min(max(0, completed_files), total)
|
||||
progress = completed / total * 100
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET preview_progress=%s, preview_completed_files=%s, updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
AND preview_status='running' AND preview_run_id=%s
|
||||
RETURNING id
|
||||
""",
|
||||
(progress, completed, utcnow(), task_id, preview_run_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def complete_preview(self, task_id: str, preview_run_id: str) -> bool:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET workflow_step='preview', preview_status='completed',
|
||||
preview_progress=100, preview_run_id=NULL,
|
||||
preview_failure_reason=NULL,
|
||||
preview_completed_files=preview_total_files, updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
AND preview_status='running' AND preview_run_id=%s
|
||||
RETURNING id
|
||||
""",
|
||||
(utcnow(), task_id, preview_run_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def mark_preview_failed(
|
||||
self,
|
||||
task_id: str,
|
||||
reason: str,
|
||||
*,
|
||||
preview_run_id: str,
|
||||
) -> bool:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET preview_status='failed', preview_run_id=NULL,
|
||||
preview_failure_reason=%s, updated_at=%s
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
AND preview_status IN ('queued', 'running') AND preview_run_id=%s
|
||||
RETURNING id
|
||||
""",
|
||||
(reason[:4000], utcnow(), task_id, preview_run_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def preview_progress(self, task_id: str) -> dict[str, Any]:
|
||||
task = self.get_task(task_id)
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"workflow_step": task.get("workflow_step") or "create",
|
||||
"preview_status": task.get("preview_status") or "idle",
|
||||
"preview_progress": float(task.get("preview_progress") or 0),
|
||||
"preview_run_id": task.get("preview_run_id"),
|
||||
"preview_failure_reason": task.get("preview_failure_reason"),
|
||||
"preview_total_files": int(task.get("preview_total_files") or 0),
|
||||
"preview_completed_files": int(task.get("preview_completed_files") or 0),
|
||||
}
|
||||
|
||||
def list_preview_items(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -1332,7 +1625,7 @@ class DataProcessStore:
|
||||
UPDATE data_process_tasks
|
||||
SET status='pending', progress=20, output_count=0, filtered_count=0,
|
||||
duplicate_count=0, error_count=0, failure_reason=NULL,
|
||||
generation_run_id=NULL, updated_at=%s
|
||||
generation_run_id=NULL, results_confirmed=FALSE, updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(now, task_id),
|
||||
@@ -1348,6 +1641,8 @@ class DataProcessStore:
|
||||
raise InvalidStateError("published task cannot be regenerated")
|
||||
if task["status"] == "running":
|
||||
raise ConflictError("data process task is already running")
|
||||
if task.get("preview_status") in ACTIVE_PREVIEW_STATUSES:
|
||||
raise ConflictError("preview is still running")
|
||||
preview_count = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM data_process_preview_items WHERE task_id=%s",
|
||||
(task_id,),
|
||||
@@ -1365,7 +1660,8 @@ class DataProcessStore:
|
||||
SET config=%s, status='running', progress=30, failure_reason=NULL,
|
||||
started_at=%s, completed_at=NULL, output_dataset_id=NULL,
|
||||
output_count=0, filtered_count=0, duplicate_count=0, error_count=0,
|
||||
generation_run_id=%s, updated_at=%s
|
||||
generation_run_id=%s, results_confirmed=FALSE,
|
||||
workflow_step='generate', updated_at=%s
|
||||
WHERE id=%s
|
||||
RETURNING *
|
||||
""",
|
||||
@@ -1391,10 +1687,19 @@ class DataProcessStore:
|
||||
return _decode_row(row) or {}
|
||||
|
||||
def generation_is_running(self, task_id: str, generation_run_id: str) -> bool:
|
||||
task = self.get_task(task_id)
|
||||
return (
|
||||
task["status"] == "running"
|
||||
and task.get("generation_run_id") == generation_run_id
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT status, generation_run_id
|
||||
FROM data_process_tasks
|
||||
WHERE id=%s AND deleted_at IS NULL
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return bool(
|
||||
row
|
||||
and row.get("status") == "running"
|
||||
and row.get("generation_run_id") == generation_run_id
|
||||
)
|
||||
|
||||
def update_generation_progress(
|
||||
@@ -1469,7 +1774,8 @@ class DataProcessStore:
|
||||
UPDATE data_process_tasks
|
||||
SET status='completed', progress=100, output_count=%s, filtered_count=%s,
|
||||
duplicate_count=%s, error_count=%s, failure_reason=NULL,
|
||||
completed_at=%s, generation_run_id=NULL, updated_at=%s
|
||||
completed_at=%s, generation_run_id=NULL, results_confirmed=FALSE,
|
||||
updated_at=%s
|
||||
WHERE id=%s AND generation_run_id=%s RETURNING *
|
||||
""",
|
||||
(
|
||||
@@ -1519,10 +1825,59 @@ class DataProcessStore:
|
||||
"duplicate_count": int(task.get("duplicate_count") or 0),
|
||||
"error_count": int(task.get("error_count") or 0),
|
||||
"failure_reason": task.get("failure_reason"),
|
||||
"results_confirmed": bool(task.get("results_confirmed")),
|
||||
"started_at": task.get("started_at"),
|
||||
"completed_at": task.get("completed_at"),
|
||||
}
|
||||
|
||||
def confirm_results(self, task_id: str) -> dict[str, Any]:
|
||||
"""确认第六步结果,确认前再次校验所有生成记录。"""
|
||||
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
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")
|
||||
if task.get("results_confirmed"):
|
||||
return task
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT status, instruction, output
|
||||
FROM data_process_results
|
||||
WHERE task_id=%s
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
raise InvalidStateError("task has no results to confirm")
|
||||
invalid_count = sum(
|
||||
1
|
||||
for row in rows
|
||||
if row["status"] == "invalid"
|
||||
or not str(row.get("instruction") or "").strip()
|
||||
or not str(row.get("output") or "").strip()
|
||||
or (
|
||||
_task_output_type(task) == "reasoning"
|
||||
and not _reasoning_output_is_valid(row.get("output"))
|
||||
)
|
||||
)
|
||||
if invalid_count:
|
||||
raise InvalidStateError(
|
||||
f"task contains {invalid_count} invalid results"
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET results_confirmed=TRUE, updated_at=%s
|
||||
WHERE id=%s RETURNING *
|
||||
""",
|
||||
(utcnow(), task_id),
|
||||
).fetchone()
|
||||
return _decode_row(row) or {}
|
||||
|
||||
def list_results(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -1651,6 +2006,83 @@ class DataProcessStore:
|
||||
)
|
||||
return _decode_row(row) or {}
|
||||
|
||||
def replace_generated_result(
|
||||
self,
|
||||
task_id: str,
|
||||
result_id: str,
|
||||
replacement: dict[str, Any],
|
||||
*,
|
||||
expected_updated_at: str,
|
||||
) -> dict[str, Any]:
|
||||
"""用新模型结果原位替换失败项,并将新内容设为恢复基线。"""
|
||||
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
if task["status"] != "completed" or task.get("workflow_step") != "results":
|
||||
raise InvalidStateError("task is not editing generation results")
|
||||
if task.get("results_confirmed"):
|
||||
raise InvalidStateError("confirmed results cannot be regenerated")
|
||||
if task.get("output_dataset_id"):
|
||||
raise InvalidStateError("published results cannot be regenerated")
|
||||
|
||||
current = conn.execute(
|
||||
"""SELECT * FROM data_process_results
|
||||
WHERE id=%s AND task_id=%s FOR UPDATE""",
|
||||
(result_id, task_id),
|
||||
).fetchone()
|
||||
if not current:
|
||||
raise NotFoundError("data process result not found")
|
||||
if current.get("status") != "invalid":
|
||||
raise InvalidStateError("only an invalid result can be regenerated")
|
||||
current_updated_at = _serialize_value(current.get("updated_at"))
|
||||
if expected_updated_at != current_updated_at:
|
||||
raise ConflictError("data process result was modified by another request")
|
||||
|
||||
instruction = str(replacement.get("instruction") or "").strip()
|
||||
input_text = str(replacement.get("input") or "").strip()
|
||||
output = str(replacement.get("output") or "").strip()
|
||||
quality_score = replacement.get("quality_score") or {}
|
||||
if not instruction or not output or not bool(quality_score.get("is_valid")):
|
||||
raise InvalidStateError("regenerated result did not pass quality validation")
|
||||
if _task_output_type(task) == "reasoning" and not _reasoning_output_is_valid(output):
|
||||
raise InvalidStateError("regenerated reasoning result has an invalid output format")
|
||||
|
||||
now = utcnow()
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_results
|
||||
SET instruction=%s, input=%s, output=%s,
|
||||
original_instruction=%s, original_input=%s, original_output=%s,
|
||||
status='valid', error=NULL, quality_score=%s, updated_at=%s
|
||||
WHERE id=%s AND task_id=%s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
instruction,
|
||||
input_text,
|
||||
output,
|
||||
instruction,
|
||||
input_text,
|
||||
output,
|
||||
json_dumps(quality_score),
|
||||
now,
|
||||
result_id,
|
||||
task_id,
|
||||
),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET error_count=(
|
||||
SELECT COUNT(*) FROM data_process_results
|
||||
WHERE task_id=%s AND status='invalid'
|
||||
), updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(task_id, now, task_id),
|
||||
)
|
||||
return _decode_row(row) or {}
|
||||
|
||||
def get_generation_model(self, model_id: str) -> dict[str, Any]:
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
@@ -1704,6 +2136,8 @@ class DataProcessStore:
|
||||
)
|
||||
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")
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM data_process_results
|
||||
|
||||
@@ -109,6 +109,24 @@ class DataProcessStatus(StrEnum):
|
||||
stopped = "stopped"
|
||||
|
||||
|
||||
class DataProcessWorkflowStep(StrEnum):
|
||||
create = "create"
|
||||
model = "model"
|
||||
upload = "upload"
|
||||
preview = "preview"
|
||||
generate = "generate"
|
||||
results = "results"
|
||||
|
||||
|
||||
class DataProcessPreviewStatus(StrEnum):
|
||||
idle = "idle"
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class ProcessType(StrEnum):
|
||||
structured = "structured"
|
||||
unstructured = "unstructured"
|
||||
@@ -164,6 +182,14 @@ class DataProcessTaskUpdate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class DataProcessWorkflowStepUpdate(BaseModel):
|
||||
"""仅保存创建向导位置,不修改配置或使下游产物失效。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
workflow_step: DataProcessWorkflowStep
|
||||
|
||||
|
||||
class DataProcessRegenerateRequest(BaseModel):
|
||||
"""以一份完整配置准备任务重新生成。
|
||||
|
||||
@@ -288,6 +314,32 @@ class ResultUpdate(BaseModel):
|
||||
expected_updated_at: str | None = None
|
||||
|
||||
|
||||
class ResultRegenerateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_updated_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ResultBatchRegenerateItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
result_id: str = Field(min_length=1, max_length=100)
|
||||
expected_updated_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ResultBatchRegenerateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[ResultBatchRegenerateItem] = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_results(self) -> "ResultBatchRegenerateRequest":
|
||||
result_ids = [item.result_id for item in self.items]
|
||||
if len(result_ids) != len(set(result_ids)):
|
||||
raise ValueError("result_id values must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class DatasetSplit(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -88,6 +88,344 @@ def test_generate_model_records_uses_prompt_auth_and_stable_split() -> None:
|
||||
assert progress_updates == [(1, 1)]
|
||||
|
||||
|
||||
def test_minimax_m3_uses_split_reasoning_and_completion_token_budget() -> None:
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
requests.append(payload)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"reasoning_content": "模型内部思考不应混入业务 JSON",
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "申请编号有什么作用?",
|
||||
"reasoning": "来源说明它用于标识报销申请。",
|
||||
"answer": "它用于唯一标识一笔报销申请。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
"output_sensitive": False,
|
||||
"base_resp": {"status_code": 0, "status_msg": ""},
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-minimax", "edited_content": "申请编号用于标识报销申请。"}],
|
||||
model={
|
||||
"name": "MiniMax",
|
||||
"online_model_name": "MiniMax-M3",
|
||||
"api_url": "https://api.minimaxi.com/v1",
|
||||
},
|
||||
config={
|
||||
"output_type": "reasoning",
|
||||
"json_mode": True,
|
||||
"max_tokens": 1024,
|
||||
"generation_retries": 0,
|
||||
},
|
||||
task_id="task-minimax",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert records[0]["status"] == "valid"
|
||||
assert len(requests) == 1
|
||||
assert requests[0]["reasoning_split"] is True
|
||||
assert requests[0]["max_completion_tokens"] >= 4096
|
||||
assert "max_tokens" not in requests[0]
|
||||
assert "response_format" not in requests[0]
|
||||
|
||||
|
||||
def test_minimax_m3_keeps_larger_configured_completion_budget() -> None:
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "问题",
|
||||
"output": "这是满足测试要求的完整答案。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
generate_model_records(
|
||||
[{"id": "preview-minimax-budget", "edited_content": "来源正文"}],
|
||||
model={
|
||||
"online_model_name": "MiniMax-M3",
|
||||
"api_url": "https://api.minimax.io/v1",
|
||||
},
|
||||
config={"max_tokens": 8192, "generation_retries": 0},
|
||||
task_id="task-minimax-budget",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert requests[0]["max_completion_tokens"] == 8192
|
||||
|
||||
|
||||
def test_minimax_m3_name_on_custom_proxy_keeps_generic_openai_parameters() -> None:
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "问题",
|
||||
"output": "这是代理服务返回的完整答案。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
generate_model_records(
|
||||
[{"id": "preview-minimax-proxy", "edited_content": "来源正文"}],
|
||||
model={
|
||||
"online_model_name": "MiniMax-M3",
|
||||
"api_url": "https://model-proxy.example/v1",
|
||||
},
|
||||
config={"max_tokens": 1024, "json_mode": True, "generation_retries": 0},
|
||||
task_id="task-minimax-proxy",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert requests[0]["max_tokens"] == 1024
|
||||
assert requests[0]["response_format"] == {"type": "json_object"}
|
||||
assert "reasoning_split" not in requests[0]
|
||||
assert "max_completion_tokens" not in requests[0]
|
||||
|
||||
|
||||
def test_generate_model_records_extracts_json_surrounded_by_model_explanation() -> None:
|
||||
content = "模型结果如下:\n```json\n" + json.dumps(
|
||||
{
|
||||
"items": [{
|
||||
"instruction": "字段有什么作用?",
|
||||
"output": "该字段用于唯一标识记录。",
|
||||
}],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
) + "\n```\n生成完毕。"
|
||||
client = httpx.Client(
|
||||
transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": content}}]},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-explanation", "edited_content": "字段用于唯一标识记录。"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 0},
|
||||
task_id="task-explanation",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert records[0]["status"] == "valid"
|
||||
assert records[0]["output"] == "该字段用于唯一标识记录。"
|
||||
|
||||
|
||||
def test_generate_model_records_reports_token_truncation_instead_of_json_error() -> None:
|
||||
client = httpx.Client(
|
||||
transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": ""},
|
||||
}],
|
||||
"output_sensitive": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-truncated", "edited_content": "来源正文"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 0},
|
||||
task_id="task-truncated",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert records[0]["status"] == "invalid"
|
||||
assert "Token" in records[0]["error"]
|
||||
assert "截断" in records[0]["error"]
|
||||
|
||||
|
||||
def test_token_truncation_is_not_retried_even_when_json_looks_complete() -> None:
|
||||
request_count = 0
|
||||
content = json.dumps({
|
||||
"items": [{
|
||||
"instruction": "问题",
|
||||
"output": "表面完整但服务端已声明截断。",
|
||||
}],
|
||||
}, ensure_ascii=False)
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"finish_reason": "length",
|
||||
"message": {"content": content},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-length", "edited_content": "来源正文"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 5},
|
||||
task_id="task-length",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 1
|
||||
assert records[0]["status"] == "invalid"
|
||||
assert "finish_reason=length" in records[0]["error"]
|
||||
|
||||
|
||||
def test_sensitive_model_response_is_not_retried_or_saved() -> None:
|
||||
request_count = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {"content": "{}"},
|
||||
}],
|
||||
"output_sensitive": True,
|
||||
"base_resp": {"status_code": 1027, "status_msg": "output sensitive"},
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-sensitive", "edited_content": "来源正文"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 5},
|
||||
task_id="task-sensitive",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 1
|
||||
assert records[0]["status"] == "invalid"
|
||||
assert "安全拦截" in records[0]["error"]
|
||||
assert "1027" in records[0]["error"]
|
||||
|
||||
|
||||
def test_empty_model_content_can_retry_then_succeed() -> None:
|
||||
request_count = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
if request_count == 1:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"finish_reason": "stop", "message": {"content": ""}}]},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "问题",
|
||||
"output": "第二次请求返回了完整答案。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-empty-retry", "edited_content": "来源正文"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 1},
|
||||
task_id="task-empty-retry",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 2
|
||||
assert records[0]["status"] == "valid"
|
||||
|
||||
|
||||
def test_multiple_top_level_json_documents_are_rejected_as_ambiguous() -> None:
|
||||
first = json.dumps({
|
||||
"items": [{"instruction": "问题一", "output": "答案一"}],
|
||||
}, ensure_ascii=False)
|
||||
second = json.dumps({
|
||||
"items": [{"instruction": "问题二", "output": "答案二"}],
|
||||
}, ensure_ascii=False)
|
||||
client = httpx.Client(
|
||||
transport=httpx.MockTransport(
|
||||
lambda _: httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": f"{first}\n{second}"}}]},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-ambiguous", "edited_content": "来源正文"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 0},
|
||||
task_id="task-ambiguous",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert records[0]["status"] == "invalid"
|
||||
assert "多个 JSON" in records[0]["error"]
|
||||
|
||||
|
||||
def test_generate_model_records_builds_reasoning_output_with_think_tags() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
payload = json.loads(request.content)
|
||||
@@ -414,6 +752,112 @@ def test_generate_model_records_retries_short_batch_then_marks_it_invalid() -> N
|
||||
assert "expected 10, got 1" in records[0]["error"]
|
||||
|
||||
|
||||
def test_generate_model_records_does_not_retry_non_retryable_http_errors() -> None:
|
||||
request_count = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
return httpx.Response(401, json={"error": {"message": "unauthorized"}})
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-auth", "edited_content": "来源内容"}],
|
||||
model={
|
||||
"api_url": "https://model.example/v1",
|
||||
"online_model_name": "test-model",
|
||||
"api_key": "invalid",
|
||||
},
|
||||
config={"generation_retries": 5},
|
||||
task_id="task-auth",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 1
|
||||
assert records[0]["status"] == "invalid"
|
||||
assert "401" in records[0]["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [408, 425, 429, 500])
|
||||
def test_generate_model_records_retries_retryable_http_statuses(
|
||||
status_code: int,
|
||||
) -> None:
|
||||
request_count = 0
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
if request_count == 1:
|
||||
return httpx.Response(status_code)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "来源内容是什么?",
|
||||
"output": "这是用于验证可重试错误的来源内容。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-retryable", "edited_content": "来源内容"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 1},
|
||||
task_id="task-retryable",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 2
|
||||
assert records[0]["status"] == "valid"
|
||||
|
||||
|
||||
def test_generate_model_records_retries_transient_network_errors() -> None:
|
||||
request_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
if request_count == 1:
|
||||
raise httpx.ConnectError("temporary connection failure", request=request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": json.dumps({
|
||||
"items": [{
|
||||
"instruction": "网络恢复了吗?",
|
||||
"output": "临时连接错误后,第二次模型请求已经成功。",
|
||||
}],
|
||||
}, ensure_ascii=False),
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
records = generate_model_records(
|
||||
[{"id": "preview-network", "edited_content": "网络重试来源"}],
|
||||
model={"name": "model", "api_url": "https://model.example/v1"},
|
||||
config={"generation_retries": 1},
|
||||
task_id="task-network",
|
||||
split={"train": 100, "validation": 0, "test": 0},
|
||||
qa_pairs_per_item=1,
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
assert request_count == 2
|
||||
assert records[0]["status"] == "valid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("qa_pairs_per_item", [0, 51])
|
||||
def test_generate_model_records_rejects_out_of_range_count(
|
||||
qa_pairs_per_item: int,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.data_process.schema_cli import _target_label
|
||||
from app.modules.data_process.schema_cli import REQUIRED_TASK_COLUMNS, _target_label
|
||||
|
||||
|
||||
def test_runtime_migration_fails_fast_on_incompatible_schema() -> None:
|
||||
@@ -18,6 +18,24 @@ def test_runtime_migration_fails_fast_on_incompatible_schema() -> None:
|
||||
assert "requires 001_platform_runtime.sql first" in sql
|
||||
assert "supports only the current TEXT runtime schema" in sql
|
||||
assert "generation_run_id" in sql
|
||||
assert "results_confirmed BOOLEAN NOT NULL DEFAULT TRUE" in sql
|
||||
assert "WHERE status <> 'completed' AND results_confirmed=TRUE" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS workflow_step VARCHAR(20)" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_status VARCHAR(20)" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_progress NUMERIC(5,2)" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_run_id TEXT" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_failure_reason TEXT" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_total_files INTEGER" in sql
|
||||
assert "ADD COLUMN IF NOT EXISTS preview_completed_files INTEGER" in sql
|
||||
assert "data_process_workflow_backfill_ids" in sql
|
||||
assert "ck_data_process_tasks_workflow_step" in sql
|
||||
assert "ck_data_process_tasks_preview_status" in sql
|
||||
assert "ck_data_process_tasks_preview_progress" in sql
|
||||
assert "ck_data_process_tasks_preview_file_counts" in sql
|
||||
for value in ("create", "model", "upload", "preview", "generate", "results"):
|
||||
assert f"'{value}'" in sql
|
||||
for value in ("idle", "queued", "running", "completed", "failed", "cancelled"):
|
||||
assert f"'{value}'" in sql
|
||||
assert "CREATE TABLE IF NOT EXISTS data_process_results" in sql
|
||||
assert sql.count("BEGIN;") == 1
|
||||
assert sql.rstrip().endswith("COMMIT;")
|
||||
@@ -27,3 +45,17 @@ def test_schema_cli_target_label_never_contains_credentials() -> None:
|
||||
label = _target_label("postgresql://secret-user:secret-password@db.example:5433/yg_ft")
|
||||
assert label == "db.example:5433/yg_ft"
|
||||
assert "secret" not in label
|
||||
|
||||
|
||||
def test_schema_check_requires_current_runtime_columns() -> None:
|
||||
assert REQUIRED_TASK_COLUMNS == (
|
||||
"generation_run_id",
|
||||
"results_confirmed",
|
||||
"workflow_step",
|
||||
"preview_status",
|
||||
"preview_progress",
|
||||
"preview_run_id",
|
||||
"preview_failure_reason",
|
||||
"preview_total_files",
|
||||
"preview_completed_files",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -20,6 +21,14 @@ from app.modules.data_process.store import (
|
||||
)
|
||||
|
||||
|
||||
def test_preview_replace_sql_never_uses_untyped_null_placeholders() -> None:
|
||||
source = inspect.getsource(DataProcessStore.replace_preview_items)
|
||||
|
||||
assert "%s IS NULL" not in source
|
||||
assert "is_direct_build = preview_run_id is None" in source
|
||||
assert "workflow_step=CASE WHEN %s THEN 'preview'" in source
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, *, row: dict[str, Any] | None = None, rows: list[dict[str, Any]] | None = None):
|
||||
self.row = row
|
||||
@@ -146,6 +155,7 @@ class _PublishStore(DataProcessStore):
|
||||
return {
|
||||
"id": task_id,
|
||||
"status": "completed",
|
||||
"results_confirmed": True,
|
||||
"description": "",
|
||||
"config": self._task_config,
|
||||
"output_dataset_id": train_dataset and train_dataset["id"],
|
||||
@@ -454,12 +464,14 @@ class _LegacyRecoveryConnection:
|
||||
record["preview_item_id"] = params[1]
|
||||
return _Result()
|
||||
if normalized.startswith("UPDATE data_process_tasks SET status='completed'"):
|
||||
assert "workflow_step='results'" in normalized
|
||||
self.task.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"output_dataset_id": params[0],
|
||||
"output_count": params[1],
|
||||
"workflow_step": "results",
|
||||
}
|
||||
)
|
||||
return _Result()
|
||||
@@ -496,6 +508,7 @@ class _StartGenerationConnection:
|
||||
config=config,
|
||||
output_dataset_id="dataset_train" if published_prepared else None,
|
||||
output_count=28,
|
||||
results_confirmed=published_prepared,
|
||||
),
|
||||
"generation_run_id": None,
|
||||
}
|
||||
@@ -512,6 +525,7 @@ class _StartGenerationConnection:
|
||||
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
|
||||
assert "results_confirmed=FALSE" in normalized
|
||||
self.task.update(
|
||||
{
|
||||
"config": params[0],
|
||||
@@ -526,6 +540,7 @@ class _StartGenerationConnection:
|
||||
"duplicate_count": 0,
|
||||
"error_count": 0,
|
||||
"generation_run_id": params[2],
|
||||
"results_confirmed": False,
|
||||
"updated_at": params[3],
|
||||
}
|
||||
)
|
||||
@@ -713,6 +728,7 @@ def test_start_generation_clears_previous_output_count() -> None:
|
||||
|
||||
assert task["status"] == "running"
|
||||
assert task["output_count"] == 0
|
||||
assert task["results_confirmed"] is False
|
||||
assert conn.results == []
|
||||
|
||||
|
||||
@@ -737,6 +753,7 @@ def test_prepared_published_task_survives_generation_preflight_failure() -> None
|
||||
assert conn.task["status"] == "completed"
|
||||
assert conn.task["output_dataset_id"] == "dataset_train"
|
||||
assert conn.task["output_count"] == 28
|
||||
assert conn.task["results_confirmed"] is True
|
||||
assert "_regeneration_prepared" in conn.task["config"]
|
||||
assert conn.results == [{"id": "old-result"}]
|
||||
|
||||
@@ -752,6 +769,7 @@ def test_legacy_aborted_regeneration_recovers_results_and_published_state() -> N
|
||||
assert conn.task["progress"] == 100
|
||||
assert conn.task["output_dataset_id"] == "dataset_train"
|
||||
assert conn.task["output_count"] == 2
|
||||
assert conn.task["workflow_step"] == "results"
|
||||
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"]
|
||||
@@ -1185,3 +1203,96 @@ def test_source_storage_descriptor_rejects_unowned_or_unsupported_references(
|
||||
"dpt_task",
|
||||
"dpsf_source",
|
||||
)
|
||||
|
||||
|
||||
class _LifecycleConnection:
|
||||
def __init__(self) -> None:
|
||||
self.task: dict[str, Any] = {
|
||||
"id": "task-lifecycle",
|
||||
"status": "running",
|
||||
"generation_run_id": "generation-active",
|
||||
"workflow_step": "generate",
|
||||
"preview_status": "running",
|
||||
"preview_progress": Decimal("40.00"),
|
||||
"preview_run_id": "preview-active",
|
||||
"preview_failure_reason": None,
|
||||
"preview_total_files": 5,
|
||||
"preview_completed_files": 2,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
self.last_update_sql = ""
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> _Result:
|
||||
normalized = " ".join(sql.split())
|
||||
if params is not None:
|
||||
assert normalized.count("%s") == len(params)
|
||||
if normalized.startswith("SELECT * FROM data_process_tasks"):
|
||||
row = None if self.task["deleted_at"] is not None else dict(self.task)
|
||||
return _Result(row=row)
|
||||
if normalized.startswith("UPDATE data_process_tasks SET workflow_step="):
|
||||
self.last_update_sql = normalized
|
||||
workflow_step, updated_at, task_id = params
|
||||
assert task_id == self.task["id"]
|
||||
self.task.update(workflow_step=workflow_step, updated_at=updated_at)
|
||||
return _Result(row=dict(self.task))
|
||||
if normalized.startswith("UPDATE data_process_tasks SET status=CASE"):
|
||||
self.last_update_sql = normalized
|
||||
deleted_at, deleted_by, updated_at, task_id = params
|
||||
assert task_id == self.task["id"]
|
||||
self.task.update(
|
||||
status="stopped",
|
||||
generation_run_id=None,
|
||||
preview_status="cancelled",
|
||||
preview_run_id=None,
|
||||
deleted_at=deleted_at,
|
||||
deleted_by=deleted_by,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
return _Result()
|
||||
if normalized.startswith("SELECT status, generation_run_id"):
|
||||
row = None if self.task["deleted_at"] is not None else dict(self.task)
|
||||
return _Result(row=row)
|
||||
if normalized.startswith("SELECT preview_status, preview_run_id"):
|
||||
row = None if self.task["deleted_at"] is not None else dict(self.task)
|
||||
return _Result(row=row)
|
||||
raise AssertionError(f"unexpected SQL: {normalized}")
|
||||
|
||||
|
||||
class _LifecycleStore(DataProcessStore):
|
||||
def __init__(self, conn: _LifecycleConnection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[_LifecycleConnection]:
|
||||
yield self._conn
|
||||
|
||||
|
||||
def test_workflow_step_update_does_not_invalidate_active_runs() -> None:
|
||||
conn = _LifecycleConnection()
|
||||
|
||||
task = _LifecycleStore(conn).update_workflow_step("task-lifecycle", "results")
|
||||
|
||||
assert task["workflow_step"] == "results"
|
||||
assert task["status"] == "running"
|
||||
assert task["generation_run_id"] == "generation-active"
|
||||
assert task["preview_status"] == "running"
|
||||
assert task["preview_run_id"] == "preview-active"
|
||||
assert "generation_run_id" not in conn.last_update_sql
|
||||
assert "preview_run_id" not in conn.last_update_sql
|
||||
|
||||
|
||||
def test_delete_atomically_invalidates_generation_and_preview_runs() -> None:
|
||||
conn = _LifecycleConnection()
|
||||
store = _LifecycleStore(conn)
|
||||
|
||||
store.delete_task("task-lifecycle", deleted_by="user-1")
|
||||
|
||||
assert conn.task["status"] == "stopped"
|
||||
assert conn.task["generation_run_id"] is None
|
||||
assert conn.task["preview_status"] == "cancelled"
|
||||
assert conn.task["preview_run_id"] is None
|
||||
assert conn.task["deleted_by"] == "user-1"
|
||||
assert conn.task["deleted_at"] is not None
|
||||
assert store.generation_is_running("task-lifecycle", "generation-active") is False
|
||||
assert store.preview_is_running("task-lifecycle", "preview-active") is False
|
||||
|
||||
Reference in New Issue
Block a user