Deevyankar commited on
Commit
b4fb0db
·
verified ·
1 Parent(s): f72d406

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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 by repeated good performance. A topic badge is awarded after at least 3 strong answers or quiz successes in that topic.",
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 buen rendimiento repetido. Una insignia de tema se concede después de al menos 3 respuestas o intentos fuertes en ese tema.",
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
- sdf = df[df["student_id"] == student_id]
 
 
 
 
419
  badges = set()
 
420
  for topic in TOPICS:
421
- good = sdf[(sdf["topic"] == topic) & (sdf["percent"] >= 70)]
422
- if len(good) >= 3:
423
- badges.add(topic.split(" /")[0] + " Badge")
424
- if len(sdf[sdf["percent"] >= 70]) >= 10:
425
- badges.add("Clinical Reasoning Builder")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {n_questions} MCQ questions for the topic: {topic}.
 
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.35,
479
  )
480
  mcqs = safe_json_from_text(resp.choices[0].message.content or "[]")
481
- clean = []
482
- for item in mcqs[:n_questions]:
 
 
 
483
  opts = item.get("options", [])
484
  if isinstance(opts, dict):
485
- opts = [f"{k}. {v}" for k, v in opts.items()]
486
- opts = [str(o) for o in opts][:5]
487
- while len(opts) < 5:
488
- opts.append(f"Option {chr(65+len(opts))}")
489
- correct = str(item.get("correct_option", "A")).strip().upper()[:1]
 
 
 
 
 
 
 
 
 
490
  if correct not in list("ABCDE"):
491
  correct = "A"
 
 
 
 
 
492
  clean.append({
493
- "question": str(item.get("question", "")),
494
- "options": opts,
495
  "correct_option": correct,
496
- "explanation": str(item.get("explanation", "")),
497
- "subtopic": str(item.get("subtopic", topic)),
498
  })
499
- # Sometimes the model returns one fewer question than requested.
500
- # Complete the quiz with fallback questions so the selected number is always respected.
501
  if len(clean) < n_questions:
502
- clean.extend(fallback_mcqs(topic, n_questions - len(clean), language))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 after repeated good performance in a topic.</p></div>
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
  """