Spaces:
Running
Running
| from __future__ import annotations | |
| import random | |
| import re | |
| from dataclasses import dataclass | |
| from functools import lru_cache | |
| from typing import List, Optional | |
| import torch | |
| from transformers import pipeline | |
| class MCQItem: | |
| question: str | |
| correct_answer: str | |
| options: List[str] | |
| difficulty: Optional[str] = None | |
| def _normalize(text: str) -> str: | |
| return re.sub(r"\s+", " ", text.strip().lower()) | |
| def _clean_text(text: str) -> str: | |
| return re.sub(r"\s+", " ", text.strip()) | |
| def _extract_qa_pairs(raw_output: str) -> List[tuple[str, str]]: | |
| pairs: List[tuple[str, str]] = [] | |
| # Try block-based extraction first | |
| blocks = re.split(r"\n\s*\n", raw_output.strip()) | |
| for block in blocks: | |
| q_match = re.search(r"(?:Question|Q)\s*[\d.]*\s*:\s*(.+)", block, re.IGNORECASE) | |
| a_match = re.search(r"(?:Answer|A)\s*[\d.]*\s*:\s*(.+)", block, re.IGNORECASE) | |
| if not q_match or not a_match: | |
| continue | |
| question = _clean_text(q_match.group(1)) | |
| answer = _clean_text(a_match.group(1)) | |
| if not question or not answer: | |
| continue | |
| if "?" not in question: | |
| question = question.rstrip(".") + "?" | |
| pairs.append((question, answer)) | |
| # Fallback: line-by-line scan | |
| if not pairs: | |
| lines = raw_output.strip().splitlines() | |
| last_q = None | |
| for line in lines: | |
| line = line.strip() | |
| q_match = re.match(r"(?:Q(?:uestion)?\s*[\d.]*\s*:)\s*(.+)", line, re.IGNORECASE) | |
| a_match = re.match(r"(?:A(?:nswer)?\s*[\d.]*\s*:)\s*(.+)", line, re.IGNORECASE) | |
| if q_match: | |
| last_q = _clean_text(q_match.group(1)) | |
| if "?" not in last_q: | |
| last_q = last_q.rstrip(".") + "?" | |
| elif a_match and last_q: | |
| pairs.append((last_q, _clean_text(a_match.group(1)))) | |
| last_q = None | |
| return pairs | |
| def _extract_questions(raw_output: str) -> List[str]: | |
| questions: List[str] = [] | |
| seen = set() | |
| # Prefer explicit question lines first. | |
| for line in raw_output.splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| q_match = re.match(r"(?:Q(?:uestion)?\s*[\d.)-]*\s*:?)\s*(.+)", line, re.IGNORECASE) | |
| candidate = q_match.group(1).strip() if q_match else line | |
| if "?" not in candidate: | |
| continue | |
| candidate = candidate[: candidate.rfind("?") + 1].strip() | |
| norm = _normalize(candidate) | |
| if norm not in seen: | |
| seen.add(norm) | |
| questions.append(candidate) | |
| # Fallback: pull any sentence ending with '?'. | |
| if not questions: | |
| for q in re.findall(r"([^?.!\n][^?\n]{4,}\?)", raw_output): | |
| candidate = _clean_text(q) | |
| norm = _normalize(candidate) | |
| if norm not in seen: | |
| seen.add(norm) | |
| questions.append(candidate) | |
| return questions | |
| def _split_sentences(text: str) -> List[str]: | |
| sentences = re.split(r"(?<=[.!?])\s+", _clean_text(text)) | |
| return [s.strip() for s in sentences if len(s.strip()) >= 35] | |
| QUESTION_STYLE_TEMPLATES = [ | |
| "Which statement best describes {topic}?", | |
| "How is {topic} characterized in the passage?", | |
| "What is the main idea about {topic}?", | |
| "Which interpretation of {topic} is most accurate?", | |
| "What can be inferred about {topic} from the passage?", | |
| "In context, what does the passage suggest about {topic}?", | |
| ] | |
| def _topic_from_sentence(sentence: str) -> str: | |
| cleaned = sentence.strip().rstrip(". ") | |
| lead = re.split(r"[,;:()]", cleaned)[0].strip() | |
| words = lead.split() | |
| if not words: | |
| return "this concept" | |
| # Skip very common lead-in words to get a stronger topic phrase. | |
| stop = { | |
| "the", | |
| "a", | |
| "an", | |
| "this", | |
| "that", | |
| "these", | |
| "those", | |
| "in", | |
| "on", | |
| "at", | |
| "for", | |
| "to", | |
| "of", | |
| "and", | |
| "with", | |
| "by", | |
| "from", | |
| } | |
| filtered = [w for w in words if w.lower() not in stop] | |
| source = filtered if filtered else words | |
| return " ".join(source[: min(8, len(source))]).rstrip(",;:") | |
| def _diversify_question(question: str, answer: str, index_seed: int) -> str: | |
| q = _clean_text(question) | |
| pattern = re.compile(r"^according to the passage,\s*what is true about\s*(.+)\?$", re.IGNORECASE) | |
| match = pattern.match(q) | |
| if not match: | |
| return q | |
| topic = _clean_text(match.group(1).strip()) or _topic_from_sentence(answer) | |
| template = QUESTION_STYLE_TEMPLATES[index_seed % len(QUESTION_STYLE_TEMPLATES)] | |
| return template.format(topic=topic) | |
| def _heuristic_pairs_from_text(text: str, needed: int) -> List[tuple[str, str]]: | |
| """Create simple fallback QA pairs so MCQ generation can still proceed.""" | |
| pairs: List[tuple[str, str]] = [] | |
| for idx, sentence in enumerate(_split_sentences(text)): | |
| answer = sentence.rstrip(". ") | |
| topic = _topic_from_sentence(answer) | |
| if not topic: | |
| continue | |
| template = QUESTION_STYLE_TEMPLATES[idx % len(QUESTION_STYLE_TEMPLATES)] | |
| question = template.format(topic=topic) | |
| pairs.append((question, answer)) | |
| if len(pairs) >= needed: | |
| break | |
| return pairs | |
| def _fallback_distractors(correct_answer: str) -> List[str]: | |
| generic = [ | |
| "All of the above", | |
| "None of the above", | |
| "Insufficient information provided", | |
| "A different concept from the passage", | |
| "An unrelated example", | |
| "A broader definition applies here", | |
| ] | |
| correct_norm = _normalize(correct_answer) | |
| distractors = [item for item in generic if _normalize(item) != correct_norm] | |
| return distractors[:3] | |
| class QuestionGenerator: | |
| """Generate MCQs from source text with free HuggingFace transformer models.""" | |
| # Free, HuggingFace-hosted models (no API key needed) | |
| DEFAULT_MODEL = "google/flan-t5-small" # Lighter and faster first-run download | |
| ALT_MODEL = "google/flan-t5-base" # Higher quality fallback | |
| def __init__( | |
| self, | |
| model_name: str = DEFAULT_MODEL, | |
| max_input_chars: int = 4000, | |
| seed: int = 42, | |
| ) -> None: | |
| self.model_name = model_name | |
| self.max_input_chars = max_input_chars | |
| self.random = random.Random(seed) | |
| def generator(self): | |
| return _get_generator(self.model_name) | |
| def _generate_qa_pairs(self, text: str, max_questions: int) -> List[tuple[str, str]]: | |
| cleaned = " ".join(text.split()) | |
| if not cleaned: | |
| return [] | |
| clipped = cleaned[: self.max_input_chars] | |
| seen: set[tuple[str, str]] = set() | |
| unique: List[tuple[str, str]] = [] | |
| def add_pair(question: str, answer: str): | |
| q = _clean_text(question) | |
| a = _clean_text(answer) | |
| if not q or not a: | |
| return | |
| if "?" not in q: | |
| q = q.rstrip(".") + "?" | |
| q = _diversify_question(q, a, len(unique)) | |
| key = (_normalize(q), _normalize(a)) | |
| if key not in seen: | |
| seen.add(key) | |
| unique.append((q, a)) | |
| def recover_answers(questions: List[str]): | |
| for question in questions: | |
| answer_prompt = ( | |
| "Answer the question using only the passage. " | |
| "Return only a short answer phrase.\n\n" | |
| f"Passage: {clipped}\n" | |
| f"Question: {question}\n" | |
| "Answer:" | |
| ) | |
| try: | |
| ans_raw = self.generator( | |
| answer_prompt, | |
| max_new_tokens=36, | |
| do_sample=False, | |
| num_return_sequences=1, | |
| )[0]["generated_text"] | |
| except Exception: | |
| ans_raw = "" | |
| answer = _clean_text(ans_raw.splitlines()[0] if ans_raw else "") | |
| answer = re.sub(r"^(?:answer\s*:\s*)", "", answer, flags=re.IGNORECASE).strip() | |
| if answer: | |
| add_pair(question, answer) | |
| if len(unique) >= max_questions: | |
| return | |
| attempts = 4 | |
| for attempt in range(attempts): | |
| remaining = max_questions - len(unique) | |
| if remaining <= 0: | |
| break | |
| request_count = max(remaining + 2, remaining) | |
| prompt = ( | |
| f"Generate {request_count} quiz questions with answers from the passage. " | |
| "Use a mix of styles: definition, cause-effect, comparison, chronology, application, and inference. " | |
| "Avoid repeating the same opening phrase. Do not start every question with 'According to the passage'. " | |
| "Format each item exactly as:\nQuestion: <question text>\nAnswer: <short answer>\n\n" | |
| f"Passage: {clipped}" | |
| ) | |
| try: | |
| result = self.generator( | |
| prompt, | |
| max_new_tokens=640, | |
| do_sample=True, | |
| temperature=0.7 + (attempt * 0.08), | |
| top_p=0.92, | |
| num_return_sequences=1, | |
| ) | |
| raw = result[0]["generated_text"] | |
| except Exception: | |
| try: | |
| raw = self.generator(prompt, max_new_tokens=320)[0]["generated_text"] | |
| except Exception: | |
| raw = "" | |
| for q, a in _extract_qa_pairs(raw): | |
| add_pair(q, a) | |
| if len(unique) >= max_questions: | |
| break | |
| if len(unique) >= max_questions: | |
| break | |
| questions_only = _extract_questions(raw) | |
| if questions_only: | |
| recover_answers(questions_only) | |
| if len(unique) < max_questions: | |
| for q, a in _heuristic_pairs_from_text(clipped, max_questions - len(unique)): | |
| add_pair(q, a) | |
| if len(unique) >= max_questions: | |
| break | |
| return unique[:max_questions] | |
| def _build_options(self, correct_answer: str, answer_pool: List[str]) -> List[str]: | |
| correct_norm = _normalize(correct_answer) | |
| seen = {correct_norm} | |
| distractors: List[str] = [] | |
| pool = list(answer_pool) | |
| self.random.shuffle(pool) | |
| for answer in pool: | |
| n = _normalize(answer) | |
| if n and n not in seen: | |
| seen.add(n) | |
| distractors.append(answer) | |
| if len(distractors) == 3: | |
| break | |
| if len(distractors) < 3: | |
| for fb in _fallback_distractors(correct_answer): | |
| if _normalize(fb) not in seen: | |
| distractors.append(fb) | |
| seen.add(_normalize(fb)) | |
| if len(distractors) == 3: | |
| break | |
| options = [correct_answer] + distractors[:3] | |
| self.random.shuffle(options) | |
| return options | |
| def generate_mcqs( | |
| self, | |
| text: str, | |
| max_questions: int = 5, | |
| difficulty_filter: Optional[str] = None, | |
| difficulty_classifier=None, | |
| ) -> List[MCQItem]: | |
| # Generate extra if filtering by difficulty | |
| fetch_count = max_questions * 3 if difficulty_filter and difficulty_classifier else max_questions | |
| qa_pairs = self._generate_qa_pairs(text, max_questions=fetch_count) | |
| answer_pool = [a for _, a in qa_pairs] | |
| mcqs: List[MCQItem] = [] | |
| for question, correct_answer in qa_pairs: | |
| options = self._build_options(correct_answer, answer_pool) | |
| if len(options) < 4: | |
| continue | |
| diff = None | |
| if difficulty_classifier: | |
| try: | |
| pred = difficulty_classifier.classify(question) | |
| diff = pred.get("difficulty") | |
| except Exception: | |
| pass | |
| # Filter by difficulty if requested | |
| if difficulty_filter and diff: | |
| if diff.lower() != difficulty_filter.lower(): | |
| continue | |
| mcqs.append( | |
| MCQItem( | |
| question=question, | |
| correct_answer=correct_answer, | |
| options=options, | |
| difficulty=diff, | |
| ) | |
| ) | |
| if len(mcqs) >= max_questions: | |
| break | |
| return mcqs[:max_questions] | |
| def _get_generator(model_name: str): | |
| device = 0 if torch.cuda.is_available() else -1 | |
| last_error = None | |
| for task_name in ("text2text-generation", "any-to-any"): | |
| try: | |
| return pipeline( | |
| task_name, | |
| model=model_name, | |
| device=device, | |
| ) | |
| except Exception as exc: | |
| last_error = exc | |
| raise RuntimeError( | |
| f"Could not initialize generation pipeline for model '{model_name}'." | |
| ) from last_error | |