Spaces:
Sleeping
Sleeping
File size: 9,008 Bytes
e84e505 | 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 | """Phase 1 dataset generation.
Generates Bloom-level student questions and writes them to JSONL.
"""
import json
import os
import random
import sys
import time
from dotenv import load_dotenv
# Let this script run from project root.
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config # noqa: E402
from scripts.generators import build_source # noqa: E402
class BloomQuestionGenerator:
"""Generate labeled student questions by Bloom level."""
def __init__(self, generation_config, source):
"""Store generation config and provider source."""
self.cfg = generation_config
self.source = source
def _build_prompt(self, domain_label, topic, sublevel):
"""Build one prompt for a domain/topic/sublevel combo."""
readable_domain = domain_label.replace("_", " ")
definition = config.SUBLEVEL_DEFINITIONS[sublevel]
anchors = config.SUBLEVEL_FEWSHOT[sublevel]
all_stems = config.SUBLEVEL_STEMS[sublevel]
# Sample stems each time to vary phrasing.
sampled_stems = random.sample(all_stems, min(6, len(all_stems)))
template = random.choice(self.cfg.instruction_templates)
instruction = template.format(
n=self.cfg.batch_size, topic=topic, domain=readable_domain
)
trap_count = max(1, round(self.cfg.batch_size * self.cfg.anti_shortcut_fraction))
anchor_block = "\n".join(f" - {a}" for a in anchors)
stem_block = ", ".join(f'"{s}..."' for s in sampled_stems)
prompt = (
f"{instruction}\n\n"
f"Every question must sit at the cognitive level '{sublevel}', "
f"meaning a question that {definition}.\n\n"
f"Reference examples of '{sublevel}' questions (different subject, "
f"shown only to convey the cognitive level, do not copy them):\n"
f"{anchor_block}\n\n"
f"Vary the phrasing widely. You may open questions in different ways, "
f"for example: {stem_block}. Do not start most questions the same way.\n\n"
f"Important diversity requirement: at least {trap_count} of the "
f"{self.cfg.batch_size} questions must be 'counter-stereotypical', "
f"meaning their opening word would NOT normally signal the "
f"'{sublevel}' level, yet the question genuinely belongs to it. "
f"For example a critical, limitation-probing question that begins "
f"with 'What' or 'Why', or a factual recall question that begins "
f"with 'Define' or 'List' rather than 'What'. The cognitive intent, "
f"not the first word, determines the level.\n\n"
f"Constraints:\n"
f" - Each question must genuinely match the '{sublevel}' level.\n"
f" - Vary length and structure across the questions.\n"
f" - Keep each question to a single sentence.\n\n"
f"Return a JSON object with a single key \"questions\" whose value is "
f"an array of {self.cfg.batch_size} question strings. "
f'Example: {{"questions": ["...", "..."]}}'
)
return prompt
def _parse_questions(self, raw_text):
"""Parse generated questions from JSON response text."""
text = raw_text.strip()
if text.startswith("```"):
text = text.strip("`")
if text.startswith("json"):
text = text[4:]
parsed = json.loads(text)
if isinstance(parsed, dict):
questions = parsed.get("questions", [])
elif isinstance(parsed, list):
questions = parsed
else:
questions = []
return [q.strip() for q in questions if isinstance(q, str) and q.strip()]
def _generate_batch(self, prompt):
"""Call provider with retry and backoff."""
for attempt in range(self.cfg.max_retries):
try:
raw = self.source.complete(prompt)
return self._parse_questions(raw)
except json.JSONDecodeError:
wait = self.cfg.backoff_base_seconds * (2 ** attempt)
print(f" JSON parse failed, retrying in {wait:.1f}s")
time.sleep(wait)
except Exception as error:
wait = self.cfg.backoff_base_seconds * (2 ** attempt)
print(f" API error ({type(error).__name__}), retrying in {wait:.1f}s")
time.sleep(wait)
print(" giving up on this batch after max retries")
return []
@staticmethod
def _completed_combos(output_path):
"""Return already finished (domain, sublevel) combos."""
done = set()
if not os.path.exists(output_path):
return done
with open(output_path, "r", encoding="utf-8") as handle:
for line in handle:
try:
record = json.loads(line)
done.add((record["domain"], record["bloom_sublevel"]))
except (json.JSONDecodeError, KeyError):
continue
return done
def _write_records(self, records, output_path):
"""Append records to JSONL output."""
with open(output_path, "a", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
def _generate_for_combo(self, domain_label, subtopics, sublevel, n_batches, split):
"""Generate records for one (domain, sublevel)."""
records = []
for batch_index in range(n_batches):
topic = random.choice(subtopics)
prompt = self._build_prompt(domain_label, topic, sublevel)
questions = self._generate_batch(prompt)
for question in questions:
records.append({
"question": question,
"bloom_sublevel": sublevel,
"bloom_class": config.SUBLEVEL_TO_CLASS[sublevel],
"domain": domain_label,
"topic": topic,
"split": split,
"source": self.source.label,
})
print(f" batch {batch_index + 1}/{n_batches} on '{topic}': "
f"{len(questions)} questions")
time.sleep(self.cfg.seconds_between_calls)
return records
def run(self):
"""Run full generation loop."""
output_path = self.cfg.output_path
already_done = self._completed_combos(output_path)
if already_done:
print(f"Resuming: {len(already_done)} combos already complete.\n")
plan = [
(config.TRAIN_DOMAINS, "train", self.cfg.train_batches_per_combo),
(config.OOD_DOMAINS, "ood", self.cfg.ood_batches_per_combo),
]
total_records = 0
for domain_dict, split, n_batches in plan:
for domain_label, subtopics in domain_dict.items():
for sublevel in config.BLOOM_SUBLEVELS:
if (domain_label, sublevel) in already_done:
print(f"[skip] {domain_label} / {sublevel} (already done)")
continue
print(f"[{split}] {domain_label} / {sublevel}")
records = self._generate_for_combo(
domain_label, subtopics, sublevel, n_batches, split
)
self._write_records(records, output_path)
total_records += len(records)
print(f" -> wrote {len(records)} records "
f"(running total {total_records})\n")
print(f"Done. Output at {output_path}")
self._print_summary(output_path)
@staticmethod
def _print_summary(output_path):
"""Print simple distribution summary."""
class_counts, domain_counts, split_counts = {}, {}, {}
with open(output_path, "r", encoding="utf-8") as handle:
for line in handle:
record = json.loads(line)
class_counts[record["bloom_class"]] = class_counts.get(record["bloom_class"], 0) + 1
domain_counts[record["domain"]] = domain_counts.get(record["domain"], 0) + 1
split_counts[record["split"]] = split_counts.get(record["split"], 0) + 1
print("\nClass distribution:", class_counts)
print("Domain distribution:", domain_counts)
print("Split distribution:", split_counts)
def main():
"""Load env, build source, and run generation."""
load_dotenv()
generation_config = config.GenerationConfig()
source = build_source(
generation_config.provider,
generation_config.model_name,
generation_config.temperature,
)
print(f"Generating with source: {source.label}\n")
generator = BloomQuestionGenerator(generation_config, source)
generator.run()
if __name__ == "__main__":
main()
|