ptestgen 0.2.1__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.
- main.py +718 -0
- ptestgen/__init__.py +1 -0
- ptestgen/agents/base.py +33 -0
- ptestgen/agents/evaluator.py +136 -0
- ptestgen/agents/generator.py +372 -0
- ptestgen/agents/reviewer.py +132 -0
- ptestgen/artifacts.py +614 -0
- ptestgen/config.py +206 -0
- ptestgen/correct.py +150 -0
- ptestgen/correct_online.py +463 -0
- ptestgen/evaluate.py +85 -0
- ptestgen/input_parser/parser.py +259 -0
- ptestgen/llm_providers/__init__.py +51 -0
- ptestgen/llm_providers/anthropic.py +72 -0
- ptestgen/llm_providers/base.py +63 -0
- ptestgen/llm_providers/google.py +87 -0
- ptestgen/llm_providers/openai_compatible.py +267 -0
- ptestgen/merge.py +55 -0
- ptestgen/pexams_converter.py +84 -0
- ptestgen/pipeline.py +381 -0
- ptestgen/schemas.py +131 -0
- ptestgen/shuffle.py +52 -0
- ptestgen/split.py +108 -0
- ptestgen-0.2.1.dist-info/METADATA +553 -0
- ptestgen-0.2.1.dist-info/RECORD +29 -0
- ptestgen-0.2.1.dist-info/WHEEL +5 -0
- ptestgen-0.2.1.dist-info/entry_points.txt +2 -0
- ptestgen-0.2.1.dist-info/licenses/LICENSE +201 -0
- ptestgen-0.2.1.dist-info/top_level.txt +2 -0
main.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
def load_project_dotenv():
|
|
13
|
+
"""Load .env files from the current workspace and package parents."""
|
|
14
|
+
seen = set()
|
|
15
|
+
for start in (Path.cwd(), Path(__file__).resolve()):
|
|
16
|
+
folder = start if start.is_dir() else start.parent
|
|
17
|
+
for candidate in (folder, *folder.parents):
|
|
18
|
+
if candidate in seen:
|
|
19
|
+
continue
|
|
20
|
+
seen.add(candidate)
|
|
21
|
+
load_dotenv(candidate / ".env")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Load .env file BEFORE importing config or pipeline.
|
|
25
|
+
# This ensures environment variables are set when config is loaded.
|
|
26
|
+
load_project_dotenv()
|
|
27
|
+
|
|
28
|
+
# Now import local modules
|
|
29
|
+
from ptestgen.pipeline import PTestGenPipeline
|
|
30
|
+
from ptestgen import config # Import config AFTER dotenv load
|
|
31
|
+
from ptestgen import artifacts # Import artifacts module
|
|
32
|
+
from ptestgen.split import handle_split_command
|
|
33
|
+
from ptestgen.merge import handle_merge_command
|
|
34
|
+
from ptestgen.shuffle import handle_shuffle_command
|
|
35
|
+
from ptestgen.correct import handle_correct
|
|
36
|
+
from ptestgen.correct_online import handle_correct_wooclap, handle_correct_moodle
|
|
37
|
+
from ptestgen.evaluate import handle_evaluate
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def setup_logging(log_level):
|
|
41
|
+
"""Configures the root logger."""
|
|
42
|
+
log_level_name = log_level.upper()
|
|
43
|
+
logging.basicConfig(level=log_level_name,
|
|
44
|
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s')
|
|
45
|
+
logging.info(f"Logging level set to: {log_level_name}")
|
|
46
|
+
|
|
47
|
+
def handle_test(args):
|
|
48
|
+
"""Handler for the 'test' command."""
|
|
49
|
+
logging.info("Running TEST command...")
|
|
50
|
+
|
|
51
|
+
output_dir = "generated_test"
|
|
52
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
53
|
+
print(f"Test artifacts will be saved in '{os.path.abspath(output_dir)}'")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
script_dir = os.path.dirname(__file__)
|
|
57
|
+
image_path = os.path.join(script_dir, "media", "image.jpg")
|
|
58
|
+
test_questions = 2
|
|
59
|
+
|
|
60
|
+
instructions = r"This is a TEST RUN for the PTestGen library, a library that allows you to generate test questions from text or images using LLMs. Generate questions about Python programming. MAKE SURE to include at least one instance of all the formatting options in the questions and answers: **bold text**, *italic text*, `code`, $LaTeX_expression$ (such as $\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$)."
|
|
61
|
+
|
|
62
|
+
# --- 1. Generation ---
|
|
63
|
+
print("\n--- Step 1: Generating questions (OpenRouter) ---")
|
|
64
|
+
or_md_path = os.path.join(output_dir, "openrouter_questions.md")
|
|
65
|
+
args_gen_or = argparse.Namespace(
|
|
66
|
+
input_material=None, output_md_path=or_md_path,
|
|
67
|
+
generator_instructions=instructions, reviewer_instructions=None, evaluator_instructions=None,
|
|
68
|
+
images=[image_path] if image_path else [], num_questions=test_questions, provider="openrouter",
|
|
69
|
+
generator_model="google/gemini-3-flash-preview", # A quicker model for testing
|
|
70
|
+
reviewer_model="google/gemini-3-flash-preview",
|
|
71
|
+
evaluator_model="google/gemini-3-flash-preview",
|
|
72
|
+
use_llm_review=True, language="es",
|
|
73
|
+
evaluate_initial=True, evaluate_reviewed=True,
|
|
74
|
+
num_questions_per_image=1,
|
|
75
|
+
question_type="multiple_choice",
|
|
76
|
+
)
|
|
77
|
+
handle_generate(args_gen_or)
|
|
78
|
+
|
|
79
|
+
# print("\n--- Step 1b: Generating questions (OpenAI) ---")
|
|
80
|
+
# openai_md_path = os.path.join(output_dir, "openai_questions.md")
|
|
81
|
+
# args_gen_openai = argparse.Namespace(
|
|
82
|
+
# input_material=None, output_md_path=openai_md_path,
|
|
83
|
+
# generator_instructions=instructions, reviewer_instructions=None, evaluator_instructions=None,
|
|
84
|
+
# images=[image_path] if image_path else [], num_questions=test_questions, provider="openai",
|
|
85
|
+
# generator_model="gpt-4o",
|
|
86
|
+
# reviewer_model="gpt-4o-mini",
|
|
87
|
+
# evaluator_model="gpt-4o",
|
|
88
|
+
# use_llm_review=True, language="es",
|
|
89
|
+
# evaluate_initial=True, evaluate_reviewed=True,
|
|
90
|
+
# num_questions_per_image=1
|
|
91
|
+
# )
|
|
92
|
+
# handle_generate(args_gen_openai)
|
|
93
|
+
openai_md_path = None
|
|
94
|
+
|
|
95
|
+
# --- 2. Split ---
|
|
96
|
+
print("\n--- Step 2: Splitting test ---")
|
|
97
|
+
split_dir = os.path.join(output_dir, "splits")
|
|
98
|
+
args_split = argparse.Namespace(
|
|
99
|
+
input_md_path=or_md_path, splits=["1", "1", "-1"],
|
|
100
|
+
output_dir=split_dir, shuffle_questions=None
|
|
101
|
+
)
|
|
102
|
+
handle_split_command(args_split)
|
|
103
|
+
|
|
104
|
+
# --- 3. Merge ---
|
|
105
|
+
print("\n--- Step 3: Merging tests ---")
|
|
106
|
+
merged_md_path = os.path.join(output_dir, "merged_questions.md")
|
|
107
|
+
split_files = [os.path.join(split_dir, f) for f in os.listdir(split_dir) if f.endswith('.md')]
|
|
108
|
+
args_merge = argparse.Namespace(
|
|
109
|
+
input_md_paths=split_files + ([openai_md_path] if openai_md_path else []),
|
|
110
|
+
output_md_path=merged_md_path
|
|
111
|
+
)
|
|
112
|
+
handle_merge_command(args_merge)
|
|
113
|
+
|
|
114
|
+
# --- 4. Shuffle ---
|
|
115
|
+
print("\n--- Step 4: Shuffling test ---")
|
|
116
|
+
args_shuffle = argparse.Namespace(
|
|
117
|
+
input_md_file=merged_md_path, # Shuffle the new file
|
|
118
|
+
seed=42,
|
|
119
|
+
yes=True
|
|
120
|
+
)
|
|
121
|
+
handle_shuffle_command(args_shuffle)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- 5. Simulate Manual Edit ---
|
|
125
|
+
print("\n--- Step 5: Simulating manual edits ---")
|
|
126
|
+
|
|
127
|
+
# Read the TSV to find a valid ID to change
|
|
128
|
+
shuffled_tsv_path = artifacts.get_artifact_paths(merged_md_path)[1]
|
|
129
|
+
records = artifacts.read_metadata_tsv(shuffled_tsv_path)
|
|
130
|
+
|
|
131
|
+
if len(records) > 1:
|
|
132
|
+
# Find an ID to change and one to modify
|
|
133
|
+
id_to_change = records[0].question_id
|
|
134
|
+
new_id = f"{id_to_change}_modified"
|
|
135
|
+
id_to_modify_content = records[1].question_id
|
|
136
|
+
|
|
137
|
+
with open(merged_md_path, 'r', encoding='utf-8') as f:
|
|
138
|
+
content = f.read()
|
|
139
|
+
|
|
140
|
+
# Change an ID by replacing its markdown header
|
|
141
|
+
content = content.replace(f"## {id_to_change}", f"## {new_id}")
|
|
142
|
+
|
|
143
|
+
# Modify content of another question
|
|
144
|
+
question_header_to_find = f"## {id_to_modify_content}"
|
|
145
|
+
q_start_index = content.find(question_header_to_find)
|
|
146
|
+
if q_start_index != -1:
|
|
147
|
+
# Find the end of this question block by looking for the next header or end of file
|
|
148
|
+
next_q_start_index = content.find("\n## ", q_start_index + 1)
|
|
149
|
+
if next_q_start_index == -1:
|
|
150
|
+
next_q_start_index = len(content)
|
|
151
|
+
|
|
152
|
+
# Find the start of the first answer option within this question's block
|
|
153
|
+
first_answer_index = content.find("\n* ", q_start_index, next_q_start_index)
|
|
154
|
+
|
|
155
|
+
if first_answer_index != -1:
|
|
156
|
+
# Append "(modified)" right before the answers start
|
|
157
|
+
content = content[:first_answer_index] + " (modified)" + content[first_answer_index:]
|
|
158
|
+
|
|
159
|
+
with open(merged_md_path, 'w', encoding='utf-8') as f:
|
|
160
|
+
f.write(content)
|
|
161
|
+
print("Applied edits to markdown file.")
|
|
162
|
+
else:
|
|
163
|
+
print("Not enough questions to simulate edits, skipping.")
|
|
164
|
+
|
|
165
|
+
# --- 6. Export ---
|
|
166
|
+
print("\n--- Step 6: Exporting to final formats ---")
|
|
167
|
+
common_export_args = {
|
|
168
|
+
'input_md_path': merged_md_path,
|
|
169
|
+
'shuffle_questions': None, 'shuffle_answers': None, 'num_final_questions': None,
|
|
170
|
+
'evaluate_final': True, 'evaluator_instructions': None, 'exam_title': 'Test Exam', 'exam_course': 'PTestGen Course',
|
|
171
|
+
'exam_date': '2025-01-01', 'num_models': 1, 'lang': 'en',
|
|
172
|
+
'font_size': '11pt', 'columns': 2,
|
|
173
|
+
# 'max_image_width': 400, # If two columns, do not use max width
|
|
174
|
+
'max_image_height': 300,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Wooclap
|
|
178
|
+
print("Exporting to Wooclap...")
|
|
179
|
+
args_export_wooclap = argparse.Namespace(format='wooclap', **common_export_args)
|
|
180
|
+
handle_export(args_export_wooclap)
|
|
181
|
+
|
|
182
|
+
# Disable final evaluation for subsequent exports
|
|
183
|
+
common_export_args['evaluate_final'] = False
|
|
184
|
+
|
|
185
|
+
# Moodle XML
|
|
186
|
+
print("Exporting to Moodle XML...")
|
|
187
|
+
args_export_moodle = argparse.Namespace(format='moodle_xml', **common_export_args)
|
|
188
|
+
handle_export(args_export_moodle)
|
|
189
|
+
|
|
190
|
+
# Pexams
|
|
191
|
+
print("Exporting to pexams (with fakes)...")
|
|
192
|
+
pexams_export_args = common_export_args.copy()
|
|
193
|
+
pexams_export_args.update({
|
|
194
|
+
'generate_fakes': 1,
|
|
195
|
+
'generate_references': True,
|
|
196
|
+
'columns': 2,
|
|
197
|
+
'font_size': '10pt',
|
|
198
|
+
'custom_header': '## Instructions\nAnswer the questions carefully.',
|
|
199
|
+
'shuffle_answers': 42 # Explicitly test deterministic answer shuffling
|
|
200
|
+
})
|
|
201
|
+
args_export_pexams = argparse.Namespace(format='pexams', **pexams_export_args)
|
|
202
|
+
handle_export(args_export_pexams)
|
|
203
|
+
|
|
204
|
+
# --- 7. Correct (New) ---
|
|
205
|
+
print("\n--- Step 7: Correcting pexams scans via ptestgen correct ---")
|
|
206
|
+
base_name = os.path.splitext(os.path.basename(merged_md_path))[0]
|
|
207
|
+
pexams_output_dir = os.path.join(output_dir, f"{base_name}_pexams_output")
|
|
208
|
+
simulated_scans_dir = os.path.join(pexams_output_dir, "simulated_scans")
|
|
209
|
+
correction_dir = os.path.join(pexams_output_dir, "correction_results")
|
|
210
|
+
|
|
211
|
+
args_correct = argparse.Namespace(
|
|
212
|
+
input_md_path=merged_md_path,
|
|
213
|
+
input_path=simulated_scans_dir,
|
|
214
|
+
exam_dir=pexams_output_dir,
|
|
215
|
+
output_dir=correction_dir,
|
|
216
|
+
evaluate_final=True, # Test the new evaluation feature
|
|
217
|
+
evaluator_instructions=None,
|
|
218
|
+
lang="en",
|
|
219
|
+
void_questions=None, void_questions_nicely=None,
|
|
220
|
+
input_csv=None, id_column=None, mark_column=None, name_column=None, simplify_csv=False,
|
|
221
|
+
fuzzy_id_match=100, penalty=0.0, input_encoding="utf-8", input_sep=",", output_decimal_sep=".",
|
|
222
|
+
name_match_threshold=70.0, use_llm_name_ocr=False, openrouter_name_model="google/gemini-3-flash-preview",
|
|
223
|
+
only_analysis=False
|
|
224
|
+
)
|
|
225
|
+
handle_correct(args_correct)
|
|
226
|
+
|
|
227
|
+
# Verify stats update
|
|
228
|
+
shuffled_tsv_path = artifacts.get_artifact_paths(merged_md_path)[1]
|
|
229
|
+
records = artifacts.read_metadata_tsv(shuffled_tsv_path)
|
|
230
|
+
stats_updated = any(r.stats_total_answers is not None for r in records)
|
|
231
|
+
if stats_updated:
|
|
232
|
+
print("Verified: Statistics have been updated in the metadata TSV.")
|
|
233
|
+
else:
|
|
234
|
+
print("WARNING: Statistics were NOT updated in the metadata TSV (this might be expected if pexams fakes were skipped, but fakes were enabled).")
|
|
235
|
+
|
|
236
|
+
# --- 8. Evaluate (New) ---
|
|
237
|
+
print("\n--- Step 8: Testing evaluate command ---")
|
|
238
|
+
args_eval = argparse.Namespace(
|
|
239
|
+
input_md_path=merged_md_path,
|
|
240
|
+
stages=['generated', 'reviewed', 'final'],
|
|
241
|
+
evaluator_instructions=None,
|
|
242
|
+
language="en",
|
|
243
|
+
missing_only=True
|
|
244
|
+
)
|
|
245
|
+
handle_evaluate(args_eval)
|
|
246
|
+
|
|
247
|
+
# --- 9. Test online correction (wooclap & moodle) ---
|
|
248
|
+
print("\n--- Step 9: Testing online correction (wooclap & moodle) ---")
|
|
249
|
+
from ptestgen.schemas import QuestionContent, QuestionRecord, QuestionStageContent
|
|
250
|
+
|
|
251
|
+
online_test_dir = os.path.join(output_dir, "online_correction_test")
|
|
252
|
+
os.makedirs(online_test_dir, exist_ok=True)
|
|
253
|
+
|
|
254
|
+
# Minimal synthetic records (no LLM needed)
|
|
255
|
+
def _sc(text, correct, distractors):
|
|
256
|
+
return QuestionStageContent(content=QuestionContent(text=text, correct_answer=correct, distractors=distractors))
|
|
257
|
+
|
|
258
|
+
synthetic_records = [
|
|
259
|
+
QuestionRecord(question_id="q_test_001", reviewed=_sc("What is 2 + 2?", "4", ["3", "5", "22"])),
|
|
260
|
+
QuestionRecord(question_id="q_test_002", reviewed=_sc("Capital of France?", "Paris", ["Berlin", "Madrid", "Rome"])),
|
|
261
|
+
]
|
|
262
|
+
online_md_path = os.path.join(online_test_dir, "questions.md")
|
|
263
|
+
online_tsv_path = os.path.join(online_test_dir, "questions.tsv")
|
|
264
|
+
artifacts.write_artifacts(synthetic_records, online_md_path, online_tsv_path)
|
|
265
|
+
|
|
266
|
+
# Wooclap
|
|
267
|
+
wooclap_csv = os.path.join(online_test_dir, "wooclap_results.csv")
|
|
268
|
+
pd.DataFrame({
|
|
269
|
+
"Alumno": ["1", "2"],
|
|
270
|
+
"Q1 - What is 2 + 2? (1 pts)": ["V - 4", "X - 3"],
|
|
271
|
+
"Q2 - Capital of France? (1 pts)": ["V - Paris", "X - Berlin"],
|
|
272
|
+
"Total": ["2 / 2", "0 / 2"],
|
|
273
|
+
}).to_csv(wooclap_csv, index=False)
|
|
274
|
+
|
|
275
|
+
wooclap_out_dir = os.path.join(online_test_dir, "wooclap_output")
|
|
276
|
+
args_correct_wooclap = argparse.Namespace(
|
|
277
|
+
input_md_path=online_md_path, results=wooclap_csv, output_dir=wooclap_out_dir,
|
|
278
|
+
fuzzy_threshold=80, encoding="auto", sep="auto",
|
|
279
|
+
penalty=0.0, evaluate_final=False, evaluator_instructions=None, lang="en",
|
|
280
|
+
no_generate_report=True,
|
|
281
|
+
)
|
|
282
|
+
handle_correct_wooclap(args_correct_wooclap)
|
|
283
|
+
wooclap_correction_csv = os.path.join(wooclap_out_dir, "correction_results.csv")
|
|
284
|
+
if not os.path.exists(wooclap_correction_csv):
|
|
285
|
+
raise RuntimeError("Wooclap: correction_results.csv was not created.")
|
|
286
|
+
wc_df = pd.read_csv(wooclap_correction_csv)
|
|
287
|
+
assert len(wc_df) == 2, f"Wooclap: expected 2 students, got {len(wc_df)}"
|
|
288
|
+
print(f"Wooclap correction OK — {len(wc_df)} student(s) processed.")
|
|
289
|
+
|
|
290
|
+
# Moodle
|
|
291
|
+
moodle_csv = os.path.join(online_test_dir, "moodle_results.csv")
|
|
292
|
+
pd.DataFrame({
|
|
293
|
+
"Cognoms": ["Smith", "Jones"],
|
|
294
|
+
"Nom": ["Alice", "Bob"],
|
|
295
|
+
"Resposta 1": ["4", "3"],
|
|
296
|
+
"Resposta 2": ["Paris", "Berlin"],
|
|
297
|
+
}).to_csv(moodle_csv, index=False)
|
|
298
|
+
|
|
299
|
+
moodle_out_dir = os.path.join(online_test_dir, "moodle_output")
|
|
300
|
+
args_correct_moodle = argparse.Namespace(
|
|
301
|
+
input_md_path=online_md_path, results=moodle_csv, output_dir=moodle_out_dir,
|
|
302
|
+
question_order=None, encoding="auto", sep="auto",
|
|
303
|
+
penalty=0.0, evaluate_final=False, evaluator_instructions=None, lang="en",
|
|
304
|
+
no_generate_report=True,
|
|
305
|
+
)
|
|
306
|
+
handle_correct_moodle(args_correct_moodle)
|
|
307
|
+
moodle_correction_csv = os.path.join(moodle_out_dir, "correction_results.csv")
|
|
308
|
+
if not os.path.exists(moodle_correction_csv):
|
|
309
|
+
raise RuntimeError("Moodle: correction_results.csv was not created.")
|
|
310
|
+
mo_df = pd.read_csv(moodle_correction_csv)
|
|
311
|
+
assert len(mo_df) == 2, f"Moodle: expected 2 students, got {len(mo_df)}"
|
|
312
|
+
print(f"Moodle correction OK — {len(mo_df)} student(s) processed.")
|
|
313
|
+
|
|
314
|
+
print("\n--- Test command finished successfully! ---")
|
|
315
|
+
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logging.error(f"Test command failed: {e}", exc_info=True)
|
|
318
|
+
print(f"\n--- ERROR: Test command failed ---\n{e}\n")
|
|
319
|
+
sys.exit(1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def handle_generate(args):
|
|
323
|
+
"""Handler for the 'generate' command."""
|
|
324
|
+
logging.info("Running GENERATE command...")
|
|
325
|
+
|
|
326
|
+
# --- Argument Validation ---
|
|
327
|
+
if not args.input_material and not args.generator_instructions:
|
|
328
|
+
logging.warning("Generating without an input file or specific generator instructions. Relying on default prompts.")
|
|
329
|
+
print("Warning: No input file or --generator-instructions provided. Generation might be generic.", file=sys.stderr)
|
|
330
|
+
|
|
331
|
+
# --- Configuration ---
|
|
332
|
+
effective_provider = args.provider
|
|
333
|
+
config_override = {"llm_provider": effective_provider}
|
|
334
|
+
|
|
335
|
+
# Determine effective models
|
|
336
|
+
model_keys = ["generator_model", "reviewer_model", "evaluator_model"]
|
|
337
|
+
model_maps = [config.GENERATOR_MODEL_MAP, config.REVIEWER_MODEL_MAP, config.EVALUATOR_MODEL_MAP]
|
|
338
|
+
|
|
339
|
+
for key, model_map in zip(model_keys, model_maps):
|
|
340
|
+
cli_model = getattr(args, key)
|
|
341
|
+
if cli_model:
|
|
342
|
+
config_override[key] = cli_model
|
|
343
|
+
else:
|
|
344
|
+
config_override[key] = model_map.get(effective_provider, f"stub-{key}")
|
|
345
|
+
|
|
346
|
+
# Override review flag if explicitly set
|
|
347
|
+
if args.use_llm_review != config.DEFAULT_LLM_REVIEW_ENABLED:
|
|
348
|
+
config_override["use_llm_review"] = args.use_llm_review
|
|
349
|
+
|
|
350
|
+
# --- API Key Check ---
|
|
351
|
+
if effective_provider not in ["stub", "ollama"]:
|
|
352
|
+
api_key_var = config.PROVIDER_API_KEY_MAP.get(effective_provider)
|
|
353
|
+
if not api_key_var or not os.getenv(api_key_var):
|
|
354
|
+
print(f"Error: Provider '{effective_provider}' selected, but its API key ({api_key_var}) was not found in .env or environment variables.", file=sys.stderr)
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
# --- Pipeline Execution ---
|
|
358
|
+
md_path, tsv_path = artifacts.get_artifact_paths(args.output_md_path)
|
|
359
|
+
pipeline = PTestGenPipeline(config_override=config_override)
|
|
360
|
+
pipeline.generate(
|
|
361
|
+
input_material_paths=args.input_material,
|
|
362
|
+
image_paths=args.images,
|
|
363
|
+
output_md_path=md_path,
|
|
364
|
+
output_tsv_path=tsv_path,
|
|
365
|
+
num_questions=args.num_questions,
|
|
366
|
+
language=args.language,
|
|
367
|
+
generator_instructions=args.generator_instructions,
|
|
368
|
+
reviewer_instructions=args.reviewer_instructions,
|
|
369
|
+
evaluator_instructions=args.evaluator_instructions,
|
|
370
|
+
evaluate_initial=args.evaluate_initial,
|
|
371
|
+
evaluate_reviewed=args.evaluate_reviewed,
|
|
372
|
+
num_questions_per_image=args.num_questions_per_image,
|
|
373
|
+
question_type=args.question_type,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def handle_export(args):
|
|
377
|
+
"""Handler for the 'export' command."""
|
|
378
|
+
logging.info(f"Running EXPORT command from file: {args.input_md_path}")
|
|
379
|
+
|
|
380
|
+
# --- Read Artifacts ---
|
|
381
|
+
md_path, tsv_path = artifacts.get_artifact_paths(args.input_md_path)
|
|
382
|
+
if not os.path.exists(md_path) or not os.path.exists(tsv_path):
|
|
383
|
+
logging.error(f"Input file '{args.input_md_path}' must contain both '{artifacts.QUESTIONS_FILENAME}' and '{artifacts.METADATA_FILENAME}'.")
|
|
384
|
+
sys.exit(1)
|
|
385
|
+
|
|
386
|
+
records = artifacts.read_metadata_tsv(tsv_path)
|
|
387
|
+
manually_edited_questions = artifacts.read_questions_md(md_path)
|
|
388
|
+
|
|
389
|
+
# --- Synchronize Artifacts ---
|
|
390
|
+
synced_records = artifacts.synchronize_artifacts(records, manually_edited_questions)
|
|
391
|
+
|
|
392
|
+
# Filter out removed questions for export
|
|
393
|
+
records_to_export = [
|
|
394
|
+
rec for rec in synced_records
|
|
395
|
+
if not (rec.changes_rev_to_man and rec.changes_rev_to_man.status == "removed") and
|
|
396
|
+
not (rec.changes_gen_to_rev and rec.changes_gen_to_rev.status == "removed")
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
if not records_to_export:
|
|
400
|
+
logging.warning("No valid questions remaining after synchronization. Nothing to export.")
|
|
401
|
+
# Save the updated (potentially empty) metadata
|
|
402
|
+
artifacts.write_metadata_tsv(synced_records, tsv_path)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# --- Pipeline Execution for Export ---
|
|
406
|
+
config_override = {}
|
|
407
|
+
if getattr(args, "evaluator_model", None):
|
|
408
|
+
config_override["evaluator_model"] = args.evaluator_model
|
|
409
|
+
|
|
410
|
+
pipeline = PTestGenPipeline(config_override=config_override)
|
|
411
|
+
pipeline.export(
|
|
412
|
+
records_to_export=records_to_export,
|
|
413
|
+
input_md_path=md_path, # Pass the markdown path
|
|
414
|
+
output_formats=[args.format],
|
|
415
|
+
shuffle_questions_seed=args.shuffle_questions,
|
|
416
|
+
shuffle_answers_seed=args.shuffle_answers,
|
|
417
|
+
num_final_questions=args.num_final_questions,
|
|
418
|
+
evaluate_final=args.evaluate_final,
|
|
419
|
+
evaluator_instructions=getattr(args, "evaluator_instructions", None),
|
|
420
|
+
# Safely access exam-specific args that might not exist for all formats
|
|
421
|
+
exam_title=getattr(args, 'exam_title', None),
|
|
422
|
+
exam_course=getattr(args, 'exam_course', None),
|
|
423
|
+
exam_date=getattr(args, 'exam_date', None),
|
|
424
|
+
exam_models=getattr(args, 'num_models', getattr(args, 'exam_models', 1)),
|
|
425
|
+
language=getattr(args, 'lang', getattr(args, 'exam_language', config.DEFAULT_LANGUAGE)),
|
|
426
|
+
# Safely access pexams-specific args
|
|
427
|
+
font_size=getattr(args, 'font_size', "11pt"),
|
|
428
|
+
columns=getattr(args, 'columns', 1),
|
|
429
|
+
generate_fakes=getattr(args, 'generate_fakes', 0),
|
|
430
|
+
generate_references=getattr(args, 'generate_references', False),
|
|
431
|
+
total_students=getattr(args, 'total_students', 0),
|
|
432
|
+
extra_model_templates=getattr(args, 'extra_model_templates', 0),
|
|
433
|
+
keep_html=getattr(args, 'keep_html', False) or (hasattr(args, 'log_level') and args.log_level == 'DEBUG'),
|
|
434
|
+
max_image_width=getattr(args, 'max_image_width', None),
|
|
435
|
+
max_image_height=getattr(args, 'max_image_height', None),
|
|
436
|
+
custom_header=getattr(args, 'custom_header', None)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# --- Save Updated Metadata ---
|
|
440
|
+
artifacts.write_metadata_tsv(synced_records, tsv_path)
|
|
441
|
+
print(f"Metadata file at '{tsv_path}' has been updated with manual review changes.")
|
|
442
|
+
|
|
443
|
+
def main():
|
|
444
|
+
parser = argparse.ArgumentParser(
|
|
445
|
+
description="PTestGen: A tool for AI-assisted generation of test questions.",
|
|
446
|
+
formatter_class=argparse.RawTextHelpFormatter
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# --- Parent Parser for common arguments ---
|
|
450
|
+
common_parser = argparse.ArgumentParser(add_help=False)
|
|
451
|
+
common_parser.add_argument("--log-level",
|
|
452
|
+
default="INFO",
|
|
453
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
454
|
+
help="Set the logging verbosity.")
|
|
455
|
+
|
|
456
|
+
subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
|
|
457
|
+
|
|
458
|
+
# --- Generate Command ---
|
|
459
|
+
parser_generate = subparsers.add_parser("generate", help="Generate questions from a source.", parents=[common_parser])
|
|
460
|
+
parser_generate.add_argument("input_material",
|
|
461
|
+
nargs='*',
|
|
462
|
+
default=[],
|
|
463
|
+
help="Paths to the input material files (e.g., .txt, .pdf). Can be multiple. If omitted, generation relies on instructions.")
|
|
464
|
+
parser_generate.add_argument("-o", "--output-md-path", default="generated/questions.md", help="Path to save the output markdown file. A .tsv file will be created alongside it.")
|
|
465
|
+
parser_generate.add_argument("--generator-instructions", type=str, default=None, help="Custom instructions for the generator prompt.")
|
|
466
|
+
parser_generate.add_argument("--reviewer-instructions", type=str, default=None, help="Custom instructions for the reviewer prompt.")
|
|
467
|
+
parser_generate.add_argument("--evaluator-instructions", type=str, default=None, help="Custom instructions for the evaluator prompt.")
|
|
468
|
+
parser_generate.add_argument("-i", "--images", nargs='+', help="Optional paths to image files.", default=[])
|
|
469
|
+
parser_generate.add_argument("-n", "--num-questions", type=int, default=config.DEFAULT_NUM_QUESTIONS, help=f"Number of questions to generate from text (default: {config.DEFAULT_NUM_QUESTIONS}).")
|
|
470
|
+
parser_generate.add_argument("--num-questions-per-image", type=int, default=1, help="Number of questions to generate per image (default: 1).")
|
|
471
|
+
parser_generate.add_argument("--question-type", choices=["multiple_choice", "open_answer", "mixed"], default="multiple_choice", help="Type of questions to generate: multiple_choice, open_answer, or mixed.")
|
|
472
|
+
parser_generate.add_argument("--provider", choices=config.GENERATOR_MODEL_MAP.keys(), default=config.LLM_PROVIDER, help=f"LLM provider to use (default: {config.LLM_PROVIDER}).")
|
|
473
|
+
parser_generate.add_argument("--generator-model", default=None, help="Specific model for the generator agent.")
|
|
474
|
+
parser_generate.add_argument("--reviewer-model", default=None, help="Specific model for the reviewer agent.")
|
|
475
|
+
parser_generate.add_argument("--evaluator-model", default=None, help="Specific model for the evaluator agent.")
|
|
476
|
+
parser_generate.add_argument("--use-llm-review", action=argparse.BooleanOptionalAction, default=config.DEFAULT_LLM_REVIEW_ENABLED, help="Enable LLM-based review.")
|
|
477
|
+
parser_generate.add_argument("--skip-manual-review", action='store_true', help="Skip the manual review step (not recommended with new workflow).")
|
|
478
|
+
parser_generate.add_argument("--language", default=config.DEFAULT_LANGUAGE, help=f"Language for the questions (default: {config.DEFAULT_LANGUAGE}).")
|
|
479
|
+
parser_generate.add_argument("--evaluate-initial", action="store_true", help="Run evaluator on questions after generation.")
|
|
480
|
+
parser_generate.add_argument("--evaluate-reviewed", action="store_true", help="Run evaluator on questions after the review stage.")
|
|
481
|
+
parser_generate.set_defaults(func=handle_generate)
|
|
482
|
+
|
|
483
|
+
# --- Export Command ---
|
|
484
|
+
# This parser is just a container for the format subparsers
|
|
485
|
+
parser_export = subparsers.add_parser("export", help="Export questions to a specified format.")
|
|
486
|
+
export_subparsers = parser_export.add_subparsers(dest="format", required=True, help="The output format.")
|
|
487
|
+
|
|
488
|
+
# Parent parser for common export arguments
|
|
489
|
+
export_common_parser = argparse.ArgumentParser(add_help=False)
|
|
490
|
+
export_common_parser.add_argument("input_md_path", help="Path to the input questions.md file.")
|
|
491
|
+
export_common_parser.add_argument("--shuffle-questions", type=int, metavar='SEED', nargs='?', const=random.randint(1, 10000), default=None, help="Shuffle question order.")
|
|
492
|
+
export_common_parser.add_argument("--shuffle-answers", type=int, metavar='SEED', nargs='?', const=random.randint(1, 10000), default=0, help="Shuffle answer order.")
|
|
493
|
+
export_common_parser.add_argument("--num-final-questions", type=int, help="Randomly select N questions.")
|
|
494
|
+
export_common_parser.add_argument("--evaluate-final", action="store_true", help="Run evaluator on the final questions.")
|
|
495
|
+
export_common_parser.add_argument("--evaluator-model", default=None, help="Specific model for the evaluator agent.")
|
|
496
|
+
export_common_parser.add_argument("--evaluator-instructions", type=str, default=None, help="Custom instructions for the evaluator prompt.")
|
|
497
|
+
export_common_parser.add_argument("--max-image-width", type=int, default=None, help="Maximum width for images in pixels.")
|
|
498
|
+
export_common_parser.add_argument("--max-image-height", type=int, default=None, help="Maximum height for images in pixels.")
|
|
499
|
+
|
|
500
|
+
# Parent parser for exam-specific arguments (pexams, rexams)
|
|
501
|
+
exam_parser = argparse.ArgumentParser(add_help=False)
|
|
502
|
+
exam_parser.add_argument("--exam-title", help="Custom title for the exam PDF.")
|
|
503
|
+
exam_parser.add_argument("--exam-course", help="Custom course name for the exam PDF.")
|
|
504
|
+
exam_parser.add_argument("--exam-date", help="Custom date for the exam PDF.")
|
|
505
|
+
exam_parser.add_argument("--exam-models", type=int, default=1, help="Number of different exam versions to generate.")
|
|
506
|
+
exam_parser.add_argument("--exam-language", default=config.DEFAULT_LANGUAGE, help="Language for the exam PDF.")
|
|
507
|
+
|
|
508
|
+
# Create subparsers for each format, now inheriting common_parser
|
|
509
|
+
export_subparsers.add_parser("moodle_xml", parents=[common_parser, export_common_parser], help="Export to Moodle XML format.")
|
|
510
|
+
export_subparsers.add_parser("gift", parents=[common_parser, export_common_parser], help="Export to GIFT format.")
|
|
511
|
+
export_subparsers.add_parser("wooclap", parents=[common_parser, export_common_parser], help="Export to Wooclap CSV format.")
|
|
512
|
+
export_subparsers.add_parser("none", parents=[common_parser, export_common_parser], help="Run export pre-processing without creating a final file.")
|
|
513
|
+
|
|
514
|
+
# Pexams subparser
|
|
515
|
+
parser_pexams = export_subparsers.add_parser("pexams", parents=[common_parser, export_common_parser], help="Export to pexams PDF format.")
|
|
516
|
+
parser_pexams.add_argument("--num-models", type=int, default=1, help="Number of different exam models to generate (default: 4).")
|
|
517
|
+
parser_pexams.add_argument("--exam-title", default="Final Exam", help="Title of the exam (default: \"Final Exam\").")
|
|
518
|
+
parser_pexams.add_argument("--exam-course", help="Course name for the exam (optional).")
|
|
519
|
+
parser_pexams.add_argument("--exam-date", help="Date of the exam (optional).")
|
|
520
|
+
parser_pexams.add_argument("--columns", type=int, default=1, choices=[1, 2, 3], help="Number of columns for the questions (1, 2, or 3; default: 1).")
|
|
521
|
+
parser_pexams.add_argument("--font-size", default="10pt", help="Base font size for the exam (e.g., '10pt', '12px'; default: '10pt').")
|
|
522
|
+
parser_pexams.add_argument("--total-students", type=int, default=0, help="Total number of students for mass PDF generation (default: 0).")
|
|
523
|
+
parser_pexams.add_argument("--extra-model-templates", type=int, default=0, help="Number of extra template sheets (answer sheet only) to generate per model (default: 0).")
|
|
524
|
+
parser_pexams.add_argument("--lang", default=config.DEFAULT_LANGUAGE, help="Language for the answer sheet labels (e.g., 'en', 'es'; default: 'en').")
|
|
525
|
+
parser_pexams.add_argument("--keep-html", action="store_true", help="If set, keeps the intermediate HTML files used for PDF generation.")
|
|
526
|
+
parser_pexams.add_argument("--generate-fakes", type=int, default=0, help="Generates a number of simulated scans with fake answers for testing the correction process (default: 0).")
|
|
527
|
+
parser_pexams.add_argument("--generate-references", action="store_true", help="If set, generates a reference scan with the correct answers marked for each model.")
|
|
528
|
+
parser_pexams.add_argument("--custom-header", help="Markdown string or path to a Markdown file to insert before the questions (e.g., instructions).")
|
|
529
|
+
|
|
530
|
+
# Rexams subparser
|
|
531
|
+
export_subparsers.add_parser("rexams", parents=[common_parser, export_common_parser, exam_parser], help="Export to R/exams format.")
|
|
532
|
+
|
|
533
|
+
parser_export.set_defaults(func=handle_export)
|
|
534
|
+
|
|
535
|
+
# --- Correct Command (with subparsers for pexams / wooclap / moodle) ---
|
|
536
|
+
parser_correct = subparsers.add_parser(
|
|
537
|
+
"correct",
|
|
538
|
+
help="Correct exams (pexams scans, Wooclap results, or Moodle results) and update metadata.",
|
|
539
|
+
parents=[common_parser],
|
|
540
|
+
)
|
|
541
|
+
correct_subparsers = parser_correct.add_subparsers(
|
|
542
|
+
dest="source", required=True,
|
|
543
|
+
help="The source format of the student results.",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Common args shared by all correct sub-subparsers
|
|
547
|
+
_correct_common = argparse.ArgumentParser(add_help=False)
|
|
548
|
+
_correct_common.add_argument(
|
|
549
|
+
"input_md_path",
|
|
550
|
+
help="Path to the questions.md file (used to update statistics in metadata.tsv).",
|
|
551
|
+
)
|
|
552
|
+
_correct_common.add_argument(
|
|
553
|
+
"--output-dir", required=True,
|
|
554
|
+
help="Directory to save correction results.",
|
|
555
|
+
)
|
|
556
|
+
_correct_common.add_argument(
|
|
557
|
+
"--evaluate-final", action="store_true",
|
|
558
|
+
help="Run the LLM evaluator on questions after correction.",
|
|
559
|
+
)
|
|
560
|
+
_correct_common.add_argument(
|
|
561
|
+
"--evaluator-instructions", type=str, default=None,
|
|
562
|
+
help="Custom instructions for the evaluator prompt.",
|
|
563
|
+
)
|
|
564
|
+
_correct_common.add_argument(
|
|
565
|
+
"--lang", default=config.DEFAULT_LANGUAGE,
|
|
566
|
+
help="Language for evaluation.",
|
|
567
|
+
)
|
|
568
|
+
_correct_common.add_argument(
|
|
569
|
+
"--penalty", type=float, default=0.0,
|
|
570
|
+
help="Score penalty for wrong answers (positive float; default: 0.0).",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# --- pexams sub-subparser (existing functionality) ---
|
|
574
|
+
parser_correct_pexams = correct_subparsers.add_parser(
|
|
575
|
+
"pexams",
|
|
576
|
+
parents=[common_parser, _correct_common],
|
|
577
|
+
help="Correct scanned pexams answer sheets.",
|
|
578
|
+
)
|
|
579
|
+
parser_correct_pexams.add_argument("--input-path", required=False, help="Path to the scanned PDF or folder of images.")
|
|
580
|
+
parser_correct_pexams.add_argument("--exam-dir", required=True, help="Path to the directory containing exam model JSONs.")
|
|
581
|
+
parser_correct_pexams.add_argument("--void-questions", default=None, help="Comma-separated list of question numbers to void.")
|
|
582
|
+
parser_correct_pexams.add_argument("--void-questions-nicely", default=None, help="Comma-separated list of question IDs to void nicely.")
|
|
583
|
+
parser_correct_pexams.add_argument("--input-csv", help="Path to input CSV for filling marks.")
|
|
584
|
+
parser_correct_pexams.add_argument("--id-column", help="Column name for student IDs.")
|
|
585
|
+
parser_correct_pexams.add_argument("--mark-column", help="Column name for marks.")
|
|
586
|
+
parser_correct_pexams.add_argument("--name-column", help="Column name for student names.")
|
|
587
|
+
parser_correct_pexams.add_argument("--simplify-csv", action="store_true", help="Simplify the output CSV.")
|
|
588
|
+
parser_correct_pexams.add_argument("--fuzzy-id-match", type=int, default=100, help="Fuzzy matching threshold (0-100).")
|
|
589
|
+
parser_correct_pexams.add_argument("--name-match-threshold", type=float, default=70.0, help="Fuzzy matching threshold (0-100) for matching OCR names to --input-csv before analysis.")
|
|
590
|
+
parser_correct_pexams.add_argument("--use-llm-name-ocr", action="store_true", help="Use OpenRouter vision OCR for student names before roster matching.")
|
|
591
|
+
parser_correct_pexams.add_argument("--openrouter-name-model", default="google/gemini-3-flash-preview", help="OpenRouter vision model for --use-llm-name-ocr.")
|
|
592
|
+
parser_correct_pexams.add_argument("--input-encoding", default="utf-8", help="Encoding of input CSV.")
|
|
593
|
+
parser_correct_pexams.add_argument("--input-sep", default=",", help="Separator for input CSV.")
|
|
594
|
+
parser_correct_pexams.add_argument("--output-decimal-sep", default=".", help="Decimal separator for output marks.")
|
|
595
|
+
parser_correct_pexams.add_argument("--only-analysis", action="store_true", help="Skip image processing and run analysis on existing results.")
|
|
596
|
+
parser_correct_pexams.set_defaults(func=handle_correct)
|
|
597
|
+
|
|
598
|
+
# --- wooclap sub-subparser ---
|
|
599
|
+
parser_correct_wooclap = correct_subparsers.add_parser(
|
|
600
|
+
"wooclap",
|
|
601
|
+
parents=[common_parser, _correct_common],
|
|
602
|
+
help="Ingest Wooclap quiz results (CSV/XLSX from 'Export to Excel').",
|
|
603
|
+
)
|
|
604
|
+
parser_correct_wooclap.add_argument(
|
|
605
|
+
"--results", required=True,
|
|
606
|
+
help="Path to the Wooclap results file (CSV or XLSX).",
|
|
607
|
+
)
|
|
608
|
+
parser_correct_wooclap.add_argument(
|
|
609
|
+
"--fuzzy-threshold", type=int, default=80,
|
|
610
|
+
help="Minimum similarity (0–100) for fuzzy question-text matching. Default: 80.",
|
|
611
|
+
)
|
|
612
|
+
parser_correct_wooclap.add_argument(
|
|
613
|
+
"--encoding", default="auto",
|
|
614
|
+
help="File encoding (default: auto-detect).",
|
|
615
|
+
)
|
|
616
|
+
parser_correct_wooclap.add_argument(
|
|
617
|
+
"--sep", default="auto",
|
|
618
|
+
help="CSV separator (default: auto-detect).",
|
|
619
|
+
)
|
|
620
|
+
parser_correct_wooclap.add_argument(
|
|
621
|
+
"--no-generate-report", action="store_true",
|
|
622
|
+
help="Skip PDF report generation.",
|
|
623
|
+
)
|
|
624
|
+
parser_correct_wooclap.set_defaults(func=handle_correct_wooclap)
|
|
625
|
+
|
|
626
|
+
# --- moodle sub-subparser ---
|
|
627
|
+
parser_correct_moodle = correct_subparsers.add_parser(
|
|
628
|
+
"moodle",
|
|
629
|
+
parents=[common_parser, _correct_common],
|
|
630
|
+
help="Ingest Moodle quiz results (CSV/XLSX from Results > Responses > Download).",
|
|
631
|
+
)
|
|
632
|
+
parser_correct_moodle.add_argument(
|
|
633
|
+
"--results", required=True,
|
|
634
|
+
help="Path to the Moodle results file (CSV or XLSX).",
|
|
635
|
+
)
|
|
636
|
+
parser_correct_moodle.add_argument(
|
|
637
|
+
"--question-order", default=None,
|
|
638
|
+
help=(
|
|
639
|
+
"Comma-separated 1-based question indices mapping 'Resposta 1' to question N. "
|
|
640
|
+
"Default: sequential order."
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
parser_correct_moodle.add_argument(
|
|
644
|
+
"--encoding", default="auto",
|
|
645
|
+
help="File encoding (default: auto-detect).",
|
|
646
|
+
)
|
|
647
|
+
parser_correct_moodle.add_argument(
|
|
648
|
+
"--sep", default="auto",
|
|
649
|
+
help="CSV separator (default: auto-detect).",
|
|
650
|
+
)
|
|
651
|
+
parser_correct_moodle.add_argument(
|
|
652
|
+
"--no-generate-report", action="store_true",
|
|
653
|
+
help="Skip PDF report generation.",
|
|
654
|
+
)
|
|
655
|
+
parser_correct_moodle.set_defaults(func=handle_correct_moodle)
|
|
656
|
+
|
|
657
|
+
# --- Evaluate Command ---
|
|
658
|
+
parser_evaluate = subparsers.add_parser("evaluate", help="Run evaluator on questions.", parents=[common_parser])
|
|
659
|
+
parser_evaluate.add_argument("input_md_path", help="Path to the questions.md file.")
|
|
660
|
+
parser_evaluate.add_argument("--stages", nargs='+', choices=['generated', 'reviewed', 'final'], default=['generated', 'reviewed', 'final'], help="Stages to evaluate.")
|
|
661
|
+
parser_evaluate.add_argument("--evaluator-instructions", type=str, default=None, help="Custom instructions for the evaluator prompt.")
|
|
662
|
+
parser_evaluate.add_argument("--language", default=config.DEFAULT_LANGUAGE, help="Language for evaluation.")
|
|
663
|
+
parser_evaluate.add_argument("--missing-only", action="store_true", help="Only evaluate questions missing an evaluation.")
|
|
664
|
+
parser_evaluate.set_defaults(func=handle_evaluate)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --- Split Command ---
|
|
668
|
+
parser_split = subparsers.add_parser("split", help="Split a test into multiple parts.", parents=[common_parser])
|
|
669
|
+
parser_split.add_argument("input_md_path", help="Path to the input questions.md file to split.")
|
|
670
|
+
parser_split.add_argument("--splits",
|
|
671
|
+
nargs='+',
|
|
672
|
+
type=str,
|
|
673
|
+
required=True,
|
|
674
|
+
help="A list of integers (number of questions) or floats (proportion) defining the splits.")
|
|
675
|
+
parser_split.add_argument("--output-dir", help="Directory to save the split files (optional, defaults to the input file's directory).")
|
|
676
|
+
parser_split.add_argument("--shuffle-questions",
|
|
677
|
+
type=int,
|
|
678
|
+
metavar='SEED',
|
|
679
|
+
nargs='?',
|
|
680
|
+
const=random.randint(1, 10000),
|
|
681
|
+
default=None,
|
|
682
|
+
help="Shuffle questions before splitting. Provide an optional seed.")
|
|
683
|
+
parser_split.set_defaults(func=handle_split_command)
|
|
684
|
+
|
|
685
|
+
# --- Merge Command ---
|
|
686
|
+
parser_merge = subparsers.add_parser("merge", help="Merge multiple tests into one.", parents=[common_parser])
|
|
687
|
+
parser_merge.add_argument("input_md_paths", nargs='+', help="List of paths to the input questions.md files to merge.")
|
|
688
|
+
parser_merge.add_argument("-o", "--output-md-path", required=True, help="Path to save the merged output markdown file.")
|
|
689
|
+
parser_merge.set_defaults(func=handle_merge_command)
|
|
690
|
+
|
|
691
|
+
# --- Shuffle Command ---
|
|
692
|
+
parser_shuffle = subparsers.add_parser("shuffle", help="Shuffle questions in a markdown file in-place.", parents=[common_parser])
|
|
693
|
+
parser_shuffle.add_argument("input_md_file", help="Path to the markdown file to shuffle.")
|
|
694
|
+
parser_shuffle.add_argument("--seed", type=int, help="Optional integer seed for reproducible shuffling.")
|
|
695
|
+
parser_shuffle.add_argument("-y", "--yes", action="store_true", help="Bypass the confirmation prompt and overwrite the file directly.")
|
|
696
|
+
parser_shuffle.set_defaults(func=handle_shuffle_command)
|
|
697
|
+
|
|
698
|
+
# --- Test Command ---
|
|
699
|
+
parser_test = subparsers.add_parser("test", help="Run a full pipeline test to check for runtime errors.", parents=[common_parser])
|
|
700
|
+
parser_test.set_defaults(func=handle_test)
|
|
701
|
+
|
|
702
|
+
args = parser.parse_args()
|
|
703
|
+
setup_logging(args.log_level) # Setup logging right after parsing args
|
|
704
|
+
|
|
705
|
+
if hasattr(args, 'func'):
|
|
706
|
+
try:
|
|
707
|
+
args.func(args)
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logging.error(f"An unexpected error occurred: {e}", exc_info=True)
|
|
710
|
+
print(f"\n--- Error ---\nAn unexpected error occurred: {e}")
|
|
711
|
+
# print traceback
|
|
712
|
+
import traceback
|
|
713
|
+
traceback.print_exc()
|
|
714
|
+
|
|
715
|
+
if __name__ == "__main__":
|
|
716
|
+
main()
|
|
717
|
+
|
|
718
|
+
|