feat(data-process): 支持任务重新生成
This commit is contained in:
@@ -68,6 +68,7 @@ from app.modules.data_process.store import (
|
||||
new_id,
|
||||
)
|
||||
from app.schemas.data_process import (
|
||||
DataProcessRegenerateRequest,
|
||||
DataProcessStatus,
|
||||
DataProcessTaskCreate,
|
||||
DataProcessTaskUpdate,
|
||||
@@ -732,6 +733,19 @@ def update_task(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/regenerate")
|
||||
def prepare_regeneration(
|
||||
task_id: str,
|
||||
payload: DataProcessRegenerateRequest,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
return ok(
|
||||
store.prepare_regeneration(task_id, payload.model_dump(mode="json")),
|
||||
"data process task prepared for regeneration",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(
|
||||
task_id: str,
|
||||
|
||||
@@ -20,6 +20,28 @@ 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"}
|
||||
|
||||
_PREVIEW_CONFIG_ALIASES = {
|
||||
"preprocess_options": "preprocessOptions",
|
||||
"chunk_method": "chunkMethod",
|
||||
"chunk_size": "chunkSize",
|
||||
"chunk_overlap": "chunkOverlap",
|
||||
"min_chunk_size": "minChunkSize",
|
||||
"semantic_breakpoint_percentile": "semanticBreakpointPercentile",
|
||||
"preserve_tables": "preserveTables",
|
||||
"preserve_code_blocks": "preserveCodeBlocks",
|
||||
"preserve_lists": "preserveLists",
|
||||
}
|
||||
_UNSTRUCTURED_PREVIEW_DEFAULTS: dict[str, Any] = {
|
||||
"chunk_method": "layout_hybrid",
|
||||
"chunk_size": 800,
|
||||
"chunk_overlap": 100,
|
||||
"min_chunk_size": 100,
|
||||
"semantic_breakpoint_percentile": 95,
|
||||
"preserve_tables": True,
|
||||
"preserve_code_blocks": True,
|
||||
"preserve_lists": True,
|
||||
}
|
||||
|
||||
|
||||
class DataProcessStoreError(RuntimeError):
|
||||
pass
|
||||
@@ -64,6 +86,49 @@ def _json_value(value: Any, default: Any) -> Any:
|
||||
return default
|
||||
|
||||
|
||||
def _preview_config_value(config: dict[str, Any], key: str, default: Any) -> Any:
|
||||
if key in config:
|
||||
return config[key]
|
||||
return config.get(_PREVIEW_CONFIG_ALIASES[key], default)
|
||||
|
||||
|
||||
def _normalized_preprocess_options(config: dict[str, Any]) -> Any:
|
||||
value = _preview_config_value(config, "preprocess_options", [])
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return tuple(sorted({str(item) for item in value}))
|
||||
return value
|
||||
|
||||
|
||||
def _preview_config_projection(
|
||||
process_type: str,
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""只投影会改变预览切片的配置。
|
||||
|
||||
生成模型、提示词、温度等参数不影响源文切片,因此不应该
|
||||
破坏用户已经校对过的预览内容。
|
||||
"""
|
||||
|
||||
projection: dict[str, Any] = {
|
||||
"preprocess_options": _normalized_preprocess_options(config),
|
||||
}
|
||||
if process_type != "unstructured":
|
||||
return projection
|
||||
for key, default in _UNSTRUCTURED_PREVIEW_DEFAULTS.items():
|
||||
projection[key] = _preview_config_value(config, key, default)
|
||||
return projection
|
||||
|
||||
|
||||
def _preview_config_changed(
|
||||
process_type: str,
|
||||
current_config: dict[str, Any],
|
||||
next_config: dict[str, Any],
|
||||
) -> bool:
|
||||
return _preview_config_projection(process_type, current_config) != _preview_config_projection(
|
||||
process_type, next_config
|
||||
)
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
@@ -361,6 +426,102 @@ class DataProcessStore:
|
||||
raise ConflictError("data process task name already exists") from exc
|
||||
return _decode_row(row) or {}
|
||||
|
||||
def prepare_regeneration(
|
||||
self,
|
||||
task_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""在一个事务中将已有任务恢复为可重新生成状态。
|
||||
|
||||
已发布数据集是可被其他任务使用的独立产物,此处只解除当前任务的
|
||||
输出指针,不删除数据集实体。用户完成新一轮生成后,可再次发布
|
||||
以原子替换三个分割数据集的内容。
|
||||
"""
|
||||
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
task = self._task_in_connection(conn, task_id, for_update=True)
|
||||
if task["status"] == "running":
|
||||
raise ConflictError("running task cannot be prepared for regeneration")
|
||||
|
||||
current_updated_at = _serialize_value(task.get("updated_at"))
|
||||
if payload["expected_updated_at"] != current_updated_at:
|
||||
raise ConflictError("data process task was modified by another request")
|
||||
|
||||
process_type = str(payload["process_type"])
|
||||
if process_type != str(task["process_type"]):
|
||||
raise InvalidStateError("process_type cannot be changed during regeneration")
|
||||
|
||||
current_config = dict(task.get("config") or {})
|
||||
next_config = dict(payload.get("config") or {})
|
||||
preview_invalidated = _preview_config_changed(
|
||||
process_type,
|
||||
current_config,
|
||||
next_config,
|
||||
)
|
||||
published_row = conn.execute(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM datasets
|
||||
WHERE source_task_id=%s AND source='task'
|
||||
) AS exists
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
published_outputs_preserved = bool(task.get("output_dataset_id")) or bool(
|
||||
published_row and published_row.get("exists")
|
||||
)
|
||||
|
||||
# 删除顺序保证结果不再指向即将被替换的切片。
|
||||
conn.execute(
|
||||
"DELETE FROM data_process_results WHERE task_id=%s",
|
||||
(task_id,),
|
||||
)
|
||||
if preview_invalidated:
|
||||
conn.execute(
|
||||
"DELETE FROM data_process_preview_items WHERE task_id=%s",
|
||||
(task_id,),
|
||||
)
|
||||
preview_count = 0
|
||||
else:
|
||||
preview_row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count FROM data_process_preview_items
|
||||
WHERE task_id=%s
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
preview_count = int((preview_row or {}).get("count") or 0)
|
||||
|
||||
now = utcnow()
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET name=%s, description=%s, config=%s, status='pending', progress=%s,
|
||||
output_dataset_id=NULL, 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,
|
||||
updated_at=%s
|
||||
WHERE id=%s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
payload["name"],
|
||||
payload.get("description") or "",
|
||||
json_dumps(next_config),
|
||||
20 if preview_count else 0,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
).fetchone()
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ConflictError("data process task name already exists") from exc
|
||||
return {
|
||||
"task": _decode_row(row) or {},
|
||||
"preview_invalidated": preview_invalidated,
|
||||
"published_outputs_preserved": published_outputs_preserved,
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -480,12 +641,21 @@ class DataProcessStore:
|
||||
),
|
||||
).fetchone()
|
||||
created.append(_decode_row(row) or {})
|
||||
# 前端会只对本次新增的源文件构建预览,因此必须保留旧
|
||||
# 文件的切片。任何源文件增加都会使旧生成结果失效。
|
||||
conn.execute("DELETE FROM data_process_results WHERE task_id=%s", (task_id,))
|
||||
conn.execute("DELETE FROM data_process_preview_items WHERE task_id=%s", (task_id,))
|
||||
preview_row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count FROM data_process_preview_items
|
||||
WHERE task_id=%s
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
preview_count = int((preview_row or {}).get("count") or 0)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE data_process_tasks
|
||||
SET status='pending', progress=0, output_count=0, filtered_count=0,
|
||||
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, input_count=(
|
||||
SELECT COALESCE(SUM(record_count), 0)
|
||||
@@ -494,7 +664,7 @@ class DataProcessStore:
|
||||
), updated_at=%s
|
||||
WHERE id=%s
|
||||
""",
|
||||
(task_id, now, task_id),
|
||||
(20 if preview_count else 0, task_id, now, task_id),
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ConflictError(
|
||||
|
||||
@@ -156,6 +156,36 @@ class DataProcessTaskUpdate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class DataProcessRegenerateRequest(BaseModel):
|
||||
"""以一份完整配置准备任务重新生成。
|
||||
|
||||
``expected_updated_at`` 用于防止详情页的旧快照覆盖其他人刚刚
|
||||
保存的配置。重新生成不允许改变处理类型,避免旧源文件在新解析
|
||||
规则下被静默误用。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=150)
|
||||
description: str
|
||||
process_type: ProcessType
|
||||
config: dict[str, Any]
|
||||
expected_updated_at: str = Field(min_length=1)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def normalize_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("task name cannot be empty")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_config(self) -> "DataProcessRegenerateRequest":
|
||||
_validate_process_config(self.config)
|
||||
return self
|
||||
|
||||
|
||||
class PreviewBuildRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user