ecuartasm commited on
Commit
2ac15a9
·
1 Parent(s): 217abc3

check yml

Browse files
Files changed (2) hide show
  1. ui/app.py +15 -2
  2. ui/edit_handlers.py +359 -56
ui/app.py CHANGED
@@ -2,7 +2,7 @@ import gradio as gr
2
  from dotenv import load_dotenv
3
  from .objective_handlers import process_files, regenerate_objectives, process_files_and_generate_questions
4
  from .question_handlers import generate_questions
5
- from .edit_handlers import load_quiz_for_editing, accept_and_next, go_previous, save_and_download
6
  from .formatting import format_quiz_for_ui
7
  from .run_manager import get_run_manager
8
  from models import MODELS
@@ -95,7 +95,14 @@ def create_ui():
95
  with gr.Row():
96
  with gr.Column():
97
  edit_status = gr.Textbox(label="Status", interactive=False)
98
- edit_button = gr.Button("Edit questions", variant="primary")
 
 
 
 
 
 
 
99
  question_editor = gr.Textbox(
100
  label="Question",
101
  lines=15,
@@ -157,6 +164,12 @@ def create_ui():
157
  outputs=[edit_status, question_editor, questions_state, index_state, edited_state, next_button]
158
  )
159
 
 
 
 
 
 
 
160
  next_button.click(
161
  accept_and_next,
162
  inputs=[question_editor, questions_state, index_state, edited_state],
 
2
  from dotenv import load_dotenv
3
  from .objective_handlers import process_files, regenerate_objectives, process_files_and_generate_questions
4
  from .question_handlers import generate_questions
5
+ from .edit_handlers import load_quiz_for_editing, load_file_for_editing, accept_and_next, go_previous, save_and_download
6
  from .formatting import format_quiz_for_ui
7
  from .run_manager import get_run_manager
8
  from models import MODELS
 
95
  with gr.Row():
96
  with gr.Column():
97
  edit_status = gr.Textbox(label="Status", interactive=False)
98
+ with gr.Accordion("Load questions from file (.md or .yml)", open=False):
99
+ file_upload = gr.File(
100
+ label="Upload a quiz file (.md or .yml)",
101
+ file_types=[".md", ".yml", ".yaml"],
102
+ type="filepath"
103
+ )
104
+ load_file_button = gr.Button("Load from file")
105
+ edit_button = gr.Button("Edit questions from Tab 2", variant="primary")
106
  question_editor = gr.Textbox(
107
  label="Question",
108
  lines=15,
 
164
  outputs=[edit_status, question_editor, questions_state, index_state, edited_state, next_button]
165
  )
166
 
167
+ load_file_button.click(
168
+ load_file_for_editing,
169
+ inputs=[file_upload],
170
+ outputs=[edit_status, question_editor, questions_state, index_state, edited_state, next_button]
171
+ )
172
+
173
  next_button.click(
174
  accept_and_next,
175
  inputs=[question_editor, questions_state, index_state, edited_state],
ui/edit_handlers.py CHANGED
@@ -1,9 +1,27 @@
1
  import re
 
2
  import tempfile
 
3
  import gradio as gr
 
 
 
4
  from .run_manager import get_run_manager
5
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  def _next_button_label(index, total):
8
  """Return 'Accept & Finish' for the last question, 'Accept & Next' otherwise."""
9
  if total > 0 and index >= total - 1:
@@ -11,39 +29,86 @@ def _next_button_label(index, total):
11
  return gr.update(value="Accept & Next")
12
 
13
 
14
- def _parse_questions(md_content):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """Split formatted_quiz.md content into individual question blocks."""
16
  parts = re.split(r'(?=\*\*Question \d+)', md_content.strip())
17
  return [p.strip() for p in parts if p.strip()]
18
 
19
 
20
- def _parse_question_block(block_text):
21
- """Parse a single markdown question block into structured data."""
22
- prompt = ""
23
- options = []
 
 
 
 
24
  current_option = None
 
25
 
26
  for line in block_text.split('\n'):
27
  stripped = line.strip()
28
 
29
- # Question text line (colon may be inside or outside the bold markers)
30
- q_match = re.match(r'\*\*Question \d+.*?\*\*:?\s*(.+)', stripped)
31
  if q_match:
32
- prompt = q_match.group(1).strip()
 
 
 
33
  continue
34
 
35
- # Skip ranking reasoning
36
  if stripped.startswith('Ranking Reasoning:'):
 
37
  continue
38
 
39
  # Option line: • A [Correct]: text or • A: text
40
- opt_match = re.match(r'•\s*[A-D]\s*(\[Correct\])?\s*:\s*(.+)', stripped)
41
  if opt_match:
 
42
  if current_option:
43
  options.append(current_option)
44
  current_option = {
45
- 'answer': opt_match.group(2).strip(),
46
- 'isCorrect': opt_match.group(1) is not None,
47
  'feedback': ''
48
  }
49
  continue
@@ -54,17 +119,29 @@ def _parse_question_block(block_text):
54
  current_option['feedback'] = fb_match.group(1).strip()
55
  continue
56
 
 
 
 
 
57
  if current_option:
58
  options.append(current_option)
59
 
60
- return {'prompt': prompt, 'options': options}
 
 
 
 
 
61
 
 
 
62
 
63
- def _generate_yml(questions_data):
64
- """Generate YAML quiz format from parsed question data."""
 
65
  lines = [
66
  "name: Quiz 1",
67
- "passingThreshold: 4",
68
  "estimatedTimeSec: 600",
69
  "maxTrialsPer24Hrs: 3",
70
  "courseSlug: course_Slug",
@@ -78,104 +155,330 @@ def _generate_yml(questions_data):
78
  lines.append(" points: 1")
79
  lines.append(" shuffle: true")
80
  lines.append(" prompt: |-")
81
- for prompt_line in q['prompt'].split('\n'):
82
  lines.append(f" {prompt_line}")
83
  lines.append(" options:")
84
  for opt in q['options']:
85
- answer = opt['answer'].replace('"', '\\"')
 
86
  is_correct = 'true' if opt['isCorrect'] else 'false'
87
- lines.append(f' - answer: "{answer}"')
 
 
88
  lines.append(f" isCorrect: {is_correct}")
89
- lines.append(f" feedback: {opt['feedback']}")
 
 
90
 
91
  return '\n'.join(lines) + '\n'
92
 
93
 
94
- def load_quiz_for_editing(formatted_quiz_text=""):
95
- """Load formatted quiz for editing. Tries disk first, falls back to UI text."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  run_manager = get_run_manager()
97
  content = None
98
 
99
- # Try loading from disk
100
  quiz_path = run_manager.get_latest_formatted_quiz_path()
101
  if quiz_path is not None:
102
  with open(quiz_path, "r", encoding="utf-8") as f:
103
  content = f.read()
104
 
105
- # Fall back to the formatted quiz text from the UI
106
  if not content and formatted_quiz_text:
107
  content = formatted_quiz_text
108
 
109
  if not content:
110
  return (
111
  "No formatted quiz found. Generate questions in the 'Generate Questions' tab first.",
112
- "",
113
- [],
114
- 0,
115
- [],
116
- gr.update(),
117
  )
118
 
119
  questions = _parse_questions(content)
120
  if not questions:
121
  return "The quiz file is empty.", "", [], 0, [], gr.update()
122
 
123
- status = f"Question 1 of {len(questions)}"
124
- edited = list(questions) # start with originals
125
- return status, questions[0], questions, 0, edited, _next_button_label(0, len(questions))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
 
 
127
 
128
- def accept_and_next(current_text, questions, index, edited):
129
- """Save current edit and advance to the next question."""
 
130
  if not questions:
131
  return "No quiz loaded.", "", questions, index, edited, gr.update()
132
 
133
- # Save the current edit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  edited[index] = current_text
135
 
136
  if index + 1 < len(questions):
137
  new_index = index + 1
138
- status = f"Question {new_index + 1} of {len(questions)}"
139
- return status, edited[new_index], questions, new_index, edited, _next_button_label(new_index, len(questions))
 
 
 
 
 
140
  else:
141
- # All questions reviewed
 
 
142
  return (
143
- f"All {len(questions)} questions reviewed. Click 'Download edited quiz' to save.",
144
- current_text,
145
- questions,
146
- index,
147
- edited,
148
  gr.update(value="Accept & Finish"),
149
  )
150
 
151
 
152
- def go_previous(current_text, questions, index, edited):
153
  """Save current edit and go back to the previous question."""
154
  if not questions:
155
  return "No quiz loaded.", "", questions, index, edited, gr.update()
156
 
157
- # Save the current edit before moving
158
  edited[index] = current_text
159
 
160
  if index > 0:
161
  new_index = index - 1
162
- status = f"Question {new_index + 1} of {len(questions)}"
163
- return status, edited[new_index], questions, new_index, edited, _next_button_label(new_index, len(questions))
164
- else:
165
- return f"Question 1 of {len(questions)} (already at first question)", current_text, questions, index, edited, _next_button_label(index, len(questions))
 
 
 
 
 
 
166
 
167
 
168
- def save_and_download(current_text, questions, index, edited):
169
- """Join edited questions, save to output folder, and return files for download."""
170
  if not edited:
171
  return "No edited questions to save.", None
172
 
173
- # Save the current edit in case user didn't click accept
174
  edited[index] = current_text
175
 
176
- combined_md = "\n\n".join(edited) + "\n"
177
-
178
- # Generate YAML
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  questions_data = [_parse_question_block(q) for q in edited]
180
  yml_content = _generate_yml(questions_data)
181
 
@@ -184,7 +487,7 @@ def save_and_download(current_text, questions, index, edited):
184
  saved_path = run_manager.save_edited_quiz(combined_md, "formatted_quiz_edited.md")
185
  run_manager.save_edited_quiz(yml_content, "formatted_quiz_edited.yml")
186
 
187
- # Create temp files for Gradio download
188
  tmp_md = tempfile.NamedTemporaryFile(delete=False, suffix=".md", mode="w", encoding="utf-8")
189
  tmp_md.write(combined_md)
190
  tmp_md.close()
 
1
  import re
2
+ import json
3
  import tempfile
4
+ import yaml
5
  import gradio as gr
6
+ from openai import OpenAI
7
+ from pydantic import BaseModel
8
+ from typing import List
9
  from .run_manager import get_run_manager
10
 
11
 
12
+ # ---------------------------------------------------------------------------
13
+ # Pydantic model for LLM validation response
14
+ # ---------------------------------------------------------------------------
15
+
16
+ class _QuestionValidation(BaseModel):
17
+ is_valid: bool
18
+ issues: List[str]
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
  def _next_button_label(index, total):
26
  """Return 'Accept & Finish' for the last question, 'Accept & Next' otherwise."""
27
  if total > 0 and index >= total - 1:
 
29
  return gr.update(value="Accept & Next")
30
 
31
 
32
+ def _sanitize_text(text, keep_bullets: bool = False) -> str:
33
+ """Normalize Unicode typography then strip any remaining non-ASCII characters.
34
+
35
+ Only standard printable ASCII (32-126), newlines, and tabs are kept.
36
+ Set keep_bullets=True to additionally preserve the bullet chars (• ◦) used
37
+ as structural markers in the .md editing format.
38
+ """
39
+ if not text:
40
+ return text
41
+
42
+ # Normalize common Unicode typography to ASCII equivalents
43
+ _REPLACEMENTS = {
44
+ '\u2018': "'", '\u2019': "'", # ' ' (smart single quotes)
45
+ '\u201c': '"', '\u201d': '"', # " " (smart double quotes)
46
+ '\u2013': '-', '\u2014': '-', # – — (en/em dashes)
47
+ '\u2026': '...', # … (ellipsis)
48
+ '\u00a0': ' ', # non-breaking space
49
+ '\u00b2': '2', '\u00b3': '3', # superscript digits
50
+ }
51
+ for uc, rep in _REPLACEMENTS.items():
52
+ text = text.replace(uc, rep)
53
+
54
+ # Chars always allowed: printable ASCII + newline + tab
55
+ def _allowed(c: str) -> bool:
56
+ if 32 <= ord(c) <= 126 or c in '\n\t':
57
+ return True
58
+ if keep_bullets and c in '\u2022\u25e6': # • ◦
59
+ return True
60
+ return False
61
+
62
+ return ''.join(c for c in text if _allowed(c))
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Markdown parsing
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _parse_questions(md_content: str) -> List[str]:
70
  """Split formatted_quiz.md content into individual question blocks."""
71
  parts = re.split(r'(?=\*\*Question \d+)', md_content.strip())
72
  return [p.strip() for p in parts if p.strip()]
73
 
74
 
75
+ def _parse_question_block(block_text: str) -> dict:
76
+ """Parse a single markdown question block into structured data.
77
+
78
+ Supports multi-line prompts: non-empty lines between the question header
79
+ and the first option are accumulated as additional prompt text.
80
+ """
81
+ prompt_lines: List[str] = []
82
+ options: List[dict] = []
83
  current_option = None
84
+ in_prompt = False
85
 
86
  for line in block_text.split('\n'):
87
  stripped = line.strip()
88
 
89
+ # Question header (colon may be inside or outside bold markers)
90
+ q_match = re.match(r'\*\*Question \d+.*?\*\*:?\s*(.*)', stripped)
91
  if q_match:
92
+ first_line = q_match.group(1).strip()
93
+ if first_line:
94
+ prompt_lines.append(first_line)
95
+ in_prompt = True
96
  continue
97
 
98
+ # Skip ranking reasoning line and stop prompt accumulation
99
  if stripped.startswith('Ranking Reasoning:'):
100
+ in_prompt = False
101
  continue
102
 
103
  # Option line: • A [Correct]: text or • A: text
104
+ opt_match = re.match(r'•\s*([A-D])\s*(\[Correct\])?\s*:\s*(.+)', stripped)
105
  if opt_match:
106
+ in_prompt = False
107
  if current_option:
108
  options.append(current_option)
109
  current_option = {
110
+ 'answer': opt_match.group(3).strip(),
111
+ 'isCorrect': opt_match.group(2) is not None,
112
  'feedback': ''
113
  }
114
  continue
 
119
  current_option['feedback'] = fb_match.group(1).strip()
120
  continue
121
 
122
+ # Accumulate additional prompt lines
123
+ if in_prompt and stripped:
124
+ prompt_lines.append(stripped)
125
+
126
  if current_option:
127
  options.append(current_option)
128
 
129
+ return {'prompt': '\n'.join(prompt_lines), 'options': options}
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # YAML generation
134
+ # ---------------------------------------------------------------------------
135
 
136
+ def _generate_yml(questions_data: List[dict]) -> str:
137
+ """Generate YAML quiz from parsed question data using the standard format.
138
 
139
+ All text fields (prompt, answer, feedback) use the '|-' block scalar
140
+ and are sanitized to contain only standard printable ASCII characters.
141
+ """
142
  lines = [
143
  "name: Quiz 1",
144
+ "passingThreshold: 5",
145
  "estimatedTimeSec: 600",
146
  "maxTrialsPer24Hrs: 3",
147
  "courseSlug: course_Slug",
 
155
  lines.append(" points: 1")
156
  lines.append(" shuffle: true")
157
  lines.append(" prompt: |-")
158
+ for prompt_line in _sanitize_text(q['prompt']).split('\n'):
159
  lines.append(f" {prompt_line}")
160
  lines.append(" options:")
161
  for opt in q['options']:
162
+ answer_clean = _sanitize_text(opt['answer'])
163
+ feedback_clean = _sanitize_text(opt['feedback'])
164
  is_correct = 'true' if opt['isCorrect'] else 'false'
165
+ lines.append(" - answer: |-")
166
+ for answer_line in answer_clean.split('\n'):
167
+ lines.append(f" {answer_line}")
168
  lines.append(f" isCorrect: {is_correct}")
169
+ lines.append(" feedback: |-")
170
+ for fb_line in feedback_clean.split('\n'):
171
+ lines.append(f" {fb_line}")
172
 
173
  return '\n'.join(lines) + '\n'
174
 
175
 
176
+ # ---------------------------------------------------------------------------
177
+ # YAML loading (converts any valid YAML quiz to md blocks)
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _parse_yml_to_md_blocks(yml_content: str):
181
+ """Parse a YAML quiz file into Markdown question blocks.
182
+
183
+ Handles both '|-' block scalars and quoted-string answer formats since
184
+ PyYAML normalizes both to plain Python strings.
185
+
186
+ Returns (blocks, error_message). On success error_message is None.
187
+ """
188
+ try:
189
+ data = yaml.safe_load(yml_content)
190
+ except yaml.YAMLError as e:
191
+ return None, f"Failed to parse YAML: {e}"
192
+
193
+ if not isinstance(data, dict):
194
+ return None, "Invalid YAML structure: expected a mapping at the top level."
195
+
196
+ questions = data.get('questions', [])
197
+ if not questions:
198
+ return None, "No questions found in the YAML file."
199
+
200
+ option_letters = ['A', 'B', 'C', 'D']
201
+ blocks = []
202
+
203
+ for i, q in enumerate(questions, start=1):
204
+ prompt = str(q.get('prompt', '')).strip()
205
+ options = q.get('options', [])
206
+
207
+ prompt_lines = prompt.split('\n')
208
+ first_line = prompt_lines[0] if prompt_lines else ''
209
+ extra_lines = [l.strip() for l in prompt_lines[1:] if l.strip()]
210
+
211
+ block_lines = [f"**Question {i}:** {first_line}"]
212
+ for extra in extra_lines:
213
+ block_lines.append(extra)
214
+ block_lines.append("")
215
+
216
+ for j, opt in enumerate(options):
217
+ if j >= len(option_letters):
218
+ break
219
+ letter = option_letters[j]
220
+ answer = str(opt.get('answer', '')).strip()
221
+ is_correct = opt.get('isCorrect', False)
222
+ feedback = str(opt.get('feedback', '')).strip()
223
+
224
+ correct_marker = " [Correct]" if is_correct else ""
225
+ block_lines.append(f"\t• {letter}{correct_marker}: {answer}")
226
+ if feedback:
227
+ block_lines.append(f"\t ◦ Feedback: {feedback}")
228
+ block_lines.append("")
229
+
230
+ blocks.append('\n'.join(block_lines).strip())
231
+
232
+ return blocks, None
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # LLM validation
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def _validate_question_block(block_text: str) -> List[str]:
240
+ """Validate a question block structurally, then with LLM semantic check.
241
+
242
+ Returns a list of issue strings. An empty list means the question is valid.
243
+ Structural issues block advancement; LLM issues produce warnings but still
244
+ surface as returned issues so the caller can decide how to handle them.
245
+ """
246
+ parsed = _parse_question_block(block_text)
247
+ issues: List[str] = []
248
+
249
+ # --- Structural validation (fast, no API call) ---
250
+ if not parsed['prompt'].strip():
251
+ issues.append("Missing question prompt.")
252
+
253
+ n_opts = len(parsed['options'])
254
+ if n_opts != 4:
255
+ issues.append(f"Expected 4 answer options, found {n_opts}.")
256
+ else:
257
+ correct_count = sum(1 for o in parsed['options'] if o['isCorrect'])
258
+ if correct_count == 0:
259
+ issues.append("No option is marked as correct. Add [Correct] to one option.")
260
+ elif correct_count > 1:
261
+ issues.append(f"{correct_count} options are marked correct; exactly 1 is required.")
262
+ for i, opt in enumerate(parsed['options']):
263
+ letter = chr(65 + i)
264
+ if not opt['answer'].strip():
265
+ issues.append(f"Option {letter} has no answer text.")
266
+ if not opt['feedback'].strip():
267
+ issues.append(f"Option {letter} is missing feedback.")
268
+
269
+ # Don't call the LLM if the question is structurally broken
270
+ if issues:
271
+ return issues
272
+
273
+ # --- LLM semantic validation ---
274
+ try:
275
+ client = OpenAI()
276
+ options_text = "\n".join(
277
+ f"{'[CORRECT] ' if o['isCorrect'] else ''}Answer: {o['answer']}\n"
278
+ f"Feedback: {o['feedback']}"
279
+ for o in parsed['options']
280
+ )
281
+ prompt = (
282
+ "You are an educational quality reviewer. Evaluate this multiple-choice question.\n\n"
283
+ f"Question: {parsed['prompt']}\n\n"
284
+ f"{options_text}\n\n"
285
+ "Check for: (1) clarity and unambiguity of the question, "
286
+ "(2) factual correctness of the marked answer, "
287
+ "(3) plausibility but clear incorrectness of the distractors, "
288
+ "(4) accuracy and helpfulness of the feedback for each option.\n"
289
+ 'Return JSON with schema: {"is_valid": bool, "issues": ["issue1", ...]}'
290
+ )
291
+ result = client.beta.chat.completions.parse(
292
+ model="gpt-4o-mini",
293
+ messages=[{"role": "user", "content": prompt}],
294
+ response_format=_QuestionValidation,
295
+ )
296
+ validation = result.choices[0].message.parsed
297
+ if not validation.is_valid and validation.issues:
298
+ issues.extend(validation.issues)
299
+ except Exception:
300
+ # Never block saving if the LLM is unavailable
301
+ pass
302
+
303
+ return issues
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Public handlers (called by ui/app.py)
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def load_quiz_for_editing(formatted_quiz_text: str = ""):
311
+ """Load the generated quiz for editing. Tries disk first, falls back to UI text."""
312
  run_manager = get_run_manager()
313
  content = None
314
 
 
315
  quiz_path = run_manager.get_latest_formatted_quiz_path()
316
  if quiz_path is not None:
317
  with open(quiz_path, "r", encoding="utf-8") as f:
318
  content = f.read()
319
 
 
320
  if not content and formatted_quiz_text:
321
  content = formatted_quiz_text
322
 
323
  if not content:
324
  return (
325
  "No formatted quiz found. Generate questions in the 'Generate Questions' tab first.",
326
+ "", [], 0, [], gr.update(),
 
 
 
 
327
  )
328
 
329
  questions = _parse_questions(content)
330
  if not questions:
331
  return "The quiz file is empty.", "", [], 0, [], gr.update()
332
 
333
+ edited = list(questions)
334
+ return (
335
+ f"Question 1 of {len(questions)}",
336
+ questions[0], questions, 0, edited,
337
+ _next_button_label(0, len(questions)),
338
+ )
339
+
340
+
341
+ def load_file_for_editing(file_path):
342
+ """Load a user-uploaded .md or .yml quiz file and initialise the editing flow."""
343
+ if file_path is None:
344
+ return "No file uploaded.", "", [], 0, [], gr.update()
345
+
346
+ try:
347
+ with open(file_path, 'r', encoding='utf-8') as f:
348
+ content = f.read()
349
+ except Exception as e:
350
+ return f"Error reading file: {e}", "", [], 0, [], gr.update()
351
+
352
+ file_lower = str(file_path).lower()
353
+
354
+ if file_lower.endswith('.yml') or file_lower.endswith('.yaml'):
355
+ questions, error = _parse_yml_to_md_blocks(content)
356
+ if error:
357
+ return error, "", [], 0, [], gr.update()
358
+ elif file_lower.endswith('.md'):
359
+ questions = _parse_questions(content)
360
+ if not questions:
361
+ return "No questions found in the Markdown file.", "", [], 0, [], gr.update()
362
+ else:
363
+ return "Unsupported file format. Please upload a .md or .yml file.", "", [], 0, [], gr.update()
364
+
365
+ if not questions:
366
+ return "No questions found in the file.", "", [], 0, [], gr.update()
367
+
368
+ n = len(questions)
369
+ edited = list(questions)
370
+ return (
371
+ f"Loaded {n} question(s) from file. Showing Question 1 of {n}.",
372
+ questions[0], questions, 0, edited,
373
+ _next_button_label(0, n),
374
+ )
375
+
376
 
377
+ def accept_and_next(current_text: str, questions: list, index: int, edited: list):
378
+ """Validate current question, then save and advance to the next one.
379
 
380
+ Structural errors block advancement. LLM semantic issues are surfaced as
381
+ warnings but still allow the user to proceed.
382
+ """
383
  if not questions:
384
  return "No quiz loaded.", "", questions, index, edited, gr.update()
385
 
386
+ # --- Validate before saving ---
387
+ issues = _validate_question_block(current_text)
388
+
389
+ # Separate structural issues (must be fixed) from LLM warnings
390
+ structural_keywords = [
391
+ "Missing question", "Expected 4", "No option is marked",
392
+ "options are marked correct", "has no answer text", "is missing feedback"
393
+ ]
394
+ structural_issues = [i for i in issues if any(k in i for k in structural_keywords)]
395
+ llm_warnings = [i for i in issues if i not in structural_issues]
396
+
397
+ if structural_issues:
398
+ error_msg = "Cannot advance — please fix: " + "; ".join(structural_issues)
399
+ return (
400
+ error_msg, current_text, questions, index, edited,
401
+ _next_button_label(index, len(questions)),
402
+ )
403
+
404
+ # Save the (valid) edit
405
  edited[index] = current_text
406
 
407
  if index + 1 < len(questions):
408
  new_index = index + 1
409
+ base_status = f"Question {new_index + 1} of {len(questions)}"
410
+ if llm_warnings:
411
+ base_status += f" | WARNING (previous Q): {'; '.join(llm_warnings)}"
412
+ return (
413
+ base_status, edited[new_index], questions, new_index, edited,
414
+ _next_button_label(new_index, len(questions)),
415
+ )
416
  else:
417
+ base_status = f"All {len(questions)} questions reviewed. Click 'Download edited quiz' to save."
418
+ if llm_warnings:
419
+ base_status += f" | WARNING: {'; '.join(llm_warnings)}"
420
  return (
421
+ base_status, current_text, questions, index, edited,
 
 
 
 
422
  gr.update(value="Accept & Finish"),
423
  )
424
 
425
 
426
+ def go_previous(current_text: str, questions: list, index: int, edited: list):
427
  """Save current edit and go back to the previous question."""
428
  if not questions:
429
  return "No quiz loaded.", "", questions, index, edited, gr.update()
430
 
 
431
  edited[index] = current_text
432
 
433
  if index > 0:
434
  new_index = index - 1
435
+ return (
436
+ f"Question {new_index + 1} of {len(questions)}",
437
+ edited[new_index], questions, new_index, edited,
438
+ _next_button_label(new_index, len(questions)),
439
+ )
440
+ return (
441
+ f"Question 1 of {len(questions)} (already at first question)",
442
+ current_text, questions, index, edited,
443
+ _next_button_label(index, len(questions)),
444
+ )
445
 
446
 
447
+ def save_and_download(current_text: str, questions: list, index: int, edited: list):
448
+ """Validate all questions structurally, then join, sanitize, and export."""
449
  if not edited:
450
  return "No edited questions to save.", None
451
 
452
+ # Save the current edit in case user did not click Accept
453
  edited[index] = current_text
454
 
455
+ # --- Structural validation of every question before export ---
456
+ all_errors: List[str] = []
457
+ for i, block in enumerate(edited, start=1):
458
+ parsed = _parse_question_block(block)
459
+ q_errors: List[str] = []
460
+ if not parsed['prompt'].strip():
461
+ q_errors.append("missing prompt")
462
+ if len(parsed['options']) != 4:
463
+ q_errors.append(f"expected 4 options, found {len(parsed['options'])}")
464
+ else:
465
+ correct_count = sum(1 for o in parsed['options'] if o['isCorrect'])
466
+ if correct_count != 1:
467
+ q_errors.append(f"expected 1 correct option, found {correct_count}")
468
+ for j, opt in enumerate(parsed['options']):
469
+ if not opt['feedback'].strip():
470
+ q_errors.append(f"option {chr(65+j)} missing feedback")
471
+ if q_errors:
472
+ all_errors.append(f"Question {i}: {'; '.join(q_errors)}")
473
+
474
+ if all_errors:
475
+ return "Export blocked — fix these issues first:\n" + "\n".join(all_errors), None
476
+
477
+ # --- Build outputs ---
478
+ # .md: sanitize text content but keep bullet markers (• ◦) for readability
479
+ combined_md = _sanitize_text("\n\n".join(edited) + "\n", keep_bullets=True)
480
+
481
+ # .yml: fully sanitized via _generate_yml
482
  questions_data = [_parse_question_block(q) for q in edited]
483
  yml_content = _generate_yml(questions_data)
484
 
 
487
  saved_path = run_manager.save_edited_quiz(combined_md, "formatted_quiz_edited.md")
488
  run_manager.save_edited_quiz(yml_content, "formatted_quiz_edited.yml")
489
 
490
+ # Temp files for Gradio download
491
  tmp_md = tempfile.NamedTemporaryFile(delete=False, suffix=".md", mode="w", encoding="utf-8")
492
  tmp_md.write(combined_md)
493
  tmp_md.close()