Spaces:
Paused
Paused
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +135 -28
src/streamlit_app.py
CHANGED
|
@@ -79,7 +79,7 @@ TRANSLATIONS = {
|
|
| 79 |
"confidence_orange": "Orange = medium confidence. The answer is partly supported but should be revised.",
|
| 80 |
"confidence_red": "Red = low confidence. The system found weak or insufficient support.",
|
| 81 |
"similarity_help": "Similarity is a value from 0 to 1. A higher value means the retrieved course material is closer to the question.",
|
| 82 |
-
"badge_help": "Badges are earned
|
| 83 |
"ask_question": "Write your free question here",
|
| 84 |
"send": "Ask BrainChat",
|
| 85 |
"student_tip": "Student Mode stores your quiz attempts, scores, weak topics, and badge progress.",
|
|
@@ -113,7 +113,7 @@ TRANSLATIONS = {
|
|
| 113 |
"confidence_orange": "Naranja = confianza media. La respuesta tiene apoyo parcial y debe revisarse.",
|
| 114 |
"confidence_red": "Rojo = baja confianza. El sistema encontró apoyo débil o insuficiente.",
|
| 115 |
"similarity_help": "La similitud es un valor de 0 a 1. Un valor más alto significa que el material recuperado se parece más a la pregunta.",
|
| 116 |
-
"badge_help": "Las insignias se obtienen con
|
| 117 |
"ask_question": "Escribe aquí tu pregunta libre",
|
| 118 |
"send": "Preguntar a BrainChat",
|
| 119 |
"student_tip": "El modo estudiante guarda intentos, puntuaciones, temas débiles y progreso de insignias.",
|
|
@@ -412,17 +412,49 @@ def confidence_from_similarity(similarity: float) -> str:
|
|
| 412 |
|
| 413 |
|
| 414 |
def badges_for_student(student_id: str) -> List[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
df = load_attempts_df()
|
| 416 |
if df.empty:
|
| 417 |
return []
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
badges = set()
|
|
|
|
| 420 |
for topic in TOPICS:
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
return sorted(badges)
|
| 427 |
|
| 428 |
# =====================================================
|
|
@@ -440,6 +472,24 @@ def safe_json_from_text(text: str):
|
|
| 440 |
return json.loads(text)
|
| 441 |
|
| 442 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
def generate_mcqs(topic: str, difficulty: str, n_questions: int, language: str) -> Tuple[List[Dict[str, Any]], str]:
|
| 444 |
records, err = search_hybrid(topic + " neurology PMQSN exam questions", final_k=8)
|
| 445 |
context = build_context(records)
|
|
@@ -450,17 +500,21 @@ def generate_mcqs(topic: str, difficulty: str, n_questions: int, language: str)
|
|
| 450 |
return fallback_mcqs(topic, n_questions, language), "OPENAI_API_KEY missing. Showing demo questions."
|
| 451 |
|
| 452 |
lang_instruction = "Write everything in English." if language == "English" else "Escribe todo en español."
|
|
|
|
| 453 |
prompt = f"""
|
| 454 |
You are BrainChat, an exam-focused neurology tutor.
|
| 455 |
-
Generate {
|
|
|
|
| 456 |
Difficulty: {difficulty}.
|
| 457 |
{lang_instruction}
|
| 458 |
|
| 459 |
Rules:
|
| 460 |
-
- Output ONLY valid JSON array.
|
| 461 |
- Each item must have: question, options, correct_option, explanation, subtopic.
|
| 462 |
- options must be exactly 5 options labelled A, B, C, D, E.
|
|
|
|
| 463 |
- Only one correct answer.
|
|
|
|
| 464 |
- The style should follow PMQSN neurology exam questions.
|
| 465 |
- Use the course context as the main knowledge source.
|
| 466 |
- Do not mention JSON, files, or internal retrieval.
|
|
@@ -475,38 +529,91 @@ Course context:
|
|
| 475 |
resp = client.chat.completions.create(
|
| 476 |
model=OPENAI_MODEL,
|
| 477 |
messages=[{"role": "user", "content": prompt}],
|
| 478 |
-
temperature=0.
|
| 479 |
)
|
| 480 |
mcqs = safe_json_from_text(resp.choices[0].message.content or "[]")
|
| 481 |
-
clean = []
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
| 483 |
opts = item.get("options", [])
|
| 484 |
if isinstance(opts, dict):
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
if correct not in list("ABCDE"):
|
| 491 |
correct = "A"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
clean.append({
|
| 493 |
-
"question":
|
| 494 |
-
"options":
|
| 495 |
"correct_option": correct,
|
| 496 |
-
"explanation":
|
| 497 |
-
"subtopic": str(item.get("subtopic", topic)),
|
| 498 |
})
|
| 499 |
-
|
| 500 |
-
# Complete the quiz with fallback questions so the selected number is always respected.
|
| 501 |
if len(clean) < n_questions:
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
if clean:
|
| 504 |
-
return clean[:n_questions], ""
|
| 505 |
return fallback_mcqs(topic, n_questions, language), "Could not generate AI quiz. Showing demo questions."
|
| 506 |
except Exception as e:
|
| 507 |
return fallback_mcqs(topic, n_questions, language), f"AI generation failed: {e}"
|
| 508 |
|
| 509 |
-
|
| 510 |
def fallback_mcqs(topic: str, n: int, language: str) -> List[Dict[str, Any]]:
|
| 511 |
if language == "Spanish":
|
| 512 |
q = f"Pregunta de práctica sobre {topic}: ¿cuál opción es más correcta?"
|
|
@@ -578,7 +685,7 @@ def html_report_student(student_id: str, name: str, language: str) -> str:
|
|
| 578 |
<p><span class='orange'><b>Orange</b></span>: medium confidence / needs revision.</p>
|
| 579 |
<p><span class='red'><b>Red</b></span>: low confidence / weak support or low performance.</p>
|
| 580 |
<p><b>Similarity</b> is from 0 to 1 and shows how closely course material matched the question.</p>
|
| 581 |
-
<p><b>Badges</b> are earned
|
| 582 |
<div class='card'><h2>Quiz attempts</h2><table><tr><th>Date</th><th>Topic</th><th>Difficulty</th><th>Score</th><th>Percent</th><th>Confidence</th></tr>{rows}</table></div>
|
| 583 |
</body></html>
|
| 584 |
"""
|
|
|
|
| 79 |
"confidence_orange": "Orange = medium confidence. The answer is partly supported but should be revised.",
|
| 80 |
"confidence_red": "Red = low confidence. The system found weak or insufficient support.",
|
| 81 |
"similarity_help": "Similarity is a value from 0 to 1. A higher value means the retrieved course material is closer to the question.",
|
| 82 |
+
"badge_help": "Badges are earned from quiz marks only: Bronze = 70% or above in 2 quizzes of the same topic; Silver = 80% or above in 3 quizzes; Gold = 90% or above in 5 quizzes. General badges are also awarded for overall learning consistency.",
|
| 83 |
"ask_question": "Write your free question here",
|
| 84 |
"send": "Ask BrainChat",
|
| 85 |
"student_tip": "Student Mode stores your quiz attempts, scores, weak topics, and badge progress.",
|
|
|
|
| 113 |
"confidence_orange": "Naranja = confianza media. La respuesta tiene apoyo parcial y debe revisarse.",
|
| 114 |
"confidence_red": "Rojo = baja confianza. El sistema encontró apoyo débil o insuficiente.",
|
| 115 |
"similarity_help": "La similitud es un valor de 0 a 1. Un valor más alto significa que el material recuperado se parece más a la pregunta.",
|
| 116 |
+
"badge_help": "Las insignias se obtienen solo con las notas del cuestionario: Bronce = 70% o más en 2 cuestionarios del mismo tema; Plata = 80% o más en 3 cuestionarios; Oro = 90% o más en 5 cuestionarios. También hay insignias generales por constancia.",
|
| 117 |
"ask_question": "Escribe aquí tu pregunta libre",
|
| 118 |
"send": "Preguntar a BrainChat",
|
| 119 |
"student_tip": "El modo estudiante guarda intentos, puntuaciones, temas débiles y progreso de insignias.",
|
|
|
|
| 412 |
|
| 413 |
|
| 414 |
def badges_for_student(student_id: str) -> List[str]:
|
| 415 |
+
"""
|
| 416 |
+
Quiz-score based badge system.
|
| 417 |
+
|
| 418 |
+
Topic badges:
|
| 419 |
+
- Bronze: 70% or above in at least 2 quizzes of the same topic
|
| 420 |
+
- Silver: 80% or above in at least 3 quizzes of the same topic
|
| 421 |
+
- Gold: 90% or above in at least 5 quizzes of the same topic
|
| 422 |
+
|
| 423 |
+
General badges:
|
| 424 |
+
- Consistent Learner: completed at least 10 quizzes
|
| 425 |
+
- Neurology Master: overall average score 85% or above after at least 5 quizzes
|
| 426 |
+
"""
|
| 427 |
df = load_attempts_df()
|
| 428 |
if df.empty:
|
| 429 |
return []
|
| 430 |
+
|
| 431 |
+
sdf = df[df["student_id"] == student_id].copy()
|
| 432 |
+
if sdf.empty:
|
| 433 |
+
return []
|
| 434 |
+
|
| 435 |
badges = set()
|
| 436 |
+
|
| 437 |
for topic in TOPICS:
|
| 438 |
+
topic_df = sdf[sdf["topic"] == topic]
|
| 439 |
+
topic_short = topic.split(" /")[0]
|
| 440 |
+
|
| 441 |
+
bronze_count = len(topic_df[topic_df["percent"] >= 70])
|
| 442 |
+
silver_count = len(topic_df[topic_df["percent"] >= 80])
|
| 443 |
+
gold_count = len(topic_df[topic_df["percent"] >= 90])
|
| 444 |
+
|
| 445 |
+
if bronze_count >= 2:
|
| 446 |
+
badges.add(f"🥉 {topic_short} Bronze")
|
| 447 |
+
if silver_count >= 3:
|
| 448 |
+
badges.add(f"🥈 {topic_short} Silver")
|
| 449 |
+
if gold_count >= 5:
|
| 450 |
+
badges.add(f"🥇 {topic_short} Gold")
|
| 451 |
+
|
| 452 |
+
if len(sdf) >= 10:
|
| 453 |
+
badges.add("📘 Consistent Learner")
|
| 454 |
+
|
| 455 |
+
if len(sdf) >= 5 and float(sdf["percent"].mean()) >= 85:
|
| 456 |
+
badges.add("🏆 Neurology Master")
|
| 457 |
+
|
| 458 |
return sorted(badges)
|
| 459 |
|
| 460 |
# =====================================================
|
|
|
|
| 472 |
return json.loads(text)
|
| 473 |
|
| 474 |
|
| 475 |
+
def normalize_mcq_option(opt: Any, index: int) -> str:
|
| 476 |
+
"""Convert option formats into clean display strings like 'A. Text'."""
|
| 477 |
+
letter = chr(65 + index)
|
| 478 |
+
if isinstance(opt, dict):
|
| 479 |
+
opt_letter = str(opt.get("letter", letter)).strip().upper()[:1] or letter
|
| 480 |
+
text = str(opt.get("text", opt.get("option", opt.get("value", "")))).strip()
|
| 481 |
+
if not text:
|
| 482 |
+
text = str(opt)
|
| 483 |
+
return f"{opt_letter}. {text}"
|
| 484 |
+
|
| 485 |
+
text = str(opt).strip()
|
| 486 |
+
# If the model already gives 'A. text' or 'A) text', keep it clean.
|
| 487 |
+
m = re.match(r"^([A-Ea-e])\s*[\.|\)]\s*(.+)$", text)
|
| 488 |
+
if m:
|
| 489 |
+
return f"{m.group(1).upper()}. {m.group(2).strip()}"
|
| 490 |
+
return f"{letter}. {text}"
|
| 491 |
+
|
| 492 |
+
|
| 493 |
def generate_mcqs(topic: str, difficulty: str, n_questions: int, language: str) -> Tuple[List[Dict[str, Any]], str]:
|
| 494 |
records, err = search_hybrid(topic + " neurology PMQSN exam questions", final_k=8)
|
| 495 |
context = build_context(records)
|
|
|
|
| 500 |
return fallback_mcqs(topic, n_questions, language), "OPENAI_API_KEY missing. Showing demo questions."
|
| 501 |
|
| 502 |
lang_instruction = "Write everything in English." if language == "English" else "Escribe todo en español."
|
| 503 |
+
requested_from_model = n_questions + 2 # ask for a few extra, then keep exactly the selected number
|
| 504 |
prompt = f"""
|
| 505 |
You are BrainChat, an exam-focused neurology tutor.
|
| 506 |
+
Generate at least {requested_from_model} MCQ questions for the topic: {topic}.
|
| 507 |
+
The final app will keep exactly {n_questions} questions, so do not return fewer than {n_questions}.
|
| 508 |
Difficulty: {difficulty}.
|
| 509 |
{lang_instruction}
|
| 510 |
|
| 511 |
Rules:
|
| 512 |
+
- Output ONLY a valid JSON array.
|
| 513 |
- Each item must have: question, options, correct_option, explanation, subtopic.
|
| 514 |
- options must be exactly 5 options labelled A, B, C, D, E.
|
| 515 |
+
- The options may be strings only, for example: ["A. ...", "B. ...", "C. ...", "D. ...", "E. ..."].
|
| 516 |
- Only one correct answer.
|
| 517 |
+
- Avoid generic demo questions such as "Option A".
|
| 518 |
- The style should follow PMQSN neurology exam questions.
|
| 519 |
- Use the course context as the main knowledge source.
|
| 520 |
- Do not mention JSON, files, or internal retrieval.
|
|
|
|
| 529 |
resp = client.chat.completions.create(
|
| 530 |
model=OPENAI_MODEL,
|
| 531 |
messages=[{"role": "user", "content": prompt}],
|
| 532 |
+
temperature=0.30,
|
| 533 |
)
|
| 534 |
mcqs = safe_json_from_text(resp.choices[0].message.content or "[]")
|
| 535 |
+
clean: List[Dict[str, Any]] = []
|
| 536 |
+
|
| 537 |
+
for item in mcqs:
|
| 538 |
+
if len(clean) >= n_questions:
|
| 539 |
+
break
|
| 540 |
opts = item.get("options", [])
|
| 541 |
if isinstance(opts, dict):
|
| 542 |
+
# Handles {"A":"...", "B":"..."} format
|
| 543 |
+
opts = [{"letter": k, "text": v} for k, v in opts.items()]
|
| 544 |
+
if not isinstance(opts, list):
|
| 545 |
+
continue
|
| 546 |
+
|
| 547 |
+
formatted_opts = [normalize_mcq_option(opt, idx) for idx, opt in enumerate(opts[:5])]
|
| 548 |
+
if len(formatted_opts) != 5:
|
| 549 |
+
continue
|
| 550 |
+
|
| 551 |
+
question_text = str(item.get("question", "")).strip()
|
| 552 |
+
if not question_text:
|
| 553 |
+
continue
|
| 554 |
+
|
| 555 |
+
correct = str(item.get("correct_option", item.get("answer", "A"))).strip().upper()[:1]
|
| 556 |
if correct not in list("ABCDE"):
|
| 557 |
correct = "A"
|
| 558 |
+
|
| 559 |
+
explanation = str(item.get("explanation", "")).strip()
|
| 560 |
+
if not explanation:
|
| 561 |
+
explanation = "Review the selected topic and compare the clinical features carefully."
|
| 562 |
+
|
| 563 |
clean.append({
|
| 564 |
+
"question": question_text,
|
| 565 |
+
"options": formatted_opts,
|
| 566 |
"correct_option": correct,
|
| 567 |
+
"explanation": explanation,
|
| 568 |
+
"subtopic": str(item.get("subtopic", topic)).strip() or topic,
|
| 569 |
})
|
| 570 |
+
|
|
|
|
| 571 |
if len(clean) < n_questions:
|
| 572 |
+
# Try one more small request instead of showing generic fallback questions.
|
| 573 |
+
missing = n_questions - len(clean)
|
| 574 |
+
retry_prompt = f"""
|
| 575 |
+
Generate exactly {missing} additional PMQSN-style neurology MCQs for topic: {topic}.
|
| 576 |
+
{lang_instruction}
|
| 577 |
+
Return ONLY valid JSON array. Each question must have exactly 5 string options labelled A-E, one correct_option, explanation, and subtopic.
|
| 578 |
+
Do not use generic placeholders.
|
| 579 |
+
"""
|
| 580 |
+
retry = client.chat.completions.create(
|
| 581 |
+
model=OPENAI_MODEL,
|
| 582 |
+
messages=[{"role": "user", "content": retry_prompt}],
|
| 583 |
+
temperature=0.30,
|
| 584 |
+
)
|
| 585 |
+
more = safe_json_from_text(retry.choices[0].message.content or "[]")
|
| 586 |
+
for item in more:
|
| 587 |
+
if len(clean) >= n_questions:
|
| 588 |
+
break
|
| 589 |
+
opts = item.get("options", [])
|
| 590 |
+
if isinstance(opts, dict):
|
| 591 |
+
opts = [{"letter": k, "text": v} for k, v in opts.items()]
|
| 592 |
+
if not isinstance(opts, list):
|
| 593 |
+
continue
|
| 594 |
+
formatted_opts = [normalize_mcq_option(opt, idx) for idx, opt in enumerate(opts[:5])]
|
| 595 |
+
if len(formatted_opts) != 5:
|
| 596 |
+
continue
|
| 597 |
+
question_text = str(item.get("question", "")).strip()
|
| 598 |
+
if not question_text:
|
| 599 |
+
continue
|
| 600 |
+
correct = str(item.get("correct_option", item.get("answer", "A"))).strip().upper()[:1]
|
| 601 |
+
if correct not in list("ABCDE"):
|
| 602 |
+
correct = "A"
|
| 603 |
+
clean.append({
|
| 604 |
+
"question": question_text,
|
| 605 |
+
"options": formatted_opts,
|
| 606 |
+
"correct_option": correct,
|
| 607 |
+
"explanation": str(item.get("explanation", "Review the selected topic carefully.")),
|
| 608 |
+
"subtopic": str(item.get("subtopic", topic)).strip() or topic,
|
| 609 |
+
})
|
| 610 |
+
|
| 611 |
if clean:
|
| 612 |
+
return clean[:n_questions], "" if len(clean) >= n_questions else "Generated fewer questions than requested."
|
| 613 |
return fallback_mcqs(topic, n_questions, language), "Could not generate AI quiz. Showing demo questions."
|
| 614 |
except Exception as e:
|
| 615 |
return fallback_mcqs(topic, n_questions, language), f"AI generation failed: {e}"
|
| 616 |
|
|
|
|
| 617 |
def fallback_mcqs(topic: str, n: int, language: str) -> List[Dict[str, Any]]:
|
| 618 |
if language == "Spanish":
|
| 619 |
q = f"Pregunta de práctica sobre {topic}: ¿cuál opción es más correcta?"
|
|
|
|
| 685 |
<p><span class='orange'><b>Orange</b></span>: medium confidence / needs revision.</p>
|
| 686 |
<p><span class='red'><b>Red</b></span>: low confidence / weak support or low performance.</p>
|
| 687 |
<p><b>Similarity</b> is from 0 to 1 and shows how closely course material matched the question.</p>
|
| 688 |
+
<p><b>Badges</b> are earned from quiz marks only: Bronze = 70% or above in 2 quizzes of the same topic; Silver = 80% or above in 3 quizzes; Gold = 90% or above in 5 quizzes. General badges are awarded for consistency and strong overall performance.</p></div>
|
| 689 |
<div class='card'><h2>Quiz attempts</h2><table><tr><th>Date</th><th>Topic</th><th>Difficulty</th><th>Score</th><th>Percent</th><th>Confidence</th></tr>{rows}</table></div>
|
| 690 |
</body></html>
|
| 691 |
"""
|