Merge pull request #375 from unslothai/feature/llm-assist-detection

Feature/llm assist detection
This commit is contained in:
Roland Tannous 2026-03-11 22:02:33 +04:00 committed by GitHub
commit 859bfe23c4
17 changed files with 1599 additions and 70 deletions

View file

@ -99,6 +99,71 @@ class UnslothTrainer:
'is_lora': True, # Default to LoRA
}
def pre_detect_and_load_tokenizer(
self,
model_name: str,
max_seq_length: int = 2048,
hf_token: Optional[str] = None,
is_dataset_image: bool = False,
is_dataset_audio: bool = False,
trust_remote_code: bool = False,
) -> None:
"""Lightweight detection and tokenizer load — no model weights, no VRAM.
Sets is_vlm, _audio_type, is_audio_vlm, model_name and loads a
lightweight tokenizer for dataset formatting. Call this before
load_and_format_dataset() when you want to process the dataset
BEFORE loading the training model (avoids VRAM contention with
the LLM-assisted detection helper).
load_model() may be called afterwards it will re-detect and load
the full model + tokenizer, overwriting the lightweight one set here.
"""
self.model_name = model_name
self.max_seq_length = max_seq_length
self.trust_remote_code = trust_remote_code
if hf_token:
os.environ["HF_TOKEN"] = hf_token
# --- Detect audio type (reads config.json only, no VRAM) ---
self._audio_type = detect_audio_type(model_name, hf_token)
if self._audio_type == 'audio_vlm':
self.is_audio = False
self.is_audio_vlm = is_dataset_audio
self._audio_type = None
else:
self.is_audio = self._audio_type is not None
self.is_audio_vlm = False
if not self.is_audio and not self.is_audio_vlm:
self._cuda_audio_used = False
# --- Detect VLM ---
vision = is_vision_model(model_name) if not self.is_audio else False
self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image
logger.info(
"pre_detect: audio_type=%s, is_audio=%s, is_audio_vlm=%s, is_vlm=%s",
self._audio_type, self.is_audio, self.is_audio_vlm, self.is_vlm,
)
# --- Load lightweight tokenizer/processor (CPU only, no VRAM) ---
# Whisper needs AutoProcessor (has feature_extractor + tokenizer).
# All others work with AutoTokenizer (CSM loads its own processor inline).
if self._audio_type == 'whisper':
from transformers import AutoProcessor
self.tokenizer = AutoProcessor.from_pretrained(
model_name, trust_remote_code=trust_remote_code, token=hf_token,
)
else:
from transformers import AutoTokenizer
self.tokenizer = AutoTokenizer.from_pretrained(
model_name, trust_remote_code=trust_remote_code, token=hf_token,
)
logger.info("Pre-loaded tokenizer for %s", model_name)
def add_progress_callback(self, callback: Callable[[TrainingProgress], None]):
"""Add callback for training progress updates"""
self.progress_callbacks.append(callback)
@ -2653,6 +2718,16 @@ class UnslothTrainer:
)
logger.info(f"Post-filter dataset size: {filtered_len} samples\n")
# [DEBUG] Decode first sample AFTER train_on_completions applied
try:
_row = self.trainer.train_dataset[0]
_space = self.tokenizer(" ", add_special_tokens=False).input_ids[0]
print("[DEBUG] === After train_on_completions ===", flush=True)
print(f"[DEBUG] input_ids decoded:\n{self.tokenizer.decode(_row['input_ids'])}\n", flush=True)
print(f"[DEBUG] labels decoded (-100 → space):\n{self.tokenizer.decode([_space if x == -100 else x for x in _row['labels']])}\n", flush=True)
except Exception as _dbg_e:
print(f"[DEBUG] Could not decode post-completions sample: {_dbg_e}", flush=True)
except Exception as e:
logger.warning(f"Failed to apply train on responses only: {e}")
train_on_responses_enabled = False

View file

@ -212,11 +212,87 @@ def run_training_process(
stop_thread.start()
# ── 4. Execute the training pipeline ──
# Order: detect → dataset → model → prepare → train
# Dataset processing (including LLM-assisted detection) runs BEFORE model
# loading so both never occupy VRAM at the same time.
try:
hf_token = config.get("hf_token", "")
hf_token = hf_token if hf_token and hf_token.strip() else None
# Load model
# ── 4a. Lightweight detection + tokenizer (no VRAM) ──
_send_status(event_queue, "Detecting model type...")
trainer.pre_detect_and_load_tokenizer(
model_name=model_name,
max_seq_length=config["max_seq_length"],
hf_token=hf_token,
is_dataset_image=config.get("is_dataset_image", False),
is_dataset_audio=config.get("is_dataset_audio", False),
trust_remote_code=config.get("trust_remote_code", False),
)
if trainer.should_stop:
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
return
# ── 4b. Load and format dataset (LLM helper may use VRAM briefly) ──
_send_status(event_queue, "Loading and formatting dataset...")
hf_dataset = config.get("hf_dataset", "")
dataset_result = trainer.load_and_format_dataset(
dataset_source=hf_dataset if hf_dataset and hf_dataset.strip() else None,
format_type=config.get("format_type", ""),
local_datasets=config.get("local_datasets") or None,
custom_format_mapping=config.get("custom_format_mapping"),
subset=config.get("subset"),
train_split=config.get("train_split", "train"),
eval_split=config.get("eval_split"),
eval_steps=config.get("eval_steps", 0.00),
dataset_slice_start=config.get("dataset_slice_start"),
dataset_slice_end=config.get("dataset_slice_end"),
)
if isinstance(dataset_result, tuple):
dataset, eval_dataset = dataset_result
else:
dataset = dataset_result
eval_dataset = None
# [DEBUG] Print first sample before model is loaded
# dataset is a dict {"dataset": <Dataset>, "detected_format": ..., ...}
# or a raw Dataset for audio paths
try:
ds = dataset["dataset"] if isinstance(dataset, dict) else dataset
print(f"\n[DEBUG] Dataset loaded BEFORE model. type={type(ds).__name__}, len={len(ds)}", flush=True)
print(f"[DEBUG] Columns: {ds.column_names}", flush=True)
sample = ds[0]
preview = {k: str(v)[:300] for k, v in sample.items()}
print(f"[DEBUG] First sample: {preview}\n", flush=True)
except Exception as e:
print(f"[DEBUG] Could not preview first sample: {type(e).__name__}: {e}", flush=True)
# Disable eval if eval_steps <= 0
eval_steps = config.get("eval_steps", 0.00)
if eval_steps is not None and float(eval_steps) <= 0:
eval_dataset = None
# Tell the parent process that eval is configured so the frontend
# shows "Waiting for first evaluation step..." instead of "not configured"
if eval_dataset is not None:
event_queue.put({
"type": "eval_configured",
"ts": time.time(),
})
if dataset is None or trainer.should_stop:
if trainer.should_stop:
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
else:
event_queue.put({
"type": "error",
"error": trainer.training_progress.error or "Failed to load dataset",
"stack": "", "ts": time.time(),
})
return
# ── 4c. Load training model (uses VRAM — dataset already formatted) ──
_send_status(event_queue, "Loading model...")
success = trainer.load_model(
model_name=model_name,
@ -239,7 +315,7 @@ def run_training_process(
})
return
# Prepare model (LoRA or full finetuning)
# ── 4d. Prepare model (LoRA or full finetuning) ──
training_type = config.get("training_type", "LoRA/QLoRA")
use_lora = (training_type == "LoRA/QLoRA")
if use_lora:
@ -273,52 +349,6 @@ def run_training_process(
})
return
# Load dataset
_send_status(event_queue, "Loading and formatting dataset...")
hf_dataset = config.get("hf_dataset", "")
dataset_result = trainer.load_and_format_dataset(
dataset_source=hf_dataset if hf_dataset and hf_dataset.strip() else None,
format_type=config.get("format_type", ""),
local_datasets=config.get("local_datasets") or None,
custom_format_mapping=config.get("custom_format_mapping"),
subset=config.get("subset"),
train_split=config.get("train_split", "train"),
eval_split=config.get("eval_split"),
eval_steps=config.get("eval_steps", 0.00),
dataset_slice_start=config.get("dataset_slice_start"),
dataset_slice_end=config.get("dataset_slice_end"),
)
if isinstance(dataset_result, tuple):
dataset, eval_dataset = dataset_result
else:
dataset = dataset_result
eval_dataset = None
# Disable eval if eval_steps <= 0
eval_steps = config.get("eval_steps", 0.00)
if eval_steps is not None and float(eval_steps) <= 0:
eval_dataset = None
# Tell the parent process that eval is configured so the frontend
# shows "Waiting for first evaluation step..." instead of "not configured"
if eval_dataset is not None:
event_queue.put({
"type": "eval_configured",
"ts": time.time(),
})
if dataset is None or trainer.should_stop:
if trainer.should_stop:
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
else:
event_queue.put({
"type": "error",
"error": trainer.training_progress.error or "Failed to load dataset",
"stack": "", "ts": time.time(),
})
return
# Convert learning rate
try:
lr_value = float(config.get("learning_rate", "2e-4"))

View file

@ -72,6 +72,17 @@ async def lifespan(app: FastAPI):
f"GPU sm_{sm_version} detected — setting UNSLOTH_FLEX_ATTENTION=0"
)
# Pre-cache the helper GGUF model for LLM-assisted dataset detection.
# Runs in a background thread so it doesn't block server startup.
import threading
def _precache():
try:
from utils.datasets.llm_assist import precache_helper_gguf
precache_helper_gguf()
except Exception:
pass # non-critical
threading.Thread(target=_precache, daemon=True).start()
if not storage.is_initialized():
setup_token = secrets.token_urlsafe(32)
storage.save_setup_token(setup_token)

View file

@ -44,6 +44,29 @@ class CheckFormatResponse(BaseModel):
warning: Optional[str] = None
class AiAssistMappingRequest(BaseModel):
"""Request for LLM-assisted column classification (user-triggered)."""
columns: List[str]
samples: List[Dict[str, Any]] # Preview rows already loaded in the dialog
dataset_name: Optional[str] = None # For LLM context
hf_token: Optional[str] = None # For fetching dataset card
model_name: Optional[str] = None
model_type: Optional[str] = None
class AiAssistMappingResponse(BaseModel):
"""Response from LLM-assisted column classification and conversion advice."""
success: bool
suggested_mapping: Optional[Dict[str, str]] = None
warning: Optional[str] = None
# Conversion advisor fields
system_prompt: Optional[str] = None
label_mapping: Optional[Dict[str, Dict[str, str]]] = None
dataset_type: Optional[str] = None
is_conversational: Optional[bool] = None
user_notification: Optional[str] = None
class UploadDatasetResponse(BaseModel):
"""Response with stored dataset path for training."""
filename: str = Field(..., description="Original filename")

View file

@ -39,9 +39,14 @@ class TrainingStartRequest(BaseModel):
if isinstance(values, dict) and "split" in values:
values.setdefault("train_split", values.pop("split"))
return values
custom_format_mapping: Optional[Dict[str, str]] = Field(
custom_format_mapping: Optional[Dict[str, Any]] = Field(
None,
description="User-provided column-to-role mapping, e.g. {'image': 'image', 'caption': 'text'} for VLM or {'instruction': 'user', 'output': 'assistant'} for LLM"
description=(
"User-provided column-to-role mapping, e.g. {'image': 'image', 'caption': 'text'} "
"for VLM or {'instruction': 'user', 'output': 'assistant'} for LLM. "
"Enhanced format includes __system_prompt, __user_template, "
"__assistant_template, __label_mapping metadata keys."
),
)
# Training parameters
num_epochs: int = Field(1, description="Number of training epochs")

View file

@ -30,6 +30,8 @@ logger = get_logger(__name__)
from models.datasets import (
AiAssistMappingRequest,
AiAssistMappingResponse,
CheckFormatRequest,
CheckFormatResponse,
LocalDatasetItem,
@ -432,18 +434,19 @@ def check_format(
else:
preview_samples = _serialize_preview_rows(preview_slice)
# Lightweight URL-based image detection for VLM datasets
warning = None
# Collect warnings: from check_dataset_format + 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://")):
warning = (
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
@ -472,3 +475,62 @@ def check_format(
status_code=500,
detail=f"Failed to check dataset format: {str(e)}"
)
@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 using a 7B helper model:
Pass 1: Classify dataset type from HF card + samples
Pass 2: Generate conversion strategy (system prompt, templates)
Pass 3: Validate conversion 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=f"AI assist failed: {str(e)}"
)

View file

@ -18,6 +18,8 @@ All internal utilities have been moved to separate modules:
- model_mappings: TEMPLATE_TO_MODEL_MAPPER
"""
import json
# Import from modular files
from .format_detection import (
detect_dataset_format,
@ -89,6 +91,21 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
vlm_structure = detect_vlm_dataset_structure(dataset)
requires_mapping = vlm_structure["format"] == "unknown"
warning = None
if requires_mapping:
img_col = vlm_structure.get("image_column")
txt_col = vlm_structure.get("text_column")
missing = []
if not img_col:
missing.append("image")
if not txt_col:
missing.append("text")
if missing:
warning = (
f"Could not auto-detect {' or '.join(missing)} column. "
"Please assign image and text columns manually."
)
return {
"requires_manual_mapping": requires_mapping,
"detected_format": vlm_structure["format"],
@ -98,6 +115,7 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
"detected_text_column": vlm_structure.get("text_column"),
"is_image": multimodal_info["is_image"],
"multimodal_columns": multimodal_info.get("multimodal_columns"),
"warning": warning,
**audio_fields,
}
@ -118,7 +136,7 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
**audio_fields,
}
# LLM flow
# Text / LLM flow
detected = detect_dataset_format(dataset)
# If format is unknown, try heuristic detection
@ -137,6 +155,7 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
**audio_fields,
}
else:
# Heuristic failed — user must map manually (or use AI Assist)
return {
"requires_manual_mapping": True,
"detected_format": "unknown",
@ -146,6 +165,10 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
"detected_text_column": None,
"is_image": False,
"multimodal_columns": None,
"warning": (
f"Could not auto-detect column roles for columns: {columns}. "
"Please assign roles manually, or use AI Assist."
),
**audio_fields,
}
@ -179,12 +202,23 @@ def _apply_user_mapping(dataset, mapping: dict, batch_size: int = 1000):
Accepts chatml (user/assistant/system), sharegpt (human/gpt/system), and
alpaca (instruction/input/output) role names all normalised to chatml output.
If the mapping contains ``__``-prefixed metadata keys (from the conversion
advisor), routes to template-based conversion instead of simple role mapping.
Returns:
Dataset with single 'conversations' column
"""
# Split metadata from column roles
meta = {k: v for k, v in mapping.items() if k.startswith("__")}
column_roles = {k: v for k, v in mapping.items() if not k.startswith("__")}
if meta:
return _apply_template_mapping(dataset, column_roles, meta, batch_size)
# ── Simple mode (original logic) ──
# Pre-compute: group columns by canonical chatml role
role_groups: dict[str, list[str]] = {r: [] for r in _CHATML_ROLE_ORDER}
for col_name, role in mapping.items():
for col_name, role in column_roles.items():
canonical = _TO_CHATML.get(role)
if canonical:
role_groups[canonical].append(col_name)
@ -205,6 +239,98 @@ def _apply_user_mapping(dataset, mapping: dict, batch_size: int = 1000):
return dataset.map(_convert, batched=True, batch_size=batch_size, remove_columns=dataset.column_names)
def _extract_column_value(val, col: str, label_mapping: dict) -> str:
"""Extract a string value from a column, handling complex types and label mapping."""
# Handle complex types (dicts, lists) — extract useful text instead of raw repr
if isinstance(val, dict):
# Common pattern: {"text": [...]} in QA datasets
if "text" in val:
inner = val["text"]
str_val = inner[0] if isinstance(inner, list) and inner else str(inner)
else:
str_val = json.dumps(val, ensure_ascii=False)
elif isinstance(val, list):
str_val = val[0] if len(val) == 1 else ", ".join(str(v) for v in val)
else:
str_val = str(val) if val is not None else ""
# Apply label mapping if this column has one
if col in label_mapping and isinstance(label_mapping[col], dict):
str_val = label_mapping[col].get(str_val, str_val)
return str_val
def _apply_template_mapping(
dataset, column_roles: dict, meta: dict, batch_size: int = 1000
):
"""
Apply advisor-driven mapping for non-conversational datasets.
Groups columns by their assigned role (user/assistant), concatenates
values within each role into a single message, and injects an optional
system prompt. Label mapping is applied to convert integer labels
to human-readable strings.
Returns:
Dataset with single 'conversations' column
"""
system_prompt = meta.get("__system_prompt", "")
label_mapping = meta.get("__label_mapping", {}) # {col: {int_str: label_str}}
# Group columns by canonical chatml role
role_groups: dict[str, list[str]] = {"user": [], "assistant": []}
for col, role in column_roles.items():
canonical = _TO_CHATML.get(role, role)
if canonical in role_groups:
role_groups[canonical].append(col)
import logging as _log
_log.getLogger(__name__).info(
f"Applying role mapping: sys={bool(system_prompt)}, "
f"user_cols={role_groups['user']}, asst_cols={role_groups['assistant']}, "
f"label_map={list(label_mapping.keys())}"
)
def _convert(examples):
num = len(next(iter(examples.values())))
conversations = []
for i in range(num):
convo = []
# System prompt (generated, static across all rows)
if system_prompt:
convo.append({"role": "system", "content": system_prompt})
# User message: concatenate all user-role column values
user_parts = []
for col in role_groups["user"]:
if col in examples:
user_parts.append(
_extract_column_value(examples[col][i], col, label_mapping)
)
if user_parts:
convo.append({"role": "user", "content": "\n".join(user_parts)})
# Assistant message: concatenate all assistant-role column values
asst_parts = []
for col in role_groups["assistant"]:
if col in examples:
asst_parts.append(
_extract_column_value(examples[col][i], col, label_mapping)
)
if asst_parts:
convo.append({"role": "assistant", "content": "\n".join(asst_parts)})
conversations.append(convo)
return {"conversations": conversations}
return dataset.map(
_convert, batched=True, batch_size=batch_size,
remove_columns=dataset.column_names,
)
def _apply_user_mapping_alpaca(dataset, mapping: dict, batch_size: int = 1000):
"""
Apply user-provided column mapping to convert dataset to Alpaca format.
@ -776,8 +902,22 @@ def format_and_template_dataset(
vlm_image_column = vlm_structure["image_column"]
if vlm_text_column is None or vlm_image_column is None:
columns = list(next(iter(dataset)).keys()) if dataset else []
issues = [
f"Could not auto-detect image and text columns from: {columns}",
f"VLM structure detected: {vlm_structure.get('format', 'unknown')}",
]
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues, dataset_name=dataset_name, modality="vision",
column_names=columns,
)
except Exception:
pass
errors.append(
f"Could not auto-detect image/text columns. Found: {vlm_structure}. "
friendly or f"Could not auto-detect image/text columns. Found: {vlm_structure}. "
)
return {
"dataset": dataset,

View file

@ -435,7 +435,21 @@ def convert_to_vlm_format(
throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0
if fail_rate >= MAX_FAIL_RATE:
msg = (
issues = [
f"{fail_rate:.0%} of the first {PROBE_SIZE} image URLs failed to download ({probe_fail}/{probe_total})",
"Images are external URLs, not embedded in the dataset",
]
# Try LLM-friendly warning
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues, dataset_name=dataset_name, modality="vision",
column_names=[image_column, text_column],
)
except Exception:
pass
msg = friendly or (
f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download "
f"({probe_fail}/{probe_total}). "
"This dataset has too many broken or unreachable image URLs. "
@ -524,7 +538,20 @@ def convert_to_vlm_format(
logger.info(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images")
# For datasets that skipped the probe (small URL datasets), check fail rate now
if has_urls and fail_rate >= MAX_FAIL_RATE:
msg = (
issues = [
f"{fail_rate:.0%} of images failed to download ({failed_count}/{total})",
"Images are external URLs, not embedded in the dataset",
]
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues, dataset_name=dataset_name, modality="vision",
column_names=[image_column, text_column],
)
except Exception:
pass
msg = friendly or (
f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). "
"This dataset has too many broken or unreachable image URLs. "
"Consider using a dataset with embedded images instead."
@ -533,9 +560,25 @@ def convert_to_vlm_format(
raise ValueError(msg)
if len(converted_list) == 0:
issues = [
f"All {total} samples failed during VLM conversion — no usable images found",
f"Image column '{image_column}' may contain URLs that are no longer accessible, "
"or local file paths that don't exist",
]
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues, dataset_name=dataset_name, modality="vision",
column_names=[image_column, text_column],
)
except Exception:
pass
raise ValueError(
f"All {total} samples failed during VLM conversion — no usable images found. "
"This dataset may contain only image URLs that are no longer accessible."
friendly or (
f"All {total} samples failed during VLM conversion — no usable images found. "
"This dataset may contain only image URLs that are no longer accessible."
)
)
logger.info(f"✅ Converted {len(converted_list)}/{total} samples")

View file

@ -0,0 +1,837 @@
# SPDX-License-Identifier: AGPL-3.0-only - See /studio/LICENSE.AGPL-3.0
# Copyright © 2025 Unsloth AI
"""
LLM-assisted dataset analysis using an ephemeral GGUF helper model.
Complements heuristic-based detection in format_detection.py and
vlm_processing.py. Only invoked when heuristics are uncertain.
Architecture:
- Instantiates LlamaCppBackend, loads model, runs completion(s), unloads.
- Not kept warm VRAM is freed immediately after use.
- Gracefully degrades: returns None when unavailable (no binary, OOM, disabled).
"""
import json
import logging
import os
import re
import textwrap
import time
from itertools import islice
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_HELPER_MODEL_REPO = "Qwen/Qwen2.5-7B-Instruct-GGUF"
DEFAULT_HELPER_MODEL_VARIANT = "Q8_0"
README_MAX_CHARS = 1500
def precache_helper_gguf():
"""
Pre-download the helper GGUF to HF cache.
Called on FastAPI startup in a background thread so subsequent
``_run_with_helper()`` calls skip the download and only pay for
llama-server startup. No-op if already cached or disabled.
"""
if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"):
return
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
try:
from huggingface_hub import HfApi, hf_hub_download
# Find the GGUF file matching the variant
api = HfApi()
files = api.list_repo_files(repo, repo_type="model")
gguf_files = [f for f in files if f.endswith(".gguf")]
# Find all GGUF files matching the variant (may be split into shards)
variant_lower = variant.lower().replace("-", "_")
matching = sorted(
f for f in gguf_files
if variant_lower in f.lower().replace("-", "_")
)
if matching:
logger.info(f"Pre-caching helper GGUF: {repo}/{matching[0]}"
+ (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else ""))
for target in matching:
hf_hub_download(repo_id=repo, filename=target)
logger.info(f"Helper GGUF cached: {len(matching)} file(s)")
else:
logger.warning(f"No GGUF matching variant '{variant}' in {repo}")
except Exception as e:
logger.warning(f"Failed to pre-cache helper GGUF: {e}")
def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
"""
Load helper model, run one chat completion, unload.
Returns the completion text, or None on any failure.
"""
if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"):
return None
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
backend = None
try:
from core.inference.llama_cpp import LlamaCppBackend
backend = LlamaCppBackend()
logger.info(f"Loading helper model: {repo} ({variant})")
print(f"🤖 Loading helper model: {repo} ({variant})...")
ok = backend.load_model(
hf_repo=repo,
hf_variant=variant,
model_identifier=f"helper:{repo}:{variant}",
is_vision=False,
n_ctx=2048,
n_gpu_layers=-1,
)
if not ok:
logger.warning("Helper model failed to start")
return None
messages = [{"role": "user", "content": prompt}]
cumulative = ""
for text in backend.generate_chat_completion(
messages=messages,
temperature=0.1,
top_p=0.9,
top_k=20,
max_tokens=max_tokens,
repetition_penalty=1.0,
):
cumulative = text # cumulative — last value is full text
result = cumulative.strip()
logger.info(f"Helper model response ({len(result)} chars)")
return result if result else None
except Exception as e:
logger.warning(f"Helper model failed: {e}")
return None
finally:
if backend is not None:
try:
backend.unload_model()
print("🤖 Helper model unloaded")
except Exception:
pass
# ─── Public API ───────────────────────────────────────────────────────
def llm_generate_vlm_instruction(
column_names: list[str],
samples: list[dict],
dataset_name: Optional[str] = None,
) -> Optional[dict]:
"""
Ask a helper LLM to generate a task-specific VLM instruction.
Called when heuristic instruction generation returns low confidence
or falls back to generic.
Args:
column_names: Column names in the dataset.
samples: 3-5 sample rows with text values (images replaced by "<image>").
dataset_name: Optional HF dataset identifier for context.
Returns:
{"instruction": str, "confidence": 0.85} or None.
"""
# Format samples for the prompt
formatted = ""
for i, row in enumerate(samples[:5], 1):
parts = []
for col in column_names:
val = str(row.get(col, ""))[:300]
parts.append(f" {col}: {val}")
formatted += f"Sample {i}:\n" + "\n".join(parts) + "\n\n"
prompt = (
"You are a dataset analyst. Given a vision-language dataset, generate ONE "
"instruction sentence that describes what the model should do with each image.\n\n"
f"Dataset: {dataset_name or 'unknown'}\n"
f"Columns: {column_names}\n\n"
f"{formatted}"
"Write ONE instruction sentence. Examples:\n"
'- "Solve the math problem shown in the image and explain your reasoning."\n'
'- "Transcribe all text visible in this image."\n'
'- "Answer the question about this image."\n\n'
"Respond with ONLY the instruction sentence, nothing else."
)
result = _run_with_helper(prompt, max_tokens=100)
if not result:
return None
# Clean up: strip quotes, ensure it's a single sentence
instruction = result.strip().strip('"').strip("'").strip()
# Reject obviously bad outputs (too short, too long, or multi-line)
if len(instruction) < 10 or len(instruction) > 200 or "\n" in instruction:
logger.warning(f"Helper model returned unusable instruction: {instruction!r}")
return None
print(f"🤖 LLM-generated instruction: {instruction}")
return {
"instruction": instruction,
"confidence": 0.85,
}
def llm_classify_columns(
column_names: list[str],
samples: list[dict],
) -> Optional[dict[str, str]]:
"""
Ask a helper LLM to classify dataset columns into roles.
Called when heuristic column detection fails (returns None).
Args:
column_names: Column names in the dataset.
samples: 3-5 sample rows with values truncated to 200 chars.
Returns:
Dict mapping column_name role ("user"|"assistant"|"system"|"metadata"),
or None on failure.
"""
formatted = ""
for i, row in enumerate(samples[:5], 1):
parts = []
for col in column_names:
val = str(row.get(col, ""))[:200]
parts.append(f" {col}: {val}")
formatted += f"Sample {i}:\n" + "\n".join(parts) + "\n\n"
prompt = (
"Classify each column in this dataset into one of these roles:\n"
"- user: The input/question/prompt from the human\n"
"- assistant: The expected output/answer/response from the AI\n"
"- system: Context, persona, or task description\n"
"- metadata: IDs, scores, labels, timestamps — not part of conversation\n\n"
f"Columns: {column_names}\n\n"
f"{formatted}"
"Respond with ONLY a JSON object mapping column names to roles.\n"
'Example: {"question": "user", "answer": "assistant", "id": "metadata"}'
)
result = _run_with_helper(prompt, max_tokens=200)
if not result:
return None
# Parse JSON from response (may have markdown fences)
text = result.strip()
if text.startswith("```"):
# Strip markdown code fence
lines = text.split("\n")
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
text = text.strip()
try:
mapping = json.loads(text)
except json.JSONDecodeError:
# Try to find JSON object in the response
import re
match = re.search(r"\{[^}]+\}", text)
if match:
try:
mapping = json.loads(match.group())
except json.JSONDecodeError:
logger.warning(f"Could not parse helper model JSON: {text!r}")
return None
else:
logger.warning(f"No JSON found in helper model response: {text!r}")
return None
if not isinstance(mapping, dict):
return None
# Validate: all values must be valid roles
valid_roles = {"user", "assistant", "system", "metadata"}
cleaned = {}
for col, role in mapping.items():
if col in column_names and isinstance(role, str) and role.lower() in valid_roles:
cleaned[col] = role.lower()
if not cleaned:
return None
# Must have at least user + assistant
roles_present = set(cleaned.values())
if "user" not in roles_present or "assistant" not in roles_present:
logger.warning(f"Helper model mapping missing user/assistant: {cleaned}")
return None
print(f"🤖 LLM-classified columns: {cleaned}")
return cleaned
def llm_generate_dataset_warning(
issues: list[str],
dataset_name: Optional[str] = None,
modality: str = "text",
column_names: Optional[list[str]] = None,
) -> Optional[str]:
"""
Ask the helper LLM to turn technical dataset issues into a user-friendly warning.
Works for all modalities (text, vision, audio).
Args:
issues: List of technical issue descriptions found during analysis.
dataset_name: Optional HF dataset name.
modality: "text", "vision", or "audio".
column_names: Optional list of column names for context.
Returns:
A human-friendly warning string, or None on failure.
"""
if not issues:
return None
issues_text = "\n".join(f"- {issue}" for issue in issues)
cols_text = f"\nColumns: {column_names}" if column_names else ""
prompt = (
"You are a helpful assistant. A user is trying to fine-tune a model on a dataset.\n"
"The following issues were found during dataset analysis:\n\n"
f"{issues_text}\n\n"
f"Dataset: {dataset_name or 'unknown'}\n"
f"Modality: {modality}"
f"{cols_text}\n\n"
"Write a brief, friendly explanation of what's wrong and what the user can do about it.\n"
"Keep it under 3 sentences. Be specific about the dataset."
)
result = _run_with_helper(prompt, max_tokens=200)
if not result:
return None
warning = result.strip()
# Reject obviously bad outputs
if len(warning) < 10 or len(warning) > 500:
return None
print(f"🤖 LLM-generated warning: {warning}")
return warning
# ─── Dataset Conversion Advisor ──────────────────────────────────────
def _parse_json_response(text: str) -> Optional[dict]:
"""Parse JSON from LLM response, handling markdown fences and noise."""
if not text:
return None
cleaned = text.strip()
# Strip markdown code fences
if cleaned.startswith("```"):
lines = cleaned.split("\n")
end = -1 if lines[-1].strip().startswith("```") else len(lines)
cleaned = "\n".join(lines[1:end]).strip()
# Try direct parse
try:
obj = json.loads(cleaned)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
# Greedy match for outermost {...}
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if match:
try:
obj = json.loads(match.group())
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
return None
def _generate_with_backend(
backend, messages: list[dict], max_tokens: int = 512
) -> str:
"""Run one chat completion on an already-loaded backend. Returns raw text."""
cumulative = ""
for text in backend.generate_chat_completion(
messages=messages,
temperature=0.1,
top_p=0.9,
top_k=20,
max_tokens=max_tokens,
repetition_penalty=1.0,
):
cumulative = text
return cumulative.strip()
def fetch_hf_dataset_card(
dataset_name: str, hf_token: Optional[str] = None
) -> tuple[Optional[str], Optional[dict]]:
"""
Fetch HF dataset card (README) and metadata.
Returns:
(readme_text, metadata_dict) or (None, None) on failure.
"""
try:
from huggingface_hub import DatasetCard
card = DatasetCard.load(dataset_name, token=hf_token)
readme = card.text or ""
# Truncate at sentence boundary
if len(readme) > README_MAX_CHARS:
cut = readme[:README_MAX_CHARS].rfind(".")
if cut > README_MAX_CHARS // 2:
readme = readme[: cut + 1] + "\n[...truncated]"
else:
readme = readme[:README_MAX_CHARS] + "\n[...truncated]"
# Extract metadata from YAML frontmatter
metadata = {}
if card.data:
for key in (
"task_categories", "task_ids", "language",
"size_categories", "tags", "license", "pretty_name",
):
val = getattr(card.data, key, None)
if val is not None:
metadata[key] = val
logger.info(f"Fetched dataset card: {len(readme)} chars, {len(metadata)} metadata fields")
return readme, metadata
except Exception as e:
logger.warning(f"Could not fetch dataset card for {dataset_name}: {e}")
return None, None
def _run_multi_pass_advisor(
columns: list[str],
samples: list[dict],
dataset_name: Optional[str] = None,
dataset_card: Optional[str] = None,
dataset_metadata: Optional[dict] = None,
model_name: Optional[str] = None,
model_type: Optional[str] = None,
hf_token: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
Multi-pass LLM analysis: classify convert validate.
Keeps model loaded across all passes. Returns combined result dict or None.
"""
if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"):
return None
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
backend = None
try:
from core.inference.llama_cpp import LlamaCppBackend
backend = LlamaCppBackend()
print(f"🤖 Loading advisor model: {repo} ({variant})...")
t0 = time.monotonic()
ok = backend.load_model(
hf_repo=repo,
hf_variant=variant,
model_identifier=f"advisor:{repo}:{variant}",
is_vision=False,
n_ctx=2048,
n_gpu_layers=-1,
)
if not ok:
logger.warning("Advisor model failed to start")
return None
print(f"🤖 Advisor model loaded in {time.monotonic() - t0:.1f}s")
# ── Format samples ──
samples_text = ""
for i, row in enumerate(samples[:5], 1):
parts = [f" {col}: {str(row.get(col, ''))[:200]}" for col in columns]
samples_text += f"Row {i}:\n" + "\n".join(parts) + "\n"
metadata_str = (
json.dumps(dataset_metadata, indent=2, default=str)[:500]
if dataset_metadata else "N/A"
)
card_excerpt = (dataset_card or "")[:1200] or "N/A"
# ── Target Model Hints ──
target_hints = ""
is_gemma_3n = False
if model_name:
try:
from utils.models.model_config import load_model_config
config = load_model_config(model_name, use_auth=True, token=hf_token)
archs = getattr(config, "architectures", [])
if archs and "Gemma3nForConditionalGeneration" in archs:
is_gemma_3n = True
except Exception:
is_gemma_3n = "gemma-3n" in model_name.lower()
if model_type == "audio" and not is_gemma_3n:
target_hints = (
"\n\nHINT: The user is training an AUDIO model. The dataset MUST contain "
"a column with audio files/paths. Ensure one such column is selected "
"as part of the input."
)
elif model_type == "embeddings":
target_hints = (
"\n\nHINT: The user is training an EMBEDDING model. These models typically "
"do not use standard conversational input/output formats but instead use "
"specific formats like:\n"
"- Pairs of texts for Semantic Textual Similarity (STS)\n"
"- Premise, hypothesis, and label for Natural Language Inference (NLI)\n"
"- Queries and positive/negative documents for information retrieval\n"
"Ensure the dataset format mapped reflects these specialized tasks."
)
# ── Pass 1: Classify ──
print("🤖 Pass 1: Classifying dataset...", flush=True)
t1 = time.monotonic()
messages1 = [
{
"role": "system",
"content": (
"You are a dataset analyst. Your job is to look at a HuggingFace dataset "
"and figure out what kind of data it contains and whether it is already in "
"a conversational format suitable for LLM fine-tuning. A dataset is "
'"conversational" if it already has columns like "messages", "conversations", '
'or multiturn "user"/"assistant" pairs. Some datasets are NOT conversational '
"— they are things like summarization, question answering, translation, "
"classification, etc. Those need conversion. You must respond with ONLY a "
"valid JSON object. Do not write any explanation before or after the JSON."
f"{target_hints}"
),
},
{
"role": "user",
"content": textwrap.dedent(f"""\
Look at this HuggingFace dataset and classify it.
DATASET CARD (excerpt):
{card_excerpt}
METADATA:
{metadata_str}
COLUMNS: {columns}
SAMPLE DATA (first 3 rows):
{samples_text}
Based on the above, respond with this exact JSON structure:
{{
"dataset_type": "<one of: summarization, question_answering, translation, classification, natural_language_inference, instruction_following, conversational, code_generation, other>",
"is_conversational": <true if the dataset already has message/conversation columns, false otherwise>,
"needs_conversion": <true if it needs to be converted into user/assistant turns, false if it is already conversational>,
"description": "<one sentence describing what this dataset contains>",
"task_description": "<one sentence describing the task: what input goes in and what output comes out>"
}}
Respond with ONLY the JSON object. No markdown, no explanation."""),
},
]
raw1 = _generate_with_backend(backend, messages1, max_tokens=256)
pass1 = _parse_json_response(raw1)
print(f"🤖 Pass 1 done ({time.monotonic() - t1:.1f}s): {pass1}", flush=True)
if not pass1:
logger.warning(f"Advisor Pass 1 failed to produce JSON: {raw1[:200]}")
return None
# If dataset is already conversational, skip passes 2-3
if pass1.get("is_conversational") and not pass1.get("needs_conversion"):
return {
"success": True,
"dataset_type": pass1.get("dataset_type"),
"is_conversational": True,
"user_notification": (
"This dataset is already in conversational format. "
"No conversion needed — columns can be mapped directly."
),
}
# ── Pass 2: Map columns to roles ──
print("🤖 Pass 2: Mapping columns to roles...", flush=True)
t2 = time.monotonic()
messages2 = [
{
"role": "system",
"content": (
"You are a data preparation assistant. Your job is to assign each column "
"in a dataset to a conversation role for LLM fine-tuning. There are exactly "
"two roles:\n"
'- "user" = This column contains INPUT that the model will receive as a prompt.\n'
'- "assistant" = This column contains OUTPUT that the model should learn to generate.\n\n'
"CRITICAL RULES:\n"
"1. There MUST be at least one column assigned to \"user\" AND at least one "
"column assigned to \"assistant\". Never assign all columns to the same role.\n"
"2. The column that contains the TARGET or OUTPUT or ANSWER or LABEL must "
"ALWAYS be assigned to \"assistant\". This is the thing the model should learn "
"to produce.\n"
"3. The columns that contain the SOURCE or INPUT or CONTEXT or QUESTION must "
"be assigned to \"user\". This is what the model receives.\n"
'4. Metadata columns like "id", "index", "source", "url", "date" should be '
'set to "skip".\n\n'
"You must respond with ONLY a valid JSON object."
f"{target_hints}"
),
},
{
"role": "user",
"content": textwrap.dedent(f"""\
Here is a dataset that has been classified:
CLASSIFICATION:
{json.dumps(pass1, indent=2)}
COLUMNS AVAILABLE: {columns}
SAMPLE DATA (first 3 rows):
{samples_text}
Your task: assign each column to either "user", "assistant", or "skip".
Here are worked examples to guide you:
Example 1 Summarization dataset with columns ["document", "summary"]:
"document" is the input text "user"
"summary" is the output the model should generate "assistant"
Result: {{"document": "user", "summary": "assistant"}}
Example 2 Question answering dataset with columns ["context", "question", "answer"]:
"context" is input "user"
"question" is input "user"
"answer" is what the model should generate "assistant"
Result: {{"context": "user", "question": "user", "answer": "assistant"}}
Example 3 Classification dataset with columns ["text", "label"]:
"text" is input "user"
"label" is the output the model should predict "assistant"
Result: {{"text": "user", "label": "assistant"}}
Example 4 Translation dataset with columns ["en", "fr"]:
"en" is the source language (input) "user"
"fr" is the target language (output) "assistant"
Result: {{"en": "user", "fr": "assistant"}}
Now apply this logic to the actual dataset columns listed above.
Respond with this exact JSON structure:
{{
"column_roles": {{
"<column_name>": "<user|assistant|skip>"
}},
"label_mapping": <if any column contains integer labels (like 0, 1, 2), provide a mapping like {{"label": {{"0": "entailment", "1": "neutral", "2": "contradiction"}}}}, otherwise null>,
"notes": "<brief explanation of why you assigned roles this way>"
}}
REMEMBER: There must be at least one "user" column AND at least one "assistant" column. If all columns are "user", you made a mistake the output/target column should be "assistant".
Respond with ONLY the JSON object."""),
},
]
raw2 = _generate_with_backend(backend, messages2, max_tokens=512)
pass2 = _parse_json_response(raw2)
print(f"🤖 Pass 2 done ({time.monotonic() - t2:.1f}s): {pass2}", flush=True)
if not pass2:
logger.warning(f"Advisor Pass 2 failed to produce JSON: {raw2[:200]}")
return None
# ── Extract and validate column roles from Pass 2 ──
column_roles = pass2.get("column_roles", {})
label_map = pass2.get("label_mapping") or {} # may be null
# Validate: must have at least one user AND one assistant
roles_present = set(column_roles.values())
if "user" not in roles_present or "assistant" not in roles_present:
print(
f"🤖 Pass 2 sanity fail: missing user or assistant role: {column_roles}",
flush=True,
)
return None # triggers fallback to simple classification
# ── Pass 3: System prompt (non-conversational datasets only) ──
sys_prompt = ""
dtype = pass1.get("dataset_type", "unknown")
is_conv = pass1.get("is_conversational", False)
if not is_conv:
print("🤖 Pass 3: Generating system prompt...", flush=True)
t3 = time.monotonic()
# Format label mapping info for the prompt
label_info = ""
if label_map:
for col, mapping in label_map.items():
if isinstance(mapping, dict) and mapping:
pairs = ", ".join(f"{k} = {v}" for k, v in mapping.items())
label_info += f"\nLabel mapping for '{col}': {pairs}"
# Describe the role assignments for context
user_cols = [c for c, r in column_roles.items() if r == "user"]
asst_cols = [c for c, r in column_roles.items() if r == "assistant"]
task_desc = pass1.get("task_description") or pass1.get("description", "")
messages3 = [
{
"role": "user",
"content": textwrap.dedent(f"""\
I am building a fine-tuning dataset for an LLM. I need you to write a \
system prompt that will be included in every training example to tell \
the model what task it is performing.
Here is the task information:
- Dataset type: {dtype}
- Task description: {task_desc}
- The USER (input) columns are: {user_cols}
- The ASSISTANT (output) columns are: {asst_cols}
{label_info}
Write a system prompt that:
1. Explains what task the model is performing in plain language
2. Describes what input it will receive
3. Describes what output it should produce
4. Is 2-4 sentences long
Write ONLY the system prompt text. No quotes, no labels, no explanation around it."""),
},
]
raw3 = _generate_with_backend(backend, messages3, max_tokens=256)
print(f"🤖 Pass 3 done ({time.monotonic() - t3:.1f}s): {raw3[:200] if raw3 else None}", flush=True)
if raw3:
# Pass 3 returns raw text, not JSON — clean it up
cleaned = raw3.strip().strip('"').strip("'").strip()
if len(cleaned) >= 20 and cleaned.lower() not in ("null", "none", ""):
sys_prompt = cleaned
# Build suggested_mapping (column → role, for the frontend dropdowns)
suggested_mapping = {}
for col, role in column_roles.items():
if col in columns and role in ("user", "assistant", "system"):
suggested_mapping[col] = role
# Build user notification from Pass 1 classification
desc = pass1.get("task_description") or pass1.get("description", "")
note_parts = [f"This is a {dtype} dataset (not conversational)."]
if desc:
note_parts.append(desc)
note_parts.append("Columns have been mapped to conversation roles. You can adjust the mapping if needed.")
user_notification = " ".join(note_parts)
total_time = time.monotonic() - t0
print(
f"🤖 Advisor complete ({total_time:.1f}s): type={dtype}, "
f"mapping={suggested_mapping}, sys_prompt={bool(sys_prompt)}, label_map={bool(label_map)}",
flush=True,
)
return {
"success": True,
"suggested_mapping": suggested_mapping,
"system_prompt": sys_prompt,
"label_mapping": label_map if label_map else None,
"dataset_type": dtype,
"is_conversational": is_conv,
"user_notification": user_notification,
}
except Exception as e:
logger.warning(f"Advisor multi-pass failed: {e}")
return None
finally:
if backend is not None:
try:
backend.unload_model()
print("🤖 Advisor model unloaded")
except Exception:
pass
def llm_conversion_advisor(
column_names: list[str],
samples: list[dict],
dataset_name: Optional[str] = None,
hf_token: Optional[str] = None,
model_name: Optional[str] = None,
model_type: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""
Full conversion advisor: fetch HF card multi-pass LLM analysis.
Falls back to simple llm_classify_columns() if the multi-pass advisor fails.
Returns:
Dict with keys: success, suggested_mapping, system_prompt, user_template,
assistant_template, label_mapping, dataset_type, is_conversational,
user_notification. Or None on complete failure.
"""
# Fetch HF dataset card if this looks like a HF dataset (has a slash)
dataset_card = None
dataset_metadata = None
if dataset_name and "/" in dataset_name:
dataset_card, dataset_metadata = fetch_hf_dataset_card(dataset_name, hf_token)
# Try multi-pass advisor
result = _run_multi_pass_advisor(
columns=column_names,
samples=samples,
dataset_name=dataset_name,
dataset_card=dataset_card,
dataset_metadata=dataset_metadata,
model_name=model_name,
model_type=model_type,
hf_token=hf_token,
)
if result and result.get("success"):
print(f"🤖 Conversion advisor succeeded: type={result.get('dataset_type')}")
return result
# Fallback: simple column classification
logger.info("Advisor failed, falling back to simple column classification")
simple_mapping = llm_classify_columns(column_names, samples)
if simple_mapping:
return {
"success": True,
"suggested_mapping": {
col: role for col, role in simple_mapping.items()
if role in ("user", "assistant", "system")
},
"dataset_type": None,
"is_conversational": None,
"user_notification": None,
}
return None

View file

@ -9,6 +9,7 @@ for VLM datasets based on content analysis and heuristics.
"""
import re
from itertools import islice
def generate_smart_vlm_instruction(
@ -176,7 +177,46 @@ def generate_smart_vlm_instruction(
"confidence": 0.75,
}
# ===== LEVEL 4: Generic Fallback =====
# ===== LEVEL 4: LLM-Assisted Instruction Generation =====
try:
from .llm_assist import llm_generate_vlm_instruction
sample_rows = []
for s in islice(dataset, 5):
row = {}
for col in s:
val = s[col]
if hasattr(val, 'size') and hasattr(val, 'mode'): # PIL Image
row[col] = "<image>"
elif isinstance(val, list):
row[col] = str(val)[:300]
else:
row[col] = str(val)[:300]
sample_rows.append(row)
llm_result = llm_generate_vlm_instruction(
column_names=list(column_names),
samples=sample_rows,
dataset_name=dataset_name,
)
if llm_result and llm_result.get("instruction"):
print(
f"\n[DEBUG] LLM-assisted VLM instruction generated: "
f"'{llm_result['instruction']}' (confidence={llm_result.get('confidence', 'N/A')})\n",
flush=True,
)
return {
"instruction": llm_result["instruction"],
"instruction_column": None,
"instruction_type": "llm_assisted",
"uses_dynamic_instruction": False,
"confidence": llm_result.get("confidence", 0.85),
}
except Exception as e:
import logging
logging.getLogger(__name__).debug(f"LLM-assisted instruction skipped: {e}")
# ===== LEVEL 5: Generic Fallback =====
return {
"instruction": "Describe this image in detail.",
"instruction_column": None,

View file

@ -14,6 +14,7 @@ import type { CheckFormatResponse } from "@/features/training/types/datasets";
import { cn } from "@/lib/utils";
import { AlertCircleIcon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loader2, Sparkles } from "lucide-react";
const CHATML_ROLES = ["system", "user", "assistant"] as const;
const ALPACA_ROLES = ["instruction", "input", "output"] as const;
@ -96,6 +97,11 @@ export function DatasetMappingCard({
isVlm = false,
isAudio = false,
format,
onAiAssist,
isAiLoading = false,
aiError,
advisorNotification,
advisorSystemPrompt,
}: {
mapping: Record<string, string>;
mappingOk: boolean;
@ -103,6 +109,11 @@ export function DatasetMappingCard({
isVlm?: boolean;
isAudio?: boolean;
format?: string;
onAiAssist?: () => void;
isAiLoading?: boolean;
aiError?: string | null;
advisorNotification?: string | null;
advisorSystemPrompt?: string;
}) {
const entries = Object.entries(mapping);
const requiredLabel = isAudio
@ -157,7 +168,7 @@ export function DatasetMappingCard({
>
{mappingOk
? autoDetected
? "We auto-detected the column mapping below. You can change it using the dropdowns in the column headers."
? "We auto-detected the column mapping below. You can change it using the dropdowns in the column headers, or use AI Assist for a smarter mapping strategy."
: "Looks good. We'll convert this dataset automatically."
: `Assign roles to columns using the dropdowns in the headers. At minimum, assign ${requiredLabel}.`}
</p>
@ -181,6 +192,47 @@ export function DatasetMappingCard({
Use the dropdowns in the column headers to assign roles.
</p>
)}
{onAiAssist && (
<div className="mt-3 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onAiAssist}
disabled={isAiLoading}
className="cursor-pointer bg-white/60 dark:bg-transparent"
>
{isAiLoading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Analyzing dataset...
</>
) : (
<>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
AI Assist
<Badge variant="outline" className="ml-1.5 text-[9px] px-1 py-0 h-4 font-medium">Beta</Badge>
</>
)}
</Button>
{aiError && (
<p className="text-xs text-amber-700 dark:text-amber-300">{aiError}</p>
)}
</div>
)}
{advisorNotification && (
<div className="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2.5 text-xs text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-300 space-y-2">
<div className="flex items-start gap-2">
<Sparkles className="size-3.5 shrink-0 mt-0.5" />
<span>{advisorNotification}</span>
</div>
{advisorSystemPrompt && (
<div className="pl-5.5 text-[11px] font-mono text-indigo-600/80 dark:text-indigo-400/80">
<span className="font-sans font-medium text-indigo-500 dark:text-indigo-400">System:</span>{" "}
<span className="break-words">{advisorSystemPrompt}</span>
</div>
)}
</div>
)}
</div>
</div>
</div>

View file

@ -2,6 +2,7 @@
// Copyright © 2025 Unsloth AI
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { aiAssistMapping } from "@/features/training/api/datasets-api";
import type { ColumnDef } from "@tanstack/react-table";
import {
Dialog,
@ -29,6 +30,12 @@ import {
remapRolesForFormat,
} from "./dataset-preview-dialog-mapping";
/** Chatml → format-specific role remap (only for formats that differ from chatml). */
const ROLE_REMAP: Record<string, Record<string, string>> = {
alpaca: { user: "instruction", system: "input", assistant: "output" },
sharegpt: { user: "human", assistant: "gpt", system: "system" },
};
type DatasetPreviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -58,11 +65,22 @@ export function DatasetPreviewDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { manualMapping, setManualMapping, datasetFormat } = useTrainingConfigStore(
const {
manualMapping, setManualMapping, datasetFormat,
setDatasetAdvisorFields, datasetAdvisorNotification,
datasetSystemPrompt,
selectedModel,
modelType,
} = useTrainingConfigStore(
useShallow((s) => ({
manualMapping: s.datasetManualMapping,
setManualMapping: s.setDatasetManualMapping,
datasetFormat: s.datasetFormat,
setDatasetAdvisorFields: s.setDatasetAdvisorFields,
datasetAdvisorNotification: s.datasetAdvisorNotification,
datasetSystemPrompt: s.datasetSystemPrompt,
selectedModel: s.selectedModel,
modelType: s.modelType,
})),
);
const { isStarting, startError, startTrainingRun } = useTrainingActions();
@ -79,6 +97,52 @@ export function DatasetPreviewDialog({
const availableRoles = getAvailableRoles(effectiveIsVlm, datasetFormat, effectiveIsAudio);
const isHfDataset = datasetSource === "huggingface";
// ── AI Assist ──────────────────────────────────────────────────────
const [isAiLoading, setIsAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const handleAiAssist = useCallback(async () => {
if (!data?.columns || !data?.preview_samples) return;
setIsAiLoading(true);
setAiError(null);
try {
const result = await aiAssistMapping({
columns: data.columns,
samples: data.preview_samples,
datasetName: datasetName,
hfToken: hfToken,
modelName: selectedModel,
modelType: modelType,
});
if (result.success && result.suggested_mapping) {
// Remap from chatml roles (user/assistant/system) to format-specific roles
const table = ROLE_REMAP[datasetFormat];
const mapped: Record<string, string> = {};
for (const [col, role] of Object.entries(result.suggested_mapping)) {
mapped[col] = table ? (table[role] ?? role) : role;
}
setManualMapping(mapped);
// Store conversion advisor fields (system prompt, label mapping, notification)
if (result.system_prompt || result.label_mapping || result.user_notification) {
setDatasetAdvisorFields({
systemPrompt: result.system_prompt ?? undefined,
labelMapping: result.label_mapping ?? undefined,
notification: result.user_notification ?? null,
});
}
} else {
setAiError(result.warning || "AI could not determine column roles.");
}
} catch (err) {
setAiError(err instanceof Error ? err.message : "AI assist failed.");
} finally {
setIsAiLoading(false);
}
}, [data, datasetFormat, datasetName, hfToken, setManualMapping, setDatasetAdvisorFields, selectedModel, modelType]);
// When format changes, remap existing mapping roles to the new format's role names
const prevFormatRef = useRef(datasetFormat);
useEffect(() => {
@ -180,7 +244,8 @@ export function DatasetPreviewDialog({
// Build TanStack Table columns from the column names
const tableColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
if (!columns.length) return [];
return columns.map((colName) => ({
const dataCols: ColumnDef<Record<string, unknown>>[] = columns.map((colName) => ({
accessorKey: colName,
header: () => (
<div className="flex flex-col gap-2">
@ -247,12 +312,42 @@ export function DatasetPreviewDialog({
);
},
}));
// Prepend generated system prompt column when advisor is active
if (datasetSystemPrompt) {
dataCols.unshift({
id: "__system_generated",
header: () => (
<div className="flex flex-col gap-2">
<span className="font-heading text-[13px] font-semibold tracking-tight text-foreground">
System <span className="text-muted-foreground font-normal">(generated)</span>
</span>
{mappingEnabled && (
<Badge variant="outline" className="h-6 w-fit text-[10px] px-2 py-0 border-dashed text-muted-foreground">
System
</Badge>
)}
</div>
),
cell: () => (
<p
className="text-[13px] leading-relaxed line-clamp-6 text-muted-foreground italic"
title={datasetSystemPrompt}
>
{datasetSystemPrompt}
</p>
),
});
}
return dataCols;
}, [
columns,
manualMapping,
handleRoleChange,
mappingEnabled,
availableRoles,
datasetSystemPrompt,
]);
return (
@ -274,7 +369,7 @@ export function DatasetPreviewDialog({
</DialogHeader>
{/* Body */}
<div className="flex flex-col min-h-0 flex-1 overflow-hidden px-6 pb-6">
<div className="flex flex-col min-h-0 flex-1 overflow-auto px-6 pb-6">
{/* Loading */}
{loading && (
<div className="py-24 flex flex-col items-center justify-center gap-3">
@ -361,11 +456,16 @@ export function DatasetPreviewDialog({
isVlm={effectiveIsVlm}
isAudio={effectiveIsAudio}
format={datasetFormat}
onAiAssist={handleAiAssist}
isAiLoading={isAiLoading}
aiError={aiError}
advisorNotification={datasetAdvisorNotification}
advisorSystemPrompt={datasetSystemPrompt || undefined}
/>
)}
{/* Data table */}
<div className="flex-1 min-h-0 rounded-xl corner-squircle ring-1 ring-border/60 overflow-auto">
<div className="flex-1 min-h-[250px] rounded-xl corner-squircle ring-1 ring-border/60 overflow-auto">
<DataTable columns={tableColumns} data={rows} />
</div>

View file

@ -62,6 +62,58 @@ export async function uploadTrainingDataset(
return res.json();
}
// ── AI Assist ────────────────────────────────────────────────────────
type AiAssistMappingArgs = {
columns: string[];
samples: Record<string, unknown>[];
datasetName?: string | null;
hfToken?: string | null;
modelName?: string | null;
modelType?: "text" | "vision" | "audio" | "embeddings" | null;
};
export type AiAssistMappingResponse = {
success: boolean;
suggested_mapping?: Record<string, string> | null;
warning?: string | null;
// Conversion advisor fields
system_prompt?: string | null;
label_mapping?: Record<string, Record<string, string>> | null;
dataset_type?: string | null;
is_conversational?: boolean | null;
user_notification?: string | null;
};
export async function aiAssistMapping({
columns,
samples,
datasetName,
hfToken,
modelName,
modelType,
}: AiAssistMappingArgs): Promise<AiAssistMappingResponse> {
const res = await authFetch("/api/datasets/ai-assist-mapping", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
columns,
samples: samples.slice(0, 5),
dataset_name: datasetName || undefined,
hf_token: hfToken || undefined,
model_name: modelName || undefined,
model_type: modelType || undefined,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail || `AI assist failed (${res.status})`);
}
return res.json();
}
export async function listLocalDatasets(): Promise<LocalDatasetsResponse> {
const res = await authFetch("/api/datasets/local");
if (!res.ok) {

View file

@ -31,8 +31,23 @@ export function buildTrainingStartPayload(
config.datasetSource === "upload" && config.uploadedFile
? [config.uploadedFile]
: [];
const customFormatMapping =
Object.keys(config.datasetManualMapping).length > 0 ? config.datasetManualMapping : undefined;
let customFormatMapping: Record<string, unknown> | undefined =
Object.keys(config.datasetManualMapping).length > 0
? { ...config.datasetManualMapping }
: undefined;
// Inject conversion advisor metadata into the mapping (__ prefix keys)
const hasAdvisorMeta =
config.datasetSystemPrompt ||
Object.keys(config.datasetLabelMapping).length > 0;
if (customFormatMapping && hasAdvisorMeta) {
if (config.datasetSystemPrompt) {
customFormatMapping.__system_prompt = config.datasetSystemPrompt;
}
if (Object.keys(config.datasetLabelMapping).length > 0) {
customFormatMapping.__label_mapping = config.datasetLabelMapping;
}
}
return {
model_name: config.selectedModel ?? "",

View file

@ -31,6 +31,11 @@ const initialState: TrainingConfigState = {
datasetSplit: null,
datasetEvalSplit: null,
datasetManualMapping: emptyManualMapping(),
datasetSystemPrompt: "",
datasetUserTemplate: "",
datasetAssistantTemplate: "",
datasetLabelMapping: {},
datasetAdvisorNotification: null,
datasetSliceStart: null,
datasetSliceEnd: null,
uploadedFile: null,
@ -241,6 +246,11 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
datasetSplit: null,
datasetEvalSplit: null,
datasetManualMapping: emptyManualMapping(),
datasetSystemPrompt: "",
datasetUserTemplate: "",
datasetAssistantTemplate: "",
datasetLabelMapping: {},
datasetAdvisorNotification: null,
datasetSliceStart: null,
datasetSliceEnd: null,
isDatasetImage: null,
@ -404,6 +414,22 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
},
setDatasetManualMapping: (datasetManualMapping) =>
set({ datasetManualMapping }),
setDatasetAdvisorFields: (fields) =>
set({
datasetSystemPrompt: fields.systemPrompt ?? get().datasetSystemPrompt,
datasetUserTemplate: "", // templates no longer used
datasetAssistantTemplate: "", // templates no longer used
datasetLabelMapping: fields.labelMapping ?? get().datasetLabelMapping,
datasetAdvisorNotification: fields.notification !== undefined ? fields.notification : get().datasetAdvisorNotification,
}),
clearDatasetAdvisorFields: () =>
set({
datasetSystemPrompt: "",
datasetUserTemplate: "",
datasetAssistantTemplate: "",
datasetLabelMapping: {},
datasetAdvisorNotification: null,
}),
setDatasetSliceStart: (datasetSliceStart) => set({ datasetSliceStart }),
setDatasetSliceEnd: (datasetSliceEnd) => set({ datasetSliceEnd }),
setUploadedFile: (uploadedFile) => {
@ -479,7 +505,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
},
{
name: "unsloth_training_config_v1",
version: 7,
version: 8,
migrate: (persisted, version) => {
const s = persisted as Record<string, unknown>;
if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) {
@ -502,6 +528,13 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
s.datasetSliceStart ??= null;
s.datasetSliceEnd ??= null;
}
if (version < 8) {
s.datasetSystemPrompt ??= "";
s.datasetUserTemplate ??= "";
s.datasetAssistantTemplate ??= "";
s.datasetLabelMapping ??= {};
s.datasetAdvisorNotification ??= null;
}
return s as unknown as TrainingConfigStore;
},
partialize: partializePersistedState,

View file

@ -17,7 +17,7 @@ export interface TrainingStartRequest {
dataset_slice_end: number | null;
local_datasets: string[];
format_type: string;
custom_format_mapping?: Record<string, string> | null;
custom_format_mapping?: Record<string, unknown> | null;
num_epochs: number;
learning_rate: string;
batch_size: number;

View file

@ -29,6 +29,11 @@ export interface TrainingConfigState {
datasetSplit: string | null;
datasetEvalSplit: string | null;
datasetManualMapping: DatasetManualMapping;
datasetSystemPrompt: string;
datasetUserTemplate: string;
datasetAssistantTemplate: string;
datasetLabelMapping: Record<string, Record<string, string>>;
datasetAdvisorNotification: string | null;
datasetSliceStart: string | null;
datasetSliceEnd: string | null;
uploadedFile: string | null;
@ -95,6 +100,12 @@ export interface TrainingConfigActions {
setDatasetSplit: (split: string | null) => void;
setDatasetEvalSplit: (split: string | null) => void;
setDatasetManualMapping: (mapping: DatasetManualMapping) => void;
setDatasetAdvisorFields: (fields: {
systemPrompt?: string;
labelMapping?: Record<string, Record<string, string>>;
notification?: string | null;
}) => void;
clearDatasetAdvisorFields: () => void;
setDatasetSliceStart: (value: string | null) => void;
setDatasetSliceEnd: (value: string | null) => void;
setUploadedFile: (file: string | null) => void;