311 lines
11 KiB
Python
311 lines
11 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Body, UploadFile, File
|
|||
|
|
from fastapi.responses import FileResponse
|
|||
|
|
|
|||
|
|
from app.api.v1.endpoints.platform import ok, fail
|
|||
|
|
from app.db.platform_store import get_platform_store, new_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/data-convert", tags=["data-convert"])
|
|||
|
|
|
|||
|
|
# 存储根目录
|
|||
|
|
STORAGE_ROOT = Path(__file__).resolve().parents[3] / "storage" / "data-convert"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _task_dir(task_id: str) -> Path:
|
|||
|
|
return STORAGE_ROOT / task_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _input_dir(task_id: str) -> Path:
|
|||
|
|
return _task_dir(task_id) / "input"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _output_dir(task_id: str) -> Path:
|
|||
|
|
return _task_dir(task_id) / "output"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("")
|
|||
|
|
def list_tasks(page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
|||
|
|
store = get_platform_store()
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
rows = conn.execute(
|
|||
|
|
"SELECT * FROM data_convert_tasks WHERE deleted_at IS NULL "
|
|||
|
|
"ORDER BY create_time DESC LIMIT %s OFFSET %s",
|
|||
|
|
(page_size, (page - 1) * page_size),
|
|||
|
|
).fetchall()
|
|||
|
|
total = conn.execute(
|
|||
|
|
"SELECT COUNT(*) FROM data_convert_tasks WHERE deleted_at IS NULL"
|
|||
|
|
).fetchone()[0]
|
|||
|
|
return ok({"items": [dict(r) for r in rows], "total": total})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("")
|
|||
|
|
def create_task(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
|||
|
|
name = str(payload.get("name") or "").strip()
|
|||
|
|
if not name:
|
|||
|
|
raise fail(400, "name is required")
|
|||
|
|
task_id = new_id("dct")
|
|||
|
|
output_filename = str(payload.get("output_filename") or "converted-data.jsonl").strip()
|
|||
|
|
description = str(payload.get("description") or "").strip()
|
|||
|
|
store = get_platform_store()
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"INSERT INTO data_convert_tasks (id, name, description, output_filename) "
|
|||
|
|
"VALUES (%s, %s, %s, %s)",
|
|||
|
|
(task_id, name, description, output_filename),
|
|||
|
|
)
|
|||
|
|
# 创建目录
|
|||
|
|
_input_dir(task_id).mkdir(parents=True, exist_ok=True)
|
|||
|
|
_output_dir(task_id).mkdir(parents=True, exist_ok=True)
|
|||
|
|
return ok(_get_task(task_id))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/{task_id}")
|
|||
|
|
def get_task(task_id: str) -> dict[str, Any]:
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
# 附加输入文件列表
|
|||
|
|
input_dir = _input_dir(task_id)
|
|||
|
|
files = []
|
|||
|
|
if input_dir.exists():
|
|||
|
|
for f in sorted(input_dir.iterdir()):
|
|||
|
|
if f.is_file():
|
|||
|
|
files.append({"name": f.name, "size": f.stat().st_size})
|
|||
|
|
task["input_files"] = files
|
|||
|
|
return ok(task)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/{task_id}/source-files")
|
|||
|
|
async def upload_source_files(
|
|||
|
|
task_id: str,
|
|||
|
|
files: list[UploadFile] = File(...),
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
if task["status"] not in ("pending", "uploaded"):
|
|||
|
|
raise fail(400, "task is not editable")
|
|||
|
|
input_dir = _input_dir(task_id)
|
|||
|
|
input_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
staged = []
|
|||
|
|
for upload in files:
|
|||
|
|
name = Path(upload.filename or "input.json").name
|
|||
|
|
if not name.lower().endswith(".json"):
|
|||
|
|
raise fail(415, f"only JSON files are supported: {name}")
|
|||
|
|
target = input_dir / name
|
|||
|
|
content = await upload.read()
|
|||
|
|
target.write_bytes(content)
|
|||
|
|
staged.append({"name": name, "size": len(content)})
|
|||
|
|
store = get_platform_store()
|
|||
|
|
# 标记上传完成
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='uploaded', update_time=NOW() WHERE id=%s",
|
|||
|
|
(task_id,),
|
|||
|
|
)
|
|||
|
|
# 自动转换并导入数据集
|
|||
|
|
try:
|
|||
|
|
output_dir = _output_dir(task_id)
|
|||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
output_path = output_dir / (task["output_filename"] or "converted-data.jsonl")
|
|||
|
|
# 清空旧输出(如果重新上传)
|
|||
|
|
if output_path.exists():
|
|||
|
|
output_path.unlink()
|
|||
|
|
input_count = 0
|
|||
|
|
output_count = 0
|
|||
|
|
for json_file in sorted(input_dir.iterdir()):
|
|||
|
|
if not json_file.is_file() or not json_file.name.lower().endswith(".json"):
|
|||
|
|
continue
|
|||
|
|
input_count += 1
|
|||
|
|
with open(json_file, "r", encoding="utf-8") as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
if isinstance(data, list):
|
|||
|
|
records = data
|
|||
|
|
elif isinstance(data, dict):
|
|||
|
|
records = [data]
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"JSON must be object or array: {json_file.name}")
|
|||
|
|
with open(output_path, "a", encoding="utf-8") as f:
|
|||
|
|
for record in records:
|
|||
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|||
|
|
output_count += 1
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='completed', "
|
|||
|
|
"input_count=%s, output_count=%s, update_time=NOW() WHERE id=%s",
|
|||
|
|
(input_count, output_count, task_id),
|
|||
|
|
)
|
|||
|
|
# 自动导入数据集
|
|||
|
|
content = output_path.read_text(encoding="utf-8")
|
|||
|
|
size_bytes = len(content.encode("utf-8"))
|
|||
|
|
dataset = store.create_dataset({
|
|||
|
|
"name": task["name"],
|
|||
|
|
"type": "train",
|
|||
|
|
"storage_type": "local",
|
|||
|
|
"source": "upload",
|
|||
|
|
"task_id": task_id,
|
|||
|
|
"size": f"{size_bytes} B",
|
|||
|
|
"count": output_count,
|
|||
|
|
"description": f"由数据类型转换任务 {task_id} 自动导入",
|
|||
|
|
})
|
|||
|
|
dataset_id = dataset["id"]
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
store.add_dataset_file(conn, dataset_id, task["output_filename"] or "converted-data.jsonl", content)
|
|||
|
|
return ok({
|
|||
|
|
"staged_files": staged,
|
|||
|
|
"auto_converted": True,
|
|||
|
|
"dataset_id": dataset_id,
|
|||
|
|
"input_count": input_count,
|
|||
|
|
"output_count": output_count,
|
|||
|
|
})
|
|||
|
|
except Exception as exc:
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='failed', error_message=%s, update_time=NOW() WHERE id=%s",
|
|||
|
|
(str(exc)[:500], task_id),
|
|||
|
|
)
|
|||
|
|
return ok({"staged_files": staged, "auto_converted": False, "error": str(exc)[:500]})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/{task_id}/run")
|
|||
|
|
def run_convert(task_id: str) -> dict[str, Any]:
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
if task["status"] not in ("uploaded", "completed", "failed"):
|
|||
|
|
raise fail(400, "please upload source files first")
|
|||
|
|
# 标记运行中
|
|||
|
|
store = get_platform_store()
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='running', error_message='', update_time=NOW() WHERE id=%s",
|
|||
|
|
(task_id,),
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
input_dir = _input_dir(task_id)
|
|||
|
|
output_dir = _output_dir(task_id)
|
|||
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
output_path = output_dir / (task["output_filename"] or "converted-data.jsonl")
|
|||
|
|
input_count = 0
|
|||
|
|
output_count = 0
|
|||
|
|
for json_file in sorted(input_dir.iterdir()):
|
|||
|
|
if not json_file.is_file() or not json_file.name.lower().endswith(".json"):
|
|||
|
|
continue
|
|||
|
|
input_count += 1
|
|||
|
|
with open(json_file, "r", encoding="utf-8") as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
if isinstance(data, list):
|
|||
|
|
records = data
|
|||
|
|
elif isinstance(data, dict):
|
|||
|
|
records = [data]
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"JSON must be object or array: {json_file.name}")
|
|||
|
|
with open(output_path, "a", encoding="utf-8") as f:
|
|||
|
|
for record in records:
|
|||
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|||
|
|
output_count += 1
|
|||
|
|
# 更新任务状态
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='completed', "
|
|||
|
|
"input_count=%s, output_count=%s, update_time=NOW() WHERE id=%s",
|
|||
|
|
(input_count, output_count, task_id),
|
|||
|
|
)
|
|||
|
|
except Exception as exc:
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET status='failed', error_message=%s, update_time=NOW() WHERE id=%s",
|
|||
|
|
(str(exc)[:500], task_id),
|
|||
|
|
)
|
|||
|
|
raise fail(500, f"convert failed: {exc}")
|
|||
|
|
return ok(_get_task(task_id))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/{task_id}/download")
|
|||
|
|
def download_result(task_id: str):
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
if task["status"] != "completed":
|
|||
|
|
raise fail(400, "task is not completed")
|
|||
|
|
output_path = _output_dir(task_id) / (task["output_filename"] or "converted-data.jsonl")
|
|||
|
|
if not output_path.exists():
|
|||
|
|
raise fail(404, "output file not found")
|
|||
|
|
return FileResponse(
|
|||
|
|
str(output_path),
|
|||
|
|
media_type="application/octet-stream",
|
|||
|
|
filename=task["output_filename"] or "converted-data.jsonl",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/{task_id}/import-as-dataset")
|
|||
|
|
def import_as_dataset(
|
|||
|
|
task_id: str,
|
|||
|
|
payload: dict[str, Any] = Body(default={}),
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""把已转换的 JSONL 文件导入为数据集管理中的上传任务记录(source='task')。"""
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
if task["status"] != "completed":
|
|||
|
|
raise fail(400, "task is not completed")
|
|||
|
|
output_path = _output_dir(task_id) / (task["output_filename"] or "converted-data.jsonl")
|
|||
|
|
if not output_path.exists():
|
|||
|
|
raise fail(404, "output file not found")
|
|||
|
|
content = output_path.read_text(encoding="utf-8")
|
|||
|
|
dataset_name = str(payload.get("name") or task["name"]).strip()
|
|||
|
|
description = str(payload.get("description") or f"由数据类型转换任务 {task_id} 导入").strip()
|
|||
|
|
size_bytes = len(content.encode("utf-8"))
|
|||
|
|
store = get_platform_store()
|
|||
|
|
# 用 store 提供的接口创建数据集与文件
|
|||
|
|
dataset = store.create_dataset({
|
|||
|
|
"name": dataset_name,
|
|||
|
|
"type": "train",
|
|||
|
|
"storage_type": "local",
|
|||
|
|
"source": "upload",
|
|||
|
|
"task_id": task_id,
|
|||
|
|
"size": f"{size_bytes} B",
|
|||
|
|
"count": task["output_count"],
|
|||
|
|
"description": description,
|
|||
|
|
})
|
|||
|
|
dataset_id = dataset["id"]
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
store.add_dataset_file(conn, dataset_id, task["output_filename"] or "converted-data.jsonl", content)
|
|||
|
|
return ok({"dataset_id": dataset_id, "name": dataset_name})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.delete("/{task_id}")
|
|||
|
|
def delete_task(task_id: str) -> dict[str, Any]:
|
|||
|
|
task = _get_task(task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise fail(404, "task not found")
|
|||
|
|
store = get_platform_store()
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE data_convert_tasks SET deleted_at=NOW() WHERE id=%s",
|
|||
|
|
(task_id,),
|
|||
|
|
)
|
|||
|
|
# 清理文件
|
|||
|
|
import shutil
|
|||
|
|
task_dir = _task_dir(task_id)
|
|||
|
|
if task_dir.exists():
|
|||
|
|
shutil.rmtree(task_dir, ignore_errors=True)
|
|||
|
|
return ok({"deleted": task_id})
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_task(task_id: str) -> dict[str, Any] | None:
|
|||
|
|
store = get_platform_store()
|
|||
|
|
with store.connect() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT * FROM data_convert_tasks WHERE id=%s AND deleted_at IS NULL",
|
|||
|
|
(task_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
return dict(row) if row else None
|