fix: 完善数据预处理与 JSON 上传链路
This commit is contained in:
@@ -8,12 +8,14 @@ import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from collections.abc import Iterator, Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from threading import BoundedSemaphore, Lock
|
||||
from typing import Any, Iterator, Literal
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import httpx
|
||||
@@ -45,9 +47,10 @@ from app.modules.data_process.algorithms import (
|
||||
is_near_duplicate,
|
||||
near_duplicate_fingerprint,
|
||||
parse_text_content,
|
||||
preprocess_structured_records,
|
||||
preprocess_structured_records_with_lineage,
|
||||
remove_document_noise,
|
||||
score_quality,
|
||||
structured_json_dumps,
|
||||
)
|
||||
from app.modules.data_process.document_chunking import (
|
||||
DocumentChunk,
|
||||
@@ -75,9 +78,11 @@ from app.modules.data_process.store import (
|
||||
NotFoundError,
|
||||
get_data_process_store,
|
||||
new_id,
|
||||
repeat_task_id,
|
||||
)
|
||||
from app.schemas.data_process import (
|
||||
DataProcessRegenerateRequest,
|
||||
DataProcessRepeatRequest,
|
||||
DataProcessStatus,
|
||||
DataProcessTaskCreate,
|
||||
DataProcessTaskUpdate,
|
||||
@@ -261,7 +266,14 @@ def _parse_stored_source(source: dict[str, Any]) -> ParsedText:
|
||||
content = str(source.get("content") or "")
|
||||
file_format = str(source.get("file_format") or "").lower()
|
||||
if file_format == "xlsx":
|
||||
# XLSX 上传阶段已安全解析为 JSONL 后入库。
|
||||
raw_content = source.get("raw_content")
|
||||
if isinstance(raw_content, bytes):
|
||||
return parse_text_content(
|
||||
raw_content,
|
||||
filename=str(source.get("name") or "source.xlsx"),
|
||||
file_format="xlsx",
|
||||
)
|
||||
# 兼容原始对象已缺失的历史文件:退化为上传阶段生成的 JSONL。
|
||||
return parse_text_content(content, file_format="jsonl")
|
||||
if file_format in {"pdf", "docx", "pptx"}:
|
||||
# 文档上传阶段已抽取文本,预览阶段只需要对正文切片。
|
||||
@@ -381,11 +393,12 @@ def _build_preview_items(
|
||||
seen_near_duplicate_bands: dict[tuple[int, int], list[str]] = {}
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
def append_item(item: dict[str, Any]) -> None:
|
||||
def append_item(item: dict[str, Any], *, dedup_content: str) -> None:
|
||||
content = str(item.get("edited_content") or "").strip()
|
||||
if should_clean_invalid and not content:
|
||||
return
|
||||
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
# 去重必须基于脱敏前内容,否则不同原文可能在替换 PII 后被错误合并。
|
||||
content_hash = hashlib.sha256(dedup_content.strip().encode("utf-8")).hexdigest()
|
||||
if should_deduplicate and content_hash in seen_content_hashes:
|
||||
return
|
||||
seen_content_hashes.add(content_hash)
|
||||
@@ -447,6 +460,7 @@ def _build_preview_items(
|
||||
continue
|
||||
for key in band_keys:
|
||||
seen_near_duplicate_bands.setdefault(key, []).append(content)
|
||||
dedup_content = content
|
||||
pii_counts: dict[str, int] = {}
|
||||
if should_desensitize:
|
||||
content, pii_counts = desensitize_pii(content)
|
||||
@@ -476,7 +490,8 @@ def _build_preview_items(
|
||||
else "original"
|
||||
),
|
||||
"quality_score": quality,
|
||||
}
|
||||
},
|
||||
dedup_content=dedup_content,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -488,48 +503,76 @@ def _build_preview_items(
|
||||
"filter_anomaly",
|
||||
}
|
||||
source_records = list(parsed.records)
|
||||
processed_records = preprocess_structured_records(
|
||||
processed_records = preprocess_structured_records_with_lineage(
|
||||
source_records,
|
||||
structured_options,
|
||||
)
|
||||
if not processed_records and parsed.text and not source_records:
|
||||
processed_records = [{"value": parsed.text}]
|
||||
same_cardinality = len(processed_records) == len(source_records)
|
||||
for index, record in enumerate(processed_records):
|
||||
original_record = source_records[index] if same_cardinality else record
|
||||
original_content = json.dumps(
|
||||
original_record,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
for processed in processed_records:
|
||||
source_index = processed.source_index
|
||||
record = processed.record
|
||||
original_record = (
|
||||
source_records[source_index]
|
||||
if source_index < len(source_records)
|
||||
else record
|
||||
)
|
||||
source_locator = (
|
||||
deepcopy(parsed.record_locators[source_index])
|
||||
if source_index < len(parsed.record_locators)
|
||||
else None
|
||||
)
|
||||
original_content = structured_json_dumps(original_record)
|
||||
pii_counts: dict[str, int] = {}
|
||||
edited_record = record
|
||||
dedup_content = (
|
||||
canonical_record_json(record)
|
||||
if "normalize_format" in preprocess_options
|
||||
else structured_json_dumps(record)
|
||||
)
|
||||
if should_desensitize:
|
||||
edited_record, pii_counts = desensitize_structured_record(record)
|
||||
content = (
|
||||
canonical_record_json(edited_record)
|
||||
if "normalize_format" in preprocess_options
|
||||
else json.dumps(
|
||||
edited_record,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
else structured_json_dumps(edited_record)
|
||||
)
|
||||
quality = _preview_quality(content, config)
|
||||
quality["pii_replacements"] = pii_counts
|
||||
if source_locator is not None:
|
||||
quality["source_locator"] = source_locator
|
||||
source_start = (
|
||||
source_locator.get("source_start")
|
||||
if source_locator is not None
|
||||
else None
|
||||
)
|
||||
source_end = (
|
||||
source_locator.get("source_end")
|
||||
if source_locator is not None
|
||||
else None
|
||||
)
|
||||
source_start_line = (
|
||||
source_locator.get("start_line")
|
||||
if source_locator is not None
|
||||
else None
|
||||
)
|
||||
source_end_line = (
|
||||
source_locator.get("end_line")
|
||||
if source_locator is not None
|
||||
else None
|
||||
)
|
||||
append_item(
|
||||
{
|
||||
"source_file_id": source["id"],
|
||||
"original_content": original_content,
|
||||
"edited_content": content,
|
||||
"source_start": None,
|
||||
"source_end": None,
|
||||
"source_start_line": None,
|
||||
"source_end_line": None,
|
||||
"source_start": source_start,
|
||||
"source_end": source_end,
|
||||
"source_start_line": source_start_line,
|
||||
"source_end_line": source_end_line,
|
||||
"token_count": estimate_token_count(content),
|
||||
"status": "modified" if content != original_content else "original",
|
||||
"quality_score": quality,
|
||||
}
|
||||
},
|
||||
dedup_content=dedup_content,
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -850,6 +893,135 @@ def prepare_regeneration(
|
||||
)
|
||||
|
||||
|
||||
def _repeat_file_copies(
|
||||
store: DataProcessStore,
|
||||
storage: LocalDataProcessStorage,
|
||||
source_task_id: str,
|
||||
request_id: str,
|
||||
) -> tuple[dict[str, dict[str, str]], list[StagedSourceObject]]:
|
||||
"""为新任务创建独立的源文件引用,避免删除任一任务时互相影响。"""
|
||||
|
||||
target_task_id = repeat_task_id(source_task_id, request_id)
|
||||
copies: dict[str, dict[str, str]] = {}
|
||||
staged: list[StagedSourceObject] = []
|
||||
batch_id = storage.new_batch_id()
|
||||
for summary in store.list_source_files(source_task_id):
|
||||
old_file_id = str(summary["id"])
|
||||
source = store.get_source_file(source_task_id, old_file_id, include_content=True)
|
||||
new_file_id = new_id("dpsf")
|
||||
old_reference = str(source.get("storage_object_id") or "")
|
||||
if old_reference.startswith("local://data-process/"):
|
||||
staged_object = storage.stage_copy(
|
||||
batch_id=batch_id,
|
||||
source_reference=old_reference,
|
||||
expected_source_task_id=source_task_id,
|
||||
expected_source_file_id=old_file_id,
|
||||
task_id=target_task_id,
|
||||
source_file_id=new_file_id,
|
||||
version=1,
|
||||
name=str(source["name"]),
|
||||
)
|
||||
staged.append(staged_object)
|
||||
new_reference = staged_object.reference
|
||||
elif old_reference.startswith("db://data-process/") or not old_reference:
|
||||
new_reference = f"db://data-process/{target_task_id}/{new_file_id}/v1"
|
||||
else:
|
||||
raise ValueError("源任务包含不受支持的文件存储引用")
|
||||
copies[old_file_id] = {
|
||||
"id": new_file_id,
|
||||
"storage_object_id": new_reference,
|
||||
}
|
||||
return copies, staged
|
||||
|
||||
|
||||
def _remove_repeated_storage_objects(
|
||||
storage: LocalDataProcessStorage,
|
||||
task_id: str,
|
||||
staged: list[StagedSourceObject],
|
||||
copies: dict[str, dict[str, str]],
|
||||
) -> None:
|
||||
source_file_ids = {
|
||||
str(copy["storage_object_id"]): str(copy["id"])
|
||||
for copy in copies.values()
|
||||
}
|
||||
for item in staged:
|
||||
try:
|
||||
storage.delete(
|
||||
item.reference,
|
||||
expected_task_id=task_id,
|
||||
expected_source_file_id=source_file_ids[item.reference],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"failed to roll back repeated data process source object task_id=%s",
|
||||
task_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/repeat", status_code=202)
|
||||
def repeat_generation(
|
||||
task_id: str,
|
||||
payload: DataProcessRepeatRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
store: DataProcessStore = Depends(get_data_process_store),
|
||||
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
|
||||
) -> dict[str, Any]:
|
||||
"""按原任务快照创建独立任务,并立即在后台开始新一批生成。"""
|
||||
|
||||
with api_errors():
|
||||
repeated = store.find_repeated_task(task_id, payload.request_id)
|
||||
staged: list[StagedSourceObject] = []
|
||||
target_task_id = repeat_task_id(task_id, payload.request_id)
|
||||
if repeated is None:
|
||||
copies, staged = _repeat_file_copies(
|
||||
store,
|
||||
storage,
|
||||
task_id,
|
||||
payload.request_id,
|
||||
)
|
||||
storage.publish(staged)
|
||||
try:
|
||||
repeated = store.repeat_task(
|
||||
task_id,
|
||||
expected_updated_at=payload.expected_updated_at,
|
||||
request_id=payload.request_id,
|
||||
file_copies=copies,
|
||||
)
|
||||
except Exception:
|
||||
_remove_repeated_storage_objects(
|
||||
storage,
|
||||
target_task_id,
|
||||
staged,
|
||||
copies,
|
||||
)
|
||||
raise
|
||||
if not repeated["created"]:
|
||||
_remove_repeated_storage_objects(
|
||||
storage,
|
||||
target_task_id,
|
||||
staged,
|
||||
copies,
|
||||
)
|
||||
|
||||
repeated_task = repeated["task"]
|
||||
if repeated_task.get("status") == "pending":
|
||||
try:
|
||||
started = store.start_generation(target_task_id, replace_existing=True)
|
||||
background_tasks.add_task(
|
||||
_run_generation,
|
||||
store,
|
||||
target_task_id,
|
||||
str(started["generation_run_id"]),
|
||||
)
|
||||
except ConflictError:
|
||||
latest = store.get_task(target_task_id)
|
||||
if latest.get("status") != "running":
|
||||
raise
|
||||
repeated["task"] = store.get_task(target_task_id)
|
||||
repeated["progress"] = store.progress(target_task_id)
|
||||
return ok(repeated, "已按原配置创建新任务并开始后台生成")
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(
|
||||
task_id: str,
|
||||
@@ -919,7 +1091,7 @@ async def upload_source_files(
|
||||
f"{suffix} is not supported for {process_type} data processing",
|
||||
)
|
||||
parsed = parse_text_content(raw, filename=name)
|
||||
if not parsed.text:
|
||||
if not parsed.text.strip():
|
||||
raise fail(400, f"source file is empty: {name}")
|
||||
batch_size += len(raw)
|
||||
if batch_size > MAX_SOURCE_BATCH_BYTES:
|
||||
@@ -934,7 +1106,11 @@ async def upload_source_files(
|
||||
content=raw,
|
||||
)
|
||||
staged.append(staged_object)
|
||||
record_count = len(parsed.records) or (1 if parsed.text else 0)
|
||||
record_count = (
|
||||
len(parsed.records)
|
||||
if process_type == "structured"
|
||||
else (1 if parsed.text else 0)
|
||||
)
|
||||
prepared.append(
|
||||
{
|
||||
"id": source_file_id,
|
||||
@@ -1362,15 +1538,28 @@ def _prepare_preview_items(
|
||||
_value(config, "chunk_method", "chunkMethod", "layout_hybrid")
|
||||
)
|
||||
is_unstructured = task.get("process_type") == "unstructured"
|
||||
if is_unstructured and (
|
||||
needs_unstructured_raw = is_unstructured and (
|
||||
chunk_method == "layout_hybrid"
|
||||
or preprocess_options & {"clean_invalid", "clean_invalid_content"}
|
||||
):
|
||||
)
|
||||
has_structured_xlsx = not is_unstructured and any(
|
||||
str(source.get("file_format") or "").lower() == "xlsx"
|
||||
for source in sources
|
||||
)
|
||||
if needs_unstructured_raw or has_structured_xlsx:
|
||||
for index, source in enumerate(sources):
|
||||
if (
|
||||
chunk_method != "layout_hybrid"
|
||||
and str(source.get("file_format") or "").lower() != "pdf"
|
||||
):
|
||||
source_format = str(source.get("file_format") or "").lower()
|
||||
needs_structured_xlsx = not is_unstructured and source_format == "xlsx"
|
||||
needs_layout_raw = is_unstructured and chunk_method == "layout_hybrid"
|
||||
needs_pdf_noise = (
|
||||
is_unstructured
|
||||
and not needs_layout_raw
|
||||
and source_format == "pdf"
|
||||
and bool(
|
||||
preprocess_options & {"clean_invalid", "clean_invalid_content"}
|
||||
)
|
||||
)
|
||||
if not (needs_structured_xlsx or needs_layout_raw or needs_pdf_noise):
|
||||
continue
|
||||
storage_object_id = str(source.get("storage_object_id") or "")
|
||||
actual_size = storage.file_size(
|
||||
@@ -1379,7 +1568,7 @@ def _prepare_preview_items(
|
||||
expected_source_file_id=str(source["id"]),
|
||||
)
|
||||
if actual_size is None:
|
||||
if chunk_method == "layout_hybrid":
|
||||
if needs_layout_raw:
|
||||
raise InvalidStateError(
|
||||
"版面结构混合切分无法读取原始文件,请重新上传后再处理"
|
||||
)
|
||||
@@ -1396,12 +1585,10 @@ def _prepare_preview_items(
|
||||
)
|
||||
)
|
||||
enriched = dict(source)
|
||||
if chunk_method == "layout_hybrid":
|
||||
if needs_structured_xlsx or needs_layout_raw:
|
||||
enriched["raw_content"] = raw
|
||||
sources[index] = enriched
|
||||
continue
|
||||
if str(source.get("file_format") or "").lower() != "pdf":
|
||||
continue
|
||||
pages = extract_pdf_page_texts(raw)
|
||||
extracted_text = "\n\n".join(page.text for page in pages if page.text)
|
||||
if extracted_text != str(source.get("content") or ""):
|
||||
@@ -1413,7 +1600,7 @@ def _prepare_preview_items(
|
||||
enriched["document_noise_spans"] = detect_pdf_document_noise(pages)
|
||||
sources[index] = enriched
|
||||
items = _build_preview_items(task, sources)
|
||||
if not items and source_file_ids is None:
|
||||
if not items and source_file_ids is None and is_unstructured:
|
||||
raise InvalidStateError("source files did not produce preview items")
|
||||
return items
|
||||
|
||||
@@ -1435,6 +1622,7 @@ def _run_preview(
|
||||
len(source_file_ids),
|
||||
)
|
||||
try:
|
||||
is_unstructured = store.get_task(task_id).get("process_type") == "unstructured"
|
||||
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",
|
||||
@@ -1461,7 +1649,7 @@ def _run_preview(
|
||||
storage,
|
||||
[source_file_id],
|
||||
)
|
||||
if not items:
|
||||
if not items and is_unstructured:
|
||||
raise InvalidStateError(
|
||||
f"source file did not produce preview items: {source_file_id}"
|
||||
)
|
||||
@@ -1657,8 +1845,13 @@ def update_preview_item(
|
||||
) -> dict[str, Any]:
|
||||
with api_errors():
|
||||
task = store.get_task(task_id)
|
||||
existing = store.get_preview_item(task_id, preview_id)
|
||||
update = payload.model_dump(exclude_unset=True, mode="json")
|
||||
update["quality_score"] = _preview_quality(payload.edited_content, task.get("config") or {})
|
||||
quality = _preview_quality(payload.edited_content, task.get("config") or {})
|
||||
source_locator = (existing.get("quality_score") or {}).get("source_locator")
|
||||
if isinstance(source_locator, Mapping):
|
||||
quality["source_locator"] = deepcopy(dict(source_locator))
|
||||
update["quality_score"] = quality
|
||||
item = store.update_preview_item(
|
||||
task_id,
|
||||
preview_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,67 @@ class LocalDataProcessStorage:
|
||||
self._issued_staged_objects[temporary_path] = staged
|
||||
return staged
|
||||
|
||||
def stage_copy(
|
||||
self,
|
||||
*,
|
||||
batch_id: str,
|
||||
source_reference: str,
|
||||
expected_source_task_id: str,
|
||||
expected_source_file_id: str,
|
||||
task_id: str,
|
||||
source_file_id: str,
|
||||
version: int,
|
||||
name: str,
|
||||
) -> StagedSourceObject:
|
||||
"""为不可变源对象创建独立目录项,不把大文件重新读入内存。"""
|
||||
|
||||
batch_id = _safe_component(batch_id, "batch id")
|
||||
task_id = _safe_component(task_id, "task id")
|
||||
source_file_id = _safe_component(source_file_id, "source file id")
|
||||
if isinstance(version, bool) or not isinstance(version, int) or version < 1:
|
||||
raise DataProcessStorageError("invalid source file version")
|
||||
basename = _safe_basename(name)
|
||||
source_relative = self._relative_from_reference(source_reference)
|
||||
if source_relative is None:
|
||||
raise DataProcessStorageError("original source object is not available")
|
||||
self._assert_expected_owner(
|
||||
source_relative,
|
||||
expected_task_id=expected_source_task_id,
|
||||
expected_source_file_id=expected_source_file_id,
|
||||
)
|
||||
descriptor, source_info = self._open_read_descriptor(source_relative)
|
||||
os.close(descriptor)
|
||||
|
||||
batch_directory = self._ensure_directory(self._root / ".staging" / batch_id)
|
||||
temporary_path = batch_directory / f"{source_file_id}-{uuid.uuid4().hex}.tmp"
|
||||
source_path = self._path_for_relative(source_relative)
|
||||
try:
|
||||
os.link(source_path, temporary_path, follow_symlinks=False)
|
||||
copy_info = temporary_path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(copy_info.st_mode)
|
||||
or source_info.st_dev != copy_info.st_dev
|
||||
or source_info.st_ino != copy_info.st_ino
|
||||
):
|
||||
raise DataProcessStorageError("source storage object changed while copying")
|
||||
except Exception:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
relative_path = PurePosixPath(
|
||||
task_id,
|
||||
source_file_id,
|
||||
f"v{version}",
|
||||
basename,
|
||||
)
|
||||
reference = (
|
||||
"local://data-process/"
|
||||
f"{task_id}/{source_file_id}/v{version}/{quote(basename, safe='')}"
|
||||
)
|
||||
staged = StagedSourceObject(reference, temporary_path, relative_path)
|
||||
self._issued_staged_objects[temporary_path] = staged
|
||||
return staged
|
||||
|
||||
def publish(self, objects: Iterable[StagedSourceObject]) -> None:
|
||||
staged = list(objects)
|
||||
published: list[StagedSourceObject] = []
|
||||
|
||||
@@ -45,6 +45,13 @@ _UNSTRUCTURED_PREVIEW_DEFAULTS: dict[str, Any] = {
|
||||
"preserve_lists": True,
|
||||
}
|
||||
_REGENERATION_MARKER_KEY = "_regeneration_prepared"
|
||||
_REPEAT_SOURCE_TASK_KEY = "_repeat_source_task_id"
|
||||
_REPEAT_REQUEST_KEY = "_repeat_request_id"
|
||||
_INTERNAL_CONFIG_KEYS = {
|
||||
_REGENERATION_MARKER_KEY,
|
||||
_REPEAT_SOURCE_TASK_KEY,
|
||||
_REPEAT_REQUEST_KEY,
|
||||
}
|
||||
|
||||
|
||||
class DataProcessStoreError(RuntimeError):
|
||||
@@ -71,6 +78,13 @@ def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:20]}"
|
||||
|
||||
|
||||
def repeat_task_id(source_task_id: str, request_id: str) -> str:
|
||||
"""按源任务和请求幂等键生成稳定的新任务 ID。"""
|
||||
|
||||
digest = hashlib.sha256(f"{source_task_id}:{request_id}".encode()).hexdigest()
|
||||
return f"dpt_{digest[:20]}"
|
||||
|
||||
|
||||
def json_dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
@@ -183,17 +197,25 @@ def _is_regeneration_prepared(task: dict[str, Any]) -> bool:
|
||||
return _regeneration_marker(task) is not None
|
||||
|
||||
|
||||
def _business_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""过滤只供服务端维护的工作流标记。"""
|
||||
|
||||
return {
|
||||
key: value
|
||||
for key, value in (config or {}).items()
|
||||
if key not in _INTERNAL_CONFIG_KEYS
|
||||
}
|
||||
|
||||
|
||||
def _public_task(item: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""从 API 任务快照中移除服务端内部重新生成标记。"""
|
||||
"""从 API 任务快照中移除服务端内部工作流标记。"""
|
||||
|
||||
if item is None:
|
||||
return None
|
||||
public = dict(item)
|
||||
config = public.get("config")
|
||||
if isinstance(config, dict) and _REGENERATION_MARKER_KEY in config:
|
||||
public["config"] = {
|
||||
key: value for key, value in config.items() if key != _REGENERATION_MARKER_KEY
|
||||
}
|
||||
if isinstance(config, dict):
|
||||
public["config"] = _business_config(config)
|
||||
return public
|
||||
|
||||
|
||||
@@ -353,13 +375,7 @@ class DataProcessStore:
|
||||
payload.get("description") or "",
|
||||
payload["process_type"],
|
||||
payload.get("source_dataset_id"),
|
||||
json_dumps(
|
||||
{
|
||||
key: value
|
||||
for key, value in (payload.get("config") or {}).items()
|
||||
if key != _REGENERATION_MARKER_KEY
|
||||
}
|
||||
),
|
||||
json_dumps(_business_config(payload.get("config"))),
|
||||
payload.get("tenant_id"),
|
||||
payload.get("project_id"),
|
||||
payload.get("owner_id"),
|
||||
@@ -373,6 +389,268 @@ class DataProcessStore:
|
||||
raise ConflictError("data process task name already exists") from exc
|
||||
return _public_task(_decode_row(row)) or {}
|
||||
|
||||
@staticmethod
|
||||
def _repeat_response(
|
||||
conn: psycopg.Connection[dict[str, Any]],
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
source_task_id: str,
|
||||
created: bool,
|
||||
) -> dict[str, Any]:
|
||||
task_id = str(row["id"])
|
||||
counts = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL) AS source_file_count,
|
||||
(SELECT COUNT(*) FROM data_process_preview_items
|
||||
WHERE task_id=%s) AS preview_count
|
||||
""",
|
||||
(task_id, task_id),
|
||||
).fetchone() or {}
|
||||
task = _public_task(_decode_row(row)) or {}
|
||||
task["source_file_count"] = int(counts.get("source_file_count") or 0)
|
||||
task["preview_count"] = int(counts.get("preview_count") or 0)
|
||||
return {
|
||||
"task": task,
|
||||
"source_task_id": source_task_id,
|
||||
"created": created,
|
||||
"copied_source_file_count": task["source_file_count"],
|
||||
"copied_preview_count": task["preview_count"],
|
||||
}
|
||||
|
||||
def find_repeated_task(
|
||||
self,
|
||||
source_task_id: str,
|
||||
request_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""查找同一幂等请求已创建的新任务。"""
|
||||
|
||||
task_id = repeat_task_id(source_task_id, request_id)
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM data_process_tasks WHERE id=%s",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
decoded = _decode_row(row) or {}
|
||||
config = decoded.get("config") or {}
|
||||
if (
|
||||
config.get(_REPEAT_SOURCE_TASK_KEY) != source_task_id
|
||||
or config.get(_REPEAT_REQUEST_KEY) != request_id
|
||||
):
|
||||
raise ConflictError("再次生成请求与现有任务冲突")
|
||||
if decoded.get("deleted_at"):
|
||||
raise ConflictError("此次再次生成创建的任务已被删除,请重新发起")
|
||||
return self._repeat_response(
|
||||
conn,
|
||||
row,
|
||||
source_task_id=source_task_id,
|
||||
created=False,
|
||||
)
|
||||
|
||||
def repeat_task(
|
||||
self,
|
||||
source_task_id: str,
|
||||
*,
|
||||
expected_updated_at: str,
|
||||
request_id: str,
|
||||
file_copies: dict[str, dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
"""复制已确认任务的配置、源文件和预览,结果与发布数据保持独立。"""
|
||||
|
||||
task_id = repeat_task_id(source_task_id, request_id)
|
||||
now = utcnow()
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM data_process_tasks WHERE id=%s FOR UPDATE",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
decoded = _decode_row(existing) or {}
|
||||
config = decoded.get("config") or {}
|
||||
if (
|
||||
config.get(_REPEAT_SOURCE_TASK_KEY) != source_task_id
|
||||
or config.get(_REPEAT_REQUEST_KEY) != request_id
|
||||
):
|
||||
raise ConflictError("再次生成请求与现有任务冲突")
|
||||
if decoded.get("deleted_at"):
|
||||
raise ConflictError("此次再次生成创建的任务已被删除,请重新发起")
|
||||
return self._repeat_response(
|
||||
conn,
|
||||
existing,
|
||||
source_task_id=source_task_id,
|
||||
created=False,
|
||||
)
|
||||
|
||||
source_task = self._task_in_connection(
|
||||
conn,
|
||||
source_task_id,
|
||||
for_update=True,
|
||||
)
|
||||
if (
|
||||
source_task.get("status") != "completed"
|
||||
or source_task.get("results_confirmed") is False
|
||||
):
|
||||
raise InvalidStateError("只有已完成并确认结果的任务可以再次生成")
|
||||
if source_task.get("preview_status") in ACTIVE_PREVIEW_STATUSES:
|
||||
raise ConflictError("源任务仍在处理切分,暂时不能再次生成")
|
||||
if expected_updated_at != _serialize_value(source_task.get("updated_at")):
|
||||
raise ConflictError("源任务已被其他操作修改,请刷新后重试")
|
||||
|
||||
source_files = conn.execute(
|
||||
"""
|
||||
SELECT * FROM data_process_source_files
|
||||
WHERE task_id=%s AND deleted_at IS NULL
|
||||
ORDER BY created_at, id
|
||||
""",
|
||||
(source_task_id,),
|
||||
).fetchall()
|
||||
source_file_ids = {str(row["id"]) for row in source_files}
|
||||
if source_file_ids != set(file_copies):
|
||||
raise ConflictError("源文件快照已变化,请刷新后重试")
|
||||
previews = conn.execute(
|
||||
"""
|
||||
SELECT * FROM data_process_preview_items
|
||||
WHERE task_id=%s
|
||||
ORDER BY source_file_id NULLS LAST, source_start NULLS LAST,
|
||||
created_at, id
|
||||
""",
|
||||
(source_task_id,),
|
||||
).fetchall()
|
||||
if not previews:
|
||||
raise InvalidStateError("源任务没有可用于再次生成的切分结果")
|
||||
|
||||
suffix = f"(再次生成-{task_id[-6:]})"
|
||||
base_name = str(source_task.get("name") or "数据处理任务")
|
||||
repeated_name = f"{base_name[: max(1, 150 - len(suffix))]}{suffix}"
|
||||
repeated_config = _business_config(source_task.get("config") or {})
|
||||
repeated_config[_REPEAT_SOURCE_TASK_KEY] = source_task_id
|
||||
repeated_config[_REPEAT_REQUEST_KEY] = request_id
|
||||
input_count = sum(int(row.get("record_count") or 0) for row in source_files)
|
||||
task_row = conn.execute(
|
||||
"""
|
||||
INSERT INTO data_process_tasks
|
||||
(id, name, description, status, process_type, source_dataset_id,
|
||||
output_dataset_id, config, progress, input_count, output_count,
|
||||
filtered_count, duplicate_count, error_count, failure_reason,
|
||||
generation_run_id, results_confirmed, workflow_step,
|
||||
preview_status, preview_progress, preview_run_id,
|
||||
preview_failure_reason, preview_total_files,
|
||||
preview_completed_files, tenant_id, project_id, owner_id,
|
||||
approval_status, created_by, updated_by, created_at, updated_at)
|
||||
VALUES
|
||||
(%s, %s, %s, 'pending', %s, %s, NULL, %s, 20, %s, 0,
|
||||
0, 0, 0, NULL, NULL, FALSE, 'preview', 'completed', 100,
|
||||
NULL, NULL, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
repeated_name,
|
||||
source_task.get("description") or "",
|
||||
source_task["process_type"],
|
||||
source_task.get("source_dataset_id"),
|
||||
json_dumps(repeated_config),
|
||||
input_count,
|
||||
len(source_files),
|
||||
len(source_files),
|
||||
source_task.get("tenant_id"),
|
||||
source_task.get("project_id"),
|
||||
source_task.get("owner_id"),
|
||||
source_task.get("approval_status") or "not_required",
|
||||
source_task.get("created_by"),
|
||||
source_task.get("created_by"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
file_id_map: dict[str, str] = {}
|
||||
for source in source_files:
|
||||
old_file_id = str(source["id"])
|
||||
copy = file_copies[old_file_id]
|
||||
new_file_id = str(copy["id"])
|
||||
storage_object_id, metadata = _source_storage_descriptor(
|
||||
{
|
||||
"storage_object_id": copy["storage_object_id"],
|
||||
"metadata": _json_value(source.get("metadata"), {}),
|
||||
},
|
||||
task_id,
|
||||
new_file_id,
|
||||
)
|
||||
file_id_map[old_file_id] = new_file_id
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO data_process_source_files
|
||||
(id, task_id, storage_object_id, name, size_bytes, record_count,
|
||||
file_format, checksum_sha256, version_no, content,
|
||||
content_preview, metadata, tenant_id, project_id, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 1, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
new_file_id,
|
||||
task_id,
|
||||
storage_object_id,
|
||||
source["name"],
|
||||
source.get("size_bytes") or 0,
|
||||
source.get("record_count") or 0,
|
||||
source.get("file_format"),
|
||||
source["checksum_sha256"],
|
||||
source.get("content") or "",
|
||||
source.get("content_preview"),
|
||||
json_dumps(metadata),
|
||||
source_task.get("tenant_id"),
|
||||
source_task.get("project_id"),
|
||||
source.get("created_by") or source_task.get("created_by"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for preview in previews:
|
||||
old_source_file_id = preview.get("source_file_id")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO data_process_preview_items
|
||||
(id, task_id, source_file_id, original_content, edited_content,
|
||||
source_start, source_end, source_start_line, source_end_line,
|
||||
token_count, status, quality_score, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s)
|
||||
""",
|
||||
(
|
||||
new_id("dpp"),
|
||||
task_id,
|
||||
file_id_map.get(str(old_source_file_id))
|
||||
if old_source_file_id
|
||||
else None,
|
||||
preview.get("original_content") or "",
|
||||
preview.get("edited_content") or "",
|
||||
preview.get("source_start"),
|
||||
preview.get("source_end"),
|
||||
preview.get("source_start_line"),
|
||||
preview.get("source_end_line"),
|
||||
max(0, int(preview.get("token_count") or 0)),
|
||||
preview.get("status") or "original",
|
||||
json_dumps(_json_value(preview.get("quality_score"), {})),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self._repeat_response(
|
||||
conn,
|
||||
task_row or {},
|
||||
source_task_id=source_task_id,
|
||||
created=True,
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ConflictError("再次生成任务名称或请求发生冲突,请重试") from exc
|
||||
|
||||
def get_task(self, task_id: str, *, for_update: bool = False) -> dict[str, Any]:
|
||||
lock = " FOR UPDATE" if for_update else ""
|
||||
with self.connect() as conn:
|
||||
@@ -654,14 +932,11 @@ class DataProcessStore:
|
||||
"process type and source dataset cannot change during regeneration"
|
||||
)
|
||||
if payload.get("config") is not None:
|
||||
next_config = {
|
||||
key: value
|
||||
for key, value in payload["config"].items()
|
||||
if key != _REGENERATION_MARKER_KEY
|
||||
}
|
||||
current_marker = _regeneration_marker(task)
|
||||
if current_marker:
|
||||
next_config[_REGENERATION_MARKER_KEY] = current_marker
|
||||
next_config = _business_config(payload["config"])
|
||||
current_config = dict(task.get("config") or {})
|
||||
for key in _INTERNAL_CONFIG_KEYS:
|
||||
if key in current_config:
|
||||
next_config[key] = current_config[key]
|
||||
values["config"] = json_dumps(next_config)
|
||||
invalidates_results = (
|
||||
("config" in payload and payload.get("config") != task.get("config"))
|
||||
@@ -753,8 +1028,10 @@ class DataProcessStore:
|
||||
raise InvalidStateError("process_type cannot be changed during regeneration")
|
||||
|
||||
current_config = dict(task.get("config") or {})
|
||||
next_config = dict(payload.get("config") or {})
|
||||
next_config.pop(_REGENERATION_MARKER_KEY, None)
|
||||
next_config = _business_config(payload.get("config"))
|
||||
for key in (_REPEAT_SOURCE_TASK_KEY, _REPEAT_REQUEST_KEY):
|
||||
if key in current_config:
|
||||
next_config[key] = current_config[key]
|
||||
preview_invalidated = _preview_config_changed(
|
||||
process_type,
|
||||
current_config,
|
||||
|
||||
@@ -220,6 +220,19 @@ class DataProcessRegenerateRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class DataProcessRepeatRequest(BaseModel):
|
||||
"""按已确认任务的完整快照创建一批独立的新生成结果。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_updated_at: str = Field(min_length=1)
|
||||
request_id: str = Field(
|
||||
min_length=8,
|
||||
max_length=80,
|
||||
pattern=r"^[A-Za-z0-9_-]+$",
|
||||
)
|
||||
|
||||
|
||||
class PreviewBuildRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user