feat: Dataset Conversion Advisor — multi-pass LLM for non-conversational datasets

Non-conversational HF datasets (e.g. stanfordnlp/snli) were naively mapped
column→role, producing poor training results. The AI Assist button now runs
a 3-pass advisor using Qwen 7B that:
1. Fetches the HF dataset card/README to understand the dataset purpose
2. Classifies the dataset type and determines if conversion is needed
3. Generates a system prompt, user/assistant templates with {column}
   placeholders, and label mappings (e.g. 0→entailment)
4. Validates the conversion quality (score ≥7/10 required)

Architecture: advisor metadata flows as __-prefixed keys in
custom_format_mapping (e.g. __system_prompt, __user_template,
__assistant_template, __label_mapping). The existing _apply_user_mapping()
detects these keys and routes to template-based conversation construction.
No __ keys = existing simple mode (backwards compatible).

Backend: upgraded llm_assist.py (7B default, multi-pass advisor,
HF card fetching), extended API models, added _apply_template_mapping()
to dataset_utils.py.

Frontend: extended store with advisor state fields, wired AI Assist
to store templates/system prompt, inject __ metadata in training request,
show advisor notification banner in mapping card.
This commit is contained in:
Roland Tannous 2026-03-10 15:39:56 +00:00
commit 202780c32c
12 changed files with 672 additions and 34 deletions

View file

@ -49,13 +49,22 @@ class AiAssistMappingRequest(BaseModel):
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
class AiAssistMappingResponse(BaseModel):
"""Response from LLM-assisted column classification."""
"""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
user_template: Optional[str] = None
assistant_template: 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):

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

@ -489,14 +489,17 @@ def ai_assist_mapping(
current_subject: str = Depends(get_current_subject),
):
"""
Run LLM-assisted column classification on demand (user-triggered).
Run LLM-assisted dataset conversion advisor (user-triggered).
Receives the preview samples already loaded in the frontend dialog,
so no re-loading of the dataset is needed. The helper LLM is loaded
ephemerally: load classify unload.
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_classify_columns, llm_generate_dataset_warning
from utils.datasets.llm_assist import llm_conversion_advisor
# Truncate sample values for the LLM prompt
truncated = [
@ -504,31 +507,29 @@ def ai_assist_mapping(
for s in request.samples[:5]
]
mapping = llm_classify_columns(
result = llm_conversion_advisor(
column_names=request.columns,
samples=truncated,
dataset_name=request.dataset_name,
hf_token=request.hf_token,
)
if mapping:
# Keep only conversation roles, not metadata
conversation_mapping = {
col: role for col, role in mapping.items()
if role in ("user", "assistant", "system")
}
if result and result.get("success"):
return AiAssistMappingResponse(
success=True,
suggested_mapping=conversation_mapping,
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"),
)
# LLM classification failed — generate a helpful warning
warning = llm_generate_dataset_warning(
issues=[f"Could not determine column roles from columns: {request.columns}"],
dataset_name=request.dataset_name,
modality="text",
column_names=request.columns,
)
return AiAssistMappingResponse(
success=False,
warning=warning or "AI could not determine column roles. Please assign them manually.",
warning="AI could not determine column roles. Please assign them manually.",
)
except Exception as e:

View file

@ -196,12 +196,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)
@ -222,6 +233,82 @@ 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 _apply_template_mapping(
dataset, column_roles: dict, meta: dict, batch_size: int = 1000
):
"""
Apply template-based mapping for non-conversational datasets.
Uses ``__system_prompt``, ``__user_template``, ``__assistant_template``,
and ``__label_mapping`` metadata keys to construct conversations with
proper context and formatting.
Returns:
Dataset with single 'conversations' column
"""
system_prompt = meta.get("__system_prompt", "")
user_template = meta.get("__user_template", "")
assistant_template = meta.get("__assistant_template", "")
label_mapping = meta.get("__label_mapping", {}) # {col: {int_str: label_str}}
all_columns = list(dataset.column_names)
import logging as _log
_log.getLogger(__name__).info(
f"Applying template mapping: sys={bool(system_prompt)}, "
f"user_tpl={bool(user_template)}, asst_tpl={bool(assistant_template)}, "
f"label_map={list(label_mapping.keys())}"
)
def _convert(examples):
num = len(next(iter(examples.values())))
conversations = []
for i in range(num):
convo = []
# Build value dict for template interpolation
row_values = {}
for col in all_columns:
val = examples[col][i]
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):
mapped = label_mapping[col].get(str_val, str_val)
row_values[col] = mapped
row_values[f"{col}_name"] = mapped
else:
row_values[col] = str_val
row_values[f"{col}_name"] = str_val
# System prompt (static string, not from any column)
if system_prompt:
convo.append({"role": "system", "content": system_prompt})
# User message from template
if user_template:
try:
user_content = user_template.format(**row_values)
except (KeyError, IndexError):
user_content = user_template
convo.append({"role": "user", "content": user_content})
# Assistant message from template
if assistant_template:
try:
asst_content = assistant_template.format(**row_values)
except (KeyError, IndexError):
asst_content = assistant_template
convo.append({"role": "assistant", "content": asst_content})
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.

View file

@ -16,14 +16,19 @@ Architecture:
import json
import logging
import os
import re
import textwrap
import time
from itertools import islice
from typing import Optional
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_HELPER_MODEL_REPO = "Qwen/Qwen2.5-3B-Instruct-GGUF"
DEFAULT_HELPER_MODEL_REPO = "Qwen/Qwen2.5-7B-Instruct-GGUF"
DEFAULT_HELPER_MODEL_VARIANT = "Q8_0"
README_MAX_CHARS = 1500
def precache_helper_gguf():
"""
@ -325,3 +330,435 @@ def llm_generate_dataset_warning(
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,
) -> 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"
# ── Pass 1: Classify ──
print("🤖 Pass 1: Classifying dataset...", flush=True)
t1 = time.monotonic()
messages1 = [
{
"role": "system",
"content": (
"You are a dataset analyst specializing in HuggingFace datasets for LLM fine-tuning. "
"You classify datasets and determine if they can be used directly for conversational "
"fine-tuning or if they need conversion. Respond with ONLY valid JSON, no explanation."
),
},
{
"role": "user",
"content": textwrap.dedent(f"""\
Analyze this HuggingFace dataset and classify it.
DATASET CARD (excerpt):
{card_excerpt}
METADATA:
{metadata_str}
COLUMNS: {columns}
SAMPLE DATA:
{samples_text}
Respond with a JSON object:
{{
"dataset_type": "<type like: nli, classification, summarization, qa, translation, etc.>",
"is_conversational": <true if already has user/assistant message structure, false otherwise>,
"needs_conversion": <true if columns need to be reorganized into conversation format>,
"description": "<1-2 sentence description of what this dataset is for>",
"task_description": "<what a model fine-tuned on this should do>"
}}"""),
},
]
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: Conversion strategy ──
print("🤖 Pass 2: Generating conversion strategy...", flush=True)
t2 = time.monotonic()
messages2 = [
{
"role": "system",
"content": (
"You are a dataset conversion specialist for LLM fine-tuning. "
"You design strategies to convert non-conversational datasets into "
"user/assistant conversation format. Respond with ONLY valid JSON."
),
},
{
"role": "user",
"content": textwrap.dedent(f"""\
This dataset was classified as:
{json.dumps(pass1, indent=2)}
COLUMNS: {columns}
SAMPLE DATA:
{samples_text}
Design a conversion strategy to turn this into conversation format for fine-tuning.
The strategy should create a system prompt, a user message template, and an assistant message template.
For the user template, use {{column_name}} placeholders for column values.
For the assistant template, use {{column_name}} placeholders.
If a column has integer values that represent categories, provide a label mapping.
Respond with a JSON object:
{{
"system_prompt": "<system prompt for the fine-tuned model>",
"user_template": "<template for user message using {{column}} placeholders>",
"assistant_template": "<template for assistant message using {{column}} placeholders>",
"column_roles": {{
"<column_name>": "<role: user|assistant|system|template_var>"
}},
"label_mapping": {{
"<column_name>": {{"0": "<string label>", "1": "<string label>"}}
}},
"notes": "<any important notes about this conversion>"
}}"""),
},
]
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
# ── Pass 3: Validate ──
# Apply templates to samples for concrete examples
sys_prompt = pass2.get("system_prompt", "")
user_tpl = pass2.get("user_template", "")
asst_tpl = pass2.get("assistant_template", "")
label_map = pass2.get("label_mapping", {})
examples_text = ""
for i, row in enumerate(samples[:2], 1):
row_vals = {}
for col in columns:
val = str(row.get(col, ""))
# Apply label mapping
if col in label_map and val in label_map[col]:
row_vals[col] = label_map[col][val]
row_vals[f"{col}_name"] = label_map[col][val]
else:
row_vals[col] = val
row_vals[f"{col}_name"] = val
try:
user_msg = user_tpl.format(**row_vals)
except (KeyError, IndexError):
user_msg = user_tpl
try:
asst_msg = asst_tpl.format(**row_vals)
except (KeyError, IndexError):
asst_msg = asst_tpl
examples_text += f"Example {i}:\n System: {sys_prompt}\n User: {user_msg}\n Assistant: {asst_msg}\n\n"
print("🤖 Pass 3: Validating conversion...", flush=True)
t3 = time.monotonic()
messages3 = [
{
"role": "system",
"content": (
"You are a dataset quality reviewer for LLM fine-tuning. "
"Review converted training examples and suggest improvements. "
"Respond with ONLY valid JSON."
),
},
{
"role": "user",
"content": textwrap.dedent(f"""\
Review these converted training examples. The original dataset is:
{pass1.get('dataset_type', 'unknown')} {pass1.get('description', '')}
CONVERTED EXAMPLES:
{examples_text}
Review the quality and generate a brief user-facing notification.
Respond with a JSON object:
{{
"quality_score": <1-10>,
"is_acceptable": <true/false>,
"revised_system_prompt": "<improved system prompt if needed, or null>",
"user_notification": "<friendly 2-3 sentence message for the training studio UI>"
}}"""),
},
]
raw3 = _generate_with_backend(backend, messages3, max_tokens=512)
pass3 = _parse_json_response(raw3)
print(f"🤖 Pass 3 done ({time.monotonic() - t3:.1f}s): {pass3}", flush=True)
# ── Combine results ──
final_sys_prompt = sys_prompt
if pass3 and pass3.get("revised_system_prompt"):
final_sys_prompt = pass3["revised_system_prompt"]
# Build suggested_mapping (column → role, for the frontend dropdowns)
suggested_mapping = {}
column_roles = pass2.get("column_roles", {})
for col, role in column_roles.items():
if col in columns and role in ("user", "assistant", "system"):
suggested_mapping[col] = role
# Ensure at least user + assistant in mapping
if "user" not in set(suggested_mapping.values()):
# Try to infer from templates
for col in columns:
if f"{{{col}}}" in user_tpl and col not in suggested_mapping:
suggested_mapping[col] = "user"
break
if "assistant" not in set(suggested_mapping.values()):
for col in columns:
if f"{{{col}}}" in asst_tpl and col not in suggested_mapping:
suggested_mapping[col] = "assistant"
break
user_notification = None
if pass3:
user_notification = pass3.get("user_notification")
return {
"success": True,
"suggested_mapping": suggested_mapping,
"system_prompt": final_sys_prompt,
"user_template": user_tpl,
"assistant_template": asst_tpl,
"label_mapping": label_map if label_map else None,
"dataset_type": pass1.get("dataset_type"),
"is_conversational": pass1.get("is_conversational", False),
"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,
) -> 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,
)
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

@ -100,6 +100,7 @@ export function DatasetMappingCard({
onAiAssist,
isAiLoading = false,
aiError,
advisorNotification,
}: {
mapping: Record<string, string>;
mappingOk: boolean;
@ -110,6 +111,7 @@ export function DatasetMappingCard({
onAiAssist?: () => void;
isAiLoading?: boolean;
aiError?: string | null;
advisorNotification?: string | null;
}) {
const entries = Object.entries(mapping);
const requiredLabel = isAudio
@ -200,7 +202,7 @@ export function DatasetMappingCard({
{isAiLoading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Analyzing columns...
Analyzing dataset...
</>
) : (
<>
@ -214,6 +216,12 @@ export function DatasetMappingCard({
)}
</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 flex items-start gap-2">
<Sparkles className="size-3.5 shrink-0 mt-0.5" />
<span>{advisorNotification}</span>
</div>
)}
</div>
</div>
</div>

View file

@ -65,11 +65,16 @@ 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,
} = useTrainingConfigStore(
useShallow((s) => ({
manualMapping: s.datasetManualMapping,
setManualMapping: s.setDatasetManualMapping,
datasetFormat: s.datasetFormat,
setDatasetAdvisorFields: s.setDatasetAdvisorFields,
datasetAdvisorNotification: s.datasetAdvisorNotification,
})),
);
const { isStarting, startError, startTrainingRun } = useTrainingActions();
@ -100,6 +105,7 @@ export function DatasetPreviewDialog({
columns: data.columns,
samples: data.preview_samples,
datasetName: datasetName,
hfToken: hfToken,
});
if (result.success && result.suggested_mapping) {
@ -110,6 +116,17 @@ export function DatasetPreviewDialog({
mapped[col] = table ? (table[role] ?? role) : role;
}
setManualMapping(mapped);
// Store conversion advisor fields (templates, system prompt, etc.)
if (result.system_prompt || result.user_template || result.assistant_template) {
setDatasetAdvisorFields({
systemPrompt: result.system_prompt ?? undefined,
userTemplate: result.user_template ?? undefined,
assistantTemplate: result.assistant_template ?? undefined,
labelMapping: result.label_mapping ?? undefined,
notification: result.user_notification ?? null,
});
}
} else {
setAiError(result.warning || "AI could not determine column roles.");
}
@ -118,7 +135,7 @@ export function DatasetPreviewDialog({
} finally {
setIsAiLoading(false);
}
}, [data, datasetFormat, datasetName, setManualMapping]);
}, [data, datasetFormat, datasetName, hfToken, setManualMapping, setDatasetAdvisorFields]);
// When format changes, remap existing mapping roles to the new format's role names
const prevFormatRef = useRef(datasetFormat);
@ -405,6 +422,7 @@ export function DatasetPreviewDialog({
onAiAssist={handleAiAssist}
isAiLoading={isAiLoading}
aiError={aiError}
advisorNotification={datasetAdvisorNotification}
/>
)}

View file

@ -68,18 +68,28 @@ type AiAssistMappingArgs = {
columns: string[];
samples: Record<string, unknown>[];
datasetName?: string | null;
hfToken?: string | null;
};
export type AiAssistMappingResponse = {
success: boolean;
suggested_mapping?: Record<string, string> | null;
warning?: string | null;
// Conversion advisor fields
system_prompt?: string | null;
user_template?: string | null;
assistant_template?: 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,
}: AiAssistMappingArgs): Promise<AiAssistMappingResponse> {
const res = await authFetch("/api/datasets/ai-assist-mapping", {
method: "POST",
@ -88,6 +98,7 @@ export async function aiAssistMapping({
columns,
samples: samples.slice(0, 5),
dataset_name: datasetName || undefined,
hf_token: hfToken || undefined,
}),
});

View file

@ -30,8 +30,24 @@ 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)
if (customFormatMapping && config.datasetSystemPrompt) {
customFormatMapping.__system_prompt = config.datasetSystemPrompt;
if (config.datasetUserTemplate) {
customFormatMapping.__user_template = config.datasetUserTemplate;
}
if (config.datasetAssistantTemplate) {
customFormatMapping.__assistant_template = config.datasetAssistantTemplate;
}
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,
@ -205,6 +210,11 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
datasetSplit: null,
datasetEvalSplit: null,
datasetManualMapping: emptyManualMapping(),
datasetSystemPrompt: "",
datasetUserTemplate: "",
datasetAssistantTemplate: "",
datasetLabelMapping: {},
datasetAdvisorNotification: null,
datasetSliceStart: null,
datasetSliceEnd: null,
isDatasetImage: null,
@ -364,6 +374,22 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
},
setDatasetManualMapping: (datasetManualMapping) =>
set({ datasetManualMapping }),
setDatasetAdvisorFields: (fields) =>
set({
datasetSystemPrompt: fields.systemPrompt ?? get().datasetSystemPrompt,
datasetUserTemplate: fields.userTemplate ?? get().datasetUserTemplate,
datasetAssistantTemplate: fields.assistantTemplate ?? get().datasetAssistantTemplate,
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) => {
@ -439,7 +465,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) {
@ -462,6 +488,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;
@ -93,6 +98,14 @@ export interface TrainingConfigActions {
setDatasetSplit: (split: string | null) => void;
setDatasetEvalSplit: (split: string | null) => void;
setDatasetManualMapping: (mapping: DatasetManualMapping) => void;
setDatasetAdvisorFields: (fields: {
systemPrompt?: string;
userTemplate?: string;
assistantTemplate?: 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;