Update app.py
Browse files
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 =
|
| 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,
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
gr.update(visible=
|
| 153 |
-
gr.update(visible=False),
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 176 |
if elapsed < LISTEN_SECONDS:
|
| 177 |
m, s = divmod(int(LISTEN_SECONDS - elapsed), 60)
|
| 178 |
return (
|
| 179 |
state,
|
| 180 |
-
gr.update(visible=True),
|
| 181 |
-
gr.update(visible=False),
|
| 182 |
-
gr.update(
|
| 183 |
-
gr.update(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
)
|
| 185 |
-
|
| 186 |
-
idx = random.randrange(len(
|
| 187 |
state = {**state, "phase": "quiz", "question_idx": idx}
|
| 188 |
-
question_text =
|
|
|
|
| 189 |
return (
|
| 190 |
state,
|
| 191 |
-
gr.update(visible=False),
|
| 192 |
-
gr.update(visible=
|
| 193 |
-
gr.update(
|
| 194 |
-
gr.update(
|
| 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 |
-
"",
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
"# 🏆 Félicitations !\n\n"
|
| 261 |
-
"Vous avez complété avec succès les **
|
| 262 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|