Files
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

922 lines
29 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Dataset format detection: Alpaca/ShareGPT/ChatML, multimodal/VLM structures, heuristic column mapping."""
import re
def _keyword_in_column(keyword: str, col_name: str) -> bool:
"""Word-boundary keyword match to avoid false positives like 'pic' in 'topic'."""
return re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE) is not None
CONVERSATION_COLUMNS = ("messages", "conversations", "texts")
_CHATML_KEYS = frozenset({"role", "content"})
_SHAREGPT_KEYS = frozenset({"from", "value"})
_TRACE_SUFFIXES = ("__trace", "_trace")
def _sample_dataset_rows(dataset, limit: int = 100) -> list[dict]:
try:
total = min(len(dataset), limit)
return [dataset[index] for index in range(total)]
except Exception:
rows = []
try:
for index, row in enumerate(dataset):
if index >= limit:
break
rows.append(row)
except Exception:
return []
return rows
def _get_dataset_column_names(dataset, sample: dict) -> list[str]:
column_names = getattr(dataset, "column_names", None)
if isinstance(column_names, list):
return [str(column) for column in column_names]
return [str(column) for column in sample.keys()]
def _is_trace_conversation_name(column_name: str) -> bool:
return column_name.lower().endswith(_TRACE_SUFFIXES)
def _inspect_conversation_column(rows: list[dict], column_name: str) -> dict | None:
turn_keys: set[str] = set()
has_chatml = False
has_sharegpt = False
for row in rows:
if not isinstance(row, dict) or column_name not in row:
continue
chat_data = row[column_name]
if not isinstance(chat_data, list) or len(chat_data) == 0:
continue
for turn in chat_data:
if not isinstance(turn, dict):
continue
keys = {str(key) for key in turn.keys()}
turn_keys.update(keys)
if _SHAREGPT_KEYS.issubset(keys):
has_sharegpt = True
if _CHATML_KEYS.issubset(keys):
has_chatml = True
if has_sharegpt:
return {
"format": "sharegpt",
"chat_column": column_name,
"needs_standardization": True,
"sample_keys": sorted(turn_keys),
}
if has_chatml:
return {
"format": "chatml",
"chat_column": column_name,
"needs_standardization": False,
"sample_keys": sorted(turn_keys),
}
if turn_keys:
return {
"format": "unknown",
"chat_column": column_name,
"needs_standardization": None,
"sample_keys": sorted(turn_keys),
}
return None
def _detect_conversation_column(rows: list[dict], column_names: list[str]) -> dict | None:
column_name_set = set(column_names)
unknown_exact = None
for column_name in CONVERSATION_COLUMNS:
if column_name not in column_name_set:
continue
inspected = _inspect_conversation_column(rows, column_name)
if inspected and inspected["format"] in {"sharegpt", "chatml"}:
return inspected
if inspected and unknown_exact is None:
unknown_exact = inspected
structural_candidates = []
for column_name in column_names:
if column_name in CONVERSATION_COLUMNS:
continue
inspected = _inspect_conversation_column(rows, column_name)
if inspected and inspected["format"] in {"sharegpt", "chatml"}:
structural_candidates.append(inspected)
trace_candidates = [
candidate
for candidate in structural_candidates
if _is_trace_conversation_name(candidate["chat_column"])
]
if len(trace_candidates) == 1:
return trace_candidates[0]
if len(trace_candidates) > 1:
return unknown_exact
if len(structural_candidates) == 1:
return structural_candidates[0]
if unknown_exact is not None:
return unknown_exact
return None
def detect_dataset_format(dataset):
"""Detect dataset format by inspecting structure.
Returns:
dict: {
"format": "alpaca" | "sharegpt" | "chatml" | "unknown",
"chat_column": str | None,
"needs_standardization": bool,
"sample_keys": list of keys found in messages (for debugging)
}
"""
sample_rows = _sample_dataset_rows(dataset)
if not sample_rows:
return {
"format": "unknown",
"chat_column": None,
"needs_standardization": None,
"sample_keys": [],
}
column_names = _get_dataset_column_names(dataset, sample_rows[0])
column_name_set = set(column_names)
# Alpaca
alpaca_columns = {"instruction", "output"}
if alpaca_columns.issubset(column_name_set):
return {
"format": "alpaca",
"chat_column": None,
"needs_standardization": False,
"sample_keys": [],
}
conversation = _detect_conversation_column(sample_rows, column_names)
if conversation:
return conversation
return {
"format": "unknown",
"chat_column": None,
"needs_standardization": None,
"sample_keys": [],
}
def detect_custom_format_heuristic(dataset):
"""Detection with priority scoring.
Strategy for ambiguous keywords like 'task':
1. Detect assistant first (unambiguous)
2. Detect user using high-priority keywords first
3. Check REMAINING columns for system keywords (including 'task')
4. Only if no system match, use 'task' as fallback user
"""
sample = next(iter(dataset))
all_columns = list(sample.keys())
mapping = {}
assistant_words = [
"output",
"answer",
"response",
"assistant",
"completion",
"expected",
"recommendation",
"reply",
"result",
"target",
"solution",
"explanation",
"solve",
]
user_words_high_priority = [
"input",
"question",
"query",
"prompt",
"instruction",
"request",
"snippet",
"user",
"text",
"problem",
"exercise",
]
user_words_low_priority = ["task"] # Ambiguous - can be user OR system
user_words = user_words_high_priority + user_words_low_priority
system_words = [
"system",
"context",
"description",
"persona",
"role",
"template",
"task", # also a system keyword
]
# Metadata columns to ignore.
metadata_exact_match = {
"id",
"idx",
"index",
"key",
"timestamp",
"date",
"metadata",
"source",
"kind",
"type",
"category",
"score",
"label",
"tag",
"inference_mode",
}
metadata_prefix_patterns = [
"problem_type",
"problem_source",
"generation_model",
"pass_rate",
]
priority_patterns = {
"generated": 100,
"gen_": 90,
"model_": 80,
"predicted": 70,
"completion": 60,
}
def has_keyword(col_name, keywords):
"""True if any keyword appears in the column name."""
col_lower = col_name.lower()
col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "")
for keyword in keywords:
if keyword in col_lower or keyword in col_normalized:
return True
return False
def is_metadata(col_name):
"""True if the column is likely metadata."""
col_lower = col_name.lower()
if col_lower in metadata_exact_match:
return True
if col_lower in metadata_prefix_patterns:
return True
for pattern in metadata_prefix_patterns:
if col_lower.startswith(pattern.split("_")[0] + "_") and col_lower != pattern:
if "_" in col_lower:
prefix = col_lower.split("_")[0]
if prefix in ["generation", "pass", "inference"]:
return True
if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]:
return True
return False
def get_priority_score(col_name):
"""Priority score from column-name patterns."""
col_lower = col_name.lower()
score = 0
for pattern, pattern_score in priority_patterns.items():
if pattern in col_lower:
score += pattern_score
return score
def get_content_length(col_name):
"""Average content length for this column."""
try:
if col_name in sample and sample[col_name]:
content = str(sample[col_name])
return len(content)
return 0
except:
return 0
def score_column(col_name, keywords, role_type, num_candidates):
"""Score how likely a column is to be a given role."""
if not has_keyword(col_name, keywords):
return 0
score = 0
score += 10
# Penalize ambiguous "task" so other user columns win.
if role_type == "user":
col_lower = col_name.lower()
if "task" in col_lower and not any(kw in col_lower for kw in user_words_high_priority):
score -= 15
priority_bonus = get_priority_score(col_name)
score += priority_bonus
if role_type in ["assistant", "user"]:
avg_length = get_content_length(col_name)
if num_candidates > 1:
if avg_length > 1000:
score += 50
elif avg_length > 200:
score += 30
elif avg_length > 50:
score += 10
elif avg_length < 50:
score -= 20
else:
if avg_length > 1000:
score += 50
elif avg_length > 200:
score += 30
elif avg_length > 50:
score += 10
return score
content_columns = [col for col in all_columns if not is_metadata(col)]
assistant_potential = [col for col in content_columns if has_keyword(col, assistant_words)]
user_potential = [col for col in content_columns if has_keyword(col, user_words)]
# STEP 1: best ASSISTANT column
assistant_candidates = []
for col in assistant_potential:
score = score_column(col, assistant_words, "assistant", len(assistant_potential))
if score > 0:
assistant_candidates.append((col, score))
if assistant_candidates:
assistant_candidates.sort(key = lambda x: x[1], reverse = True)
assistant_col = assistant_candidates[0][0]
mapping[assistant_col] = "assistant"
else:
assistant_col = None
# STEP 2: best USER column (penalizing ambiguous keywords)
user_candidates = []
for col in user_potential:
if col == assistant_col:
continue
score = score_column(col, user_words, "user", len(user_potential))
if score > 0:
user_candidates.append((col, score))
if user_candidates:
user_candidates.sort(key = lambda x: x[1], reverse = True)
user_col = user_candidates[0][0]
mapping[user_col] = "user"
else:
user_col = None
# STEP 3: check remaining columns for SYSTEM matches
remaining_columns = [col for col in content_columns if col not in mapping]
system_col = None
for col in remaining_columns:
if has_keyword(col, system_words):
mapping[col] = "system"
system_col = col
break
# STEP 4: handle any additional remaining columns
if system_col:
remaining_columns = [col for col in remaining_columns if col != system_col]
if len(remaining_columns) >= 1:
remaining_col = remaining_columns[0]
# No strong keyword match: decide by what's missing.
if not has_keyword(remaining_col, user_words + assistant_words):
mapping[remaining_col] = "system"
elif user_col is None:
mapping[remaining_col] = "user"
else:
mapping[remaining_col] = "system"
# Ensure at least user + assistant.
has_user = any(role == "user" for role in mapping.values())
has_assistant = any(role == "assistant" for role in mapping.values())
if not has_user and len(remaining_columns) > 0:
for col in remaining_columns:
if col not in mapping:
mapping[col] = "user"
has_user = True
break
if has_user and has_assistant:
return mapping
return None
def detect_multimodal_dataset(dataset):
"""Detect multimodal data (images and/or audio) in a dataset.
Two passes per modality: column-name keyword heuristic, then value-type
inspection. Returns a dict with is_image/is_audio flags, detected columns,
modality types, and detected audio/text/speaker columns.
"""
sample = next(iter(dataset))
column_names = list(sample.keys())
image_keywords = [
"image",
"img",
"pixel",
"jpg",
"jpeg",
"png",
"webp",
"bmp",
"gif",
"tiff",
"svg",
"photo",
"pic",
"picture",
"visual",
"file_name",
"filename",
]
audio_keywords = ["audio", "speech", "wav", "waveform", "sound"]
multimodal_columns = []
audio_columns = []
modality_types = set()
# ── Image detection ─────────────────────────────────────
# Pass 1: column-name heuristic (word-boundary match)
for col_name in column_names:
for keyword in image_keywords:
if _keyword_in_column(keyword, col_name):
multimodal_columns.append(col_name)
modality_types.add(keyword)
break
# Pass 2: inspect actual values
already_detected = set(multimodal_columns)
for col_name in column_names:
if col_name in already_detected:
continue
value = sample[col_name]
if _is_image_value(value):
multimodal_columns.append(col_name)
modality_types.add("image")
# ── Audio detection ─────────────────────────────────────
# Pass 1: column-name heuristic (word-boundary match)
for col_name in column_names:
for keyword in audio_keywords:
if _keyword_in_column(keyword, col_name):
audio_columns.append(col_name)
modality_types.add("audio")
break
# Pass 2: inspect actual values (catches non-obvious column names)
already_audio = set(audio_columns)
for col_name in column_names:
if col_name in already_audio:
continue
value = sample[col_name]
if _is_audio_value(value):
audio_columns.append(col_name)
modality_types.add("audio")
# Drop audio columns from the image list (a {"bytes","path"} audio column
# can match _is_image_value).
if audio_columns:
audio_set = set(audio_columns)
multimodal_columns = [c for c in multimodal_columns if c not in audio_set]
# Text column for audio datasets.
detected_text_col = None
if audio_columns:
text_keywords = ["text", "sentence", "transcript", "transcription", "label"]
for col_name in column_names:
if col_name.lower() in text_keywords:
detected_text_col = col_name
break
is_audio = len(audio_columns) > 0
# speaker_id column for TTS datasets (CSM, Orpheus, Spark)
detected_speaker_col = None
if audio_columns:
speaker_keywords = ["source", "speaker", "speaker_id"]
for col_name in column_names:
if col_name.lower() in speaker_keywords:
detected_speaker_col = col_name
break
return {
"is_image": len(multimodal_columns) > 0,
"multimodal_columns": multimodal_columns,
"modality_types": list(modality_types),
"is_audio": is_audio,
"audio_columns": audio_columns,
"detected_audio_column": audio_columns[0] if audio_columns else None,
"detected_text_column": detected_text_col,
"detected_speaker_column": detected_speaker_col,
}
def _is_image_value(value) -> bool:
"""Check if a single sample value looks like image data."""
if value is None:
return False
try:
from PIL.Image import Image as PILImage
if isinstance(value, PILImage):
return True
except ImportError:
pass
# HF Image feature: decoded as PIL, or {"bytes", "path"} when undecoded.
# Exclude audio dicts (decoded audio has "array" + "sampling_rate").
if isinstance(value, dict):
if "array" in value and "sampling_rate" in value:
return False # audio, not image
if "bytes" in value and "path" in value:
# Use path extension to exclude audio files.
path = value.get("path") or ""
if isinstance(path, str) and any(
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
):
return False
return True
if isinstance(value, (bytes, bytearray)):
return _has_image_header(value)
# String that looks like an image file path or URL.
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg")
if isinstance(value, str) and len(value) < 1000:
lower = value.strip().lower()
if lower.startswith(("http://", "https://")) and any(
lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS
):
return True
if any(lower.endswith(ext) for ext in _IMAGE_EXTS):
return True
return False
_AUDIO_EXTENSIONS = (
".wav",
".mp3",
".flac",
".ogg",
".opus",
".m4a",
".aac",
".wma",
".webm",
)
def _is_audio_value(value) -> bool:
"""Check if a single sample value looks like audio data."""
if value is None:
return False
# HF Audio feature: decoded -> {"array", "sampling_rate"}; undecoded -> {"bytes", "path"}.
if isinstance(value, dict):
if "array" in value and "sampling_rate" in value:
return True
if "bytes" in value or "path" in value:
path = value.get("path") or ""
if isinstance(path, str) and any(
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
):
return True
return False
def _has_image_header(data: bytes) -> bool:
"""Quick magic-byte check for common image formats."""
if len(data) < 4:
return False
if data[:2] == b"\xff\xd8": # JPEG
return True
if data[:4] == b"\x89PNG": # PNG
return True
if data[:3] == b"GIF": # GIF
return True
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP": # WebP
return True
if data[:2] == b"BM": # BMP
return True
return False
def detect_vlm_dataset_structure(dataset):
"""Detect which VLM dataset shape this is:
- Standard VLM messages (image objects in content)
- Llava format (image indices + separate images column)
- Simple format needing conversion (image + text columns)
"""
try:
sample = next(iter(dataset))
except StopIteration:
return {
"format": "unknown",
"needs_conversion": None,
"image_column": None,
"text_column": None,
"messages_column": None,
}
column_names = set(sample.keys())
if "messages" in column_names:
messages = sample["messages"]
if messages and len(messages) > 0:
first_msg = messages[0]
if "content" in first_msg:
content = first_msg["content"]
if isinstance(content, list) and len(content) > 0:
if isinstance(content[0], dict) and "type" in content[0]:
# Llava format?
has_index = any(
"index" in item for item in content if isinstance(item, dict)
)
has_images_column = "images" in column_names
if has_index and has_images_column:
return {
"format": "vlm_messages_llava",
"needs_conversion": True,
"messages_column": "messages",
"image_column": "images",
"text_column": None,
}
# Standard VLM format
has_image = any(
"image" in item for item in content if isinstance(item, dict)
)
if has_image:
return {
"format": "vlm_messages",
"needs_conversion": False,
"messages_column": "messages",
"image_column": None,
"text_column": None,
}
# ShareGPT/ChatML conversations with <image> placeholder + companion
# image column (e.g. Lin-Chen/ShareGPT4V, LLaVA-style datasets)
for chat_col in ("conversations", "messages"):
if chat_col not in column_names:
continue
chat_data = sample[chat_col]
if not isinstance(chat_data, list) or len(chat_data) == 0:
continue
first_msg = chat_data[0]
if not isinstance(first_msg, dict):
continue
# ShareGPT (from/value) or ChatML (role/content).
msg_text = first_msg.get("value") or first_msg.get("content")
if not isinstance(msg_text, str):
continue
has_image_placeholder = any(
"<image>" in str(m.get("value", "") or m.get("content", ""))
for m in chat_data
if isinstance(m, dict)
)
if not has_image_placeholder:
continue
# Find companion image column.
image_col = None
for col in column_names:
if col == chat_col:
continue
if _keyword_in_column("image", col) or _keyword_in_column("img", col):
image_col = col
break
if image_col:
return {
"format": "sharegpt_with_images",
"needs_conversion": True,
"image_column": image_col,
"text_column": None,
"messages_column": chat_col,
}
# Find image and text columns, filtering out metadata patterns
metadata_patterns = {
"suffixes": [
"_id",
"_url",
"_name",
"_filename",
"_uri",
"_link",
"_key",
"_index",
],
"prefixes": [
"id_",
"url_",
"name_",
"filename_",
"uri_",
"link_",
"key_",
"index_",
],
}
image_keywords = [
"image",
"img",
"photo",
"picture",
"pic",
"visual",
"scan",
"file_name",
"filename",
]
text_keywords = [
"text",
"caption",
"captions",
"description",
"answer",
"output",
"response",
"label",
]
def is_metadata_column(col_name):
"""True if the column name looks like metadata."""
col_lower = col_name.lower()
if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]):
return True
if any(col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"]):
return True
return False
def _score_image_candidate(col, sample_value):
"""Score a candidate image column by how resolvable its value is."""
# PIL Image (already loaded) -> highest.
if hasattr(sample_value, "size") and hasattr(sample_value, "mode"):
return 100
# HF Image feature dict.
if isinstance(sample_value, dict) and ("bytes" in sample_value or "path" in sample_value):
return 75
if isinstance(sample_value, str):
if sample_value.startswith(("http://", "https://")): # URL
return 70 if not is_metadata_column(col) else 55
if is_metadata_column(col): # bare file path
return 30
return 50
return 0
def _probe_image_candidate(col, sample_value):
"""Probe whether an image candidate is reachable (True unless definitely broken)."""
import os
# PIL / dict — already loaded.
if not isinstance(sample_value, str):
return True
# Local file — check it exists.
if not sample_value.startswith(("http://", "https://")):
return os.path.exists(sample_value) # bare filenames return False, that's OK
# URL — quick HEAD with short timeout.
try:
import urllib.request
req = urllib.request.Request(sample_value, method = "HEAD")
resp = urllib.request.urlopen(req, timeout = 3)
return resp.status < 400
except Exception:
return False
def find_image_column():
"""Find image column by keyword match + value-based fallback, probing for one that works."""
candidates = []
# Pass 1: keyword-matched columns.
for col in column_names:
if any(_keyword_in_column(keyword, col) for keyword in image_keywords):
sample_value = sample[col]
score = _score_image_candidate(col, sample_value)
if score > 0:
candidates.append((col, score))
# Pass 2: value-based fallback for image URLs/paths even when the name
# doesn't match keywords.
already = {c[0] for c in candidates}
for col in column_names:
if col in already:
continue
sample_value = sample[col]
if _is_image_value(sample_value):
score = _score_image_candidate(col, sample_value)
# Penalise non-keyword columns so keyword matches win on ties.
candidates.append((col, max(score - 5, 1)))
if not candidates:
return None
candidates.sort(key = lambda x: x[1], reverse = True)
# Single candidate or top is PIL/dict — no probing needed.
if len(candidates) == 1 or candidates[0][1] >= 75:
return candidates[0][0]
# Multiple string candidates — probe for one that works.
for col, score in candidates:
sample_value = sample[col]
if _probe_image_candidate(col, sample_value):
return col
# None probed OK — return highest-scored; conversion may still resolve it.
return candidates[0][0]
def find_text_column():
"""Find text column: skip metadata, match keywords."""
candidates = []
for col in column_names:
if is_metadata_column(col):
continue
if any(_keyword_in_column(keyword, col) for keyword in text_keywords):
sample_value = sample[col]
if isinstance(sample_value, str) and len(sample_value) > 0:
# Longer text = higher priority (content, not a label).
priority = min(len(sample_value), 1000)
candidates.append((col, priority))
elif (
isinstance(sample_value, list)
and len(sample_value) > 0
and isinstance(sample_value[0], str)
):
# List of strings (e.g. captions) — lower priority than plain str.
priority = min(len(sample_value[0]), 1000) // 2
candidates.append((col, priority))
if candidates:
candidates.sort(key = lambda x: x[1], reverse = True)
return candidates[0][0]
return None
found_image = find_image_column()
found_text = find_text_column()
if found_image and found_text:
return {
"format": "simple_image_text",
"needs_conversion": True,
"image_column": found_image,
"text_column": found_text,
"messages_column": None,
}
return {
"format": "unknown",
"needs_conversion": None,
"image_column": found_image,
"text_column": found_text,
"messages_column": None,
}