mcqpy-core 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/PKG-INFO +1 -1
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/pyproject.toml +1 -1
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/config.py +23 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/__init__.py +10 -1
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/overall_analysis.py +56 -3
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/grader.py +12 -8
- mcqpy_core-0.2.2/src/mcqpy_core/grading/types.py → mcqpy_core-0.2.4/src/mcqpy_core/grading/helpers.py +6 -35
- mcqpy_core-0.2.4/src/mcqpy_core/grading/types.py +56 -0
- mcqpy_core-0.2.2/src/mcqpy_core/cli/build.py +0 -89
- mcqpy_core-0.2.2/src/mcqpy_core/cli/check_latex.py +0 -31
- mcqpy_core-0.2.2/src/mcqpy_core/cli/grade.py +0 -100
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/README.md +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/_selection.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/main.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/token.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/export/web.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/init.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/main.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/check_tags.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/init.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/main.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/render.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/question/validate.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/autofill.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/check_filter.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/cli/utils/main.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/front_config.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/question_analysis.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/analysis/utils.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/grading/rubric.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/header_config.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/manifest.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/base_filter.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/date.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/difficulty.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/factory.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/manifest.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/slug.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/stratified.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/filter/tag.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/question.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/question_bank.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/question/utils.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/__init__.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/bundle.py +0 -0
- {mcqpy_core-0.2.2 → mcqpy_core-0.2.4}/src/mcqpy_core/web/token.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Quiz configuration management using Pydantic and YAML."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from sys import path
|
|
4
5
|
from typing import Any, Literal
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, Field, ConfigDict, field_validator, model_validator
|
|
@@ -142,6 +143,28 @@ class QuizConfig(BaseModel):
|
|
|
142
143
|
raise FileNotFoundError(f"Questions path does not exist: {resolved_path}")
|
|
143
144
|
|
|
144
145
|
return data
|
|
146
|
+
|
|
147
|
+
@model_validator(mode="before")
|
|
148
|
+
def selection_path_make_absolute(cls, data):
|
|
149
|
+
"""Ensure that selection paths are relative to the config file's directory"""
|
|
150
|
+
selection = data.get("selection", None)
|
|
151
|
+
if selection is None:
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
filters = selection.get("filters", None)
|
|
155
|
+
|
|
156
|
+
if filters is None:
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
config_path = data.get("path")
|
|
160
|
+
for filter_name, filter_params in filters.items():
|
|
161
|
+
for name, param in filter_params.items():
|
|
162
|
+
if name.endswith("_path") and isinstance(param, str) and config_path is not None:
|
|
163
|
+
absolute_path = (Path(config_path).parent / param).resolve()
|
|
164
|
+
filter_params[name] = str(absolute_path)
|
|
165
|
+
|
|
166
|
+
return data
|
|
167
|
+
|
|
145
168
|
|
|
146
169
|
def yaml_dump(self) -> str:
|
|
147
170
|
"""Dump the current configuration to a YAML string"""
|
|
@@ -7,9 +7,14 @@ from .types import (
|
|
|
7
7
|
GradedSet,
|
|
8
8
|
ParsedQuestion,
|
|
9
9
|
ParsedSet,
|
|
10
|
-
|
|
10
|
+
GradeResult,
|
|
11
|
+
GradeState,
|
|
12
|
+
ParseResult,
|
|
13
|
+
ParseState,
|
|
11
14
|
)
|
|
12
15
|
|
|
16
|
+
from .helpers import get_grade_dataframe
|
|
17
|
+
|
|
13
18
|
__all__ = [
|
|
14
19
|
"MCQGrader",
|
|
15
20
|
"Rubric",
|
|
@@ -18,6 +23,10 @@ __all__ = [
|
|
|
18
23
|
"ParsedSet",
|
|
19
24
|
"GradedQuestion",
|
|
20
25
|
"GradedSet",
|
|
26
|
+
"GradeResult",
|
|
27
|
+
"GradeState",
|
|
28
|
+
"ParseResult",
|
|
29
|
+
"ParseState",
|
|
21
30
|
"get_grade_dataframe",
|
|
22
31
|
"grade_parsed_set",
|
|
23
32
|
]
|
|
@@ -8,8 +8,13 @@ from mcqpy_core.grading.types import GradedSet
|
|
|
8
8
|
from scipy.stats import pearsonr
|
|
9
9
|
from mcqpy_core.grading.analysis.utils import AnalysisFigure
|
|
10
10
|
|
|
11
|
+
COLROMAP = plt.cm.plasma
|
|
12
|
+
|
|
11
13
|
def get_points_array(graded_sets: list[GradedSet]):
|
|
12
|
-
|
|
14
|
+
|
|
15
|
+
n_questions = max(len(graded_set.graded_questions) for graded_set in graded_sets)
|
|
16
|
+
|
|
17
|
+
point_arrays = np.zeros((len(graded_sets), n_questions))
|
|
13
18
|
for i, graded_set in enumerate(graded_sets):
|
|
14
19
|
for j, question in enumerate(graded_set.graded_questions):
|
|
15
20
|
point_arrays[i, j] = question.point_value
|
|
@@ -34,11 +39,15 @@ def correlation_analysis(graded_sets: list[GradedSet], output_dir: str | Path):
|
|
|
34
39
|
correlations[qidx] = correlation
|
|
35
40
|
p_values[qidx] = p_value
|
|
36
41
|
|
|
42
|
+
|
|
43
|
+
# Colors based on correlation
|
|
44
|
+
colors = COLROMAP((correlations - np.min(correlations)) / (np.max(correlations) - np.min(correlations)))
|
|
45
|
+
|
|
37
46
|
fig, ax = plt.subplots(figsize=(8, 5))
|
|
38
47
|
ax.bar(
|
|
39
48
|
np.arange(1, n_questions + 1),
|
|
40
49
|
correlations,
|
|
41
|
-
color=
|
|
50
|
+
color=colors,
|
|
42
51
|
alpha=0.7,
|
|
43
52
|
edgecolor="black",
|
|
44
53
|
)
|
|
@@ -108,11 +117,15 @@ def discrimination_index_analysis(graded_sets: list[GradedSet], output_dir: str
|
|
|
108
117
|
D = (np.mean(q_points[high_group]) - np.mean(q_points[low_group])) / max_score
|
|
109
118
|
discrimination_indices[qidx] = D
|
|
110
119
|
|
|
120
|
+
|
|
121
|
+
# Colors based on discrimination index
|
|
122
|
+
colors = COLROMAP((discrimination_indices - np.min(discrimination_indices)) / (np.max(discrimination_indices) - np.min(discrimination_indices)))
|
|
123
|
+
|
|
111
124
|
fig, ax = plt.subplots(figsize=(8, 5))
|
|
112
125
|
ax.bar(
|
|
113
126
|
np.arange(1, n_questions + 1),
|
|
114
127
|
discrimination_indices,
|
|
115
|
-
color=
|
|
128
|
+
color=colors,
|
|
116
129
|
alpha=0.7,
|
|
117
130
|
edgecolor="black",
|
|
118
131
|
)
|
|
@@ -135,6 +148,42 @@ def discrimination_index_analysis(graded_sets: list[GradedSet], output_dir: str
|
|
|
135
148
|
)
|
|
136
149
|
|
|
137
150
|
|
|
151
|
+
def quesstion_point_average_analysis(graded_sets: list[GradedSet], output_dir: str | Path):
|
|
152
|
+
point_array = get_points_array(graded_sets)
|
|
153
|
+
|
|
154
|
+
n_questions = point_array.shape[1]
|
|
155
|
+
|
|
156
|
+
average_points = np.mean(point_array, axis=0)
|
|
157
|
+
|
|
158
|
+
# Colors based on average points
|
|
159
|
+
colors = COLROMAP(average_points / np.max(point_array))
|
|
160
|
+
|
|
161
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
162
|
+
ax.bar(
|
|
163
|
+
np.arange(1, n_questions + 1),
|
|
164
|
+
average_points,
|
|
165
|
+
color=colors,
|
|
166
|
+
alpha=0.7,
|
|
167
|
+
edgecolor="black",
|
|
168
|
+
)
|
|
169
|
+
plt.xlabel("Question Number")
|
|
170
|
+
plt.ylabel("Average Points")
|
|
171
|
+
plt.title("Average Points per Question")
|
|
172
|
+
|
|
173
|
+
ax.set_xticks(np.arange(1, n_questions + 1))
|
|
174
|
+
|
|
175
|
+
plt.tight_layout()
|
|
176
|
+
|
|
177
|
+
name = "quiz_question_point_average_analysis.pdf"
|
|
178
|
+
output_path = output_dir / name
|
|
179
|
+
|
|
180
|
+
plt.savefig(output_path)
|
|
181
|
+
plt.close()
|
|
182
|
+
return AnalysisFigure(
|
|
183
|
+
name=name,
|
|
184
|
+
caption=r"\textbf{Average points per question}: Shows the average points earned on each question across all students.",
|
|
185
|
+
)
|
|
186
|
+
|
|
138
187
|
def make_quiz_analysis(graded_sets: list[GradedSet], out_directory: str | Path):
|
|
139
188
|
out_directory = Path(out_directory)
|
|
140
189
|
out_directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -144,10 +193,14 @@ def make_quiz_analysis(graded_sets: list[GradedSet], out_directory: str | Path):
|
|
|
144
193
|
pd_fig = point_distribution_analysis(graded_sets, out_directory)
|
|
145
194
|
figures.append(pd_fig)
|
|
146
195
|
|
|
196
|
+
avg_fig = quesstion_point_average_analysis(graded_sets, out_directory)
|
|
197
|
+
figures.append(avg_fig)
|
|
198
|
+
|
|
147
199
|
corr_fig = correlation_analysis(graded_sets, out_directory)
|
|
148
200
|
figures.append(corr_fig)
|
|
149
201
|
|
|
150
202
|
di_fig = discrimination_index_analysis(graded_sets, out_directory)
|
|
151
203
|
figures.append(di_fig)
|
|
152
204
|
|
|
205
|
+
|
|
153
206
|
return figures
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
3
|
from mcqpy_core.manifest import Manifest
|
|
4
|
-
from mcqpy_core.grading.types import GradedQuestion, GradedSet, ParsedSet
|
|
4
|
+
from mcqpy_core.grading.types import GradedQuestion, GradedSet, ParsedSet, GradeResult, GradeState
|
|
5
5
|
from mcqpy_core.grading.rubric import Rubric
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet) ->
|
|
8
|
+
def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet) -> GradeResult:
|
|
9
9
|
graded_set = GradedSet(student_info=parsed_set.student_info, graded_questions=[])
|
|
10
10
|
|
|
11
|
+
other_info = {}
|
|
11
12
|
for parsed_question in parsed_set.questions:
|
|
12
13
|
manifest_item = manifest.get_item_by_qid(parsed_question.qid)
|
|
13
14
|
|
|
@@ -19,17 +20,20 @@ def grade_parsed_set(manifest: Manifest, rubric: Rubric, parsed_set: ParsedSet)
|
|
|
19
20
|
max_point_value=manifest_item.point_value,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
if sum(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
23
|
+
if sum(parsed_question.onehot) == 0:
|
|
24
|
+
message = f"No answers provided for question {parsed_question.qid} ({parsed_question.slug})."
|
|
25
|
+
other_info[parsed_question.qid] = message
|
|
26
26
|
|
|
27
27
|
graded_question.point_value = rubric.score_question(graded_question)
|
|
28
28
|
graded_set.graded_questions.append(graded_question)
|
|
29
29
|
|
|
30
30
|
graded_set.points = sum(q.point_value for q in graded_set.graded_questions)
|
|
31
31
|
graded_set.max_points = sum(q.max_point_value for q in graded_set.graded_questions)
|
|
32
|
-
return
|
|
32
|
+
return GradeResult(
|
|
33
|
+
state=GradeState.SUCCESS,
|
|
34
|
+
graded_set=graded_set,
|
|
35
|
+
other_info=other_info if other_info else None,
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
class MCQGrader:
|
|
@@ -38,7 +42,7 @@ class MCQGrader:
|
|
|
38
42
|
self.rubric = rubric
|
|
39
43
|
self.regex_pattern = regex_pattern
|
|
40
44
|
|
|
41
|
-
def grade(self, parsed_set: ParsedSet | None = None, student_answer: str | Path = None) ->
|
|
45
|
+
def grade(self, parsed_set: ParsedSet | None = None, student_answer: str | Path = None) -> GradeResult:
|
|
42
46
|
if parsed_set is None:
|
|
43
47
|
raise ValueError("MCQGrader.grade requires a ParsedSet in mcqpy-core")
|
|
44
48
|
return grade_parsed_set(self.manifest, self.rubric, parsed_set)
|
|
@@ -1,38 +1,4 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
@dataclass
|
|
5
|
-
class ParsedQuestion:
|
|
6
|
-
qid: str
|
|
7
|
-
slug: str
|
|
8
|
-
answers: list[int]
|
|
9
|
-
onehot: list[int]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass
|
|
13
|
-
class ParsedSet:
|
|
14
|
-
student_info: dict[str, str]
|
|
15
|
-
questions: list[ParsedQuestion]
|
|
16
|
-
file: str | None = None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@dataclass
|
|
20
|
-
class GradedQuestion:
|
|
21
|
-
qid: str
|
|
22
|
-
slug: str
|
|
23
|
-
student_answers: list[int]
|
|
24
|
-
correct_answers: list[int]
|
|
25
|
-
max_point_value: int
|
|
26
|
-
point_value: int = 0
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@dataclass
|
|
30
|
-
class GradedSet:
|
|
31
|
-
student_info: dict[str, str]
|
|
32
|
-
graded_questions: list[GradedQuestion]
|
|
33
|
-
points: int = 0
|
|
34
|
-
max_points: int = 0
|
|
35
|
-
|
|
1
|
+
from mcqpy_core.grading.types import GradedSet
|
|
36
2
|
|
|
37
3
|
def get_grade_dataframe(
|
|
38
4
|
graded_sets: list[GradedSet], sort_key: str | None = None
|
|
@@ -47,12 +13,17 @@ def get_grade_dataframe(
|
|
|
47
13
|
|
|
48
14
|
records = []
|
|
49
15
|
for graded_set in graded_sets:
|
|
16
|
+
|
|
17
|
+
# Determine the number of questions the student gave an answer to
|
|
18
|
+
answered_questions = sum(1 in q.student_answers for q in graded_set.graded_questions)
|
|
19
|
+
|
|
50
20
|
record = {}
|
|
51
21
|
record.update(graded_set.student_info)
|
|
52
22
|
record.update(
|
|
53
23
|
{
|
|
54
24
|
"total_points": graded_set.points,
|
|
55
25
|
"max_points": graded_set.max_points,
|
|
26
|
+
"answered_questions": answered_questions,
|
|
56
27
|
}
|
|
57
28
|
)
|
|
58
29
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class ParsedQuestion:
|
|
6
|
+
qid: str
|
|
7
|
+
slug: str
|
|
8
|
+
answers: list[int]
|
|
9
|
+
onehot: list[int]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ParsedSet:
|
|
14
|
+
student_info: dict[str, str]
|
|
15
|
+
questions: list[ParsedQuestion]
|
|
16
|
+
file: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GradedQuestion:
|
|
21
|
+
qid: str
|
|
22
|
+
slug: str
|
|
23
|
+
student_answers: list[int]
|
|
24
|
+
correct_answers: list[int]
|
|
25
|
+
max_point_value: int
|
|
26
|
+
point_value: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class GradedSet:
|
|
31
|
+
student_info: dict[str, str]
|
|
32
|
+
graded_questions: list[GradedQuestion]
|
|
33
|
+
points: int = 0
|
|
34
|
+
max_points: int = 0
|
|
35
|
+
|
|
36
|
+
class ParseState(Enum):
|
|
37
|
+
READER_ERROR = "Error reading PDF file. The file may be corrupted or not a valid PDF."
|
|
38
|
+
SUCCESS = "Successfully parsed PDF file."
|
|
39
|
+
|
|
40
|
+
class GradeState(Enum):
|
|
41
|
+
READER_ERROR = ParseState.READER_ERROR.value
|
|
42
|
+
SUCCESS = "Successfully graded the parsed set."
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ParseResult:
|
|
46
|
+
state: ParseState
|
|
47
|
+
parsed_set: ParsedSet | None = None
|
|
48
|
+
error_message: str | None = None
|
|
49
|
+
other_info: dict | None = None
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class GradeResult:
|
|
53
|
+
state: GradeState
|
|
54
|
+
graded_set: GradedSet | None = None
|
|
55
|
+
error_message: str | None = None
|
|
56
|
+
other_info: dict | None = None
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
CLI Build Command
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import rich_click as click
|
|
8
|
-
from rich.pretty import Pretty
|
|
9
|
-
from rich.console import Console
|
|
10
|
-
|
|
11
|
-
from mcqpy_core.cli._selection import select_questions, _build_filter, build_filters
|
|
12
|
-
from mcqpy_core.cli.config import QuizConfig
|
|
13
|
-
from mcqpy_core.cli.main import main
|
|
14
|
-
from mcqpy_pdf.compile import MultipleChoiceQuiz
|
|
15
|
-
from mcqpy_core.manifest import Manifest
|
|
16
|
-
from mcqpy_core.question import QuestionBank
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# Backward-compatible private alias kept for older tests/importers.
|
|
20
|
-
_select_questions = select_questions
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def build_solution(questions, manifest, output_path: Path):
|
|
24
|
-
from mcqpy_pdf.compile.solution_pdf import SolutionPDF
|
|
25
|
-
|
|
26
|
-
solution_pdf = SolutionPDF(file=output_path, questions=questions, manifest=manifest)
|
|
27
|
-
solution_pdf.build(generate_pdf=True)
|
|
28
|
-
|
|
29
|
-
@main.command(
|
|
30
|
-
name="build",
|
|
31
|
-
help="""Build the quiz PDF from question files based on the provided configuration.
|
|
32
|
-
This command reads the quiz configuration, selects questions from the question bank,
|
|
33
|
-
and generates the quiz PDF along with a solution PDF.
|
|
34
|
-
""",
|
|
35
|
-
)
|
|
36
|
-
@click.option(
|
|
37
|
-
"-c",
|
|
38
|
-
"--config",
|
|
39
|
-
type=click.Path(exists=True, path_type=Path),
|
|
40
|
-
default="config.yaml",
|
|
41
|
-
help="Path to the config file",
|
|
42
|
-
show_default=True,
|
|
43
|
-
)
|
|
44
|
-
def build_command(config):
|
|
45
|
-
"""
|
|
46
|
-
Build the quiz PDF from question files based on the provided configuration.
|
|
47
|
-
This command reads the quiz configuration, selects questions from the question bank,
|
|
48
|
-
and generates the quiz PDF along with a solution PDF.
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
mcqpy build --config config.yaml
|
|
52
|
-
```
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
config = QuizConfig.read_yaml(config)
|
|
56
|
-
question_bank = QuestionBank.from_directories(
|
|
57
|
-
config.questions_paths, seed=config.selection.seed
|
|
58
|
-
)
|
|
59
|
-
questions = select_questions(question_bank, config.selection)
|
|
60
|
-
|
|
61
|
-
console = Console()
|
|
62
|
-
console.print("[bold green]Quiz Configuration:[/bold green]")
|
|
63
|
-
console.print(Pretty(config))
|
|
64
|
-
console.print(
|
|
65
|
-
f"[bold green]Total questions in bank:[/bold green] {len(question_bank)}"
|
|
66
|
-
)
|
|
67
|
-
console.print(f"[bold green]Selected questions:[/bold green] {len(questions)}")
|
|
68
|
-
|
|
69
|
-
## Paths:
|
|
70
|
-
for path in [config.root_directory, config.output_directory, config.submission_directory]:
|
|
71
|
-
if path and not path.exists():
|
|
72
|
-
path.mkdir(parents=True, exist_ok=True) # pragma: no cover
|
|
73
|
-
|
|
74
|
-
mcq = MultipleChoiceQuiz(
|
|
75
|
-
file=config.file_path,
|
|
76
|
-
questions=questions,
|
|
77
|
-
front_matter=config.front_matter,
|
|
78
|
-
header_footer=config.header,
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
mcq.build(generate_pdf=True)
|
|
82
|
-
|
|
83
|
-
# Build solution PDF
|
|
84
|
-
manifest_path = mcq.get_manifest_path()
|
|
85
|
-
manifest = Manifest.load_from_file(manifest_path)
|
|
86
|
-
solution_output_path = (
|
|
87
|
-
config.output_directory / f"{config.file_name.replace('.pdf', '')}_solution.pdf"
|
|
88
|
-
)
|
|
89
|
-
build_solution(questions, manifest, solution_output_path)
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
CLI command to check LaTeX installation and configuration.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from mcqpy_core.cli import main
|
|
6
|
-
|
|
7
|
-
@main.command('check-latex', help="Check LaTeX installation and configuration.")
|
|
8
|
-
def check_latex_command():
|
|
9
|
-
"""
|
|
10
|
-
Check if LaTeX is installed and properly configured.
|
|
11
|
-
Provides the CLI command:
|
|
12
|
-
```
|
|
13
|
-
mcqpy check-latex
|
|
14
|
-
```
|
|
15
|
-
This command verifies the presence of LaTeX tools such as `pdflatex` and `latexmk`,
|
|
16
|
-
and attempts to compile a simple LaTeX document to ensure everything is functioning correctly.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from mcqpy_pdf.utils.check_latex import check_latex_installation
|
|
20
|
-
from rich.console import Console
|
|
21
|
-
|
|
22
|
-
console = Console()
|
|
23
|
-
success, details = check_latex_installation()
|
|
24
|
-
|
|
25
|
-
if success:
|
|
26
|
-
console.print("[bold green]✓ LaTeX is properly installed![/bold green]")
|
|
27
|
-
console.print(f"[green]pdflatex version[/green]: {details['pdflatex'].version}")
|
|
28
|
-
console.print(f"[green]latexmk version[/green]: {details['latexmk'].version}")
|
|
29
|
-
console.print("[green]Compilation test passed successfully.[/green]")
|
|
30
|
-
else:
|
|
31
|
-
console.print(f"[bold red]✗ LaTeX installation issue: {details['error_message']}[/bold red]")
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""Grade student submissions from a quiz."""
|
|
2
|
-
|
|
3
|
-
import rich_click as click
|
|
4
|
-
from mcqpy_core.cli.main import main
|
|
5
|
-
from mcqpy_core.cli.config import QuizConfig
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from mcqpy_core.grading import MCQGrader, get_grade_dataframe
|
|
9
|
-
from mcqpy_core.manifest import Manifest
|
|
10
|
-
from mcqpy_core.grading.rubric import StrictRubric
|
|
11
|
-
from rich.progress import track
|
|
12
|
-
|
|
13
|
-
from mcqpy_core.question.question_bank import QuestionBank
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@main.command(
|
|
17
|
-
name="grade",
|
|
18
|
-
help="""Grade student submissions.
|
|
19
|
-
Generates a grade report in the specified format (Excel or CSV).
|
|
20
|
-
Students submissions are expected to be in submission directory specified in the config file.
|
|
21
|
-
""",
|
|
22
|
-
)
|
|
23
|
-
@click.option(
|
|
24
|
-
"-c",
|
|
25
|
-
"--config",
|
|
26
|
-
type=click.Path(exists=True, path_type=Path),
|
|
27
|
-
default="config.yaml",
|
|
28
|
-
help="Path to the config file",
|
|
29
|
-
show_default=True,
|
|
30
|
-
)
|
|
31
|
-
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
|
|
32
|
-
@click.option(
|
|
33
|
-
"-f",
|
|
34
|
-
"--file-format",
|
|
35
|
-
type=click.Choice(["xlsx", "csv"]),
|
|
36
|
-
default="xlsx",
|
|
37
|
-
help="Output format for the grades",
|
|
38
|
-
show_default=True,
|
|
39
|
-
)
|
|
40
|
-
@click.option(
|
|
41
|
-
"-a",
|
|
42
|
-
"--analysis",
|
|
43
|
-
is_flag=True,
|
|
44
|
-
help="Generate question analysis reports",
|
|
45
|
-
default=False,
|
|
46
|
-
)
|
|
47
|
-
def grade_command(config, verbose: bool, file_format: str, analysis: bool):
|
|
48
|
-
"""
|
|
49
|
-
Grade student submissions based on the provided configuration file.
|
|
50
|
-
Generates a grade report in the specified format (Excel or CSV).
|
|
51
|
-
Students submissions are expected to be in submission directory specified in the config file.
|
|
52
|
-
|
|
53
|
-
Provides the CLI command:
|
|
54
|
-
```
|
|
55
|
-
mcqpy grade --config config.yaml --file-format xlsx --analysis
|
|
56
|
-
```
|
|
57
|
-
"""
|
|
58
|
-
# Load config
|
|
59
|
-
config = QuizConfig.read_yaml(config)
|
|
60
|
-
manifest_path = config.output_directory / f"{config.file_path.stem}_manifest.json"
|
|
61
|
-
manifest = Manifest.load_from_file(manifest_path)
|
|
62
|
-
|
|
63
|
-
# Read & Grade submissions
|
|
64
|
-
graded_sets = []
|
|
65
|
-
grader = MCQGrader(
|
|
66
|
-
manifest, StrictRubric(), regex_pattern=config.grading.anonymous_pattern
|
|
67
|
-
)
|
|
68
|
-
submissions = list(config.submission_directory.glob("*.pdf"))
|
|
69
|
-
for submission in track(
|
|
70
|
-
submissions,
|
|
71
|
-
description=f"Grading submissions ({len(submissions)})",
|
|
72
|
-
total=len(submissions),
|
|
73
|
-
):
|
|
74
|
-
graded_set = grader.grade(submission)
|
|
75
|
-
graded_sets.append(graded_set)
|
|
76
|
-
|
|
77
|
-
# Export grades to dataframe
|
|
78
|
-
df = get_grade_dataframe(graded_sets, sort_key=config.grading.output_sort_key)
|
|
79
|
-
output_path = (config.root_directory / f"{config.file_path.stem}_grades.{file_format}")
|
|
80
|
-
if file_format == "xlsx":
|
|
81
|
-
df.to_excel(output_path, index=False)
|
|
82
|
-
elif file_format == "csv":
|
|
83
|
-
df.to_csv(output_path, index=False)
|
|
84
|
-
|
|
85
|
-
if analysis:
|
|
86
|
-
from mcqpy_pdf.analysis.report import QuizAnalysis
|
|
87
|
-
|
|
88
|
-
analysis_directory = config.root_directory / "analysis/"
|
|
89
|
-
analysis_directory.mkdir(exist_ok=True)
|
|
90
|
-
|
|
91
|
-
question_bank = QuestionBank.from_directories(config.questions_paths)
|
|
92
|
-
print(f"Question bank loaded for analysis - {len(question_bank)}")
|
|
93
|
-
|
|
94
|
-
quiz_analysis = QuizAnalysis(
|
|
95
|
-
graded_sets,
|
|
96
|
-
question_bank=question_bank,
|
|
97
|
-
output_dir=analysis_directory,
|
|
98
|
-
grading_config=config.grading,
|
|
99
|
-
)
|
|
100
|
-
quiz_analysis.build()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|