* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error.
817 lines
29 KiB
Python
817 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 Unsloth 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")
|