curator is a config-driven system for generating high-quality synthetic training datasets for post-training LLMs. The philosophy is to minimize code complexity through DataFrame-centric operations and configuration-driven workflows.
- Config-Driven: All parameters come from YAML files, no hardcoding
- DataFrame-Centric: Every operation uses inspectable DataFrames for debugging
- Unified Loading: Single loader handles all formats (text, JSON, parquet)
- Minimal Scripts: One unified processor replaces dozens of specialized scripts
- Extraction-Based: Use LLMs to extract structured data from any source
# Run all rounds defined in config.yaml
python3 workflows/simple_unified_processor.py
# Run a specific round
python3 workflows/simple_unified_processor.py --round round_2 --config my_config.yamlThis is the primary workflow that supersedes all other processors. It handles any number of rounds based on configuration:
- Automatically chains rounds (Round 2 uses Round 1 output, etc.)
- All parameters from config.yaml (no hardcoding)
- Supports validation, filtering, and structure preservation
- Handles both text and parquet inputs seamlessly
- Automatic directory organization: Creates config-based subdirectories (e.g.,
config_mcq.yaml→outputs/mcq/)
The entire system is built around DataFrame transformations, making every step inspectable with df.head():
-
load_source_data_as_df(path, chunk_size=10000)- Input: Any file/directory (text, JSON, parquet, PDF, HTML)
- Output: DataFrame with columns:
[row_id, source_file, chunk_index, text, char_count] - For parquet: Preserves ALL existing columns from previous rounds
-
apply_template_to_df(df, template_path)- Input: DataFrame with text/data columns
- Output: Same DataFrame +
promptcolumn - Uses
.apply()to process each row through the template
-
df_to_api_requests(df, model_name, max_tokens_per_part)- Input: DataFrame with
promptcolumn - Output: JSONL files ready for batch API
- Automatically splits into parts based on token limits
- Only processes rows where
is_valid != False
- Input: DataFrame with
-
results_to_df(jsonl_path)- Input: Extracted results from API (
.extracted.jsonl) - Output: DataFrame with all extracted fields
- Input: Extracted results from API (
-
merge_results_with_source(source_df, results_df, has_branching=False)- Input: Original DataFrame + Results DataFrame
- Output: Combined DataFrame preserving source text and metadata
- has_branching=True: Use parent ID merge (for list extraction that creates new IDs)
- has_branching=False: Use direct merge (for 1:1 transformations, default)
- Handles compound row IDs (e.g.,
chunk_001_000)
-
add_sampled_columns(df, variable_configs)- Adds random variables from YAML files (e.g., personas)
- Example:
df = add_sampled_columns(df, {'persona': 'variables/persona.yaml'})
-
validate_required_columns(df, required_cols)- Adds/updates
is_validcolumn based on null checks - Used before API submission to filter incomplete rows
- Adds/updates
-
load_source_data_with_structure(root_dir)- Special loader that preserves subdirectory structure
- Adds
source_dircolumn and prefixes row_ids with directory name
Every operation returns a DataFrame, enabling:
# Debug at any step
print(df.head())
print(df.columns)
print(df['prompt'].iloc[0])
# Quick filtering
valid_df = df[df['is_valid'] == True]
# Easy inspection
df.info()
df.describe()This approach replaces hundreds of lines of custom processing logic with simple, composable DataFrame operations.
goal: Generate Q&A dataset from text
teacher_model: gemini-2.5-flash
api_type: vertex
input_data_metadata:
path: data/ # Input directory or parquet file
chunk_size: 100000 # Text chunking (ignored for parquet)
tokens_per_part: 100000000 # Batch size for API
data_gen_procedures:
round_1:
prompt_name: multi_qa_extraction
extraction_config: extraction_configs/multi_extraction.yaml
has_branching: true # List extraction creates new IDs
round_2:
# input_path: outputs/dataset/round1/ # Optional: load from split parquet
prompt_name: solve_with_thinking
extraction_config: extraction_configs/thinking_and_answer.yaml
has_branching: false # 1:1 transformation keeps same IDs
validation:
enabled: true
strategy: exact_match
compare_fields:
predicted: model_answer
ground_truth: answer
filter: 'correct_only'Parameters cascade in this order:
- Round-specific config
- Top-level config (e.g.,
teacher_model) input_data_metadatadefaults- Hardcoded sensible defaults
The system automatically creates config-based subdirectories to organize intermediate files and results:
# Config file determines output directory
config_mcq.yaml → outputs/mcq/
config_qa.yaml → outputs/qa/
config_reasoning.yaml → outputs/reasoning/Benefits:
- Clean separation: Each config gets its own directory
- No conflicts: Multiple configs can run simultaneously without file collisions
- Easy cleanup: Delete
outputs/mcq/to remove all files from that config - Organized results: Find all files related to a specific dataset in one place
Generated Files:
outputs/mcq/
├── round_1_requests.jsonl # API batch requests
├── round_1_requests.metadata.jsonl # Request metadata
├── round_1_output.parquet # Round results (auto-chained to Round 2)
├── round_2_requests.jsonl # Next round requests
├── round_2_output.parquet # Final results
└── source_with_structure.jsonl # Directory structure info (if applicable)
Override behavior: You can still specify custom output_dir in any round config to use a different directory.
When input has subdirectories, the system preserves structure through processing:
data/category_A/ → outputs/dataset/category_A/
data/category_B/ → outputs/dataset/category_B/
The key insight is that load_source_data_as_df() treats all input as text for LLM processing:
- Text files: Chunks into specified size for context windows
- JSON files: Loads as raw text - LLMs understand JSON structure naturally
- Parquet files: Special case - preserves ALL columns from previous rounds, ignores chunk_size
- PDFs/HTML: Extracts text content (requires optional libraries)
This unified approach means you don't need separate loaders or special handling - the LLM extracts what it needs based on your prompt template.
Configure validation in any round:
validation:
enabled: true
strategy: exact_match # or case_insensitive
compare_fields:
predicted: model_answer
ground_truth: answer
filter: all # Keep all with is_valid columnThe system uses a pipeline approach where each round transforms data and passes it to the next:
- Round 1: Extract structured data from raw text (e.g., Q&A pairs, MCQAs)
- Round 2+: Apply transformations (e.g., solve questions, evaluate quality)
- Final: Format for training (e.g., OpenAI chat format, DPO pairs)
Each round produces a parquet file that becomes input for the next round, maintaining full data lineage through compound row IDs (e.g., chunk_00000001_000_001).
Branching rounds (has_branching=True):
- Create multiple outputs from single input (1:N transformation)
- Example: Extract multiple Q&A pairs from one text chunk
- IDs change:
chunk_001→chunk_001_000,chunk_001_001,chunk_001_002 - Use parent ID merge when combining results
Non-branching rounds (has_branching=False, default):
- Transform each input to single output (1:1 transformation)
- Example: Solve an existing MCQA, evaluate quality
- IDs stay same:
chunk_001_000→chunk_001_000 - Use direct merge on row_id
Configure in merge function:
# Round 1: List extraction creates branching
merged_df = merge_results_with_source(source_df, results_df, has_branching=True)
# Round 2: Solving MCQAs, no new IDs created
merged_df = merge_results_with_source(source_df, results_df, has_branching=False)# Round 1: Raw text → Questions
df = load_source_data_as_df('data/') # Load raw text
df = apply_template_to_df(df, 'prompts/extract_qa.py') # Add prompts
files = df_to_api_requests(df) # Generate API requests
# ... API processing ...
results_df = results_to_df('results.jsonl') # Load results
df = merge_results_with_source(df, results_df) # Combine
# Round 2: Questions → Answers with thinking
df = load_source_data_as_df('round1.parquet') # Previous output
df = apply_template_to_df(df, 'prompts/solve.py') # Solve prompts
# ... continue pipeline ...# Complete Pipeline with Auto-Directory Detection
# All intermediate files automatically organized by config name
# Round 1: Extract MCQAs
python3 workflows/simple_unified_processor.py --config config_mcq.yaml --round round_1
python3 io/upload_api_batch.py outputs/mcq/round_1_requests.jsonl --config config_mcq.yaml
python3 io/download_extract_results.py BATCH_JOB_NAME \
--config config_mcq.yaml \
--extraction-config extraction_configs/multi_extraction.yaml
# Post-process with automatic path detection
python3 workflows/post_process_with_structure.py \
--config config_mcq.yaml \
--extracted outputs/batch_results.extracted.jsonl \
--source-parquet outputs/round_1_output.parquet \
--has-branching true \
--split-output
# Round 2: Solve MCQAs (auto-loads from config directory)
python3 workflows/simple_unified_processor.py --config config_mcq.yaml --round round_2
python3 io/upload_api_batch.py outputs/mcq/round_2_requests.jsonl --config config_mcq.yaml
python3 io/download_extract_results.py BATCH_JOB_NAME \
--config config_mcq.yaml \
--extraction-config extraction_configs/thinking_and_answer.yaml
# Post-process with auto-detection and validation
python3 workflows/post_process_with_structure.py \
--config config_mcq.yaml \
--extracted outputs/batch_results.extracted.jsonl \
--source-parquet outputs/round_2_output.parquet \
--has-branching false \
--round round_2 \
--split-outputWhen validation filters are specified in config.yaml (e.g., filter_1: model_answer == answer), you should apply these filters BEFORE running post_process_with_structure.py. The system will print a reminder about which filters to apply.
Example filters in config.yaml:
validation:
enabled: true
filter_1: model_answer == answer # Keep only correct answers
filter_2: thinking != null # Remove rows with null thinkingWhen you see these filters, interpret them and apply using pandas:
import pandas as pd
# Load the extracted results
df = pd.read_json('outputs/batch_results.extracted.jsonl', lines=True)
# Apply filter_1: model_answer == answer
df = df[df['model_answer'] == df['answer']]
# Apply filter_2: thinking != null
df = df[df['thinking'].notna()]
df = df[df['thinking'] != ''] # Also remove empty strings
# Save filtered results
df.to_json('outputs/batch_results.extracted.filtered.jsonl', orient='records', lines=True)Then use the filtered file in post_process_with_structure.py:
python3 workflows/post_process_with_structure.py \
--extracted outputs/batch_results.extracted.filtered.jsonl \
--source-parquet outputs/round_2_output.parquet \
...Handles post-API processing: merging results, applying validation, and preserving directory structure.
Key features:
- Merges API results with source DataFrame
- Applies validation filtering BEFORE splitting (if configured)
- Preserves directory structure when splitting output
- Overwrites source parquet for seamless round chaining
Usage:
# Basic usage
python3 workflows/post_process_with_structure.py \
--extracted results.extracted.jsonl \
--source-parquet outputs/round_N_output.parquet \
--has-branching [true|false]
# With validation and structure
python3 workflows/post_process_with_structure.py \
--extracted results.extracted.jsonl \
--source-parquet outputs/round_2_output.parquet \
--has-branching false \
--round round_2 \ # Reads validation config
--config config.yaml \ # From this file
--split-output \ # Split by source_dir
--output-dir outputs/filteredThe system automatically handles parquet files in subdirectories. To resume:
# config.yaml
round_2:
input_path: outputs/dataset/round1/ # Loads all */processed.parquet files
# ... rest of configThen run normally:
python3 workflows/simple_unified_processor.py --round round_2# Upload batch
python3 io/upload_api_batch.py outputs/requests.jsonl
# Check status
python3 io/upload_api_batch.py --check-status BATCH_JOB_NAME
# Download and extract
python3 io/download_extract_results.py BATCH_JOB_NAME \
--extraction-config extraction_configs/your_config.yaml# Process parts sequentially
python3 io/process_batch_parts_sequential.py \
--part-dir outputs \
--pattern "requests_part_*.jsonl"Prompts are Python files with f-string templates that reference DataFrame columns. This allows complex logic while keeping prompts readable and versionable. The extraction configs then parse the LLM's structured output.
# extraction_configs/thinking_and_answer.yaml
patterns:
thinking: '<THINKING>(.*?)</THINKING>'
model_answer: '<ANSWER>\s*([A-D])\s*</ANSWER>'
defaults:
thinking: null
model_answer: null# extraction_configs/multi_extraction.yaml
extraction_mode: list
item_pattern: '<ITEM>(.*?)</ITEM>'
patterns:
question: '<QUESTION>(.*?)</QUESTION>'
answer: '<ANSWER>(.*?)</ANSWER>'prompts/ # Prompt templates (Python files)
extraction_configs/ # Regex patterns for result extraction (YAML)
format_templates/ # Output format definitions (Python)
cookbook/ # Example configurations
workflows/
simple_unified_processor.py # Main processor - generates API requests
post_process_with_structure.py # Post-processor - merges results, validates, splits
simple_round1_processor.py # Legacy - kept for reference
simple_round2_processor.py # Legacy - kept for reference
io/ # API upload/download utilities
processing/
dataframe_core.py # Core DataFrame operations
dataset_formatter.py # Final formatting with templates
- Always use
simple_unified_processor.py- it supersedes all other processors - Validation defaults to
enabled: falsewhen not specified - The
filterparameter controls whether invalid rows are removed immediately - For maximum flexibility, use
filter: alland filter during final formatting - All JSON files are treated as plain text (the LLM extracts structured data)
See cookbook/ directory for complete examples:
instruction_answer_generation_config.yaml: Two-round Q&A generation with evaluationmcqa_generation_config.yaml: MCQA generation with thinking tracesconfig_unified_example.yaml: Comprehensive multi-round pipeline example