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.
- QuizGenerator/README.md +5 -0
- QuizGenerator/__init__.py +27 -0
- QuizGenerator/__main__.py +7 -0
- QuizGenerator/canvas/__init__.py +13 -0
- QuizGenerator/canvas/canvas_interface.py +627 -0
- QuizGenerator/canvas/classes.py +235 -0
- QuizGenerator/constants.py +149 -0
- QuizGenerator/contentast.py +1955 -0
- QuizGenerator/generate.py +253 -0
- QuizGenerator/logging.yaml +55 -0
- QuizGenerator/misc.py +579 -0
- QuizGenerator/mixins.py +548 -0
- QuizGenerator/performance.py +202 -0
- QuizGenerator/premade_questions/__init__.py +0 -0
- QuizGenerator/premade_questions/basic.py +103 -0
- QuizGenerator/premade_questions/cst334/__init__.py +1 -0
- QuizGenerator/premade_questions/cst334/languages.py +391 -0
- QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
- QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
- QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
- QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
- QuizGenerator/premade_questions/cst334/process.py +648 -0
- QuizGenerator/premade_questions/cst463/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
- QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
- QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
- QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
- QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
- QuizGenerator/premade_questions/cst463/models/__init__.py +0 -0
- QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
- QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
- QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
- QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
- QuizGenerator/premade_questions/cst463/models/text.py +203 -0
- QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
- QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
- QuizGenerator/qrcode_generator.py +293 -0
- QuizGenerator/question.py +715 -0
- QuizGenerator/quiz.py +467 -0
- QuizGenerator/regenerate.py +472 -0
- QuizGenerator/typst_utils.py +113 -0
- quizgenerator-0.4.2.dist-info/METADATA +265 -0
- quizgenerator-0.4.2.dist-info/RECORD +52 -0
- quizgenerator-0.4.2.dist-info/WHEEL +4 -0
- quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
- 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()]))
|