e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
818 lines
29 KiB
Python
818 lines
29 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Datasets API routes."""
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import sys
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
|
|
import re as _re
|
|
import structlog
|
|
from loggers import get_logger
|
|
|
|
_VALID_REPO_ID = _re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
|
|
|
|
def _is_valid_repo_id(repo_id: str) -> bool:
|
|
return bool(_VALID_REPO_ID.fullmatch(repo_id))
|
|
|
|
|
|
_dataset_size_cache: dict[str, int] = {}
|
|
|
|
|
|
def _get_dataset_size_cached(repo_id: str) -> int:
|
|
if repo_id in _dataset_size_cache:
|
|
return _dataset_size_cache[repo_id]
|
|
try:
|
|
from huggingface_hub import dataset_info as hf_dataset_info
|
|
|
|
info = hf_dataset_info(repo_id, token = None, files_metadata = True)
|
|
total = sum(s.size for s in info.siblings if getattr(s, "size", None))
|
|
_dataset_size_cache[repo_id] = total
|
|
return total
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
|
"""Resolved realpath for a HF cache repo dir: most-recent snapshot, else cache root.
|
|
|
|
Mirrors routes/models.py; duplicated here to keep this module self-contained.
|
|
"""
|
|
try:
|
|
snapshots_dir = repo_dir / "snapshots"
|
|
if snapshots_dir.is_dir():
|
|
snaps = [s for s in snapshots_dir.iterdir() if s.is_dir()]
|
|
if snaps:
|
|
latest = max(snaps, key = lambda s: s.stat().st_mtime)
|
|
return str(latest.resolve())
|
|
return str(repo_dir.resolve())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
backend_path = Path(__file__).parent.parent.parent
|
|
if str(backend_path) not in sys.path:
|
|
sys.path.insert(0, str(backend_path))
|
|
|
|
from utils.datasets import check_dataset_format
|
|
from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label
|
|
from auth.authentication import get_current_subject
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
from models.datasets import (
|
|
AiAssistMappingRequest,
|
|
AiAssistMappingResponse,
|
|
CheckFormatRequest,
|
|
CheckFormatResponse,
|
|
LocalDatasetItem,
|
|
LocalDatasetsResponse,
|
|
UploadDatasetResponse,
|
|
)
|
|
from utils.paths import (
|
|
dataset_uploads_root,
|
|
ensure_dir,
|
|
recipe_datasets_root,
|
|
resolve_dataset_path,
|
|
)
|
|
|
|
|
|
def _serialize_preview_value(value):
|
|
"""Make a value JSON-safe for the client preview."""
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
|
|
try:
|
|
from PIL.Image import Image as PILImage
|
|
if isinstance(value, PILImage):
|
|
buffer = io.BytesIO()
|
|
value.convert("RGB").save(buffer, format = "JPEG", quality = 85)
|
|
return {
|
|
"type": "image",
|
|
"mime": "image/jpeg",
|
|
"width": value.width,
|
|
"height": value.height,
|
|
"data": base64.b64encode(buffer.getvalue()).decode("ascii"),
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
if isinstance(value, dict):
|
|
return {str(key): _serialize_preview_value(item) for key, item in value.items()}
|
|
|
|
if isinstance(value, (list, tuple)):
|
|
return [_serialize_preview_value(item) for item in value]
|
|
|
|
return str(value)
|
|
|
|
|
|
def _serialize_preview_rows(rows):
|
|
return [
|
|
{str(key): _serialize_preview_value(value) for key, value in dict(row).items()}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
# Data-file extensions for single-file preview. Tier 1 only uses tabular
|
|
# files; archives/text/config fall through to full load_dataset.
|
|
_COLUMNAR_EXTS = (".parquet", ".arrow")
|
|
_RECORD_EXTS = (".jsonl", ".csv", ".tsv")
|
|
_JSON_EXTS = (".json",)
|
|
_TABULAR_EXTS = _COLUMNAR_EXTS + _RECORD_EXTS + _JSON_EXTS
|
|
LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet")
|
|
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"}
|
|
# sync: training dataset upload limits are exposed by /api/settings/upload-limit
|
|
LOCAL_DATASETS_ROOT = recipe_datasets_root()
|
|
DATASET_UPLOAD_DIR = dataset_uploads_root()
|
|
|
|
|
|
def _safe_read_metadata(path: Path) -> dict | None:
|
|
try:
|
|
payload = json.loads(path.read_text(encoding = "utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
return payload
|
|
|
|
|
|
_HF_PREVIEW_EXT_PRIORITY = {
|
|
".parquet": 0,
|
|
".arrow": 0,
|
|
".jsonl": 1,
|
|
".csv": 2,
|
|
".tsv": 3,
|
|
".json": 4,
|
|
}
|
|
_HF_NON_DATA_EXACT_FILENAMES = {
|
|
".gitattributes",
|
|
"builder_config.json",
|
|
"config.json",
|
|
"dataset_info.json",
|
|
"dataset_infos.json",
|
|
"metadata.json",
|
|
}
|
|
_HF_NON_DATA_CARD_FILENAMES = {"card.json", "dataset_card.json"}
|
|
|
|
|
|
def _normalize_hf_repo_path(path: str) -> str:
|
|
return path.strip().replace("\\", "/").lstrip("./")
|
|
|
|
|
|
def _hf_preview_extension(path: str) -> str | None:
|
|
lower = path.lower()
|
|
for ext in _HF_PREVIEW_EXT_PRIORITY:
|
|
if lower.endswith(ext):
|
|
return ext
|
|
return None
|
|
|
|
|
|
def _is_known_hf_non_data_file(path: str) -> bool:
|
|
name = Path(path).name.lower()
|
|
if name in _HF_NON_DATA_EXACT_FILENAMES:
|
|
return True
|
|
if name in _HF_NON_DATA_CARD_FILENAMES:
|
|
return True
|
|
if name == "readme" or name.startswith("readme."):
|
|
return True
|
|
if name.endswith("_config.json") or name.endswith("-config.json"):
|
|
return True
|
|
if name.endswith("_card.json") or name.endswith("-card.json"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_hf_preview_data_file(path: str) -> bool:
|
|
normalized = _normalize_hf_repo_path(path)
|
|
if not normalized or _is_known_hf_non_data_file(normalized):
|
|
return False
|
|
return _hf_preview_extension(normalized) is not None
|
|
|
|
|
|
def _extract_hf_metadata_data_paths(metadata: dict | None) -> list[str]:
|
|
if not metadata:
|
|
return []
|
|
file_paths = metadata.get("file_paths")
|
|
if not isinstance(file_paths, dict):
|
|
return []
|
|
raw_data_paths = file_paths.get("data")
|
|
if isinstance(raw_data_paths, str):
|
|
values = [raw_data_paths]
|
|
elif isinstance(raw_data_paths, list):
|
|
values = raw_data_paths
|
|
else:
|
|
return []
|
|
|
|
paths: list[str] = []
|
|
for value in values:
|
|
if not isinstance(value, str):
|
|
continue
|
|
normalized = _normalize_hf_repo_path(value)
|
|
if normalized:
|
|
paths.append(normalized)
|
|
return paths
|
|
|
|
|
|
def _select_best_hf_preview_candidate(
|
|
candidates: list[str], *, subset: str | None, split: str | None
|
|
) -> str | None:
|
|
if not candidates:
|
|
return None
|
|
subset_lower = subset.lower() if subset else None
|
|
split_lower = split.lower() if split else None
|
|
|
|
def score(path: str) -> tuple[int, int, int, int, str]:
|
|
ext = _hf_preview_extension(path)
|
|
ext_priority = _HF_PREVIEW_EXT_PRIORITY[ext] if ext else 99
|
|
stem = Path(path).stem.lower()
|
|
path_lower = path.lower()
|
|
|
|
subset_miss = 0
|
|
if subset_lower:
|
|
subset_miss = 0 if subset_lower in stem or subset_lower in path_lower else 1
|
|
|
|
split_miss = 0
|
|
if split_lower:
|
|
split_hit = (
|
|
stem == split_lower
|
|
or stem.startswith(f"{split_lower}_")
|
|
or stem.startswith(f"{split_lower}-")
|
|
or f"/{split_lower}/" in path_lower
|
|
or f"_{split_lower}." in path_lower
|
|
or f"-{split_lower}." in path_lower
|
|
or f"/{split_lower}." in path_lower
|
|
or f"/{split_lower}_" in path_lower
|
|
or f"/{split_lower}-" in path_lower
|
|
)
|
|
split_miss = 0 if split_hit else 1
|
|
|
|
return (subset_miss, split_miss, ext_priority, len(path), path)
|
|
|
|
return sorted(candidates, key = score)[0]
|
|
|
|
|
|
def _select_hf_preview_file(
|
|
repo_files: list[str], *, metadata: dict | None, subset: str | None, split: str | None
|
|
) -> str | None:
|
|
normalized_repo_files = [_normalize_hf_repo_path(path) for path in repo_files]
|
|
repo_file_set = set(normalized_repo_files)
|
|
|
|
metadata_candidates = [
|
|
path
|
|
for path in _extract_hf_metadata_data_paths(metadata)
|
|
if path in repo_file_set and _is_hf_preview_data_file(path)
|
|
]
|
|
if metadata_candidates:
|
|
return _select_best_hf_preview_candidate(metadata_candidates, subset = subset, split = split)
|
|
|
|
data_candidates = [path for path in normalized_repo_files if _is_hf_preview_data_file(path)]
|
|
return _select_best_hf_preview_candidate(data_candidates, subset = subset, split = split)
|
|
|
|
|
|
def _download_hf_metadata(*, repo_id: str, repo_files: list[str], token: str | None) -> dict | None:
|
|
metadata_file = next(
|
|
(
|
|
path
|
|
for path in repo_files
|
|
if Path(_normalize_hf_repo_path(path)).name.lower() == "metadata.json"
|
|
),
|
|
None,
|
|
)
|
|
if not metadata_file:
|
|
return None
|
|
|
|
try:
|
|
from huggingface_hub import hf_hub_download
|
|
local_path = hf_hub_download(
|
|
repo_id = repo_id,
|
|
filename = metadata_file,
|
|
repo_type = "dataset",
|
|
token = token,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"Could not read HF dataset metadata for {repo_id}: {exc}")
|
|
return None
|
|
|
|
return _safe_read_metadata(Path(local_path))
|
|
|
|
|
|
def _safe_read_rows_from_metadata(payload: dict | None) -> int | None:
|
|
if not payload:
|
|
return None
|
|
for key in ("actual_num_records", "target_num_records"):
|
|
value = payload.get(key)
|
|
if isinstance(value, int):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _safe_read_metadata_summary(payload: dict | None) -> dict | None:
|
|
if not payload:
|
|
return None
|
|
|
|
actual_num_records = (
|
|
payload.get("actual_num_records")
|
|
if isinstance(payload.get("actual_num_records"), int)
|
|
else None
|
|
)
|
|
target_num_records = (
|
|
payload.get("target_num_records")
|
|
if isinstance(payload.get("target_num_records"), int)
|
|
else actual_num_records
|
|
)
|
|
|
|
columns: list[str] | None = None
|
|
schema = payload.get("schema")
|
|
if isinstance(schema, dict):
|
|
columns = [str(key) for key in schema.keys()]
|
|
if not columns:
|
|
stats = payload.get("column_statistics")
|
|
if isinstance(stats, list):
|
|
derived = [
|
|
str(item.get("column_name"))
|
|
for item in stats
|
|
if isinstance(item, dict) and item.get("column_name")
|
|
]
|
|
columns = derived or None
|
|
|
|
parquet_files_count = None
|
|
file_paths = payload.get("file_paths")
|
|
if isinstance(file_paths, dict):
|
|
parquet_files = file_paths.get("parquet-files")
|
|
if isinstance(parquet_files, list):
|
|
parquet_files_count = len(parquet_files)
|
|
|
|
total_num_batches = (
|
|
payload.get("total_num_batches")
|
|
if isinstance(payload.get("total_num_batches"), int)
|
|
else parquet_files_count
|
|
)
|
|
num_completed_batches = (
|
|
payload.get("num_completed_batches")
|
|
if isinstance(payload.get("num_completed_batches"), int)
|
|
else total_num_batches
|
|
)
|
|
|
|
return {
|
|
"actual_num_records": actual_num_records,
|
|
"target_num_records": target_num_records,
|
|
"total_num_batches": total_num_batches,
|
|
"num_completed_batches": num_completed_batches,
|
|
"columns": columns,
|
|
}
|
|
|
|
|
|
def _build_local_dataset_items() -> list[LocalDatasetItem]:
|
|
if not LOCAL_DATASETS_ROOT.exists():
|
|
return []
|
|
|
|
items: list[LocalDatasetItem] = []
|
|
for entry in LOCAL_DATASETS_ROOT.iterdir():
|
|
if not entry.is_dir() or not entry.name.startswith("recipe_"):
|
|
continue
|
|
parquet_dir = entry / "parquet-files"
|
|
if not parquet_dir.exists() or not any(parquet_dir.glob("*.parquet")):
|
|
continue
|
|
|
|
rows = None
|
|
metadata_summary = None
|
|
metadata_path = entry / "metadata.json"
|
|
if metadata_path.exists():
|
|
metadata_payload = _safe_read_metadata(metadata_path)
|
|
rows = _safe_read_rows_from_metadata(metadata_payload)
|
|
metadata_summary = _safe_read_metadata_summary(metadata_payload)
|
|
|
|
try:
|
|
updated_at = entry.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
|
|
items.append(
|
|
LocalDatasetItem(
|
|
id = entry.name,
|
|
label = entry.name,
|
|
path = str(parquet_dir.resolve()),
|
|
rows = rows,
|
|
updated_at = updated_at,
|
|
metadata = metadata_summary,
|
|
)
|
|
)
|
|
|
|
items.sort(key = lambda item: item.updated_at or 0, reverse = True)
|
|
return items
|
|
|
|
|
|
def _load_local_preview_slice(*, dataset_path: Path, train_split: str, preview_size: int):
|
|
# Non-streaming loads take the cached builder lock; use the EACCES-safe wrapper.
|
|
from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset
|
|
|
|
if dataset_path.is_dir():
|
|
parquet_dir = (
|
|
dataset_path / "parquet-files"
|
|
if (dataset_path / "parquet-files").exists()
|
|
else dataset_path
|
|
)
|
|
parquet_files = sorted(parquet_dir.glob("*.parquet"))
|
|
if parquet_files:
|
|
dataset = load_dataset(
|
|
"parquet",
|
|
data_files = [str(path) for path in parquet_files],
|
|
split = train_split,
|
|
)
|
|
total_rows = len(dataset)
|
|
preview_slice = dataset.select(range(min(preview_size, total_rows)))
|
|
return preview_slice, total_rows
|
|
else:
|
|
candidate_files: list[Path] = []
|
|
for ext in LOCAL_FILE_EXTS:
|
|
candidate_files.extend(sorted(dataset_path.glob(f"*{ext}")))
|
|
if not candidate_files:
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = "Unsupported local dataset directory (expected parquet/json/jsonl/csv files)",
|
|
)
|
|
dataset_path = candidate_files[0]
|
|
|
|
if dataset_path.suffix in [".json", ".jsonl"]:
|
|
dataset = load_dataset("json", data_files = str(dataset_path), split = train_split)
|
|
elif dataset_path.suffix == ".csv":
|
|
dataset = load_dataset("csv", data_files = str(dataset_path), split = train_split)
|
|
elif dataset_path.suffix == ".parquet":
|
|
dataset = load_dataset("parquet", data_files = str(dataset_path), split = train_split)
|
|
else:
|
|
raise HTTPException(
|
|
status_code = 400, detail = f"Unsupported file format: {dataset_path.suffix}"
|
|
)
|
|
|
|
total_rows = len(dataset)
|
|
preview_slice = dataset.select(range(min(preview_size, total_rows)))
|
|
return preview_slice, total_rows
|
|
|
|
|
|
def _sanitize_filename(filename: str) -> str:
|
|
name = Path(filename).name.strip().replace("\x00", "")
|
|
if not name:
|
|
return "dataset_upload"
|
|
return name
|
|
|
|
|
|
@router.post("/upload", response_model = UploadDatasetResponse)
|
|
async def upload_dataset(
|
|
file: UploadFile, current_subject: str = Depends(get_current_subject)
|
|
) -> UploadDatasetResponse:
|
|
filename = _sanitize_filename(file.filename or "dataset_upload")
|
|
ext = Path(filename).suffix.lower()
|
|
if ext not in LOCAL_UPLOAD_EXTS:
|
|
allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS))
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = f"Unsupported file type: {ext}. Allowed: {allowed}",
|
|
)
|
|
|
|
ensure_dir(DATASET_UPLOAD_DIR)
|
|
stem = Path(filename).stem
|
|
stored_name = f"{uuid4().hex}_{stem}{ext}"
|
|
stored_path = DATASET_UPLOAD_DIR / stored_name
|
|
|
|
# Stream to disk in chunks to avoid holding the whole file in memory. The
|
|
# route-level cap gives a clear training-dataset error and avoids leaving
|
|
# oversized partial files in the Studio uploads directory.
|
|
upload_limit_bytes = get_upload_limit_bytes()
|
|
total_bytes = 0
|
|
upload_complete = False
|
|
try:
|
|
with open(stored_path, "wb") as f:
|
|
while chunk := await file.read(1024 * 1024):
|
|
total_bytes += len(chunk)
|
|
if total_bytes > upload_limit_bytes:
|
|
raise HTTPException(
|
|
status_code = 413,
|
|
detail = (
|
|
"Training dataset upload too large. "
|
|
f"Maximum is {get_upload_limit_label()}."
|
|
),
|
|
)
|
|
f.write(chunk)
|
|
upload_complete = True
|
|
finally:
|
|
if not upload_complete:
|
|
with suppress(OSError):
|
|
stored_path.unlink(missing_ok = True)
|
|
|
|
if stored_path.stat().st_size == 0:
|
|
stored_path.unlink(missing_ok = True)
|
|
raise HTTPException(status_code = 400, detail = "Empty upload payload")
|
|
|
|
return UploadDatasetResponse(filename = filename, stored_path = str(stored_path))
|
|
|
|
|
|
@router.get("/local", response_model = LocalDatasetsResponse)
|
|
def list_local_datasets(
|
|
current_subject: str = Depends(get_current_subject),
|
|
) -> LocalDatasetsResponse:
|
|
return LocalDatasetsResponse(datasets = _build_local_dataset_items())
|
|
|
|
|
|
@router.get("/download-progress")
|
|
async def get_dataset_download_progress(
|
|
repo_id: str = Query(..., description = "HuggingFace dataset repo ID, e.g. 'unsloth/LaTeX_OCR'"),
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""Return download progress for a HuggingFace dataset repo.
|
|
|
|
Mirrors ``GET /api/models/download-progress`` but scans the
|
|
``datasets--owner--name`` cache dir under HF_HUB_CACHE, where in-progress
|
|
download bytes are visible. Returns ``cache_path`` so the UI can show it.
|
|
"""
|
|
_empty = {
|
|
"downloaded_bytes": 0,
|
|
"expected_bytes": 0,
|
|
"progress": 0,
|
|
"cache_path": None,
|
|
}
|
|
try:
|
|
if not _is_valid_repo_id(repo_id):
|
|
return _empty
|
|
|
|
from huggingface_hub import constants as hf_constants
|
|
|
|
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
|
target = f"datasets--{repo_id.replace('/', '--')}".lower()
|
|
completed_bytes = 0
|
|
in_progress_bytes = 0
|
|
cache_path: Optional[str] = None
|
|
|
|
if cache_dir.is_dir():
|
|
for entry in cache_dir.iterdir():
|
|
if entry.name.lower() != target:
|
|
continue
|
|
cache_path = _resolve_hf_cache_realpath(entry)
|
|
blobs_dir = entry / "blobs"
|
|
if not blobs_dir.is_dir():
|
|
break
|
|
for f in blobs_dir.iterdir():
|
|
if not f.is_file():
|
|
continue
|
|
if f.name.endswith(".incomplete"):
|
|
in_progress_bytes += f.stat().st_size
|
|
else:
|
|
completed_bytes += f.stat().st_size
|
|
break
|
|
|
|
downloaded_bytes = completed_bytes + in_progress_bytes
|
|
if downloaded_bytes == 0:
|
|
return {**_empty, "cache_path": cache_path}
|
|
|
|
expected_bytes = _get_dataset_size_cached(repo_id)
|
|
if expected_bytes <= 0:
|
|
return {
|
|
"downloaded_bytes": downloaded_bytes,
|
|
"expected_bytes": 0,
|
|
"progress": 0,
|
|
"cache_path": cache_path,
|
|
}
|
|
|
|
# 95% threshold (as in the model endpoint): HF blob dedup makes
|
|
# completed_bytes drift under expected_bytes; inter-file gaps look "done".
|
|
if completed_bytes >= expected_bytes * 0.95:
|
|
progress = 1.0
|
|
else:
|
|
progress = min(downloaded_bytes / expected_bytes, 0.99)
|
|
return {
|
|
"downloaded_bytes": downloaded_bytes,
|
|
"expected_bytes": expected_bytes,
|
|
"progress": round(progress, 3),
|
|
"cache_path": cache_path,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Error checking dataset download progress for {repo_id}: {e}")
|
|
return _empty
|
|
|
|
|
|
@router.post("/check-format", response_model = CheckFormatResponse)
|
|
def check_format(request: CheckFormatRequest, current_subject: str = Depends(get_current_subject)):
|
|
"""Check if a dataset requires manual column mapping.
|
|
|
|
HuggingFace strategy:
|
|
1. list_repo_files -> select one tabular data file -> load_dataset
|
|
(avoids resolving thousands of files; ~2-4 s).
|
|
2. Full streaming load_dataset as a last-resort fallback.
|
|
|
|
Local files load directly. Plain `def` (not async) so FastAPI runs it in a
|
|
thread-pool, keeping blocking IO off the event loop.
|
|
"""
|
|
try:
|
|
from itertools import islice
|
|
from datasets import Dataset, load_dataset
|
|
from utils.datasets import format_dataset
|
|
|
|
PREVIEW_SIZE = 10
|
|
|
|
logger.info(f"Checking format for dataset: {request.dataset_name}")
|
|
|
|
dataset_path = resolve_dataset_path(request.dataset_name)
|
|
total_rows = None
|
|
|
|
if dataset_path.exists():
|
|
# ── Local file ──────────────────────────────────────────
|
|
train_split = request.train_split or "train"
|
|
preview_slice, total_rows = _load_local_preview_slice(
|
|
dataset_path = dataset_path,
|
|
train_split = train_split,
|
|
preview_size = PREVIEW_SIZE,
|
|
)
|
|
else:
|
|
# ── HuggingFace dataset ─────────────────────────────────
|
|
# Tier 1: list_repo_files -> load one selected tabular data file
|
|
preview_slice = None
|
|
|
|
try:
|
|
from huggingface_hub import HfApi
|
|
|
|
api = HfApi()
|
|
repo_files = api.list_repo_files(
|
|
request.dataset_name,
|
|
repo_type = "dataset",
|
|
token = request.hf_token or None,
|
|
)
|
|
metadata = _download_hf_metadata(
|
|
repo_id = request.dataset_name,
|
|
repo_files = repo_files,
|
|
token = request.hf_token or None,
|
|
)
|
|
selected_file = _select_hf_preview_file(
|
|
repo_files,
|
|
metadata = metadata,
|
|
subset = request.subset,
|
|
split = request.train_split or "train",
|
|
)
|
|
|
|
if selected_file:
|
|
logger.info(f"Tier 1: loading single file {selected_file}")
|
|
load_kwargs = {
|
|
"path": request.dataset_name,
|
|
"data_files": [selected_file],
|
|
"split": "train",
|
|
"streaming": True,
|
|
}
|
|
if request.hf_token:
|
|
load_kwargs["token"] = request.hf_token
|
|
|
|
streamed_ds = load_dataset(**load_kwargs)
|
|
rows = list(islice(streamed_ds, PREVIEW_SIZE))
|
|
if rows:
|
|
preview_slice = Dataset.from_list(rows)
|
|
except Exception as e:
|
|
logger.warning(f"Tier 1 (single-file) failed: {e}")
|
|
|
|
if preview_slice is None:
|
|
# Tier 2: full streaming (resolves all files; slow for large repos)
|
|
logger.info("Tier 2: falling back to full streaming load_dataset")
|
|
load_kwargs = {
|
|
"path": request.dataset_name,
|
|
"split": request.train_split,
|
|
"streaming": True,
|
|
}
|
|
if request.subset:
|
|
load_kwargs["name"] = request.subset
|
|
if request.hf_token:
|
|
load_kwargs["token"] = request.hf_token
|
|
|
|
streamed_ds = load_dataset(**load_kwargs)
|
|
|
|
rows = list(islice(streamed_ds, PREVIEW_SIZE))
|
|
if not rows:
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = "Dataset appears to be empty or could not be streamed",
|
|
)
|
|
|
|
preview_slice = Dataset.from_list(rows)
|
|
total_rows = None
|
|
|
|
result = check_dataset_format(preview_slice, is_vlm = request.is_vlm)
|
|
|
|
logger.info(
|
|
f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}"
|
|
)
|
|
|
|
preview_samples = None
|
|
if not result["requires_manual_mapping"]:
|
|
if result.get("suggested_mapping"):
|
|
# Heuristic-detected: show raw data so columns match the API response.
|
|
# Column stripping happens at training time, not preview.
|
|
preview_samples = _serialize_preview_rows(preview_slice)
|
|
else:
|
|
try:
|
|
format_result = format_dataset(
|
|
preview_slice,
|
|
format_type = "auto",
|
|
num_proc = None, # Only 10 preview rows
|
|
)
|
|
processed = format_result["dataset"]
|
|
preview_samples = _serialize_preview_rows(processed)
|
|
except Exception as e:
|
|
logger.warning(f"Processed preview generation failed (non-fatal): {e}")
|
|
preview_samples = _serialize_preview_rows(preview_slice)
|
|
else:
|
|
preview_samples = _serialize_preview_rows(preview_slice)
|
|
|
|
# Warnings from check_dataset_format plus URL-based image detection.
|
|
warning = result.get("warning")
|
|
image_col = result.get("detected_image_column")
|
|
if image_col and image_col in (result.get("columns") or []):
|
|
try:
|
|
sample_val = preview_slice[0][image_col]
|
|
if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")):
|
|
url_warning = (
|
|
"This dataset contains image URLs instead of embedded images. "
|
|
"Images will be downloaded during training, which may be slow for large datasets."
|
|
)
|
|
logger.info(f"URL-based image column detected: {image_col}")
|
|
warning = f"{warning} {url_warning}" if warning else url_warning
|
|
except Exception:
|
|
pass
|
|
|
|
return CheckFormatResponse(
|
|
requires_manual_mapping = result["requires_manual_mapping"],
|
|
detected_format = result["detected_format"],
|
|
columns = result["columns"],
|
|
is_image = result.get("is_image", False),
|
|
is_audio = result.get("is_audio", False),
|
|
multimodal_columns = result.get("multimodal_columns"),
|
|
suggested_mapping = result.get("suggested_mapping"),
|
|
detected_image_column = result.get("detected_image_column"),
|
|
detected_audio_column = result.get("detected_audio_column"),
|
|
detected_text_column = result.get("detected_text_column"),
|
|
detected_speaker_column = result.get("detected_speaker_column"),
|
|
chat_column = result.get("chat_column"),
|
|
preview_samples = preview_samples,
|
|
total_rows = total_rows,
|
|
warning = warning,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error checking dataset format: {e}", exc_info = True)
|
|
raise HTTPException(status_code = 500, detail = "Failed to check dataset format")
|
|
|
|
|
|
@router.post("/ai-assist-mapping", response_model = AiAssistMappingResponse)
|
|
def ai_assist_mapping(
|
|
request: AiAssistMappingRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Run LLM-assisted dataset conversion advisor (user-triggered).
|
|
|
|
Multi-pass analysis with a 7B helper model: classify dataset type, generate
|
|
conversion strategy, validate quality. Falls back to simple column
|
|
classification if the advisor fails.
|
|
"""
|
|
try:
|
|
from utils.datasets.llm_assist import llm_conversion_advisor
|
|
|
|
# Truncate sample values for the LLM prompt.
|
|
truncated = [
|
|
{col: str(s.get(col, ""))[:200] for col in request.columns} for s in request.samples[:5]
|
|
]
|
|
|
|
result = llm_conversion_advisor(
|
|
column_names = request.columns,
|
|
samples = truncated,
|
|
dataset_name = request.dataset_name,
|
|
hf_token = request.hf_token,
|
|
model_name = request.model_name,
|
|
model_type = request.model_type,
|
|
)
|
|
|
|
if result and result.get("success"):
|
|
return AiAssistMappingResponse(
|
|
success = True,
|
|
suggested_mapping = result.get("suggested_mapping"),
|
|
system_prompt = result.get("system_prompt"),
|
|
user_template = result.get("user_template"),
|
|
assistant_template = result.get("assistant_template"),
|
|
label_mapping = result.get("label_mapping"),
|
|
dataset_type = result.get("dataset_type"),
|
|
is_conversational = result.get("is_conversational"),
|
|
user_notification = result.get("user_notification"),
|
|
)
|
|
|
|
return AiAssistMappingResponse(
|
|
success = False,
|
|
warning = "AI could not determine column roles. Please assign them manually.",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"AI assist mapping failed: {e}", exc_info = True)
|
|
raise HTTPException(status_code = 500, detail = "AI assist failed")
|