QuizGenerator 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +627 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1955 -0
  9. QuizGenerator/generate.py +253 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +579 -0
  12. QuizGenerator/mixins.py +548 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +391 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
  22. QuizGenerator/premade_questions/cst334/process.py +648 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
  33. QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
  34. QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
  35. QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
  36. QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
  37. QuizGenerator/premade_questions/cst463/models/text.py +203 -0
  38. QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
  39. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  40. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
  41. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  42. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  43. QuizGenerator/qrcode_generator.py +293 -0
  44. QuizGenerator/question.py +715 -0
  45. QuizGenerator/quiz.py +467 -0
  46. QuizGenerator/regenerate.py +472 -0
  47. QuizGenerator/typst_utils.py +113 -0
  48. quizgenerator-0.4.2.dist-info/METADATA +265 -0
  49. quizgenerator-0.4.2.dist-info/RECORD +52 -0
  50. quizgenerator-0.4.2.dist-info/WHEEL +4 -0
  51. quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
  52. quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,202 @@
1
+ """
2
+ Performance instrumentation for quiz generation and Canvas upload pipeline.
3
+
4
+ This module provides timing instrumentation to identify bottlenecks in:
5
+ 1. Question generation (refresh, get_body, get_explanation)
6
+ 2. AST rendering for Canvas
7
+ 3. Canvas API uploads
8
+
9
+ Usage:
10
+ from QuizGenerator.performance import PerformanceTracker, timer
11
+
12
+ # Use context manager
13
+ with timer("operation_name"):
14
+ # your code here
15
+
16
+ # Access metrics
17
+ metrics = PerformanceTracker.get_metrics()
18
+ PerformanceTracker.report_summary()
19
+ """
20
+
21
+ import time
22
+ import statistics
23
+ import logging
24
+ from contextlib import contextmanager
25
+ from typing import Dict, List, Optional, Any
26
+ from dataclasses import dataclass, field
27
+ from collections import defaultdict
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ @dataclass
32
+ class TimingMetric:
33
+ """Container for timing measurements"""
34
+ operation: str
35
+ duration: float
36
+ question_name: Optional[str] = None
37
+ question_type: Optional[str] = None
38
+ variation_number: Optional[int] = None
39
+ metadata: Dict[str, Any] = field(default_factory=dict)
40
+
41
+ class PerformanceTracker:
42
+ """Global performance tracking system"""
43
+
44
+ _metrics: List[TimingMetric] = []
45
+ _active_timers: Dict[str, float] = {}
46
+
47
+ @classmethod
48
+ def start_timer(cls, operation: str, **metadata) -> str:
49
+ """Start a named timer and return a timer ID"""
50
+ timer_id = f"{operation}_{time.time()}"
51
+ cls._active_timers[timer_id] = time.perf_counter()
52
+ return timer_id
53
+
54
+ @classmethod
55
+ def end_timer(cls, timer_id: str, operation: str, **metadata) -> float:
56
+ """End a timer and record the metric"""
57
+ if timer_id not in cls._active_timers:
58
+ log.warning(f"Timer {timer_id} not found")
59
+ return 0.0
60
+
61
+ start_time = cls._active_timers.pop(timer_id)
62
+ duration = time.perf_counter() - start_time
63
+
64
+ metric = TimingMetric(
65
+ operation=operation,
66
+ duration=duration,
67
+ **metadata
68
+ )
69
+ cls._metrics.append(metric)
70
+
71
+ log.debug(f"{operation}: {duration:.3f}s {metadata}")
72
+ return duration
73
+
74
+ @classmethod
75
+ def record_timing(cls, operation: str, duration: float, **metadata):
76
+ """Record a timing metric directly"""
77
+ metric = TimingMetric(
78
+ operation=operation,
79
+ duration=duration,
80
+ **metadata
81
+ )
82
+ cls._metrics.append(metric)
83
+
84
+ @classmethod
85
+ def get_metrics(cls) -> List[TimingMetric]:
86
+ """Get all recorded metrics"""
87
+ return cls._metrics.copy()
88
+
89
+ @classmethod
90
+ def get_metrics_by_operation(cls, operation: str) -> List[TimingMetric]:
91
+ """Get metrics filtered by operation name"""
92
+ return [m for m in cls._metrics if m.operation == operation]
93
+
94
+ @classmethod
95
+ def clear_metrics(cls):
96
+ """Clear all recorded metrics"""
97
+ cls._metrics.clear()
98
+ cls._active_timers.clear()
99
+
100
+ @classmethod
101
+ def get_summary_stats(cls, operation: str) -> Dict[str, float]:
102
+ """Get summary statistics for an operation"""
103
+ metrics = cls.get_metrics_by_operation(operation)
104
+ if not metrics:
105
+ return {}
106
+
107
+ durations = [m.duration for m in metrics]
108
+ return {
109
+ 'count': len(durations),
110
+ 'total': sum(durations),
111
+ 'mean': statistics.mean(durations),
112
+ 'median': statistics.median(durations),
113
+ 'min': min(durations),
114
+ 'max': max(durations),
115
+ 'std': statistics.stdev(durations) if len(durations) > 1 else 0
116
+ }
117
+
118
+ @classmethod
119
+ def report_summary(cls, min_duration: float = 0.001) -> str:
120
+ """Generate a summary report of all operations"""
121
+ operations = set(m.operation for m in cls._metrics)
122
+
123
+ report_lines = []
124
+ report_lines.append("=== Performance Summary Report ===")
125
+ report_lines.append(f"Total metrics recorded: {len(cls._metrics)}")
126
+ report_lines.append("")
127
+
128
+ # Group by operation
129
+ for operation in sorted(operations):
130
+ stats = cls.get_summary_stats(operation)
131
+ if stats['mean'] < min_duration: # Skip very fast operations
132
+ continue
133
+
134
+ report_lines.append(f"{operation}:")
135
+ report_lines.append(f" Count: {stats['count']}")
136
+ report_lines.append(f" Total: {stats['total']:.3f}s")
137
+ report_lines.append(f" Mean: {stats['mean']:.3f}s ± {stats['std']:.3f}s")
138
+ report_lines.append(f" Range: {stats['min']:.3f}s - {stats['max']:.3f}s")
139
+ report_lines.append("")
140
+
141
+ report = '\n'.join(report_lines)
142
+ print(report)
143
+ return report
144
+
145
+ @classmethod
146
+ def report_detailed(cls, operation: Optional[str] = None) -> str:
147
+ """Generate detailed report showing individual measurements"""
148
+ metrics = cls.get_metrics_by_operation(operation) if operation else cls._metrics
149
+
150
+ report_lines = []
151
+ if operation:
152
+ report_lines.append(f"=== Detailed Report: {operation} ===")
153
+ else:
154
+ report_lines.append("=== Detailed Report: All Operations ===")
155
+
156
+ # Group by question if available
157
+ by_question = defaultdict(list)
158
+ for metric in metrics:
159
+ key = metric.question_name or "unknown"
160
+ by_question[key].append(metric)
161
+
162
+ for question_name, question_metrics in by_question.items():
163
+ report_lines.append(f"\nQuestion: {question_name}")
164
+ for metric in sorted(question_metrics, key=lambda m: m.variation_number or 0):
165
+ metadata_str = ""
166
+ if metric.question_type:
167
+ metadata_str += f" [{metric.question_type}]"
168
+ if metric.variation_number is not None:
169
+ metadata_str += f" var#{metric.variation_number}"
170
+
171
+ report_lines.append(f" {metric.operation}: {metric.duration:.3f}s{metadata_str}")
172
+
173
+ report = '\n'.join(report_lines)
174
+ print(report)
175
+ return report
176
+
177
+ @contextmanager
178
+ def timer(operation: str, **metadata):
179
+ """Context manager for timing operations"""
180
+ timer_id = PerformanceTracker.start_timer(operation, **metadata)
181
+ try:
182
+ yield
183
+ finally:
184
+ PerformanceTracker.end_timer(timer_id, operation, **metadata)
185
+
186
+ def timed_method(operation_name: Optional[str] = None):
187
+ """Decorator for timing method calls"""
188
+ def decorator(func):
189
+ def wrapper(*args, **kwargs):
190
+ op_name = operation_name or f"{func.__name__}"
191
+
192
+ # Try to extract question info from self if available
193
+ metadata = {}
194
+ if args and hasattr(args[0], 'name'):
195
+ metadata['question_name'] = getattr(args[0], 'name', None)
196
+ if args and hasattr(args[0], '__class__'):
197
+ metadata['question_type'] = args[0].__class__.__name__
198
+
199
+ with timer(op_name, **metadata):
200
+ return func(*args, **kwargs)
201
+ return wrapper
202
+ return decorator
File without changes
@@ -0,0 +1,103 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ from typing import List, Dict, Any, Tuple
5
+
6
+ import logging
7
+
8
+ from QuizGenerator.contentast import *
9
+ from QuizGenerator.question import Question, QuestionRegistry, Answer
10
+ from QuizGenerator.mixins import TableQuestionMixin
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ @QuestionRegistry.register()
16
+ class FromText(Question):
17
+
18
+ def __init__(self, *args, text, **kwargs):
19
+ super().__init__(*args, **kwargs)
20
+ self.text = text
21
+ self.answers = []
22
+ self.possible_variations = 1
23
+
24
+ def get_body(self, **kwargs) -> ContentAST.Section:
25
+
26
+ return ContentAST.Section([ContentAST.Text(self.text)])
27
+
28
+ def get_answers(self, *args, **kwargs) -> Tuple[Answer.AnswerKind, List[Dict[str,Any]]]:
29
+ return Answer.AnswerKind.ESSAY, []
30
+
31
+
32
+ @QuestionRegistry.register()
33
+ class FromGenerator(FromText, TableQuestionMixin):
34
+
35
+ def __init__(self, *args, generator=None, text=None, **kwargs):
36
+ if generator is None and text is None:
37
+ raise TypeError(f"Must supply either generator or text kwarg for {self.__class__.__name__}")
38
+
39
+ if generator is None:
40
+ generator = text
41
+
42
+ super().__init__(*args, text="", **kwargs)
43
+ self.possible_variations = kwargs.get("possible_variations", float('inf'))
44
+
45
+ def attach_function_to_object(obj, function_code, function_name='get_body_lines'):
46
+ function_code = "import random\n" + function_code
47
+
48
+ # Create a local namespace for exec with ContentAST available
49
+ local_namespace = {
50
+ 'ContentAST': ContentAST,
51
+ 'Section': ContentAST.Section,
52
+ 'Text': ContentAST.Text,
53
+ 'Table': ContentAST.Table,
54
+ 'Paragraph': ContentAST.Paragraph
55
+ }
56
+
57
+ # Define the function dynamically using exec
58
+ # Merge current globals with our local namespace for the exec
59
+ exec_globals = {**globals(), **local_namespace}
60
+ exec(f"def {function_name}(self):\n" + "\n".join(f" {line}" for line in function_code.splitlines()), exec_globals, local_namespace)
61
+
62
+ # Get the function and bind it to the object
63
+ function = local_namespace[function_name]
64
+ setattr(obj, function_name, function.__get__(obj))
65
+
66
+ self.generator_text = generator
67
+ # Attach the function dynamically
68
+ attach_function_to_object(self, generator, "generator")
69
+
70
+ self.answers = {}
71
+
72
+
73
+ def get_body(self, **kwargs) -> ContentAST.Section:
74
+ return super().get_body()
75
+
76
+ def refresh(self, *args, **kwargs):
77
+ super().refresh(*args, **kwargs)
78
+ try:
79
+ generated_content = self.generator()
80
+ # Expect generator to return a ContentAST.Section or convert string to Section
81
+ if isinstance(generated_content, ContentAST.Section):
82
+ self.text = "" # Clear text since we'll override get_body
83
+ self._generated_section = generated_content
84
+ elif isinstance(generated_content, str):
85
+ self.text = generated_content
86
+ self._generated_section = None
87
+ else:
88
+ # Fallback
89
+ self.text = str(generated_content)
90
+ self._generated_section = None
91
+ except TypeError as e:
92
+ log.error(f"Error generating from text: {e}")
93
+ log.debug(self.generator_text)
94
+ exit(8)
95
+
96
+ def get_body(self, **kwargs) -> ContentAST.Section:
97
+ if hasattr(self, '_generated_section') and self._generated_section:
98
+ return self._generated_section
99
+ return super().get_body()
100
+
101
+
102
+ class TrueFalse(FromText):
103
+ pass
@@ -0,0 +1 @@
1
+ # CST334 Operating Systems questions
@@ -0,0 +1,391 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ import abc
5
+ import enum
6
+ import itertools
7
+ from typing import List, Dict, Optional, Tuple, Any
8
+
9
+ from QuizGenerator.question import QuestionRegistry, Question, Answer
10
+
11
+ from QuizGenerator.contentast import ContentAST
12
+
13
+ import logging
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class LanguageQuestion(Question, abc.ABC):
18
+ def __init__(self, *args, **kwargs):
19
+ kwargs["topic"] = kwargs.get("topic", Question.Topic.LANGUAGES)
20
+ super().__init__(*args, **kwargs)
21
+
22
+ class BNF:
23
+
24
+ class Grammar:
25
+ def __init__(self, symbols, start_symbol=None):
26
+ self.start_symbol = start_symbol if start_symbol is not None else symbols[0]
27
+ self.symbols = symbols
28
+
29
+ def generate(self, include_spaces=False, early_exit=False, early_exit_min_iterations=5):
30
+ curr_symbols : List[BNF.Symbol] = [self.start_symbol]
31
+ prev_symbols: List[BNF.Symbol] = curr_symbols
32
+
33
+ iteration_count = 0
34
+ # Check to see if we have any non-terminals left
35
+ while any(map(lambda s: s.kind == BNF.Symbol.Kind.NonTerminal, curr_symbols)):
36
+ # Grab the previous symbols in case we are early exitting
37
+ prev_symbols = curr_symbols
38
+
39
+ # Walk through the current symbols and build a new list of symbols from it
40
+ next_symbols : List[BNF.Symbol] = []
41
+ for symbol in curr_symbols:
42
+ next_symbols.extend(symbol.expand())
43
+ curr_symbols = next_symbols
44
+
45
+ iteration_count += 1
46
+
47
+ if early_exit and iteration_count > early_exit_min_iterations:
48
+ break
49
+
50
+ if early_exit:
51
+ # If we are doing an early exit then we are going to return things with non-terminals
52
+ curr_symbols = prev_symbols
53
+
54
+ # Take all the current symbols and combine them
55
+ return ('' if not include_spaces else ' ').join([str(s) for s in curr_symbols])
56
+
57
+ def print(self):
58
+ for symbol in self.symbols:
59
+ print(symbol.get_full_str())
60
+
61
+ def get_grammar_string(self):
62
+ lines = []
63
+ for symbol in self.symbols:
64
+ lines.append(f"{symbol.get_full_str()}")
65
+
66
+ return '\n'.join(lines)
67
+
68
+ class Symbol:
69
+
70
+ class Kind(enum.Enum):
71
+ NonTerminal = enum.auto()
72
+ Terminal = enum.auto()
73
+
74
+ def __init__(self, symbol : str, kind : Kind, rng):
75
+ self.symbol = symbol
76
+ self.kind = kind
77
+ self.productions : List[BNF.Production] = [] # productions
78
+ self.rng = rng
79
+
80
+ def __str__(self):
81
+ # if self.kind == BNF.Symbol.Kind.NonTerminal:
82
+ # return f"`{self.symbol}`"
83
+ # else:
84
+ # return f"{self.symbol}"
85
+ return f"{self.symbol}"
86
+
87
+ def get_full_str(self):
88
+ return f"{self.symbol} ::= {' | '.join([str(p) for p in self.productions])}"
89
+
90
+ def add_production(self, production: BNF.Production):
91
+ self.productions.append(production)
92
+
93
+ def expand(self) -> List[BNF.Symbol]:
94
+ if self.kind == BNF.Symbol.Kind.Terminal:
95
+ return [self]
96
+ return self.rng.choice(self.productions).production
97
+
98
+ class Production:
99
+ def __init__(self, production_line, nonterminal_symbols: Dict[str, BNF.Symbol], rng):
100
+ if len(production_line.strip()) == 0:
101
+ self.production = []
102
+ else:
103
+ self.production = [
104
+ (nonterminal_symbols.get(symbol, BNF.Symbol(symbol, BNF.Symbol.Kind.Terminal, rng=rng)))
105
+ for symbol in production_line.split(' ')
106
+ ]
107
+
108
+ def __str__(self):
109
+ if len(self.production) == 0:
110
+ return '""'
111
+ return f"{' '.join([str(s) for s in self.production])}"
112
+
113
+
114
+ @staticmethod
115
+ def parse_bnf(grammar_str, rng) -> BNF.Grammar:
116
+
117
+ # Figure out all the nonterminals and create a Token for them
118
+ terminal_symbols = {}
119
+ start_symbol = None
120
+ for line in grammar_str.strip().splitlines():
121
+ if "::=" in line:
122
+ non_terminal_str, _ = line.split("::=", 1)
123
+ non_terminal_str = non_terminal_str.strip()
124
+
125
+ terminal_symbols[non_terminal_str] = BNF.Symbol(non_terminal_str, BNF.Symbol.Kind.NonTerminal, rng=rng)
126
+ if start_symbol is None:
127
+ start_symbol = terminal_symbols[non_terminal_str]
128
+
129
+ # Parse the grammar statement
130
+ for line in grammar_str.strip().splitlines():
131
+ if "::=" in line:
132
+ # Split the line into non-terminal and its expansions
133
+ non_terminal_str, expansions = line.split("::=", 1)
134
+ non_terminal_str = non_terminal_str.strip()
135
+
136
+ non_terminal = terminal_symbols[non_terminal_str]
137
+
138
+ for production_str in expansions.split('|'):
139
+ production_str = production_str.strip()
140
+ non_terminal.add_production(BNF.Production(production_str, terminal_symbols, rng=rng))
141
+ bnf_grammar = BNF.Grammar(list(terminal_symbols.values()), start_symbol)
142
+ return bnf_grammar
143
+
144
+
145
+ @QuestionRegistry.register("LanguageQuestion")
146
+ class ValidStringsInLanguageQuestion(LanguageQuestion):
147
+ MAX_TRIES = 1000
148
+
149
+ def __init__(self, grammar_str_good: Optional[str] = None, grammar_str_bad: Optional[str] = None, *args, **kwargs):
150
+ # Preserve question-specific params for QR code config
151
+ if grammar_str_good is not None:
152
+ kwargs['grammar_str_good'] = grammar_str_good
153
+ if grammar_str_bad is not None:
154
+ kwargs['grammar_str_bad'] = grammar_str_bad
155
+
156
+ super().__init__(*args, **kwargs)
157
+
158
+ if grammar_str_good is not None and grammar_str_bad is not None:
159
+ self.grammar_str_good = grammar_str_good
160
+ self.grammar_str_bad = grammar_str_bad
161
+ self.include_spaces = kwargs.get("include_spaces", False)
162
+ self.MAX_LENGTH = kwargs.get("max_length", 30)
163
+ else:
164
+ which_grammar = self.rng.choice(range(4))
165
+
166
+ if which_grammar == 0:
167
+ # todo: make a few different kinds of grammars that could be picked
168
+ self.grammar_str_good = """
169
+ <expression> ::= <term> | <expression> + <term> | <expression> - <term>
170
+ <term> ::= <factor> | <term> * <factor> | <term> / <factor>
171
+ <factor> ::= <number>
172
+ <number> ::= <digit> | <number> <digit>
173
+ <digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
174
+ """
175
+ # Adding in a plus to number
176
+ self.grammar_str_bad = """
177
+ <expression> ::= <term> | <expression> + <term> | <expression> - <term>
178
+ <term> ::= <factor> | <term> * <factor> | <term> / <factor>
179
+ <factor> ::= <number>
180
+ <number> ::= <digit> + | <digit> <number>
181
+ <digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
182
+ """
183
+ self.include_spaces = False
184
+ self.MAX_LENGTH = 30
185
+ elif which_grammar == 1:
186
+ self.grammar_str_good = """
187
+ <sentence> ::= <subject> <verb> <object>
188
+ <subject> ::= The cat | A dog | The bird | A child | <adjective> <animal>
189
+ <animal> ::= cat | dog | bird | child
190
+ <adjective> ::= happy | sad | angry | playful
191
+ <verb> ::= chases | sees | hates | loves
192
+ <object> ::= the ball | the toy | the tree | <adjective> <object>
193
+ """
194
+ self.grammar_str_bad = """
195
+ <sentence> ::= <subject> <verb> <object>
196
+ <subject> ::= The human | The dog | A bird | Some child | A <adjective> <animal>
197
+ <animal> ::= cat | dog | bird | child
198
+ <adjective> ::= happy | sad | angry | playful
199
+ <verb> ::= chases | sees | hates | loves
200
+ <object> ::= the ball | the toy | the tree | <adjective> <object>
201
+ """
202
+ self.include_spaces = True
203
+ self.MAX_LENGTH = 100
204
+ elif which_grammar == 2:
205
+ self.grammar_str_good = """
206
+ <poem> ::= <line> | <line> <poem>
207
+ <line> ::= <subject> <verb> <object> <modifier>
208
+ <subject> ::= whispers | shadows | dreams | echoes | <compound-subject>
209
+ <compound-subject> ::= <subject> and <subject>
210
+ <verb> ::= dance | dissolve | shimmer | collapse | <compound-verb>
211
+ <compound-verb> ::= <verb> then <verb>
212
+ <object> ::= beneath | between | inside | around | <compound-object>
213
+ <compound-object> ::= <object> through <object>
214
+ <modifier> ::= silently | violently | mysteriously | endlessly | <recursive-modifier>
215
+ <recursive-modifier> ::= <modifier> and <modifier>
216
+ """
217
+ self.grammar_str_bad = """
218
+ <bad-poem> ::= <almost-valid-line> | <bad-poem> <bad-poem>
219
+ <almost-valid-line> ::= <tricky-subject> <tricky-verb> <tricky-object> <tricky-modifier>
220
+ <tricky-subject> ::= whispers | shadows and and | <duplicate-subject>
221
+ <duplicate-subject> ::= whispers whispers
222
+ <tricky-verb> ::= dance | <incorrect-verb-placement> | <verb-verb>
223
+ <incorrect-verb-placement> ::= dance dance
224
+ <verb-verb> ::= dance whispers
225
+ <tricky-object> ::= beneath | <object-verb-swap> | <duplicate-object>
226
+ <object-verb-swap> ::= dance beneath
227
+ <duplicate-object> ::= beneath beneath
228
+ <tricky-modifier> ::= silently | <modifier-subject-swap> | <duplicate-modifier>
229
+ <modifier-subject-swap> ::= whispers silently
230
+ <duplicate-modifier> ::= silently silently
231
+ """
232
+ self.include_spaces = True
233
+ self.MAX_LENGTH = 100
234
+ elif which_grammar == 3:
235
+ self.grammar_str_good = """
236
+ <A> ::= a <B> a |
237
+ <B> ::= b <C> b |
238
+ <C> ::= c <A> c |
239
+ """
240
+ self.grammar_str_bad = """
241
+ <A> ::= a <B> c
242
+ <B> ::= b <C> a |
243
+ <C> ::= c <A> b |
244
+ """
245
+ self.include_spaces = False
246
+ self.MAX_LENGTH = 100
247
+
248
+ self.grammar_good = BNF.parse_bnf(self.grammar_str_good, self.rng)
249
+ self.grammar_bad = BNF.parse_bnf(self.grammar_str_bad, self.rng)
250
+
251
+ self.num_answer_options = kwargs.get("num_answer_options", 4)
252
+ self.num_answer_blanks = kwargs.get("num_answer_blanks", 4)
253
+
254
+ def refresh(self, *args, **kwargs):
255
+ super().refresh(*args, **kwargs)
256
+
257
+ self.answers = {}
258
+
259
+ self.num_answer_options = kwargs.get("num_answer_options", 4)
260
+ self.num_answer_blanks = kwargs.get("num_answer_blanks", 4)
261
+
262
+ self.refresh()
263
+
264
+ def refresh(self, *args, **kwargs):
265
+ super().refresh(*args, **kwargs)
266
+
267
+ self.answers = {}
268
+
269
+ self.answers.update(
270
+ {
271
+ "answer_good" : Answer(
272
+ f"answer_good",
273
+ self.grammar_good.generate(self.include_spaces),
274
+ Answer.AnswerKind.MULTIPLE_ANSWER,
275
+ correct=True
276
+ )
277
+ }
278
+ )
279
+
280
+ self.answers.update(
281
+ {
282
+ "answer_bad":
283
+ Answer(
284
+ f"answer_bad",
285
+ self.grammar_bad.generate(self.include_spaces),
286
+ Answer.AnswerKind.MULTIPLE_ANSWER,
287
+ correct=False
288
+ )
289
+ })
290
+ self.answers.update({
291
+ "answer_bad_early":
292
+ Answer(
293
+ f"answer_bad_early",
294
+ self.grammar_bad.generate(self.include_spaces, early_exit=True),
295
+ Answer.AnswerKind.MULTIPLE_ANSWER,
296
+ correct=False
297
+ )
298
+ })
299
+
300
+ answer_text_set = {a.value for a in self.answers.values()}
301
+ num_tries = 0
302
+ while len(self.answers) < 10 and num_tries < self.MAX_TRIES:
303
+
304
+ correct = self.rng.choice([True, False])
305
+ if not correct:
306
+ early_exit = self.rng.choice([True, False])
307
+ else:
308
+ early_exit = False
309
+ new_answer = Answer(
310
+ f"answer_{num_tries}",
311
+ (
312
+ self.grammar_good
313
+ if correct or early_exit
314
+ else self.grammar_bad
315
+ ).generate(self.include_spaces, early_exit=early_exit),
316
+ Answer.AnswerKind.MULTIPLE_ANSWER,
317
+ correct= correct and not early_exit
318
+ )
319
+ if len(new_answer.value) < self.MAX_LENGTH and new_answer.value not in answer_text_set:
320
+ self.answers.update({new_answer.key : new_answer})
321
+ answer_text_set.add(new_answer.value)
322
+ num_tries += 1
323
+
324
+ # Generate answers that will be used only for the latex version.
325
+ self.featured_answers = {
326
+ self.grammar_good.generate(),
327
+ self.grammar_bad.generate(),
328
+ self.grammar_good.generate(early_exit=True)
329
+ }
330
+ while len(self.featured_answers) < self.num_answer_options:
331
+ self.featured_answers.add(
332
+ self.rng.choice([
333
+ lambda: self.grammar_good.generate(),
334
+ lambda: self.grammar_bad.generate(),
335
+ lambda: self.grammar_good.generate(early_exit=True),
336
+ ])()
337
+ )
338
+
339
+
340
+ def get_body(self, *args, **kwargs) -> ContentAST.Section:
341
+ body = ContentAST.Section()
342
+
343
+ body.add_elements([
344
+ ContentAST.Paragraph([
345
+ ContentAST.OnlyHtml([
346
+ ContentAST.Text("Given the following grammar, which of the below strings are part of the language?")
347
+ ]),
348
+ ContentAST.OnlyLatex([
349
+ ContentAST.Text(
350
+ "Given the following grammar "
351
+ "please circle any provided strings that are part of the language (or indicate clearly if there are none), "
352
+ "and on each blank line provide generate a new, unique string that is part of the language."
353
+ )
354
+ ])
355
+ ])
356
+ ])
357
+
358
+ body.add_element(
359
+ ContentAST.Code(self.grammar_good.get_grammar_string())
360
+ )
361
+
362
+ # Add in some answers as latex-only options to be circled
363
+ body.add_element(
364
+ ContentAST.OnlyLatex([
365
+ ContentAST.Text(f"- `{str(answer)}`")
366
+ for answer in self.featured_answers
367
+ ])
368
+ )
369
+
370
+ # For Latex-only, ask students to generate some more.
371
+ body.add_element(
372
+ ContentAST.OnlyLatex([
373
+ ContentAST.AnswerBlock([ContentAST.Answer(Answer.string(f"blank_line_{i}", f"blank_line_{i}"), label=f"blank_line_{i}") for i in range(self.num_answer_blanks)])
374
+ ])
375
+ )
376
+
377
+ return body
378
+
379
+ def get_explanation(self, *args, **kwargs) -> ContentAST.Section:
380
+ explanation = ContentAST.Section()
381
+ explanation.add_element(
382
+ ContentAST.Paragraph([
383
+ "Remember, for a string to be part of our language, we need to be able to derive it from our grammar.",
384
+ "Unfortunately, there isn't space here to demonstrate the derivation so please work through them on your own!"
385
+ ])
386
+ )
387
+ return explanation
388
+
389
+ def get_answers(self, *args, **kwargs) -> Tuple[Answer.AnswerKind, List[Dict[str,Any]]]:
390
+
391
+ return Answer.AnswerKind.MULTIPLE_ANSWER, list(itertools.chain(*[a.get_for_canvas() for a in self.answers.values()]))