Deevyankar commited on
Commit
e83a5b3
·
verified ·
1 Parent(s): 963a94f

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +453 -167
src/streamlit_app.py CHANGED
@@ -7,6 +7,7 @@ import sqlite3
7
  import hashlib
8
  import uuid
9
  import base64
 
10
  from datetime import datetime
11
  from typing import Dict, List, Any, Optional, Tuple
12
 
@@ -30,13 +31,10 @@ EMBED_PATH = os.path.join(BUILD_DIR, "embeddings.npy")
30
  CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
31
  QUESTION_BANK_FILE = os.path.join(BASE_DIR, "exam_questions_pmqs.json")
32
  LOGO_FILE = os.path.join(BASE_DIR, "logo.png")
33
- MEDICAL_IMAGE_DIR = os.path.join(BASE_DIR, "medical_images")
34
- IMAGE_MANIFEST_FILE = os.path.join(BASE_DIR, "medical_images_manifest.json")
35
-
36
  DB_PATH = os.getenv("BRAINCHAT_DB", "brainchat.db")
37
  OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
38
  OPENAI_IMAGE_MODEL = os.getenv("OPENAI_IMAGE_MODEL", "gpt-image-1")
39
- ENABLE_AI_IMAGES = os.getenv("ENABLE_AI_IMAGES", "false").lower() == "true"
40
  TEACHER_PASSWORD = os.getenv("TEACHER_PASSWORD", "teacher123")
41
 
42
  TOPICS = [
@@ -302,6 +300,19 @@ def init_db() -> None:
302
  )
303
  """)
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  # Migrate older installations without deleting data.
306
  ensure_column(conn, "quiz_attempts", "source_refs_json", "TEXT")
307
  ensure_column(conn, "quiz_attempts", "source_mix_json", "TEXT")
@@ -685,14 +696,72 @@ def load_rejected_patterns(topic: str) -> List[Dict[str, Any]]:
685
 
686
  def is_rejected_or_too_similar(question: str, rejected: List[Dict[str, Any]]) -> bool:
687
  q_hash = question_hash(question)
 
688
  for item in rejected:
 
689
  if item.get("question_hash") == q_hash:
690
  return True
691
- if token_jaccard(question, item.get("question", "")) >= 0.72:
 
 
692
  return True
693
  return False
694
 
695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  def load_approved_questions(topic: str, difficulty: str, limit: int = 12) -> List[Dict[str, Any]]:
697
  conn = get_conn()
698
  rows = conn.execute("""
@@ -744,7 +813,7 @@ def create_question_review(
744
  reporter_id: str,
745
  issue_type: str,
746
  comment: str,
747
- ) -> None:
748
  qid = question.get("question_id") or str(uuid.uuid4())
749
  conn = get_conn()
750
 
@@ -757,7 +826,8 @@ def create_question_review(
757
  subtopic, source_refs_json, question_hash, status, created_at
758
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
759
  """, (
760
- qid, reporter_id, "", question.get("topic", "General Neurology"), question.get("difficulty", "Any"),
 
761
  question.get("question", ""), json.dumps(question.get("options", []), ensure_ascii=False),
762
  question.get("correct_option", "A"), question.get("explanation", ""),
763
  question.get("subtopic", ""), json.dumps(question.get("source_refs", []), ensure_ascii=False),
@@ -766,6 +836,15 @@ def create_question_review(
766
  else:
767
  conn.execute("UPDATE generated_questions SET status='flagged' WHERE question_id=?", (qid,))
768
 
 
 
 
 
 
 
 
 
 
769
  conn.execute("""
770
  INSERT INTO question_reviews(
771
  question_id, reporter_type, reporter_id, issue_type,
@@ -774,6 +853,7 @@ def create_question_review(
774
  """, (qid, reporter_type, reporter_id, issue_type, comment, now_iso()))
775
  conn.commit()
776
  conn.close()
 
777
 
778
 
779
  def load_pending_reviews() -> pd.DataFrame:
@@ -859,8 +939,33 @@ def approve_review(
859
  corrected_explanation, reviewer, now_iso(), review_id,
860
  ))
861
  conn.execute("UPDATE generated_questions SET status='approved' WHERE question_id=?", (question_id,))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
  conn.commit()
863
  conn.close()
 
864
 
865
 
866
  def reject_review(
@@ -875,15 +980,20 @@ def reject_review(
875
  conn.close()
876
  raise ValueError("Generated question not found.")
877
 
878
- conn.execute("""
879
- INSERT INTO rejected_question_patterns(
880
- question_id, topic, question, question_hash, reason,
881
- rejected_by, created_at, active
882
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
883
- """, (
884
- question_id, row["topic"], row["question"], row["question_hash"],
885
- reason, reviewer, now_iso(),
886
- ))
 
 
 
 
 
887
  conn.execute("""
888
  UPDATE question_reviews SET
889
  professor_comment=?, review_status='rejected', reviewer=?, reviewed_at=?
@@ -893,6 +1003,8 @@ def reject_review(
893
  conn.execute("UPDATE approved_questions SET active=0 WHERE question_id=?", (question_id,))
894
  conn.commit()
895
  conn.close()
 
 
896
 
897
  # =====================================================
898
  # AI HELPERS
@@ -978,6 +1090,7 @@ def generate_mcqs(
978
  approved = load_approved_questions(topic, difficulty, limit=n_questions)
979
  approved_items = [approved_to_quiz_item(x) for x in approved]
980
  rejected = load_rejected_patterns(topic)
 
981
 
982
  # Use some approved questions directly so professor corrections affect the next quiz immediately.
983
  final_questions = approved_items[: min(len(approved_items), max(1, n_questions // 2))]
@@ -1013,6 +1126,7 @@ def generate_mcqs(
1013
  {"question": x.get("question", ""), "reason": x.get("reason", "")}
1014
  for x in rejected[:10]
1015
  ]
 
1016
 
1017
  lang_instruction = "Write everything in English." if language == "English" else "Escribe todo en español."
1018
  requested = needed + 4
@@ -1047,6 +1161,9 @@ Past exam-style examples:
1047
  Rejected patterns and reasons:
1048
  {json.dumps(rejected_guidance, ensure_ascii=False)[:5000]}
1049
 
 
 
 
1050
  Course context:
1051
  {context}
1052
  """
@@ -1225,51 +1342,82 @@ Context:
1225
  return "", source_refs, source_mix, str(exc)
1226
 
1227
 
1228
- def generate_ai_medical_image(topic: str, depth_level: str, language: str) -> Tuple[Optional[bytes], Optional[str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
  if not ENABLE_AI_IMAGES:
1230
- return None, "AI image generation is disabled. Set ENABLE_AI_IMAGES=true to enable it."
 
 
 
1231
  client = get_client()
1232
  if client is None:
1233
- return None, "OPENAI_API_KEY is missing."
1234
-
1235
- language_instruction = "English labels" if language == "English" else "Spanish labels"
1236
- prompt = (
1237
- f"Create a clean medical education illustration for {topic}, suitable for {depth_level.lower()} learners, "
1238
- f"with {language_instruction}. White background, simple labelled anatomy or mechanism, no decorative art, "
1239
- "no patient-identifying features, no treatment recommendation, and no exact dosage information. "
1240
- "The image must be clearly educational rather than diagnostic."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1241
  )
 
1242
  try:
1243
  response = client.images.generate(
1244
  model=OPENAI_IMAGE_MODEL,
1245
- prompt=prompt,
1246
  size="1024x1024",
1247
  )
1248
  item = response.data[0]
1249
  if getattr(item, "b64_json", None):
1250
- return base64.b64decode(item.b64_json), None
1251
- return None, "The image API returned no image data."
1252
  except Exception as exc:
1253
- return None, str(exc)
1254
 
1255
- # =====================================================
1256
- # MEDICAL IMAGE LIBRARY
1257
- # =====================================================
1258
- @st.cache_data(show_spinner=False)
1259
- def load_image_manifest() -> List[Dict[str, Any]]:
1260
- if not os.path.exists(IMAGE_MANIFEST_FILE):
1261
- return []
1262
- try:
1263
- with open(IMAGE_MANIFEST_FILE, "r", encoding="utf-8") as f:
1264
- data = json.load(f)
1265
- return data if isinstance(data, list) else []
1266
- except Exception:
1267
- return []
1268
-
1269
-
1270
- def topic_images(topic: str) -> List[Dict[str, Any]]:
1271
- manifest = load_image_manifest()
1272
- return [x for x in manifest if x.get("topic") in [topic, "General Neurology"] and x.get("approved", False)]
1273
 
1274
  # =====================================================
1275
  # BADGES AND REPORTS
@@ -1437,6 +1585,20 @@ def issue_options(language: str) -> List[str]:
1437
  "Other",
1438
  ]
1439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
  # =====================================================
1441
  # STUDENT MODE
1442
  # =====================================================
@@ -1468,7 +1630,6 @@ def student_mode() -> None:
1468
  "Structured outline",
1469
  "Concept map",
1470
  "Clinical decision pathway",
1471
- "Approved medical images",
1472
  "AI-generated educational image",
1473
  ]
1474
  else:
@@ -1480,7 +1641,6 @@ def student_mode() -> None:
1480
  "Esquema estructurado",
1481
  "Mapa conceptual",
1482
  "Ruta de decisión clínica",
1483
- "Imágenes médicas aprobadas",
1484
  "Imagen educativa generada por IA",
1485
  ]
1486
  tutor_activity = st.selectbox(t("activity"), activities)
@@ -1488,35 +1648,40 @@ def student_mode() -> None:
1488
  free_question = tutor_activity in ["Free question", "Pregunta libre"]
1489
  q = st.text_area(t("ask_question"), height=120) if free_question else ""
1490
 
1491
- if tutor_activity in ["Approved medical images", "Imágenes médicas aprobadas"]:
1492
- images = topic_images(topic)
1493
- if not images:
1494
- st.info(
1495
- "No approved images are registered for this topic. Add files to src/medical_images and update src/medical_images_manifest.json."
1496
- )
1497
- for image in images:
1498
- image_path = os.path.join(MEDICAL_IMAGE_DIR, image.get("file", ""))
1499
- if os.path.exists(image_path):
1500
- st.image(image_path, caption=image.get("caption", "Approved educational image"), use_container_width=True)
1501
- st.caption(
1502
- f"Source: {image.get('source', 'Not specified')} | "
1503
- f"Licence: {image.get('license', 'Not specified')} | "
1504
- f"Approved by: {image.get('approved_by', 'Professor')}"
1505
- )
1506
- else:
1507
- st.warning(f"Image file not found: {image_path}")
1508
-
1509
- elif tutor_activity in ["AI-generated educational image", "Imagen educativa generada por IA"]:
1510
  st.warning(
1511
- "AI-generated educational illustration. It must not be used for diagnosis or exact anatomical measurement. Professor review is recommended."
 
 
 
 
 
 
 
 
 
 
 
 
 
1512
  )
1513
  if st.button("Generate educational image", key="generate_ai_image"):
1514
- with st.spinner("Generating educational illustration..."):
1515
- image_bytes, image_error = generate_ai_medical_image(topic, depth_level, st.session_state["language"])
 
 
1516
  if image_error:
1517
  st.error(image_error)
1518
  elif image_bytes:
1519
- st.image(image_bytes, caption=f"AI-generated educational illustration: {topic}", use_container_width=True)
 
 
 
 
 
 
 
 
1520
 
1521
  elif tutor_activity in ["Concept map", "Mapa conceptual", "Clinical decision pathway", "Ruta de decisión clínica"]:
1522
  visual_type = "concept map" if tutor_activity in ["Concept map", "Mapa conceptual"] else "clinical decision pathway"
@@ -1650,8 +1815,11 @@ def student_mode() -> None:
1650
  key=f"comment_{item.get('question_id', i)}",
1651
  )
1652
  if st.button("Send to professor", key=f"flag_{item.get('question_id', i)}"):
1653
- create_question_review(item, "student", sid, issue_type, comment)
1654
- st.success("Question sent for professor review.")
 
 
 
1655
 
1656
  display_source_mix(st.session_state.get("quiz_source_mix", {}))
1657
  display_sources(st.session_state.get("quiz_source_refs", []))
@@ -1686,115 +1854,220 @@ def student_mode() -> None:
1686
  # TEACHER MODE
1687
  # =====================================================
1688
  def render_question_review_tab() -> None:
1689
- st.subheader("Professor Question Review")
1690
  st.caption(
1691
- "Corrections are stored in the approved question bank. Rejected questions are blocked by exact and similarity checks in future generation."
 
1692
  )
1693
 
1694
  reviewer = st.text_input("Reviewer name", value=st.session_state.get("reviewer_name", "Professor"))
1695
  st.session_state["reviewer_name"] = reviewer
1696
- pending = load_pending_reviews()
1697
-
1698
- c1, c2, c3 = st.columns(3)
1699
- c1.metric("Pending reviews", len(pending))
1700
 
 
1701
  conn = get_conn()
1702
  approved_count = conn.execute("SELECT COUNT(*) FROM approved_questions WHERE active=1").fetchone()[0]
1703
  rejected_count = conn.execute("SELECT COUNT(*) FROM rejected_question_patterns WHERE active=1").fetchone()[0]
 
1704
  conn.close()
 
 
 
1705
  c2.metric("Approved questions", approved_count)
1706
  c3.metric("Rejected patterns", rejected_count)
 
1707
 
1708
- if pending.empty:
1709
- st.success("No pending question reviews.")
1710
- else:
1711
- review_labels = [
1712
- f"#{int(row.review_id)} | {row.topic} | {str(row.question)[:75]}"
1713
- for row in pending.itertuples()
1714
- ]
1715
- selected_label = st.selectbox("Select review", review_labels)
1716
- selected_index = review_labels.index(selected_label)
1717
- row = pending.iloc[selected_index]
1718
-
1719
- options = json.loads(row["options_json"] or "[]")
1720
- source_refs = json.loads(row["source_refs_json"] or "[]")
1721
-
1722
- st.markdown(f"### Original question\n{row['question']}")
1723
- st.write("Original options:")
1724
- for option in options:
1725
- st.write(option)
1726
- st.markdown(f"**Current correct answer:** {row['correct_option']}")
1727
- st.markdown(f"**Current explanation:** {row['explanation']}")
1728
- st.warning(f"Reported issue: {row['issue_type']} — {row['reporter_comment'] or 'No comment provided'}")
1729
- display_sources(source_refs)
1730
-
1731
- st.markdown("### Professor correction")
1732
- corrected_question = st.text_area(
1733
- "Corrected question", value=row["question"], key=f"corrected_q_{row['review_id']}"
1734
- )
1735
 
1736
- corrected_options = []
1737
- for i in range(5):
1738
- default = options[i] if i < len(options) else f"{chr(65+i)}. "
1739
- corrected_options.append(
1740
- st.text_input(
1741
- f"Option {chr(65+i)}", value=default,
1742
- key=f"corrected_opt_{row['review_id']}_{i}",
1743
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1744
  )
 
1745
 
1746
- answer_index = "ABCDE".find(str(row["correct_option"]).upper())
1747
- corrected_answer = st.selectbox(
1748
- "Correct answer", list("ABCDE"), index=max(answer_index, 0),
1749
- key=f"corrected_answer_{row['review_id']}",
1750
- )
1751
- corrected_explanation = st.text_area(
1752
- "Corrected explanation", value=row["explanation"],
1753
- key=f"corrected_exp_{row['review_id']}",
1754
- )
1755
- professor_comment = st.text_area(
1756
- "Professor comment", key=f"prof_comment_{row['review_id']}"
1757
- )
 
1758
 
1759
- b1, b2 = st.columns(2)
1760
- with b1:
1761
- if st.button("Correct and approve", type="primary", key=f"approve_{row['review_id']}"):
1762
- if not corrected_question.strip() or any(not x.strip() for x in corrected_options):
1763
- st.error("Question and all five options are required.")
1764
- else:
1765
- approve_review(
1766
- int(row["review_id"]), row["question_id"], reviewer or "Professor",
1767
- professor_comment, corrected_question, corrected_options,
1768
- corrected_answer, corrected_explanation,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1769
  )
1770
- st.success("Correction approved and added to the approved question bank.")
1771
  st.rerun()
1772
- with b2:
1773
- if st.button("Reject and block pattern", key=f"reject_{row['review_id']}"):
1774
- reason = professor_comment or row["issue_type"] or "Rejected by professor"
1775
- reject_review(
1776
- int(row["review_id"]), row["question_id"],
1777
- reviewer or "Professor", reason,
 
 
 
 
 
 
 
 
 
 
 
 
 
1778
  )
1779
- st.success("Question rejected. Its pattern will be blocked in future generation.")
1780
- st.rerun()
1781
 
1782
- st.divider()
1783
- st.subheader("Approved Question Bank")
1784
- conn = get_conn()
1785
- approved_df = pd.read_sql_query(
1786
- "SELECT id, question_id, topic, difficulty, question, correct_option, approved_by, approved_at, active FROM approved_questions ORDER BY approved_at DESC",
1787
- conn,
1788
- )
1789
- rejected_df = pd.read_sql_query(
1790
- "SELECT id, topic, question, reason, rejected_by, created_at, active FROM rejected_question_patterns ORDER BY created_at DESC",
1791
- conn,
1792
- )
1793
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1794
 
1795
- st.dataframe(approved_df, use_container_width=True)
1796
- with st.expander("Rejected question memory"):
 
1797
  st.dataframe(rejected_df, use_container_width=True)
 
 
1798
 
1799
 
1800
  def teacher_mode() -> None:
@@ -1875,12 +2148,25 @@ The source boost is applied only when a passage meets a minimum semantic-relevan
1875
  )
1876
  st.code(json.dumps(SOURCE_PRIORITY, indent=2), language="json")
1877
 
1878
- images = load_image_manifest()
1879
- st.metric("Approved image records", len([x for x in images if x.get("approved")]))
1880
- if images:
1881
- st.dataframe(pd.DataFrame(images), use_container_width=True)
1882
- else:
1883
- st.info("No image manifest records found. Use src/medical_images_manifest.json.")
 
 
 
 
 
 
 
 
 
 
 
 
 
1884
 
1885
  st.markdown("### RAG build status")
1886
  missing = [p for p in [CHUNKS_PATH, TOKENS_PATH, EMBED_PATH, CONFIG_PATH] if not os.path.exists(p)]
 
7
  import hashlib
8
  import uuid
9
  import base64
10
+ from difflib import SequenceMatcher
11
  from datetime import datetime
12
  from typing import Dict, List, Any, Optional, Tuple
13
 
 
31
  CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
32
  QUESTION_BANK_FILE = os.path.join(BASE_DIR, "exam_questions_pmqs.json")
33
  LOGO_FILE = os.path.join(BASE_DIR, "logo.png")
 
 
 
34
  DB_PATH = os.getenv("BRAINCHAT_DB", "brainchat.db")
35
  OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
36
  OPENAI_IMAGE_MODEL = os.getenv("OPENAI_IMAGE_MODEL", "gpt-image-1")
37
+ ENABLE_AI_IMAGES = os.getenv("ENABLE_AI_IMAGES", "true").lower() == "true"
38
  TEACHER_PASSWORD = os.getenv("TEACHER_PASSWORD", "teacher123")
39
 
40
  TOPICS = [
 
300
  )
301
  """)
302
 
303
+ cur.execute("""
304
+ CREATE TABLE IF NOT EXISTS feedback_rules (
305
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
306
+ topic TEXT,
307
+ rule_text TEXT,
308
+ source_question_id TEXT,
309
+ decision_type TEXT,
310
+ created_by TEXT,
311
+ created_at TEXT,
312
+ active INTEGER DEFAULT 1
313
+ )
314
+ """)
315
+
316
  # Migrate older installations without deleting data.
317
  ensure_column(conn, "quiz_attempts", "source_refs_json", "TEXT")
318
  ensure_column(conn, "quiz_attempts", "source_mix_json", "TEXT")
 
696
 
697
  def is_rejected_or_too_similar(question: str, rejected: List[Dict[str, Any]]) -> bool:
698
  q_hash = question_hash(question)
699
+ normalised = normalise_question_text(question)
700
  for item in rejected:
701
+ rejected_question = item.get("question", "")
702
  if item.get("question_hash") == q_hash:
703
  return True
704
+ if token_jaccard(question, rejected_question) >= 0.68:
705
+ return True
706
+ if SequenceMatcher(None, normalised, normalise_question_text(rejected_question)).ratio() >= 0.86:
707
  return True
708
  return False
709
 
710
 
711
+ def load_feedback_rules(topic: str, limit: int = 30) -> List[Dict[str, Any]]:
712
+ conn = get_conn()
713
+ rows = conn.execute("""
714
+ SELECT id, topic, rule_text, source_question_id, decision_type, created_by, created_at
715
+ FROM feedback_rules
716
+ WHERE active=1 AND (topic=? OR topic='General Neurology')
717
+ ORDER BY created_at DESC
718
+ LIMIT ?
719
+ """, (topic, limit)).fetchall()
720
+ conn.close()
721
+ return [dict(r) for r in rows]
722
+
723
+
724
+ def save_feedback_rule(
725
+ topic: str,
726
+ rule_text: str,
727
+ question_id: str,
728
+ decision_type: str,
729
+ reviewer: str,
730
+ ) -> None:
731
+ cleaned = re.sub(r"\s+", " ", (rule_text or "").strip())
732
+ if not cleaned:
733
+ return
734
+ conn = get_conn()
735
+ duplicate = conn.execute("""
736
+ SELECT id FROM feedback_rules
737
+ WHERE active=1 AND topic=? AND lower(rule_text)=lower(?)
738
+ """, (topic, cleaned)).fetchone()
739
+ if not duplicate:
740
+ conn.execute("""
741
+ INSERT INTO feedback_rules(
742
+ topic, rule_text, source_question_id, decision_type,
743
+ created_by, created_at, active
744
+ ) VALUES (?, ?, ?, ?, ?, ?, 1)
745
+ """, (topic, cleaned, question_id, decision_type, reviewer, now_iso()))
746
+ conn.commit()
747
+ conn.close()
748
+
749
+
750
+ def load_generated_questions_df(limit: int = 500) -> pd.DataFrame:
751
+ conn = get_conn()
752
+ try:
753
+ return pd.read_sql_query("""
754
+ SELECT question_id, topic, difficulty, question, options_json,
755
+ correct_option, explanation, subtopic, source_refs_json,
756
+ status, created_at, student_id, language
757
+ FROM generated_questions
758
+ ORDER BY created_at DESC
759
+ LIMIT ?
760
+ """, conn, params=(limit,))
761
+ finally:
762
+ conn.close()
763
+
764
+
765
  def load_approved_questions(topic: str, difficulty: str, limit: int = 12) -> List[Dict[str, Any]]:
766
  conn = get_conn()
767
  rows = conn.execute("""
 
813
  reporter_id: str,
814
  issue_type: str,
815
  comment: str,
816
+ ) -> bool:
817
  qid = question.get("question_id") or str(uuid.uuid4())
818
  conn = get_conn()
819
 
 
826
  subtopic, source_refs_json, question_hash, status, created_at
827
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
828
  """, (
829
+ qid, reporter_id, question.get("language", ""),
830
+ question.get("topic", "General Neurology"), question.get("difficulty", "Any"),
831
  question.get("question", ""), json.dumps(question.get("options", []), ensure_ascii=False),
832
  question.get("correct_option", "A"), question.get("explanation", ""),
833
  question.get("subtopic", ""), json.dumps(question.get("source_refs", []), ensure_ascii=False),
 
836
  else:
837
  conn.execute("UPDATE generated_questions SET status='flagged' WHERE question_id=?", (qid,))
838
 
839
+ pending = conn.execute("""
840
+ SELECT id FROM question_reviews
841
+ WHERE question_id=? AND review_status='pending'
842
+ """, (qid,)).fetchone()
843
+ if pending:
844
+ conn.commit()
845
+ conn.close()
846
+ return False
847
+
848
  conn.execute("""
849
  INSERT INTO question_reviews(
850
  question_id, reporter_type, reporter_id, issue_type,
 
853
  """, (qid, reporter_type, reporter_id, issue_type, comment, now_iso()))
854
  conn.commit()
855
  conn.close()
856
+ return True
857
 
858
 
859
  def load_pending_reviews() -> pd.DataFrame:
 
939
  corrected_explanation, reviewer, now_iso(), review_id,
940
  ))
941
  conn.execute("UPDATE generated_questions SET status='approved' WHERE question_id=?", (question_id,))
942
+
943
+ original_changed = (
944
+ normalise_question_text(row["question"]) != normalise_question_text(corrected_question)
945
+ or row["correct_option"] != corrected_answer
946
+ or json.loads(row["options_json"] or "[]") != corrected_options
947
+ or (row["explanation"] or "").strip() != (corrected_explanation or "").strip()
948
+ )
949
+ if original_changed:
950
+ reason = professor_comment.strip() or "Original formulation replaced by a professor-corrected version."
951
+ duplicate = conn.execute("""
952
+ SELECT id FROM rejected_question_patterns
953
+ WHERE active=1 AND question_hash=?
954
+ """, (row["question_hash"],)).fetchone()
955
+ if not duplicate:
956
+ conn.execute("""
957
+ INSERT INTO rejected_question_patterns(
958
+ question_id, topic, question, question_hash, reason,
959
+ rejected_by, created_at, active
960
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
961
+ """, (
962
+ question_id, row["topic"], row["question"], row["question_hash"],
963
+ reason, reviewer, now_iso(),
964
+ ))
965
+
966
  conn.commit()
967
  conn.close()
968
+ save_feedback_rule(row["topic"], professor_comment, question_id, "approved_correction", reviewer)
969
 
970
 
971
  def reject_review(
 
980
  conn.close()
981
  raise ValueError("Generated question not found.")
982
 
983
+ duplicate = conn.execute("""
984
+ SELECT id FROM rejected_question_patterns
985
+ WHERE active=1 AND question_hash=?
986
+ """, (row["question_hash"],)).fetchone()
987
+ if not duplicate:
988
+ conn.execute("""
989
+ INSERT INTO rejected_question_patterns(
990
+ question_id, topic, question, question_hash, reason,
991
+ rejected_by, created_at, active
992
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
993
+ """, (
994
+ question_id, row["topic"], row["question"], row["question_hash"],
995
+ reason, reviewer, now_iso(),
996
+ ))
997
  conn.execute("""
998
  UPDATE question_reviews SET
999
  professor_comment=?, review_status='rejected', reviewer=?, reviewed_at=?
 
1003
  conn.execute("UPDATE approved_questions SET active=0 WHERE question_id=?", (question_id,))
1004
  conn.commit()
1005
  conn.close()
1006
+ save_feedback_rule(row["topic"], reason, question_id, "rejected", reviewer)
1007
+
1008
 
1009
  # =====================================================
1010
  # AI HELPERS
 
1090
  approved = load_approved_questions(topic, difficulty, limit=n_questions)
1091
  approved_items = [approved_to_quiz_item(x) for x in approved]
1092
  rejected = load_rejected_patterns(topic)
1093
+ feedback_rules = load_feedback_rules(topic)
1094
 
1095
  # Use some approved questions directly so professor corrections affect the next quiz immediately.
1096
  final_questions = approved_items[: min(len(approved_items), max(1, n_questions // 2))]
 
1126
  {"question": x.get("question", ""), "reason": x.get("reason", "")}
1127
  for x in rejected[:10]
1128
  ]
1129
+ professor_rules = [x.get("rule_text", "") for x in feedback_rules if x.get("rule_text")]
1130
 
1131
  lang_instruction = "Write everything in English." if language == "English" else "Escribe todo en español."
1132
  requested = needed + 4
 
1161
  Rejected patterns and reasons:
1162
  {json.dumps(rejected_guidance, ensure_ascii=False)[:5000]}
1163
 
1164
+ Professor feedback rules learned from earlier reviews:
1165
+ {json.dumps(professor_rules, ensure_ascii=False)[:5000]}
1166
+
1167
  Course context:
1168
  {context}
1169
  """
 
1342
  return "", source_refs, source_mix, str(exc)
1343
 
1344
 
1345
+ def generate_ai_medical_image(
1346
+ topic: str,
1347
+ depth_level: str,
1348
+ language: str,
1349
+ visual_focus: str,
1350
+ visual_style: str,
1351
+ ) -> Tuple[Optional[bytes], List[Dict[str, Any]], Dict[str, float], str, Optional[str]]:
1352
+ records, rag_error = search_hybrid(
1353
+ f"{topic} {visual_focus} anatomy mechanism diagnosis educational illustration",
1354
+ final_k=7,
1355
+ )
1356
+ source_refs = compact_source_refs(records)
1357
+ source_mix = calculate_source_mix(records)
1358
+ context = build_context(records)
1359
+
1360
+ if rag_error:
1361
+ return None, source_refs, source_mix, "", rag_error
1362
  if not ENABLE_AI_IMAGES:
1363
+ return None, source_refs, source_mix, "", (
1364
+ "AI image generation is disabled. Set ENABLE_AI_IMAGES=true to enable it."
1365
+ )
1366
+
1367
  client = get_client()
1368
  if client is None:
1369
+ return None, source_refs, source_mix, "", "OPENAI_API_KEY is missing."
1370
+
1371
+ label_language = "English" if language == "English" else "Spanish"
1372
+ focus = visual_focus.strip() or topic
1373
+ brief_prompt = f"""
1374
+ You are preparing a source-grounded prompt for a medical education image generator.
1375
+
1376
+ Topic: {topic}
1377
+ Requested focus: {focus}
1378
+ Learner level: {depth_level}
1379
+ Visual style: {visual_style}
1380
+ Label language: {label_language}
1381
+
1382
+ Create one concise image-generation brief. Use only facts supported by the supplied course context.
1383
+ The image must be educational, uncluttered and medically cautious. Prefer a simplified labelled mechanism, anatomy overview or process illustration. Avoid exact medication doses, diagnostic certainty, photorealistic patients, identifiable people and decorative imagery. Use minimal text because image models may misspell labels. Do not include citations inside the image.
1384
+
1385
+ Course context:
1386
+ {context}
1387
+ """
1388
+
1389
+ try:
1390
+ brief_response = client.chat.completions.create(
1391
+ model=OPENAI_MODEL,
1392
+ messages=[{"role": "user", "content": brief_prompt}],
1393
+ temperature=0.10,
1394
+ )
1395
+ visual_brief = (brief_response.choices[0].message.content or "").strip()
1396
+ except Exception:
1397
+ visual_brief = (
1398
+ f"Create a clean {visual_style.lower()} for {focus} within {topic}, suitable for "
1399
+ f"{depth_level.lower()} medical learners, using {label_language} labels."
1400
+ )
1401
+
1402
+ final_prompt = (
1403
+ f"{visual_brief} White background, clear hierarchy, high-resolution educational medical illustration. "
1404
+ "No patient-identifying features. No diagnosis claim. No exact dosage. No decorative border. "
1405
+ "Use only a few large, legible labels. The output is an educational illustration, not a diagnostic image."
1406
  )
1407
+
1408
  try:
1409
  response = client.images.generate(
1410
  model=OPENAI_IMAGE_MODEL,
1411
+ prompt=final_prompt,
1412
  size="1024x1024",
1413
  )
1414
  item = response.data[0]
1415
  if getattr(item, "b64_json", None):
1416
+ return base64.b64decode(item.b64_json), source_refs, source_mix, visual_brief, None
1417
+ return None, source_refs, source_mix, visual_brief, "The image API returned no image data."
1418
  except Exception as exc:
1419
+ return None, source_refs, source_mix, visual_brief, str(exc)
1420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1421
 
1422
  # =====================================================
1423
  # BADGES AND REPORTS
 
1585
  "Other",
1586
  ]
1587
 
1588
+
1589
+ def professor_issue_options() -> List[str]:
1590
+ return [
1591
+ "Incorrect correct answer",
1592
+ "Ambiguous wording",
1593
+ "More than one defensible answer",
1594
+ "Weak or implausible distractors",
1595
+ "Incorrect or incomplete explanation",
1596
+ "Unsupported by Neurology Guiones",
1597
+ "Wrong difficulty level",
1598
+ "Duplicate or near-duplicate question",
1599
+ "Other",
1600
+ ]
1601
+
1602
  # =====================================================
1603
  # STUDENT MODE
1604
  # =====================================================
 
1630
  "Structured outline",
1631
  "Concept map",
1632
  "Clinical decision pathway",
 
1633
  "AI-generated educational image",
1634
  ]
1635
  else:
 
1641
  "Esquema estructurado",
1642
  "Mapa conceptual",
1643
  "Ruta de decisión clínica",
 
1644
  "Imagen educativa generada por IA",
1645
  ]
1646
  tutor_activity = st.selectbox(t("activity"), activities)
 
1648
  free_question = tutor_activity in ["Free question", "Pregunta libre"]
1649
  q = st.text_area(t("ask_question"), height=120) if free_question else ""
1650
 
1651
+ if tutor_activity in ["AI-generated educational image", "Imagen educativa generada por IA"]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1652
  st.warning(
1653
+ "AI-generated educational illustration. It may contain inaccuracies and must not be used for diagnosis or exact anatomical measurement."
1654
+ if st.session_state["language"] == "English"
1655
+ else "Ilustración educativa generada por IA. Puede contener inexactitudes y no debe utilizarse para diagnóstico ni mediciones anatómicas exactas."
1656
+ )
1657
+ visual_focus = st.text_input(
1658
+ "What should the image show?" if st.session_state["language"] == "English"
1659
+ else "¿Qué debe mostrar la imagen?",
1660
+ value=topic,
1661
+ key="ai_visual_focus",
1662
+ )
1663
+ visual_style = st.selectbox(
1664
+ "Image format" if st.session_state["language"] == "English" else "Formato de imagen",
1665
+ ["Labelled medical illustration", "Mechanism diagram", "Clinical infographic"],
1666
+ key="ai_visual_style",
1667
  )
1668
  if st.button("Generate educational image", key="generate_ai_image"):
1669
+ with st.spinner("Generating source-grounded educational illustration..."):
1670
+ image_bytes, refs, mix, visual_brief, image_error = generate_ai_medical_image(
1671
+ topic, depth_level, st.session_state["language"], visual_focus, visual_style
1672
+ )
1673
  if image_error:
1674
  st.error(image_error)
1675
  elif image_bytes:
1676
+ st.image(
1677
+ image_bytes,
1678
+ caption=f"AI-generated educational illustration: {visual_focus}",
1679
+ use_container_width=True,
1680
+ )
1681
+ with st.expander("Image-generation brief", expanded=False):
1682
+ st.write(visual_brief)
1683
+ display_source_mix(mix)
1684
+ display_sources(refs)
1685
 
1686
  elif tutor_activity in ["Concept map", "Mapa conceptual", "Clinical decision pathway", "Ruta de decisión clínica"]:
1687
  visual_type = "concept map" if tutor_activity in ["Concept map", "Mapa conceptual"] else "clinical decision pathway"
 
1815
  key=f"comment_{item.get('question_id', i)}",
1816
  )
1817
  if st.button("Send to professor", key=f"flag_{item.get('question_id', i)}"):
1818
+ created = create_question_review(item, "student", sid, issue_type, comment)
1819
+ if created:
1820
+ st.success("Question sent for professor review.")
1821
+ else:
1822
+ st.info("This question is already waiting for professor review.")
1823
 
1824
  display_source_mix(st.session_state.get("quiz_source_mix", {}))
1825
  display_sources(st.session_state.get("quiz_source_refs", []))
 
1854
  # TEACHER MODE
1855
  # =====================================================
1856
  def render_question_review_tab() -> None:
1857
+ st.subheader("Human-in-the-loop Question Improvement")
1858
  st.caption(
1859
+ "A professor can flag any generated question, correct and approve it, or reject it. "
1860
+ "Approved corrections become trusted examples. Rejected questions and professor rules are used to block similar future errors."
1861
  )
1862
 
1863
  reviewer = st.text_input("Reviewer name", value=st.session_state.get("reviewer_name", "Professor"))
1864
  st.session_state["reviewer_name"] = reviewer
 
 
 
 
1865
 
1866
+ pending = load_pending_reviews()
1867
  conn = get_conn()
1868
  approved_count = conn.execute("SELECT COUNT(*) FROM approved_questions WHERE active=1").fetchone()[0]
1869
  rejected_count = conn.execute("SELECT COUNT(*) FROM rejected_question_patterns WHERE active=1").fetchone()[0]
1870
+ rule_count = conn.execute("SELECT COUNT(*) FROM feedback_rules WHERE active=1").fetchone()[0]
1871
  conn.close()
1872
+
1873
+ c1, c2, c3, c4 = st.columns(4)
1874
+ c1.metric("Pending reviews", len(pending))
1875
  c2.metric("Approved questions", approved_count)
1876
  c3.metric("Rejected patterns", rejected_count)
1877
+ c4.metric("Professor rules", rule_count)
1878
 
1879
+ pending_tab, history_tab, memory_tab = st.tabs([
1880
+ "Pending Corrections", "Generated Question History", "Learning Memory"
1881
+ ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1882
 
1883
+ with pending_tab:
1884
+ if pending.empty:
1885
+ st.success("No pending question reviews.")
1886
+ else:
1887
+ review_labels = [
1888
+ f"#{int(row.review_id)} | {row.topic} | {str(row.question)[:75]}"
1889
+ for row in pending.itertuples()
1890
+ ]
1891
+ selected_label = st.selectbox("Select review", review_labels, key="pending_review_select")
1892
+ selected_index = review_labels.index(selected_label)
1893
+ row = pending.iloc[selected_index]
1894
+
1895
+ options = json.loads(row["options_json"] or "[]")
1896
+ source_refs = json.loads(row["source_refs_json"] or "[]")
1897
+
1898
+ st.markdown(f"### Original question\n{row['question']}")
1899
+ st.write("Original options:")
1900
+ for option in options:
1901
+ st.write(option)
1902
+ st.markdown(f"**Current correct answer:** {row['correct_option']}")
1903
+ st.markdown(f"**Current explanation:** {row['explanation']}")
1904
+ st.warning(
1905
+ f"Reported by {row['reporter_type']}: {row['issue_type']} — "
1906
+ f"{row['reporter_comment'] or 'No comment provided'}"
1907
  )
1908
+ display_sources(source_refs)
1909
 
1910
+ st.markdown("### Professor correction")
1911
+ corrected_question = st.text_area(
1912
+ "Corrected question", value=row["question"], key=f"corrected_q_{row['review_id']}"
1913
+ )
1914
+ corrected_options = []
1915
+ for i in range(5):
1916
+ default = options[i] if i < len(options) else f"{chr(65+i)}. "
1917
+ corrected_options.append(
1918
+ st.text_input(
1919
+ f"Option {chr(65+i)}", value=default,
1920
+ key=f"corrected_opt_{row['review_id']}_{i}",
1921
+ )
1922
+ )
1923
 
1924
+ answer_index = "ABCDE".find(str(row["correct_option"]).upper())
1925
+ corrected_answer = st.selectbox(
1926
+ "Correct answer", list("ABCDE"), index=max(answer_index, 0),
1927
+ key=f"corrected_answer_{row['review_id']}",
1928
+ )
1929
+ corrected_explanation = st.text_area(
1930
+ "Corrected explanation", value=row["explanation"],
1931
+ key=f"corrected_exp_{row['review_id']}",
1932
+ )
1933
+ professor_comment = st.text_area(
1934
+ "Professor rule or reason",
1935
+ placeholder="Example: Avoid absolute wording such as 'always'; treatment depends on seizure type and contraindications.",
1936
+ key=f"prof_comment_{row['review_id']}",
1937
+ )
1938
+ st.caption(
1939
+ "This comment is saved as a reusable rule for future question generation. "
1940
+ "Use a general instruction, not only a description of this single question."
1941
+ )
1942
+
1943
+ b1, b2 = st.columns(2)
1944
+ with b1:
1945
+ if st.button("Correct and approve", type="primary", key=f"approve_{row['review_id']}"):
1946
+ if not corrected_question.strip() or any(not x.strip() for x in corrected_options):
1947
+ st.error("Question and all five options are required.")
1948
+ else:
1949
+ approve_review(
1950
+ int(row["review_id"]), row["question_id"], reviewer or "Professor",
1951
+ professor_comment, corrected_question, corrected_options,
1952
+ corrected_answer, corrected_explanation,
1953
+ )
1954
+ st.success("Correction approved. Future quizzes will use it as a trusted example.")
1955
+ st.rerun()
1956
+ with b2:
1957
+ if st.button("Reject and block pattern", key=f"reject_{row['review_id']}"):
1958
+ reason = professor_comment or row["issue_type"] or "Rejected by professor"
1959
+ reject_review(
1960
+ int(row["review_id"]), row["question_id"],
1961
+ reviewer or "Professor", reason,
1962
  )
1963
+ st.success("Question rejected. Its question pattern and professor rule are now blocked in future generation.")
1964
  st.rerun()
1965
+
1966
+ with history_tab:
1967
+ st.markdown("### Professor direct flagging")
1968
+ st.caption(
1969
+ "Use this screen to flag a poorly formulated question even when no student has reported it."
1970
+ )
1971
+ history = load_generated_questions_df()
1972
+ if history.empty:
1973
+ st.info("No generated questions have been stored yet.")
1974
+ else:
1975
+ f1, f2 = st.columns(2)
1976
+ with f1:
1977
+ topic_filter = st.selectbox(
1978
+ "Filter topic", ["All"] + TOPICS, key="history_topic_filter"
1979
+ )
1980
+ with f2:
1981
+ status_values = sorted(history["status"].fillna("unreviewed").unique().tolist())
1982
+ status_filter = st.selectbox(
1983
+ "Filter status", ["All"] + status_values, key="history_status_filter"
1984
  )
 
 
1985
 
1986
+ filtered = history.copy()
1987
+ if topic_filter != "All":
1988
+ filtered = filtered[filtered["topic"] == topic_filter]
1989
+ if status_filter != "All":
1990
+ filtered = filtered[filtered["status"] == status_filter]
1991
+
1992
+ if filtered.empty:
1993
+ st.info("No questions match the selected filters.")
1994
+ else:
1995
+ labels = [
1996
+ f"{r.created_at} | {r.topic} | {r.status} | {str(r.question)[:80]}"
1997
+ for r in filtered.itertuples()
1998
+ ]
1999
+ chosen = st.selectbox("Select generated question", labels, key="history_question_select")
2000
+ row = filtered.iloc[labels.index(chosen)]
2001
+ options = json.loads(row["options_json"] or "[]")
2002
+ refs = json.loads(row["source_refs_json"] or "[]")
2003
+
2004
+ st.markdown(f"### {row['question']}")
2005
+ for option in options:
2006
+ st.write(option)
2007
+ st.markdown(f"**Correct answer:** {row['correct_option']}")
2008
+ st.markdown(f"**Explanation:** {row['explanation']}")
2009
+ st.caption(
2010
+ f"Topic: {row['topic']} | Difficulty: {row['difficulty']} | Status: {row['status']}"
2011
+ )
2012
+ display_sources(refs)
2013
+
2014
+ issue = st.selectbox(
2015
+ "Why is this question poor?", professor_issue_options(),
2016
+ key="professor_direct_issue",
2017
+ )
2018
+ comment = st.text_area(
2019
+ "Initial correction note",
2020
+ placeholder="Describe the error and the quality rule that future questions should follow.",
2021
+ key="professor_direct_comment",
2022
+ )
2023
+ if st.button("Flag for correction", type="primary", key="professor_direct_flag"):
2024
+ item = {
2025
+ "question_id": row["question_id"],
2026
+ "topic": row["topic"],
2027
+ "difficulty": row["difficulty"],
2028
+ "language": row["language"],
2029
+ "question": row["question"],
2030
+ "options": options,
2031
+ "correct_option": row["correct_option"],
2032
+ "explanation": row["explanation"],
2033
+ "subtopic": row["subtopic"],
2034
+ "source_refs": refs,
2035
+ }
2036
+ created = create_question_review(
2037
+ item, "professor", reviewer or "Professor", issue, comment
2038
+ )
2039
+ if created:
2040
+ st.success("Question added to Pending Corrections.")
2041
+ else:
2042
+ st.info("This question is already waiting for review.")
2043
+
2044
+ with memory_tab:
2045
+ st.markdown("### Supervised learning memory")
2046
+ st.info(
2047
+ "This is immediate human-in-the-loop learning through retrieval and filtering, not automatic model fine-tuning. "
2048
+ "Approved questions are reused directly and as examples; rejected questions are blocked; professor comments become generation rules."
2049
+ )
2050
+ conn = get_conn()
2051
+ approved_df = pd.read_sql_query(
2052
+ "SELECT id, question_id, topic, difficulty, question, correct_option, approved_by, approved_at, active FROM approved_questions ORDER BY approved_at DESC",
2053
+ conn,
2054
+ )
2055
+ rejected_df = pd.read_sql_query(
2056
+ "SELECT id, topic, question, reason, rejected_by, created_at, active FROM rejected_question_patterns ORDER BY created_at DESC",
2057
+ conn,
2058
+ )
2059
+ rules_df = pd.read_sql_query(
2060
+ "SELECT id, topic, rule_text, decision_type, created_by, created_at, active FROM feedback_rules ORDER BY created_at DESC",
2061
+ conn,
2062
+ )
2063
+ conn.close()
2064
 
2065
+ st.markdown("#### Approved question bank")
2066
+ st.dataframe(approved_df, use_container_width=True)
2067
+ st.markdown("#### Rejected question memory")
2068
  st.dataframe(rejected_df, use_container_width=True)
2069
+ st.markdown("#### Professor feedback rules")
2070
+ st.dataframe(rules_df, use_container_width=True)
2071
 
2072
 
2073
  def teacher_mode() -> None:
 
2148
  )
2149
  st.code(json.dumps(SOURCE_PRIORITY, indent=2), language="json")
2150
 
2151
+ st.markdown("### AI-generated visual content")
2152
+ c1, c2 = st.columns(2)
2153
+ c1.metric("AI image generation", "Enabled" if ENABLE_AI_IMAGES else "Disabled")
2154
+ c2.metric("Image model", OPENAI_IMAGE_MODEL)
2155
+ st.caption(
2156
+ "No medical image folder or manifest is required. Each AI image request is prepared from retrieved course passages and displayed with its supporting-source composition."
2157
+ )
2158
+
2159
+ st.markdown("### Question-learning protocol")
2160
+ st.markdown(
2161
+ """
2162
+ 1. A student or professor flags a question.
2163
+ 2. The professor corrects and approves it, or rejects it.
2164
+ 3. Corrected questions enter the approved bank and are reused directly and as examples.
2165
+ 4. Rejected questions are blocked through exact-hash, word-overlap and near-text similarity checks.
2166
+ 5. Professor comments become reusable generation rules.
2167
+ 6. The original formulation is blocked whenever the professor replaces or materially corrects it.
2168
+ """
2169
+ )
2170
 
2171
  st.markdown("### RAG build status")
2172
  missing = [p for p in [CHUNKS_PATH, TOKENS_PATH, EMBED_PATH, CONFIG_PATH] if not os.path.exists(p)]