ramedde commited on
Commit
06c8bed
·
verified ·
1 Parent(s): b4b1618

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +428 -37
app.py CHANGED
@@ -31,7 +31,7 @@ threading.Thread(target=_check_server, daemon=True).start()
31
  with open("lessons.json") as f:
32
  LESSONS = json.load(f)
33
 
34
- LISTEN_SECONDS = 10 # 15 min
35
  PASS_SCORE = 6
36
 
37
  # ── Cache audio ───────────────────────────────────────────────────────────────
@@ -146,11 +146,13 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
146
  "Écoutez pendant **15 minutes** complètes, puis cliquez sur **🔄 Vérifier le temps**."
147
  )
148
  return (
149
- state, audio, msg,
150
- gr.update(visible=True), # listen_col
151
- gr.update(visible=False), # quiz_col
152
- gr.update(visible=False), # result_col
153
- gr.update(visible=False), # cert_col
 
 
154
  gr.update(interactive=False, value="⏳ Écoute en cours…"), # quiz_btn
155
  )
156
 
@@ -171,28 +173,61 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
171
  state,
172
  )
173
 
 
 
 
 
 
 
 
 
 
174
  def open_quiz(state):
175
- elapsed = time.time() - (state["listen_start"] or 0)
 
 
176
  if elapsed < LISTEN_SECONDS:
177
  m, s = divmod(int(LISTEN_SECONDS - elapsed), 60)
178
  return (
179
  state,
180
- gr.update(visible=True), gr.update(visible=False),
181
- gr.update(visible=False), gr.update(visible=False),
182
- gr.update(value=f"⚠️ Patientez encore **{m} min {s:02d} s**."),
183
- gr.update(value=""), gr.update(value=""),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  )
185
- lesson_idx = state.get("lesson_idx", 0)
186
- idx = random.randrange(len(LESSONS[lesson_idx]["questions"]))
187
  state = {**state, "phase": "quiz", "question_idx": idx}
188
- question_text = LESSONS[lesson_idx]["questions"][idx]["q"]
 
189
  return (
190
  state,
191
- gr.update(visible=False), gr.update(visible=True),
192
- gr.update(visible=False), gr.update(visible=False),
193
- gr.update(value=""),
194
- gr.update(value=question_text),
195
- gr.update(value=""),
 
 
196
  )
197
 
198
  def submit_answer(answer, state):
@@ -203,12 +238,13 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
203
  state,
204
  gr.update(visible=True), gr.update(visible=False),
205
  gr.update(visible=False), gr.update(visible=False),
206
- "", gr.update(value="📤 Soumettre", interactive=True),
 
207
  gr.update(interactive=True),
208
  )
209
  return
210
 
211
- # Step 1: disable immediately
212
  yield (
213
  state,
214
  gr.update(visible=False), gr.update(visible=True),
@@ -237,7 +273,7 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
237
  f"Seuil requis : **{PASS_SCORE}/10**. Veuillez **réécouter la leçon** avant de retenter."
238
  )
239
 
240
- # Step 3: show result
241
  yield (
242
  state,
243
  gr.update(visible=False), gr.update(visible=False),
@@ -251,19 +287,22 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
251
  lesson_idx = state.get("lesson_idx", 0)
252
  total = len(LESSONS)
253
 
 
254
  if state["phase"] == "fail":
255
  return start_listening(state)
256
 
 
257
  if lesson_idx >= total - 1:
258
  state = {**state, "phase": "done"}
259
- cert = (
 
 
 
 
 
260
  "# 🏆 Félicitations !\n\n"
261
- "Vous avez complété avec succès les **5 leçons** du cours de procédure pénale :\n\n"
262
- "✅ Leçon 1 — Les principes directeurs et la preuve pénale\n\n"
263
- "✅ Leçon 2 — La police judiciaire, l'enquête et la garde à vue\n\n"
264
- "✅ Leçon 3 — L'action publique, l'action civile et la prescription\n\n"
265
- "✅ Leçon 4 — L'instruction préparatoire et les mesures de sûreté\n\n"
266
- "✅ Leçon 5 — Les juridictions de jugement et les voies de recours\n\n"
267
  "---\n\n*Ce certificat atteste de votre réussite aux évaluations.*"
268
  )
269
  return (
@@ -272,34 +311,386 @@ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
272
  gr.update(visible=False), gr.update(visible=True),
273
  gr.update(interactive=False, value="✅ Terminé"),
274
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  else:
276
- state = {**state, "lesson_idx": lesson_idx + 1, "listen_start": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  return start_listening(state)
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def restart_from_cert(state):
280
  return start_listening(fresh())
281
 
282
  # ── Wiring ────────────────────────────────────────────────────────────────
 
283
  LISTEN_OUT = [session, audio_out, listen_msg,
284
  listen_col, quiz_col, result_col, cert_col, quiz_btn]
285
 
286
- start_btn.click(start_listening, [session], LISTEN_OUT)
287
 
288
- check_btn.click(check_timer, [session], [quiz_btn, timer_info, session])
289
 
 
290
  QUIZ_OUT = [session, listen_col, quiz_col, result_col, cert_col,
291
  timer_info, question_md, answer_box]
292
- quiz_btn.click(open_quiz, [session], QUIZ_OUT)
293
 
 
294
  RES_OUT = [session, listen_col, quiz_col, result_col, cert_col,
295
  result_md, submit_btn, answer_box]
296
- submit_btn.click(submit_answer, [answer_box, session], RES_OUT)
297
- answer_box.submit(submit_answer, [answer_box, session], RES_OUT)
298
 
299
- next_btn.click(handle_next, [session], LISTEN_OUT)
300
- restart_btn.click(restart_from_cert, [session], LISTEN_OUT)
301
 
302
  if __name__ == "__main__":
303
  demo.queue()
304
  demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
305
 
 
 
31
  with open("lessons.json") as f:
32
  LESSONS = json.load(f)
33
 
34
+ LISTEN_SECONDS = 900 # 15 min (set to 10 for testing)
35
  PASS_SCORE = 6
36
 
37
  # ── Cache audio ───────────────────────────────────────────────────────────────
 
146
  "Écoutez pendant **15 minutes** complètes, puis cliquez sur **🔄 Vérifier le temps**."
147
  )
148
  return (
149
+ state,
150
+ audio,
151
+ msg,
152
+ gr.update(visible=True), # listen_col
153
+ gr.update(visible=False), # quiz_col
154
+ gr.update(visible=False), # result_col
155
+ gr.update(visible=False), # cert_col
156
  gr.update(interactive=False, value="⏳ Écoute en cours…"), # quiz_btn
157
  )
158
 
 
173
  state,
174
  )
175
 
176
+ # ── FIX: open_quiz ─────────────────────────────────────────────────────────
177
+ # Previously the early-return branch had mismatched gr.update() calls,
178
+ # causing the quiz column to never become visible. We now:
179
+ # 1. Guard against missing/empty questions list.
180
+ # 2. Use gr.update() (no args) for components we don't want to touch in
181
+ # the early-return path instead of gr.update(value="") which can
182
+ # collide with Gradio 5's diffing logic.
183
+ # 3. Keep listen_col visible (not hidden) in the early-return path so the
184
+ # user still sees the timer message.
185
  def open_quiz(state):
186
+ elapsed = time.time() - (state.get("listen_start") or 0)
187
+
188
+ # ── Timer not elapsed yet ──
189
  if elapsed < LISTEN_SECONDS:
190
  m, s = divmod(int(LISTEN_SECONDS - elapsed), 60)
191
  return (
192
  state,
193
+ gr.update(visible=True), # listen_col — keep showing
194
+ gr.update(visible=False), # quiz_col — stay hidden
195
+ gr.update(visible=False), # result_col
196
+ gr.update(visible=False), # cert_col
197
+ gr.update(value=f"⚠️ Patientez encore **{m} min {s:02d} s**."), # timer_info
198
+ gr.update(), # question_md — no change
199
+ gr.update(), # answer_box — no change
200
+ )
201
+
202
+ lesson_idx = state.get("lesson_idx", 0)
203
+ questions = LESSONS[lesson_idx].get("questions", [])
204
+
205
+ # ── Guard: no questions defined for this lesson ──
206
+ if not questions:
207
+ return (
208
+ state,
209
+ gr.update(visible=True),
210
+ gr.update(visible=False),
211
+ gr.update(visible=False),
212
+ gr.update(visible=False),
213
+ gr.update(value="⚠️ Aucune question disponible pour cette leçon."),
214
+ gr.update(),
215
+ gr.update(),
216
  )
217
+
218
+ idx = random.randrange(len(questions))
219
  state = {**state, "phase": "quiz", "question_idx": idx}
220
+ question_text = questions[idx]["q"]
221
+
222
  return (
223
  state,
224
+ gr.update(visible=False), # listen_col — hide
225
+ gr.update(visible=True), # quiz_col — SHOW ← the key fix
226
+ gr.update(visible=False), # result_col
227
+ gr.update(visible=False), # cert_col
228
+ gr.update(value=""), # timer_info — clear
229
+ gr.update(value=question_text), # question_md — set question
230
+ gr.update(value=""), # answer_box — clear previous answer
231
  )
232
 
233
  def submit_answer(answer, state):
 
238
  state,
239
  gr.update(visible=True), gr.update(visible=False),
240
  gr.update(visible=False), gr.update(visible=False),
241
+ "",
242
+ gr.update(value="📤 Soumettre", interactive=True),
243
  gr.update(interactive=True),
244
  )
245
  return
246
 
247
+ # Step 1: disable buttons immediately while evaluating
248
  yield (
249
  state,
250
  gr.update(visible=False), gr.update(visible=True),
 
273
  f"Seuil requis : **{PASS_SCORE}/10**. Veuillez **réécouter la leçon** avant de retenter."
274
  )
275
 
276
+ # Step 3: show result panel
277
  yield (
278
  state,
279
  gr.update(visible=False), gr.update(visible=False),
 
287
  lesson_idx = state.get("lesson_idx", 0)
288
  total = len(LESSONS)
289
 
290
+ # Failed → restart the same lesson's listening phase
291
  if state["phase"] == "fail":
292
  return start_listening(state)
293
 
294
+ # Passed last lesson → show certificate
295
  if lesson_idx >= total - 1:
296
  state = {**state, "phase": "done"}
297
+ # Build dynamic checklist from actual lesson titles
298
+ checklist = "\n\n".join(
299
+ f"✅ Leçon {i + 1} — {LESSONS[i]['title']}"
300
+ for i in range(total)
301
+ )
302
+ cert = (
303
  "# 🏆 Félicitations !\n\n"
304
+ f"Vous avez complété avec succès les **{total} leçons** du cours :\n\n"
305
+ f"{checklist}\n\n"
 
 
 
 
306
  "---\n\n*Ce certificat atteste de votre réussite aux évaluations.*"
307
  )
308
  return (
 
311
  gr.update(visible=False), gr.update(visible=True),
312
  gr.update(interactive=False, value="✅ Terminé"),
313
  )
314
+
315
+ # Passed → advance to next lesson
316
+ state = {**state, "lesson_idx": lesson_idx + 1, "listen_start": None}
317
+ return start_listening(state)
318
+
319
+ def restart_from_cert(state):
320
+ return start_listening(fresh())
321
+
322
+ # ── Wiring ────────────────────────────────────────────────────────────────
323
+ # Outputs shared by start_listening / handle_next / restart_from_cert
324
+ LISTEN_OUT = [session, audio_out, listen_msg,
325
+ listen_col, quiz_col, result_col, cert_col, quiz_btn]
326
+
327
+ start_btn.click(start_listening, inputs=[session], outputs=LISTEN_OUT)
328
+
329
+ check_btn.click(check_timer, inputs=[session], outputs=[quiz_btn, timer_info, session])
330
+
331
+ # Outputs for open_quiz — must match exactly what open_quiz() returns (8 values)
332
+ QUIZ_OUT = [session, listen_col, quiz_col, result_col, cert_col,
333
+ timer_info, question_md, answer_box]
334
+ quiz_btn.click(open_quiz, inputs=[session], outputs=QUIZ_OUT)
335
+
336
+ # Outputs for submit_answer — 7 values
337
+ RES_OUT = [session, listen_col, quiz_col, result_col, cert_col,
338
+ result_md, submit_btn, answer_box]
339
+ submit_btn.click(submit_answer, inputs=[answer_box, session], outputs=RES_OUT)
340
+ answer_box.submit(submit_answer, inputs=[answer_box, session], outputs=RES_OUT)
341
+
342
+ next_btn.click(handle_next, inputs=[session], outputs=LISTEN_OUT)
343
+ restart_btn.click(restart_from_cert, inputs=[session], outputs=LISTEN_OUT)
344
+
345
+ if __name__ == "__main__":
346
+ demo.queue()
347
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
348
+ """
349
+ Cours de Droit – Hugging Face Space (Gradio 5 compatible)
350
+ """
351
+ import os, json, time, tempfile, random, threading
352
+ import requests
353
+ import gradio as gr
354
+ from gtts import gTTS
355
+
356
+ # ── llama-server connection ───────────────────────────────────────────────────
357
+ LLAMA_URL = os.getenv("LLAMA_SERVER_URL", "http://127.0.0.1:8080")
358
+ CHAT_ENDPOINT = f"{LLAMA_URL}/v1/chat/completions"
359
+ _server_ok = False
360
+
361
+ def _check_server():
362
+ global _server_ok
363
+ for _ in range(60):
364
+ try:
365
+ r = requests.get(f"{LLAMA_URL}/health", timeout=2)
366
+ if r.status_code == 200:
367
+ _server_ok = True
368
+ print("[law-app] llama-server ready ✓")
369
+ return
370
+ except Exception:
371
+ pass
372
+ time.sleep(2)
373
+ print("[law-app] llama-server not reachable – keyword fallback active")
374
+
375
+ threading.Thread(target=_check_server, daemon=True).start()
376
+
377
+ # ── Leçons ────────────────────────────────────────────────────────────────────
378
+ with open("lessons.json") as f:
379
+ LESSONS = json.load(f)
380
+
381
+ LISTEN_SECONDS = 900 # 15 min (set to 10 for testing)
382
+ PASS_SCORE = 6
383
+
384
+ # ── Cache audio ───────────────────────────────────────────────────────────────
385
+ _audio_cache = {}
386
+ _audio_lock = threading.Lock()
387
+
388
+ def get_lecture_audio(lesson_idx):
389
+ if lesson_idx in _audio_cache and os.path.exists(_audio_cache[lesson_idx]):
390
+ return _audio_cache[lesson_idx]
391
+ with _audio_lock:
392
+ if lesson_idx in _audio_cache and os.path.exists(_audio_cache[lesson_idx]):
393
+ return _audio_cache[lesson_idx]
394
+ try:
395
+ tts = gTTS(text=LESSONS[lesson_idx]["text"], lang="fr", slow=False)
396
+ path = tempfile.mktemp(suffix=".mp3")
397
+ tts.save(path)
398
+ _audio_cache[lesson_idx] = path
399
+ return path
400
+ except Exception as e:
401
+ print(f"[law-app] gTTS erreur : {e}")
402
+ return None
403
+
404
+ # ── État ──────────────────────────────────────────────────────────────────────
405
+ def fresh():
406
+ return {"phase": "idle", "listen_start": None, "question_idx": None, "lesson_idx": 0}
407
+
408
+ # ── Évaluation ────────────────────────────────────────────────────────────────
409
+ _SYSTEM_PROMPT = (
410
+ "Tu es un professeur de droit français strict et bienveillant. "
411
+ "Tu évalues les réponses des étudiants et réponds UNIQUEMENT en JSON valide sur une seule ligne.\n\n"
412
+ "Exemple :\n"
413
+ "Question : Quelles sont les finalités de l'action publique et de l'action civile ?\n"
414
+ "Réponse : je ne sais pas\n"
415
+ 'Réponse attendue : {"score": 0, "feedback": "Aucune réponse proposée."}'
416
+ )
417
+
418
+ def _keyword_fallback(answer, hint, reason=""):
419
+ kw = [w.strip().lower() for w in hint.split(",")]
420
+ hits = sum(1 for k in kw if k in answer.lower())
421
+ score = min(10, round((hits / max(len(kw), 1)) * 10))
422
+ return score, f"(Sans IA – {reason} – mots-clés : {hits}/{len(kw)})"
423
+
424
+ def evaluate_answer(question, answer, hint):
425
+ if not _server_ok:
426
+ return _keyword_fallback(answer, hint, "serveur indisponible")
427
+ user_msg = (
428
+ f"Question : {question}\nRéponse : {answer}\nÉléments attendus : {hint}\n\n"
429
+ 'Réponds UNIQUEMENT avec ce JSON sur une seule ligne : {"score": <0-10>, "feedback": "<commentaire>"}'
430
+ )
431
+ try:
432
+ resp = requests.post(CHAT_ENDPOINT, json={
433
+ "model": "local",
434
+ "messages": [{"role": "system", "content": _SYSTEM_PROMPT},
435
+ {"role": "user", "content": user_msg}],
436
+ "max_tokens": 150, "temperature": 0.1, "stop": ["\n\n"],
437
+ }, timeout=60)
438
+ resp.raise_for_status()
439
+ text = resp.json()["choices"][0]["message"]["content"].strip()
440
+ start = text.find("{"); end = text.rfind("}") + 1
441
+ data = json.loads(text[start:end])
442
+ return max(0, min(10, int(data["score"]))), data.get("feedback", "")
443
+ except Exception as e:
444
+ return _keyword_fallback(answer, hint, str(e))
445
+
446
+ # ── UI ────────────────────────────────────────────────────────────────────────
447
+ CSS = "footer{display:none!important;} #title{text-align:center;}"
448
+
449
+ with gr.Blocks(title="Cours de Droit", css=CSS) as demo:
450
+ session = gr.State(fresh())
451
+
452
+ gr.Markdown(
453
+ "# ⚖️ Cours de Droit — Formation Juridique\n"
454
+ "Écoutez chaque leçon · Répondez à la question · Progressez leçon par leçon",
455
+ elem_id="title",
456
+ )
457
+
458
+ # ── Panels ────────────────────────────────────────────────────────────────
459
+ with gr.Column(visible=True) as listen_col:
460
+ gr.Markdown("## 🎧 Étape 1 — Écoute de la leçon")
461
+ listen_msg = gr.Markdown("Cliquez sur **Démarrer** pour commencer.")
462
+ audio_out = gr.Audio(label="Cours audio (français)", autoplay=False, interactive=False)
463
+ timer_info = gr.Markdown("")
464
+ with gr.Row():
465
+ start_btn = gr.Button("▶️ Démarrer l'écoute", variant="primary")
466
+ check_btn = gr.Button("🔄 Vérifier le temps", variant="secondary")
467
+ quiz_btn = gr.Button("✅ Accéder au Quiz", variant="secondary", interactive=False)
468
+
469
+ with gr.Column(visible=False) as quiz_col:
470
+ gr.Markdown("## 📝 Étape 2 — Question d'évaluation")
471
+ question_md = gr.Markdown("")
472
+ answer_box = gr.Textbox(label="Votre réponse", placeholder="Rédigez ici…", lines=5)
473
+ submit_btn = gr.Button("📤 Soumettre", variant="primary")
474
+
475
+ with gr.Column(visible=False) as result_col:
476
+ gr.Markdown("## 📊 Résultat")
477
+ result_md = gr.Markdown("")
478
+ next_btn = gr.Button("➡️ Leçon suivante", variant="primary")
479
+
480
+ with gr.Column(visible=False) as cert_col:
481
+ cert_md = gr.Markdown("")
482
+ restart_btn = gr.Button("🔄 Recommencer depuis le début", variant="secondary")
483
+
484
+ # ── Handlers ──────────────────────────────────────────────────────────────
485
+ def start_listening(state):
486
+ lesson_idx = state.get("lesson_idx", 0)
487
+ lesson = LESSONS[lesson_idx]
488
+ state = {**state, "phase": "listen", "listen_start": time.time(), "question_idx": None}
489
+ audio = get_lecture_audio(lesson_idx)
490
+ total = len(LESSONS)
491
+ msg = (
492
+ f"🎧 **{lesson['title']}** ({lesson_idx + 1}/{total})\n\n"
493
+ "Écoutez pendant **15 minutes** complètes, puis cliquez sur **🔄 Vérifier le temps**."
494
+ )
495
+ return (
496
+ state,
497
+ audio,
498
+ msg,
499
+ gr.update(visible=True), # listen_col
500
+ gr.update(visible=False), # quiz_col
501
+ gr.update(visible=False), # result_col
502
+ gr.update(visible=False), # cert_col
503
+ gr.update(interactive=False, value="⏳ Écoute en cours…"), # quiz_btn
504
+ )
505
+
506
+ def check_timer(state):
507
+ if state["phase"] != "listen" or not state["listen_start"]:
508
+ return gr.update(), "⚠️ Lancez d'abord l'écoute.", state
509
+ rem = max(0, LISTEN_SECONDS - (time.time() - state["listen_start"]))
510
+ m, s = divmod(int(rem), 60)
511
+ if rem <= 0:
512
+ return (
513
+ gr.update(interactive=True, value="✅ Accéder au Quiz"),
514
+ "✅ Durée atteinte ! Cliquez sur **Accéder au Quiz**.",
515
+ state,
516
+ )
517
+ return (
518
+ gr.update(interactive=False, value=f"⏳ Encore {m:02d}:{s:02d}"),
519
+ f"🎧 Patientez encore **{m} min {s:02d} s**.",
520
+ state,
521
+ )
522
+
523
+ # ── FIX: open_quiz ─────────────────────────────────────────────────────────
524
+ # Previously the early-return branch had mismatched gr.update() calls,
525
+ # causing the quiz column to never become visible. We now:
526
+ # 1. Guard against missing/empty questions list.
527
+ # 2. Use gr.update() (no args) for components we don't want to touch in
528
+ # the early-return path instead of gr.update(value="") which can
529
+ # collide with Gradio 5's diffing logic.
530
+ # 3. Keep listen_col visible (not hidden) in the early-return path so the
531
+ # user still sees the timer message.
532
+ def open_quiz(state):
533
+ elapsed = time.time() - (state.get("listen_start") or 0)
534
+
535
+ # ── Timer not elapsed yet ──
536
+ if elapsed < LISTEN_SECONDS:
537
+ m, s = divmod(int(LISTEN_SECONDS - elapsed), 60)
538
+ return (
539
+ state,
540
+ gr.update(visible=True), # listen_col — keep showing
541
+ gr.update(visible=False), # quiz_col — stay hidden
542
+ gr.update(visible=False), # result_col
543
+ gr.update(visible=False), # cert_col
544
+ gr.update(value=f"⚠️ Patientez encore **{m} min {s:02d} s**."), # timer_info
545
+ gr.update(), # question_md — no change
546
+ gr.update(), # answer_box — no change
547
+ )
548
+
549
+ lesson_idx = state.get("lesson_idx", 0)
550
+ questions = LESSONS[lesson_idx].get("questions", [])
551
+
552
+ # ── Guard: no questions defined for this lesson ──
553
+ if not questions:
554
+ return (
555
+ state,
556
+ gr.update(visible=True),
557
+ gr.update(visible=False),
558
+ gr.update(visible=False),
559
+ gr.update(visible=False),
560
+ gr.update(value="⚠️ Aucune question disponible pour cette leçon."),
561
+ gr.update(),
562
+ gr.update(),
563
+ )
564
+
565
+ idx = random.randrange(len(questions))
566
+ state = {**state, "phase": "quiz", "question_idx": idx}
567
+ question_text = questions[idx]["q"]
568
+
569
+ return (
570
+ state,
571
+ gr.update(visible=False), # listen_col — hide
572
+ gr.update(visible=True), # quiz_col — SHOW ← the key fix
573
+ gr.update(visible=False), # result_col
574
+ gr.update(visible=False), # cert_col
575
+ gr.update(value=""), # timer_info — clear
576
+ gr.update(value=question_text), # question_md — set question
577
+ gr.update(value=""), # answer_box — clear previous answer
578
+ )
579
+
580
+ def submit_answer(answer, state):
581
+ lesson_idx = state.get("lesson_idx", 0)
582
+
583
+ if state["phase"] != "quiz":
584
+ yield (
585
+ state,
586
+ gr.update(visible=True), gr.update(visible=False),
587
+ gr.update(visible=False), gr.update(visible=False),
588
+ "",
589
+ gr.update(value="📤 Soumettre", interactive=True),
590
+ gr.update(interactive=True),
591
+ )
592
+ return
593
+
594
+ # Step 1: disable buttons immediately while evaluating
595
+ yield (
596
+ state,
597
+ gr.update(visible=False), gr.update(visible=True),
598
+ gr.update(visible=False), gr.update(visible=False),
599
+ "",
600
+ gr.update(value="⏳ Correction en cours…", interactive=False),
601
+ gr.update(interactive=False),
602
+ )
603
+
604
+ # Step 2: evaluate
605
+ q = LESSONS[lesson_idx]["questions"][state["question_idx"]]
606
+ score, fb = evaluate_answer(q["q"], answer, q["hint"])
607
+ total = len(LESSONS)
608
+
609
+ if score >= PASS_SCORE:
610
+ state = {**state, "phase": "pass"}
611
+ result = (
612
+ f"## 🎉 Bravo ! Leçon {lesson_idx + 1}/{total} réussie.\n\n"
613
+ f"**Score : {score}/10**\n\n**Commentaire :** {fb}"
614
+ )
615
  else:
616
+ state = {**state, "phase": "fail"}
617
+ result = (
618
+ f"## ❌ Score insuffisant : {score}/10\n\n"
619
+ f"**Commentaire :** {fb}\n\n"
620
+ f"Seuil requis : **{PASS_SCORE}/10**. Veuillez **réécouter la leçon** avant de retenter."
621
+ )
622
+
623
+ # Step 3: show result panel
624
+ yield (
625
+ state,
626
+ gr.update(visible=False), gr.update(visible=False),
627
+ gr.update(visible=True), gr.update(visible=False),
628
+ result,
629
+ gr.update(value="📤 Soumettre", interactive=True),
630
+ gr.update(value="", interactive=True),
631
+ )
632
+
633
+ def handle_next(state):
634
+ lesson_idx = state.get("lesson_idx", 0)
635
+ total = len(LESSONS)
636
+
637
+ # Failed → restart the same lesson's listening phase
638
+ if state["phase"] == "fail":
639
  return start_listening(state)
640
 
641
+ # Passed last lesson → show certificate
642
+ if lesson_idx >= total - 1:
643
+ state = {**state, "phase": "done"}
644
+ # Build dynamic checklist from actual lesson titles
645
+ checklist = "\n\n".join(
646
+ f"✅ Leçon {i + 1} — {LESSONS[i]['title']}"
647
+ for i in range(total)
648
+ )
649
+ cert = (
650
+ "# 🏆 Félicitations !\n\n"
651
+ f"Vous avez complété avec succès les **{total} leçons** du cours :\n\n"
652
+ f"{checklist}\n\n"
653
+ "---\n\n*Ce certificat atteste de votre réussite aux évaluations.*"
654
+ )
655
+ return (
656
+ state, None, cert,
657
+ gr.update(visible=False), gr.update(visible=False),
658
+ gr.update(visible=False), gr.update(visible=True),
659
+ gr.update(interactive=False, value="✅ Terminé"),
660
+ )
661
+
662
+ # Passed → advance to next lesson
663
+ state = {**state, "lesson_idx": lesson_idx + 1, "listen_start": None}
664
+ return start_listening(state)
665
+
666
  def restart_from_cert(state):
667
  return start_listening(fresh())
668
 
669
  # ── Wiring ────────────────────────────────────────────────────────────────
670
+ # Outputs shared by start_listening / handle_next / restart_from_cert
671
  LISTEN_OUT = [session, audio_out, listen_msg,
672
  listen_col, quiz_col, result_col, cert_col, quiz_btn]
673
 
674
+ start_btn.click(start_listening, inputs=[session], outputs=LISTEN_OUT)
675
 
676
+ check_btn.click(check_timer, inputs=[session], outputs=[quiz_btn, timer_info, session])
677
 
678
+ # Outputs for open_quiz — must match exactly what open_quiz() returns (8 values)
679
  QUIZ_OUT = [session, listen_col, quiz_col, result_col, cert_col,
680
  timer_info, question_md, answer_box]
681
+ quiz_btn.click(open_quiz, inputs=[session], outputs=QUIZ_OUT)
682
 
683
+ # Outputs for submit_answer — 7 values
684
  RES_OUT = [session, listen_col, quiz_col, result_col, cert_col,
685
  result_md, submit_btn, answer_box]
686
+ submit_btn.click(submit_answer, inputs=[answer_box, session], outputs=RES_OUT)
687
+ answer_box.submit(submit_answer, inputs=[answer_box, session], outputs=RES_OUT)
688
 
689
+ next_btn.click(handle_next, inputs=[session], outputs=LISTEN_OUT)
690
+ restart_btn.click(restart_from_cert, inputs=[session], outputs=LISTEN_OUT)
691
 
692
  if __name__ == "__main__":
693
  demo.queue()
694
  demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
695
 
696
+