Files
unslothai--unsloth/studio/backend/routes/datasets.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

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")