forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathformats.py
More file actions
675 lines (564 loc) · 25.4 KB
/
Copy pathformats.py
File metadata and controls
675 lines (564 loc) · 25.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
"""Dataset format detection and conversion.
Supported formats:
- alpaca: {"instruction": ..., "input": ..., "output": ...}
- sharegpt: {"conversations": [{"from": "human", "value": ...}, ...]}
- chatml: {"messages": [{"role": "user", "content": ...}, ...]}
- dpo: {"prompt": ..., "chosen": ..., "rejected": ...}
- kto: {"prompt": ..., "completion": ..., "label": true/false}
- llava: {"image": ..., "conversations": [{"from": "human", "value": ...}, ...]}
- sharegpt4v: {"image": ..., "conversations": [{"from": "human", "value": ...}, ...]}
- plaintext: {"text": "..."} — raw text for continued pre-training
- embedding: {"anchor": ..., "positive": ..., "negative": ...} — sentence embedding pairs/triplets
- audio: {"audio": ..., "messages": [...]} — audio + conversation for speech models
- tool-calling: {"messages": [...], "tools": [...], "tool_calls": [...]} — function-calling training
"""
import json
from typing import Optional
from rich.console import Console
console = Console()
# Required keys per format
FORMAT_SIGNATURES = {
"alpaca": {"instruction", "output"},
"sharegpt": {"conversations"},
"chatml": {"messages"},
"dpo": {"prompt", "chosen", "rejected"},
"kto": {"prompt", "completion", "label"},
"llava": {"image", "conversations"},
"sharegpt4v": {"image", "conversations"},
"embedding": {"anchor", "positive"},
"audio": {"audio", "messages"},
# v0.71.32 — ASR (Whisper): audio path + reference transcript.
"asr": {"audio", "text"},
"plaintext": {"text"},
"tool-calling": {"messages", "tools", "tool_calls"},
}
def detect_format(data: list[dict]) -> str:
"""Auto-detect dataset format from first few rows."""
if not data:
raise ValueError("Empty dataset - cannot detect format")
sample = data[0]
keys = set(sample.keys())
# Check more specific formats first (llava/sharegpt4v before sharegpt).
# tool-calling (messages+tools+tool_calls) is checked BEFORE audio
# (audio+messages): a row carrying both would otherwise match audio first
# and silently drop its tools/tool_calls. tool-calling before chatml
# (signature is a superset of chatml). asr ({audio, text}) is checked
# BEFORE plaintext ({text}) — its signature is a superset, so plaintext
# would otherwise win and silently drop the audio path. plaintext last.
check_order = [
"alpaca", "llava", "sharegpt4v", "kto", "dpo", "embedding",
"tool-calling", "audio", "asr", "sharegpt", "chatml", "plaintext",
]
for fmt in check_order:
required_keys = FORMAT_SIGNATURES[fmt]
if required_keys.issubset(keys):
return fmt
raise ValueError(
f"Cannot detect format. Keys found: {keys}. "
f"Expected one of: alpaca (instruction, output), "
f"sharegpt (conversations), chatml (messages), "
f"dpo (prompt, chosen, rejected), "
f"kto (prompt, completion, label), "
f"llava/sharegpt4v (image, conversations), "
f"embedding (anchor, positive), "
f"audio (audio, messages), "
f"tool-calling (messages, tools, tool_calls), "
f"plaintext (text)"
)
def format_to_messages(row: dict, fmt: str) -> Optional[dict]:
"""Convert any format to normalized structure for training.
Returns:
- SFT formats: {"messages": [{"role": ..., "content": ...}, ...]}
- Vision formats: {"messages": [...], "image": "path"}
- DPO format: {"prompt": ..., "chosen": ..., "rejected": ...}
- KTO format: {"prompt": ..., "completion": ..., "label": bool}
"""
valid_formats = (
"chatml", "alpaca", "sharegpt", "dpo", "kto", "llava", "sharegpt4v",
"plaintext", "embedding", "audio", "tool-calling",
# v0.42.0 Part A
"prm", "pre_tokenized", "input_output", "video", "multimodal",
# v0.62.0 Part A — RAFT (Retrieval-Augmented Fine-Tuning).
"raft",
# v0.71.32 — ASR (Whisper): {"audio": path, "text": transcript}.
"asr",
)
if fmt not in valid_formats:
raise ValueError(f"Unknown format: {fmt}")
try:
if fmt == "chatml":
return _convert_chatml(row)
elif fmt == "alpaca":
return _convert_alpaca(row)
elif fmt == "sharegpt":
return _convert_sharegpt(row)
elif fmt == "dpo":
return _convert_dpo(row)
elif fmt == "kto":
return _convert_kto(row)
elif fmt == "plaintext":
return _convert_plaintext(row)
elif fmt == "embedding":
return _convert_embedding(row)
elif fmt == "audio":
return _convert_audio(row)
elif fmt == "tool-calling":
return _convert_tool_calling(row)
elif fmt == "prm":
return _convert_prm(row)
elif fmt == "pre_tokenized":
return _convert_pre_tokenized(row)
elif fmt == "input_output":
return _convert_input_output(row)
elif fmt == "video":
return _convert_video(row)
elif fmt == "multimodal":
return _convert_multimodal(row)
elif fmt == "raft":
return _convert_raft(row)
elif fmt == "asr":
return _convert_asr(row)
else:
return _convert_vision(row)
except (KeyError, TypeError, IndexError, ValueError):
return None
def _require_str_content(value: object, field: str) -> str:
"""Reject non-string message content (e.g. a JSON ``null``).
The older converters passed row values through verbatim, so a JSON
``null`` became literal ``None`` message content. Raising here routes the
row to ``format_to_messages``'s drop path instead of silently corrupting
the dataset with a ``None``-content turn.
"""
if not isinstance(value, str):
raise TypeError(f"{field} must be a string, got {type(value).__name__}")
return value
def _convert_alpaca(row: dict) -> dict:
instruction = _require_str_content(row["instruction"], "alpaca.instruction")
input_text = row.get("input") or "" # missing / null -> ""
output = _require_str_content(row["output"], "alpaca.output")
user_content = f"{instruction}\n{input_text}".strip() if input_text else instruction
messages = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": output},
]
if row.get("system"):
messages.insert(0, {"role": "system", "content": row["system"]})
return {"messages": messages}
def _convert_sharegpt(row: dict) -> dict:
conversations = row["conversations"]
role_map = {"human": "user", "gpt": "assistant", "system": "system"}
messages = []
for turn in conversations:
role = role_map.get(turn["from"], turn["from"])
messages.append(
{"role": role, "content": _require_str_content(turn["value"], "sharegpt.value")}
)
return {"messages": messages}
def _convert_chatml(row: dict) -> dict:
# Already in the right format
return {"messages": row["messages"]}
def _convert_dpo(row: dict) -> dict:
"""Convert DPO preference row to {prompt, chosen, rejected} for trl.DPOTrainer.
Note: chosen/rejected may legitimately be message LISTS (conversational
DPO), so this converter only rejects an explicit null rather than requiring
a plain string (unlike the alpaca / sharegpt / vision text converters).
"""
for field in ("prompt", "chosen", "rejected"):
if row.get(field) is None:
raise TypeError(f"dpo.{field} must not be null")
return {
"prompt": row["prompt"],
"chosen": row["chosen"],
"rejected": row["rejected"],
}
def _convert_kto(row: dict) -> dict:
"""Convert KTO row to {prompt, completion, label} for trl.KTOTrainer."""
raw_label = row["label"]
if isinstance(raw_label, str):
low = raw_label.strip().lower()
if low in ("true", "1", "yes"):
label = True
elif low in ("false", "0", "no"):
label = False
else:
raise ValueError(
f"KTO label must be true/false, got string: {raw_label!r}"
)
elif isinstance(raw_label, bool):
label = raw_label
elif isinstance(raw_label, (int, float)):
# Both the ±1 convention (+1 desirable / -1 undesirable) and the 0/1
# convention map "positive == desirable". `bool(-1)` is True, which
# would silently INVERT a -1 "bad" label — flip it to False here.
label = raw_label > 0
else:
label = bool(raw_label)
return {
"prompt": row["prompt"],
"completion": row["completion"],
"label": label,
}
def _convert_plaintext(row: dict) -> dict:
"""Convert plaintext row to {text} for continued pre-training.
Input: {"text": "raw document text..."}
Output: {"text": "raw document text..."}
"""
text = row["text"]
if not isinstance(text, str) or not text.strip():
raise ValueError("Plaintext row must have a non-empty 'text' field")
return {"text": text}
def _convert_embedding(row: dict) -> dict:
"""Convert embedding row to {anchor, positive, negative?} for embedding training.
Input: {"anchor": "query text", "positive": "similar text", "negative": "dissimilar text"}
Output: {"anchor": ..., "positive": ..., "negative": ...} (negative is optional)
"""
anchor = row["anchor"]
positive = row["positive"]
if not isinstance(anchor, str) or not anchor.strip():
raise ValueError("Embedding row must have a non-empty 'anchor' field")
if not isinstance(positive, str) or not positive.strip():
raise ValueError("Embedding row must have a non-empty 'positive' field")
result = {"anchor": anchor, "positive": positive}
negative = row.get("negative")
if isinstance(negative, str) and negative.strip():
result["negative"] = negative
return result
def _convert_audio(row: dict) -> dict:
"""Convert audio format to unified messages + audio path.
Input: {"audio": "path.wav", "messages": [{"role": "user", "content": ...}, ...]}
Output: {"messages": [...], "audio": "path.wav"}
"""
audio = row["audio"]
if not isinstance(audio, str) or not audio.strip():
raise ValueError("Audio row must have a non-empty 'audio' field")
messages = row["messages"]
if not isinstance(messages, list) or len(messages) < 1:
raise ValueError("Audio row must have a 'messages' list with at least one message")
return {"messages": messages, "audio": audio}
def _convert_asr(row: dict) -> dict:
"""Convert an ASR row to the pass-through training shape (v0.71.32).
Input: {"audio": "path.wav", "text": "transcript"}
Output: {"audio": "path.wav", "text": "transcript"}
Unlike other formats, ASR rows are NOT normalized to messages — the Whisper
trainer consumes the raw audio path + reference transcript directly. Row
validation delegates to the trainer's ``_validate_asr_row`` so the two never
drift (lazy import — the trainer module has no top-level heavy deps).
"""
from soup_cli.trainer.asr import _validate_asr_row
audio, text = _validate_asr_row(row)
return {"audio": audio, "text": text}
def _convert_vision(row: dict) -> dict:
"""Convert LLaVA / ShareGPT4V vision format to unified messages + image.
Input: {"image": "path.jpg", "conversations": [{"from": "human", "value": ...}, ...]}
Output: {"messages": [...], "image": "path.jpg"}
"""
conversations = row["conversations"]
role_map = {"human": "user", "gpt": "assistant", "system": "system"}
messages = []
for turn in conversations:
role = role_map.get(turn["from"], turn["from"])
messages.append(
{"role": role, "content": _require_str_content(turn["value"], "vision.value")}
)
result = {"messages": messages, "image": row["image"]}
# Preserve optional id field
if "id" in row:
result["id"] = row["id"]
return result
def _convert_tool_calling(row: dict) -> dict:
"""Normalize tool-calling row to unified messages format.
Input:
{
"messages": [{"role": "user", "content": ...}],
"tools": [{"type": "function", "function": {...}}, ...],
"tool_calls": [{"function": {"name": ..., "arguments": "json-string"}}],
}
Output (unified format — tool schema embedded in system message,
tool_calls attached to final assistant turn):
{
"messages": [
{"role": "system", "content": "<tool schema description>"},
{"role": "user", "content": "..."},
{"role": "assistant", "content": "", "tool_calls": [...]},
]
}
Security: every tool_call's 'arguments' must be JSON-parseable. Tool schemas
must be a list of dicts. Invalid rows raise ValueError and are mapped to None
by the outer handler.
"""
tools = row["tools"]
tool_calls = row["tool_calls"]
if not isinstance(tools, list):
raise ValueError("tool-calling 'tools' must be a list")
if not isinstance(tool_calls, list):
raise ValueError("tool-calling 'tool_calls' must be a list")
for tool in tools:
if not isinstance(tool, dict):
raise ValueError("tool-calling tool entries must be dicts")
normalized_tool_calls = []
for call in tool_calls:
if not isinstance(call, dict):
raise ValueError("tool_calls entries must be dicts")
func = call.get("function")
if not isinstance(func, dict):
raise ValueError("tool_calls entry missing 'function' dict")
name = func.get("name")
if not isinstance(name, str) or not name:
raise ValueError("tool_calls 'function.name' must be a non-empty string")
args = func.get("arguments", "{}")
if isinstance(args, str):
try:
json.loads(args)
except json.JSONDecodeError as exc:
raise ValueError(
f"tool_calls 'arguments' must be JSON-parseable: {exc}"
) from exc
args_str = args
elif isinstance(args, dict):
args_str = json.dumps(args)
else:
raise ValueError("tool_calls 'arguments' must be str or dict")
normalized_tool_calls.append({
"function": {"name": name, "arguments": args_str},
})
original_messages = row["messages"]
if not isinstance(original_messages, list) or not original_messages:
raise ValueError("tool-calling 'messages' must be a non-empty list")
tool_schema_descriptions = []
for tool in tools:
function_def = tool.get("function", {})
tool_name = function_def.get("name", "unknown")
description = function_def.get("description", "")
params = function_def.get("parameters", {})
tool_schema_descriptions.append(
f"- {tool_name}: {description}\n parameters: {json.dumps(params)}"
)
system_content = (
"You have access to the following tools. When a tool call is needed, "
"respond with a function call in JSON.\n\n"
+ "\n".join(tool_schema_descriptions)
)
messages: list[dict] = [{"role": "system", "content": system_content}]
for msg in original_messages:
if not isinstance(msg, dict) or "role" not in msg:
raise ValueError("tool-calling messages must be dicts with 'role'")
if msg["role"] == "system":
# Merge user system message into our synthesized system content
messages[0]["content"] = msg.get("content", "") + "\n\n" + messages[0]["content"]
continue
messages.append({"role": msg["role"], "content": msg.get("content", "")})
if normalized_tool_calls:
messages.append({
"role": "assistant",
"content": "",
"tool_calls": normalized_tool_calls,
})
return {"messages": messages}
def is_vision_format(fmt: str) -> bool:
"""Check if a format is a vision/multimodal format."""
return fmt in ("llava", "sharegpt4v")
def is_audio_format(fmt: str) -> bool:
"""Check if a format is an audio/speech format."""
return fmt in ("audio", "asr")
# --- Reverse conversion: messages → target format ---
CONVERTIBLE_FORMATS = ("alpaca", "sharegpt", "chatml")
def messages_to_format(row: dict, target_fmt: str) -> Optional[dict]:
"""Convert unified messages format back to a specific format.
Input: {"messages": [{"role": ..., "content": ...}, ...]}
Output: dict in target format (alpaca, sharegpt, chatml)
"""
try:
if target_fmt == "chatml":
return row # already in chatml/messages format
elif target_fmt == "alpaca":
return _to_alpaca(row["messages"])
elif target_fmt == "sharegpt":
return _to_sharegpt(row["messages"])
else:
raise ValueError(f"Cannot convert to format: {target_fmt}")
except (KeyError, TypeError, IndexError):
return None
def _to_alpaca(messages: list[dict]) -> dict:
"""Convert messages to alpaca format."""
result: dict = {"instruction": "", "input": "", "output": ""}
for msg in messages:
if msg["role"] == "system":
result["system"] = msg["content"]
elif msg["role"] == "user":
result["instruction"] = msg["content"]
elif msg["role"] == "assistant":
result["output"] = msg["content"]
return result
def _to_sharegpt(messages: list[dict]) -> dict:
"""Convert messages to sharegpt format."""
role_map = {"user": "human", "assistant": "gpt", "system": "system"}
conversations = []
for msg in messages:
conversations.append({
"from": role_map.get(msg["role"], msg["role"]),
"value": msg["content"],
})
return {"conversations": conversations}
# --- v0.42.0 Part A: New format converters ---------------------------------
_MAX_PRM_STEPS = 10_000
# v0.62.0 Part A — RAFT (Retrieval-Augmented Fine-Tuning) caps.
_MAX_RAFT_DISTRACTORS = 64
_MAX_RAFT_FIELD_LEN = 65_536 # 64 KiB per document — generous for legal/RAG corpora.
def _convert_prm(row: dict) -> dict:
"""PRM (Process Reward Model) stepwise-supervised format.
Schema: {"prompt": str, "completions": [str, ...], "labels": [bool, ...]}
Each completion is a reasoning step; each label is True if that step is
correct. Live PPO/RL wiring lands in v0.50 — v0.42.0 stores the row as-is
after schema validation so downstream consumers can opt in.
"""
prompt = row["prompt"]
completions = row["completions"]
labels = row["labels"]
if not isinstance(prompt, str) or not prompt:
raise ValueError("PRM 'prompt' must be a non-empty string")
if not isinstance(completions, list) or not isinstance(labels, list):
raise ValueError("PRM completions/labels must be lists")
if len(completions) != len(labels):
raise ValueError("PRM completions and labels must be same length")
if not completions:
raise ValueError("PRM row must have at least one completion")
if len(completions) > _MAX_PRM_STEPS:
raise ValueError(f"PRM row exceeds {_MAX_PRM_STEPS} steps")
for index, comp in enumerate(completions):
if not isinstance(comp, str):
raise ValueError(f"PRM completions[{index}] must be a string")
for index, lab in enumerate(labels):
if not isinstance(lab, bool):
raise ValueError(f"PRM labels[{index}] must be a bool")
return {"prompt": prompt, "completions": completions, "labels": labels}
def _convert_pre_tokenized(row: dict) -> dict:
"""Already-tokenized rows — pass through input_ids / labels / attention_mask."""
if "input_ids" not in row:
raise ValueError("pre_tokenized row must have 'input_ids'")
out: dict = {"input_ids": row["input_ids"]}
if "labels" in row:
out["labels"] = row["labels"]
if "attention_mask" in row:
out["attention_mask"] = row["attention_mask"]
return out
def _convert_input_output(row: dict) -> dict:
"""Template-free segments+labels format (axolotl `input_output`).
Schema: {"segments": [{"text": str, "label": bool}, ...]}
Each segment is rendered verbatim — no chat template applied — and only
segments with label=True contribute to the loss.
"""
segments = row["segments"]
if not isinstance(segments, list) or not segments:
raise ValueError("input_output row must have non-empty 'segments' list")
cleaned: list[dict] = []
for seg in segments:
if not isinstance(seg, dict):
raise ValueError("input_output segment must be a dict")
if "text" not in seg or "label" not in seg:
raise ValueError("input_output segment must have 'text' and 'label'")
if not isinstance(seg["text"], str):
raise ValueError("input_output segment.text must be a string")
if not isinstance(seg["label"], bool):
raise ValueError("input_output segment.label must be a bool")
cleaned.append({"text": seg["text"], "label": seg["label"]})
return {"segments": cleaned}
def _convert_video(row: dict) -> dict:
"""Video format. Schema: {"video": "path/url", "messages": [...]}."""
if "video" not in row:
raise ValueError("video row must have 'video' key")
video = row["video"]
if not isinstance(video, str) or not video:
raise ValueError("video row 'video' must be a non-empty string")
if "\x00" in video:
raise ValueError("video row 'video' must not contain null bytes")
if len(video) > 2048:
raise ValueError("video row 'video' must be <= 2048 chars")
messages = row.get("messages") or []
return {"video": video, "messages": messages}
def _convert_multimodal(row: dict) -> dict:
"""Axolotl multimodal content-parts schema.
Schema: {"messages": [{"role": ..., "content": [{"type": "text"|"image"
|"audio"|"video", ...}, ...]}, ...]}
Each message's content is a list of typed parts. Validates the part
types but stores them verbatim.
"""
messages = row["messages"]
if not isinstance(messages, list) or not messages:
raise ValueError("multimodal row must have non-empty 'messages' list")
valid_types = {"text", "image", "audio", "video"}
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
continue # back-compat with plain strings
if not isinstance(content, list):
raise ValueError(
"multimodal message.content must be a list of parts or a string"
)
for part in content:
if not isinstance(part, dict):
raise ValueError("multimodal content part must be a dict")
ptype = part.get("type")
if ptype not in valid_types:
raise ValueError(
f"multimodal content part.type must be in {sorted(valid_types)}"
)
return {"messages": messages}
# --- v0.62.0 Part A: RAFT (Retrieval-Augmented Fine-Tuning) ----------------
def _check_raft_string(name: str, value: object) -> str:
"""Shared validator for RAFT string fields (query / golden_doc / answer).
Returns the canonical value. Rejects non-string, empty, null-byte, and
oversize values (mirrors v0.42.0 `_convert_video` policy). The cap is
generous (64 KiB) because legal/RAG corpora frequently embed full
paragraphs verbatim in the golden_doc field.
"""
if not isinstance(value, str):
raise ValueError(
f"RAFT '{name}' must be a string, got {type(value).__name__}"
)
if not value:
raise ValueError(f"RAFT '{name}' must be a non-empty string")
if "\x00" in value:
raise ValueError(f"RAFT '{name}' must not contain null bytes")
if len(value) > _MAX_RAFT_FIELD_LEN:
raise ValueError(
f"RAFT '{name}' must be <= {_MAX_RAFT_FIELD_LEN} chars"
)
return value
def _convert_raft(row: dict) -> dict:
"""RAFT (Retrieval-Augmented Fine-Tuning) format — Stanford 2024.
Schema: ``{"query": str, "golden_doc": str, "distractor_docs": [str, ...],
"answer": str}``. The trainer composes the prompt by concatenating the
query with the golden doc + N distractor docs in randomised order; the
model learns to attend to the relevant doc while ignoring distractors.
Distractor list MAY be empty (effectively reduces to closed-book QA on
the golden doc). Live RAFT training loop ships in v0.62.1; v0.62.0
locks the schema + recipe surface.
"""
if "query" not in row:
raise ValueError("RAFT row must have 'query'")
if "golden_doc" not in row:
raise ValueError("RAFT row must have 'golden_doc'")
if "answer" not in row:
raise ValueError("RAFT row must have 'answer'")
query = _check_raft_string("query", row["query"])
golden_doc = _check_raft_string("golden_doc", row["golden_doc"])
answer = _check_raft_string("answer", row["answer"])
raw_distractors = row.get("distractor_docs", [])
if not isinstance(raw_distractors, list):
raise ValueError("RAFT 'distractor_docs' must be a list")
if len(raw_distractors) > _MAX_RAFT_DISTRACTORS:
raise ValueError(
f"RAFT 'distractor_docs' must have <= {_MAX_RAFT_DISTRACTORS} entries "
f"(got {len(raw_distractors)})"
)
cleaned_distractors: list[str] = []
for index, doc in enumerate(raw_distractors):
cleaned_distractors.append(
_check_raft_string(f"distractor_docs[{index}]", doc)
)
return {
"query": query,
"golden_doc": golden_doc,
"distractor_docs": cleaned_distractors,
"answer": answer,
}