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 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
+