Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- 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", "
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
) ->
|
| 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,
|
|
|
|
| 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 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1229 |
if not ENABLE_AI_IMAGES:
|
| 1230 |
-
return None,
|
|
|
|
|
|
|
|
|
|
| 1231 |
client = get_client()
|
| 1232 |
if client is None:
|
| 1233 |
-
return None, "OPENAI_API_KEY is missing."
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1241 |
)
|
|
|
|
| 1242 |
try:
|
| 1243 |
response = client.images.generate(
|
| 1244 |
model=OPENAI_IMAGE_MODEL,
|
| 1245 |
-
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 ["
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 1516 |
if image_error:
|
| 1517 |
st.error(image_error)
|
| 1518 |
elif image_bytes:
|
| 1519 |
-
st.image(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 1690 |
st.caption(
|
| 1691 |
-
"
|
|
|
|
| 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 |
-
|
| 1709 |
-
|
| 1710 |
-
|
| 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 |
-
|
| 1737 |
-
|
| 1738 |
-
|
| 1739 |
-
|
| 1740 |
-
|
| 1741 |
-
|
| 1742 |
-
|
| 1743 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1744 |
)
|
|
|
|
| 1745 |
|
| 1746 |
-
|
| 1747 |
-
|
| 1748 |
-
|
| 1749 |
-
|
| 1750 |
-
|
| 1751 |
-
|
| 1752 |
-
|
| 1753 |
-
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
-
|
| 1757 |
-
|
|
|
|
| 1758 |
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
|
| 1762 |
-
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
|
| 1766 |
-
|
| 1767 |
-
|
| 1768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1769 |
)
|
| 1770 |
-
st.success("
|
| 1771 |
st.rerun()
|
| 1772 |
-
|
| 1773 |
-
|
| 1774 |
-
|
| 1775 |
-
|
| 1776 |
-
|
| 1777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1778 |
)
|
| 1779 |
-
st.success("Question rejected. Its pattern will be blocked in future generation.")
|
| 1780 |
-
st.rerun()
|
| 1781 |
|
| 1782 |
-
|
| 1783 |
-
|
| 1784 |
-
|
| 1785 |
-
|
| 1786 |
-
|
| 1787 |
-
|
| 1788 |
-
|
| 1789 |
-
|
| 1790 |
-
|
| 1791 |
-
|
| 1792 |
-
|
| 1793 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1794 |
|
| 1795 |
-
|
| 1796 |
-
|
|
|
|
| 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 |
-
|
| 1879 |
-
|
| 1880 |
-
if
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)]
|