Deevyankar commited on
Commit
670089f
·
verified ·
1 Parent(s): e09d22d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1568 -0
app.py ADDED
@@ -0,0 +1,1568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import pickle
5
+ import base64
6
+ import mimetypes
7
+ from datetime import datetime
8
+
9
+ import numpy as np
10
+ import gradio as gr
11
+ from openai import OpenAI
12
+ from rank_bm25 import BM25Okapi
13
+ from sentence_transformers import SentenceTransformer
14
+
15
+
16
+ # =====================================================
17
+ # CONFIG
18
+ # =====================================================
19
+ BUILD_DIR = "brainchat_build"
20
+ CHUNKS_PATH = os.path.join(BUILD_DIR, "chunks.pkl")
21
+ TOKENS_PATH = os.path.join(BUILD_DIR, "tokenized_chunks.pkl")
22
+ EMBED_PATH = os.path.join(BUILD_DIR, "embeddings.npy")
23
+ CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
24
+
25
+ LOGO_FILE = "logo.png"
26
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
27
+
28
+ BM25 = None
29
+ CHUNKS = None
30
+ EMBEDDINGS = None
31
+ EMBED_MODEL = None
32
+ CLIENT = None
33
+
34
+ ANALYTICS_LOG = []
35
+
36
+
37
+ # =====================================================
38
+ # LOADERS
39
+ # =====================================================
40
+ def tokenize(text: str):
41
+ return re.findall(r"\w+", text.lower(), flags=re.UNICODE)
42
+
43
+
44
+ def ensure_loaded():
45
+ global BM25, CHUNKS, EMBEDDINGS, EMBED_MODEL, CLIENT
46
+
47
+ if CHUNKS is None:
48
+ missing = []
49
+ for p in [CHUNKS_PATH, TOKENS_PATH, EMBED_PATH, CONFIG_PATH]:
50
+ if not os.path.exists(p):
51
+ missing.append(p)
52
+
53
+ if missing:
54
+ raise FileNotFoundError("Missing build files:\n" + "\n".join(missing))
55
+
56
+ with open(CHUNKS_PATH, "rb") as f:
57
+ CHUNKS = pickle.load(f)
58
+
59
+ with open(TOKENS_PATH, "rb") as f:
60
+ tokenized_chunks = pickle.load(f)
61
+
62
+ EMBEDDINGS = np.load(EMBED_PATH)
63
+
64
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
65
+ cfg = json.load(f)
66
+
67
+ BM25 = BM25Okapi(tokenized_chunks)
68
+ EMBED_MODEL = SentenceTransformer(cfg["embedding_model"])
69
+
70
+ if CLIENT is None:
71
+ api_key = os.getenv("OPENAI_API_KEY")
72
+ if not api_key:
73
+ raise ValueError("OPENAI_API_KEY is missing in Hugging Face Space Secrets.")
74
+ CLIENT = OpenAI(api_key=api_key)
75
+
76
+
77
+ # =====================================================
78
+ # SOURCE CLEANING AND PRIORITY
79
+ # =====================================================
80
+ def clean_source_name(book_name: str) -> str:
81
+ name = (book_name or "").strip()
82
+
83
+ if "ilovepdf" in name.lower() or "merged" in name.lower():
84
+ return "Professor Handouts"
85
+
86
+ if name.lower().endswith(".pdf"):
87
+ name = name[:-4]
88
+
89
+ return name or "Professor Handouts"
90
+
91
+
92
+ def prioritize_professor_handouts(records):
93
+ """
94
+ Professor Handouts are always shown and used first.
95
+ Other textbooks are used only as supporting material.
96
+ """
97
+ return sorted(
98
+ records,
99
+ key=lambda r: 0 if clean_source_name(r.get("book", "")) == "Professor Handouts" else 1
100
+ )
101
+
102
+
103
+ # =====================================================
104
+ # GENERAL CHAT
105
+ # =====================================================
106
+ def is_general_chat(text: str) -> bool:
107
+ t = text.lower().strip()
108
+
109
+ general_phrases = [
110
+ "hi",
111
+ "hello",
112
+ "hola",
113
+ "hey",
114
+ "good morning",
115
+ "good afternoon",
116
+ "good evening",
117
+ "thanks",
118
+ "thank you",
119
+ "gracias",
120
+ "ok",
121
+ "okay",
122
+ "who are you",
123
+ "what can you do",
124
+ "help"
125
+ ]
126
+
127
+ return t in general_phrases
128
+
129
+
130
+ def general_chat_reply(text: str, language_mode: str) -> str:
131
+ t = text.lower().strip()
132
+
133
+ if language_mode == "English":
134
+ return (
135
+ "Hello! I am BrainChat, your AI tutor for Neurology and PMQSN. "
136
+ "You can ask me to explain topics, create short notes, generate flashcards, "
137
+ "or test you with quiz questions. I first use Professor Handouts, "
138
+ "and then supporting textbooks if needed."
139
+ )
140
+
141
+ if language_mode == "Spanish":
142
+ return (
143
+ "¡Hola! Soy BrainChat, tu tutor de IA para Neurología y PMQSN. "
144
+ "Puedes pedirme explicaciones, apuntes breves, flashcards o preguntas tipo quiz. "
145
+ "Primero usaré los apuntes del profesor y, si es necesario, otros libros de apoyo."
146
+ )
147
+
148
+ if t in ["hola", "gracias"]:
149
+ return (
150
+ "¡Hola! Soy BrainChat, tu tutor de IA para Neurología y PMQSN. "
151
+ "Primero uso los apuntes del profesor y después otros libros de apoyo si es necesario."
152
+ )
153
+
154
+ return (
155
+ "Hello! I am BrainChat, your AI tutor for Neurology and PMQSN. "
156
+ "I first use Professor Handouts and then supporting textbooks if needed."
157
+ )
158
+
159
+
160
+ # =====================================================
161
+ # RETRIEVAL WITH SIMILARITY SCORES
162
+ # =====================================================
163
+ def search_hybrid(query: str, shortlist_k: int = 20, final_k: int = 4):
164
+ ensure_loaded()
165
+
166
+ q_tokens = tokenize(query)
167
+ bm25_scores = BM25.get_scores(q_tokens)
168
+
169
+ shortlist_idx = np.argsort(bm25_scores)[::-1][:shortlist_k]
170
+ shortlist_emb = EMBEDDINGS[shortlist_idx]
171
+
172
+ qvec = EMBED_MODEL.encode([query], normalize_embeddings=True).astype("float32")[0]
173
+ dense_scores = shortlist_emb @ qvec
174
+
175
+ rerank = np.argsort(dense_scores)[::-1][:final_k]
176
+ final_idx = shortlist_idx[rerank]
177
+ final_scores = dense_scores[rerank]
178
+
179
+ results = []
180
+
181
+ for idx, score in zip(final_idx, final_scores):
182
+ record = CHUNKS[int(idx)].copy()
183
+ record["similarity_score"] = float(score)
184
+ results.append(record)
185
+
186
+ return prioritize_professor_handouts(results)
187
+
188
+
189
+ def build_context(records):
190
+ blocks = []
191
+
192
+ records = prioritize_professor_handouts(records)
193
+
194
+ for i, r in enumerate(records, start=1):
195
+ clean_book = clean_source_name(r.get("book", ""))
196
+
197
+ blocks.append(
198
+ f"""[Source {i}]
199
+ Book: {clean_book}
200
+ Source priority: {"Primary Professor Handouts" if clean_book == "Professor Handouts" else "Supporting textbook"}
201
+ Section: {r.get('section_title','')}
202
+ Pages: {r.get('page_start','')}-{r.get('page_end','')}
203
+ Similarity Score: {r.get('similarity_score', 0):.3f}
204
+ Text:
205
+ {r.get('text','')}"""
206
+ )
207
+
208
+ return "\n\n".join(blocks)
209
+
210
+
211
+ def make_sources(records):
212
+ seen = set()
213
+ lines = []
214
+
215
+ records = prioritize_professor_handouts(records)
216
+
217
+ for r in records:
218
+ clean_book = clean_source_name(r.get("book", ""))
219
+
220
+ key = (
221
+ clean_book,
222
+ r.get("section_title"),
223
+ r.get("page_start"),
224
+ r.get("page_end"),
225
+ )
226
+
227
+ if key in seen:
228
+ continue
229
+
230
+ seen.add(key)
231
+
232
+ section = r.get("section_title", "Course Material")
233
+ page_start = r.get("page_start", "")
234
+ page_end = r.get("page_end", "")
235
+ score = r.get("similarity_score", 0)
236
+
237
+ if page_start and page_end and page_start != page_end:
238
+ page_text = f"pages {page_start}-{page_end}"
239
+ elif page_start:
240
+ page_text = f"page {page_start}"
241
+ else:
242
+ page_text = "page not specified"
243
+
244
+ if clean_book == "Professor Handouts":
245
+ source_type = "Primary source"
246
+ else:
247
+ source_type = "Supporting textbook"
248
+
249
+ lines.append(
250
+ f"• {clean_book} ({source_type}) | {section} | {page_text} | similarity: {score:.2f}"
251
+ )
252
+
253
+ return "\n".join(lines)
254
+
255
+
256
+ # =====================================================
257
+ # CONFIDENCE LOGIC
258
+ # =====================================================
259
+ def is_not_found_answer(answer: str) -> bool:
260
+ a = (answer or "").lower().strip()
261
+
262
+ return (
263
+ "not found in the course material" in a
264
+ or "no encontrado en el material del curso" in a
265
+ or "no se encontró información" in a
266
+ or a == "no encontrado"
267
+ )
268
+
269
+
270
+ def compute_confidence(records, answer: str):
271
+ if is_not_found_answer(answer):
272
+ return {
273
+ "level": "red",
274
+ "label": "Not found",
275
+ "score": 0.0,
276
+ }
277
+
278
+ scores = [float(r.get("similarity_score", 0)) for r in records]
279
+
280
+ if not scores:
281
+ return {
282
+ "level": "red",
283
+ "label": "Not found",
284
+ "score": 0.0,
285
+ }
286
+
287
+ top_score = max(scores)
288
+ avg_score = sum(scores) / len(scores)
289
+
290
+ if top_score >= 0.55 and avg_score >= 0.38:
291
+ return {
292
+ "level": "green",
293
+ "label": "High confidence",
294
+ "score": top_score,
295
+ }
296
+
297
+ if top_score >= 0.38:
298
+ return {
299
+ "level": "orange",
300
+ "label": "Medium confidence",
301
+ "score": top_score,
302
+ }
303
+
304
+ return {
305
+ "level": "red",
306
+ "label": "Low confidence",
307
+ "score": top_score,
308
+ }
309
+
310
+
311
+ def confidence_html(conf):
312
+ color_map = {
313
+ "green": "#16a34a",
314
+ "orange": "#f97316",
315
+ "red": "#dc2626",
316
+ }
317
+
318
+ color = color_map.get(conf["level"], "#999999")
319
+
320
+ return f"""
321
+ <div class="bc-confidence">
322
+ <span class="bc-dot" style="background:{color};"></span>
323
+ <span><strong>{conf['label']}</strong> — similarity score: {conf['score']:.2f}</span>
324
+ </div>
325
+ """
326
+
327
+
328
+ # =====================================================
329
+ # ANALYTICS DASHBOARD
330
+ # =====================================================
331
+ def log_event(event_type, mode, language, confidence_level, similarity, query):
332
+ ANALYTICS_LOG.append({
333
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
334
+ "event": event_type,
335
+ "mode": mode,
336
+ "language": language,
337
+ "confidence": confidence_level,
338
+ "similarity": round(float(similarity), 3),
339
+ "query": query[:120],
340
+ })
341
+
342
+
343
+ def render_dashboard():
344
+ total = len(ANALYTICS_LOG)
345
+
346
+ if total == 0:
347
+ return """
348
+ <div class="bc-dashboard">
349
+ <div class="bc-dashboard-grid">
350
+ <div>
351
+ <h3>Progress Analytics Dashboard</h3>
352
+ <p>No interactions recorded yet.</p>
353
+ </div>
354
+ <div class="bc-dashboard-help">
355
+ <h4>What this dashboard shows</h4>
356
+ <p>This dashboard summarizes how students are using BrainChat.</p>
357
+ <p><strong>Total interactions:</strong> number of questions or quiz actions.</p>
358
+ <p><strong>High confidence:</strong> answers strongly supported by course material.</p>
359
+ <p><strong>Medium confidence:</strong> answers with partial support.</p>
360
+ <p><strong>Low / Not found:</strong> questions not clearly supported by the material.</p>
361
+ <p><strong>Average similarity:</strong> how closely the retrieved material matches the question.</p>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ """
366
+
367
+ green = sum(1 for x in ANALYTICS_LOG if x["confidence"] == "green")
368
+ orange = sum(1 for x in ANALYTICS_LOG if x["confidence"] == "orange")
369
+ red = sum(1 for x in ANALYTICS_LOG if x["confidence"] == "red")
370
+ quizzes = sum(1 for x in ANALYTICS_LOG if x["event"] in ["quiz_generated", "quiz_evaluated"])
371
+ avg_sim = sum(x["similarity"] for x in ANALYTICS_LOG) / total
372
+
373
+ recent_rows = ""
374
+
375
+ for item in ANALYTICS_LOG[-8:][::-1]:
376
+ recent_rows += f"""
377
+ <tr>
378
+ <td>{item['time']}</td>
379
+ <td>{item['event']}</td>
380
+ <td>{item['mode']}</td>
381
+ <td><span class="bc-pill bc-{item['confidence']}">{item['confidence']}</span></td>
382
+ <td>{item['similarity']}</td>
383
+ <td>{item['query']}</td>
384
+ </tr>
385
+ """
386
+
387
+ return f"""
388
+ <div class="bc-dashboard">
389
+ <div class="bc-dashboard-grid">
390
+ <div>
391
+ <h3>Progress Analytics Dashboard</h3>
392
+
393
+ <div class="bc-metrics">
394
+ <div class="bc-card total"><strong>{total}</strong><br>Total interactions</div>
395
+ <div class="bc-card green"><strong>{green}</strong><br>High confidence</div>
396
+ <div class="bc-card orange"><strong>{orange}</strong><br>Medium confidence</div>
397
+ <div class="bc-card red"><strong>{red}</strong><br>Low / Not found</div>
398
+ <div class="bc-card quiz"><strong>{quizzes}</strong><br>Quiz actions</div>
399
+ <div class="bc-card avg"><strong>{avg_sim:.2f}</strong><br>Avg similarity</div>
400
+ </div>
401
+ </div>
402
+
403
+ <div class="bc-dashboard-help">
404
+ <h4>What this dashboard shows</h4>
405
+ <p>This dashboard helps teachers monitor BrainChat usage and answer quality.</p>
406
+ <p><strong>🟢 High confidence:</strong> retrieved material strongly supports the answer.</p>
407
+ <p><strong>🟠 Medium confidence:</strong> answer may need checking with handouts.</p>
408
+ <p><strong>🔴 Low / Not found:</strong> material is weak or not available.</p>
409
+ <p><strong>Avg similarity:</strong> higher value means a better match between the question and course material.</p>
410
+ </div>
411
+ </div>
412
+
413
+ <h4>Recent activity</h4>
414
+ <table class="bc-table">
415
+ <tr>
416
+ <th>Time</th>
417
+ <th>Event</th>
418
+ <th>Mode</th>
419
+ <th>Confidence</th>
420
+ <th>Similarity</th>
421
+ <th>Query</th>
422
+ </tr>
423
+ {recent_rows}
424
+ </table>
425
+ </div>
426
+ """
427
+
428
+
429
+ def refresh_dashboard():
430
+ return render_dashboard()
431
+
432
+
433
+ def clear_analytics():
434
+ ANALYTICS_LOG.clear()
435
+ return render_dashboard()
436
+
437
+
438
+ # =====================================================
439
+ # PROMPTS
440
+ # =====================================================
441
+ def language_instruction(language_mode: str) -> str:
442
+ if language_mode == "English":
443
+ return "Answer only in English."
444
+
445
+ if language_mode == "Spanish":
446
+ return "Answer only in Spanish."
447
+
448
+ if language_mode == "Bilingual":
449
+ return "Answer first in English, then provide a Spanish version under the heading 'Español:'."
450
+
451
+ return "If the user's message is in Spanish, answer in Spanish; otherwise answer in English."
452
+
453
+
454
+ def choose_quiz_count(user_text: str, selector: str) -> int:
455
+ if selector in {"3", "5", "7"}:
456
+ return int(selector)
457
+
458
+ t = user_text.lower()
459
+
460
+ if any(k in t for k in ["mock test", "final exam", "exam practice", "full test"]):
461
+ return 7
462
+
463
+ if any(k in t for k in ["detailed", "revision", "comprehensive", "study"]):
464
+ return 5
465
+
466
+ return 3
467
+
468
+
469
+ def build_tutor_prompt(mode: str, language_mode: str, question: str, context: str) -> str:
470
+ styles = {
471
+ "Explain": """
472
+ Explain clearly like a friendly clinical tutor.
473
+ Use simple language.
474
+ Give the concept first, then key clinical points.
475
+ If useful, include one common mistake to avoid.
476
+ """,
477
+ "Detailed": """
478
+ Give a detailed explanation with clinical relevance.
479
+ Structure the answer using clear headings.
480
+ Only include details supported by the context.
481
+ """,
482
+ "Short Notes": """
483
+ Write concise revision notes using short bullet points.
484
+ Focus on exam-useful points from the professor handouts.
485
+ """,
486
+ "Flashcards": """
487
+ Create 6 flashcards in Q/A format using only the context.
488
+ Keep them useful for exam revision.
489
+ """,
490
+ "Case-Based": """
491
+ Create a short clinical case scenario.
492
+ Then guide the student using clinical reasoning.
493
+ Use the Socratic method where possible.
494
+ Do not simply give the answer immediately if reasoning is expected.
495
+ """,
496
+ }
497
+
498
+ return f"""
499
+ You are BrainChat, an interactive neurology tutor for PMQSN.
500
+
501
+ Core rules:
502
+ - Use ONLY the provided context.
503
+ - Always prioritize Professor Handouts first.
504
+ - Use supporting textbooks only when Professor Handouts are insufficient.
505
+ - Clearly keep Professor Handouts as the primary course source.
506
+ - If the answer is not supported by the context, say exactly:
507
+ Not found in the course material.
508
+ - Do not invent facts outside the context.
509
+ - Do not invent references.
510
+ - {language_instruction(language_mode)}
511
+
512
+ Teaching behavior:
513
+ - Act as a Socratic clinical tutor.
514
+ - Prefer guiding the student with reasoning rather than only giving direct answers.
515
+ - Keep the answer clear, structured, and useful for medical students.
516
+ - If the question asks for treatment, diagnosis, definition, or comparison, focus directly on that requested point.
517
+
518
+ Teaching style:
519
+ {styles.get(mode, "Explain clearly like a friendly clinical tutor.")}
520
+
521
+ Context:
522
+ {context}
523
+
524
+ Student question:
525
+ {question}
526
+ """.strip()
527
+
528
+
529
+ def build_quiz_generation_prompt(language_mode: str, topic: str, context: str, n_questions: int) -> str:
530
+ return f"""
531
+ You are BrainChat, an interactive neurology tutor.
532
+
533
+ Rules:
534
+ - Use ONLY the provided context.
535
+ - Always prioritize Professor Handouts first.
536
+ - Use supporting textbooks only when needed.
537
+ - Create exactly {n_questions} quiz questions.
538
+ - Questions should support autonomous study.
539
+ - Keep questions short and clear.
540
+ - Include a short answer key for each.
541
+ - Return VALID JSON only.
542
+ - {language_instruction(language_mode)}
543
+
544
+ Return JSON in this format:
545
+ {{
546
+ "title": "short quiz title",
547
+ "questions": [
548
+ {{"q": "question 1", "answer_key": "expected short answer"}},
549
+ {{"q": "question 2", "answer_key": "expected short answer"}}
550
+ ]
551
+ }}
552
+
553
+ Context:
554
+ {context}
555
+
556
+ Topic:
557
+ {topic}
558
+ """.strip()
559
+
560
+
561
+ def build_quiz_eval_prompt(language_mode: str, quiz_data: dict, user_answers: str) -> str:
562
+ quiz_json = json.dumps(quiz_data, ensure_ascii=False)
563
+
564
+ return f"""
565
+ You are BrainChat, an interactive neurology tutor.
566
+
567
+ Evaluate the student's answers fairly using the answer keys.
568
+ Accept semantically correct answers even if wording differs.
569
+ Give constructive feedback.
570
+ Return VALID JSON only.
571
+
572
+ Return JSON in this format:
573
+ {{
574
+ "score_obtained": 0,
575
+ "score_total": 0,
576
+ "summary": "short overall feedback",
577
+ "results": [
578
+ {{
579
+ "question": "question text",
580
+ "answer_key": "expected answer",
581
+ "student_answer": "student answer",
582
+ "result": "Correct / Partially Correct / Incorrect",
583
+ "feedback": "short explanation"
584
+ }}
585
+ ],
586
+ "improvement_tip": "one short study suggestion"
587
+ }}
588
+
589
+ Quiz:
590
+ {quiz_json}
591
+
592
+ Student answers:
593
+ {user_answers}
594
+
595
+ Language:
596
+ {language_instruction(language_mode)}
597
+ """.strip()
598
+
599
+
600
+ # =====================================================
601
+ # OPENAI
602
+ # =====================================================
603
+ def oai_text(prompt: str) -> str:
604
+ ensure_loaded()
605
+
606
+ resp = CLIENT.chat.completions.create(
607
+ model=OPENAI_MODEL,
608
+ temperature=0.2,
609
+ messages=[
610
+ {
611
+ "role": "system",
612
+ "content": "You are BrainChat, a careful educational assistant for neurology students."
613
+ },
614
+ {"role": "user", "content": prompt},
615
+ ],
616
+ )
617
+
618
+ return resp.choices[0].message.content.strip()
619
+
620
+
621
+ def oai_json(prompt: str) -> dict:
622
+ ensure_loaded()
623
+
624
+ resp = CLIENT.chat.completions.create(
625
+ model=OPENAI_MODEL,
626
+ temperature=0.2,
627
+ response_format={"type": "json_object"},
628
+ messages=[
629
+ {"role": "system", "content": "Return only valid JSON."},
630
+ {"role": "user", "content": prompt},
631
+ ],
632
+ )
633
+
634
+ return json.loads(resp.choices[0].message.content)
635
+
636
+
637
+ # =====================================================
638
+ # LOGO
639
+ # =====================================================
640
+ def get_logo_data_uri():
641
+ if not os.path.exists(LOGO_FILE):
642
+ return None
643
+
644
+ mime_type, _ = mimetypes.guess_type(LOGO_FILE)
645
+
646
+ if not mime_type:
647
+ mime_type = "image/png"
648
+
649
+ with open(LOGO_FILE, "rb") as f:
650
+ encoded = base64.b64encode(f.read()).decode("utf-8")
651
+
652
+ return f"data:{mime_type};base64,{encoded}"
653
+
654
+
655
+ def render_logo():
656
+ data_uri = get_logo_data_uri()
657
+
658
+ if data_uri:
659
+ return f'<img src="{data_uri}" alt="BrainChat logo" class="bc-logo-img">'
660
+
661
+ return '<div class="bc-logo-fallback">BRAIN<br>CHAT</div>'
662
+
663
+
664
+ # =====================================================
665
+ # CHAT HTML
666
+ # =====================================================
667
+ def format_text(text: str) -> str:
668
+ safe = (
669
+ text.replace("&", "&amp;")
670
+ .replace("<", "&lt;")
671
+ .replace(">", "&gt;")
672
+ )
673
+
674
+ safe = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", safe)
675
+ safe = safe.replace("\n", "<br>")
676
+
677
+ return safe
678
+
679
+
680
+ def render_chat(history):
681
+ if not history:
682
+ return """
683
+ <div class="bc-empty">
684
+ <div class="bc-empty-text">
685
+ <strong>Welcome to BrainChat.</strong><br><br>
686
+ I am your AI tutor for Neurology and PMQSN.<br>
687
+ You can ask questions, request explanations, practise clinical cases,<br>
688
+ or generate quizzes. I first use Professor Handouts,<br>
689
+ then supporting textbooks if needed.
690
+ </div>
691
+ </div>
692
+ """
693
+
694
+ rows = []
695
+
696
+ for item in history:
697
+ role = item["role"]
698
+ content = format_text(item["content"])
699
+ confidence_block = item.get("confidence_html", "")
700
+
701
+ if role == "user":
702
+ rows.append(
703
+ f'<div class="bc-row bc-user-row"><div class="bc-bubble bc-user-bubble">{content}</div></div>'
704
+ )
705
+ else:
706
+ rows.append(
707
+ f'<div class="bc-row bc-bot-row"><div class="bc-bubble bc-bot-bubble">{confidence_block}{content}</div></div>'
708
+ )
709
+
710
+ return f"""
711
+ <div class="bc-chat-wrap" id="bc-chat-wrap">
712
+ {''.join(rows)}
713
+ </div>
714
+ <script>
715
+ const chatWrap = document.getElementById("bc-chat-wrap");
716
+ if (chatWrap) {{
717
+ chatWrap.scrollTop = chatWrap.scrollHeight;
718
+ }}
719
+ </script>
720
+ """
721
+
722
+
723
+ # =====================================================
724
+ # MAIN LOGIC
725
+ # =====================================================
726
+ def respond(user_msg, history, mode, language_mode, quiz_count_mode, show_sources, quiz_state):
727
+ history = history or []
728
+ quiz_state = quiz_state or {
729
+ "active": False,
730
+ "quiz_data": None,
731
+ "language_mode": "Auto"
732
+ }
733
+
734
+ text = (user_msg or "").strip()
735
+
736
+ if not text:
737
+ return "", history, render_chat(history), quiz_state, render_dashboard()
738
+
739
+ try:
740
+ history = history + [{"role": "user", "content": text}]
741
+
742
+ # ---------------------------------------------
743
+ # General greeting / normal conversation
744
+ # ---------------------------------------------
745
+ if is_general_chat(text):
746
+ reply = general_chat_reply(text, language_mode)
747
+
748
+ conf = {
749
+ "level": "green",
750
+ "label": "Ready",
751
+ "score": 1.0
752
+ }
753
+
754
+ log_event(
755
+ event_type="general_chat",
756
+ mode=mode,
757
+ language=language_mode,
758
+ confidence_level="green",
759
+ similarity=1.0,
760
+ query=text
761
+ )
762
+
763
+ history = history + [
764
+ {
765
+ "role": "assistant",
766
+ "content": reply,
767
+ "confidence_html": confidence_html(conf)
768
+ }
769
+ ]
770
+
771
+ return "", history, render_chat(history), quiz_state, render_dashboard()
772
+
773
+ # ---------------------------------------------
774
+ # Quiz evaluation mode
775
+ # ---------------------------------------------
776
+ if quiz_state.get("active", False):
777
+ evaluation = oai_json(
778
+ build_quiz_eval_prompt(
779
+ quiz_state.get("language_mode", language_mode),
780
+ quiz_state.get("quiz_data", {}),
781
+ text
782
+ )
783
+ )
784
+
785
+ lines = []
786
+ lines.append(
787
+ f"**Score:** {evaluation.get('score_obtained', 0)}/{evaluation.get('score_total', 0)}"
788
+ )
789
+
790
+ if evaluation.get("summary"):
791
+ lines.append(f"\n**Overall feedback:** {evaluation['summary']}")
792
+
793
+ if evaluation.get("improvement_tip"):
794
+ lines.append(f"\n**Study tip:** {evaluation['improvement_tip']}\n")
795
+
796
+ results = evaluation.get("results", [])
797
+
798
+ if results:
799
+ lines.append("**Question-wise feedback:**")
800
+
801
+ for item in results:
802
+ lines.append("")
803
+ lines.append(f"**Q:** {item.get('question','')}")
804
+ lines.append(f"**Your answer:** {item.get('student_answer','')}")
805
+ lines.append(f"**Expected answer:** {item.get('answer_key','')}")
806
+ lines.append(f"**Result:** {item.get('result','')}")
807
+ lines.append(f"**Feedback:** {item.get('feedback','')}")
808
+
809
+ log_event(
810
+ event_type="quiz_evaluated",
811
+ mode=mode,
812
+ language=language_mode,
813
+ confidence_level="green",
814
+ similarity=1.0,
815
+ query=text
816
+ )
817
+
818
+ conf = {
819
+ "level": "green",
820
+ "label": "Quiz evaluated",
821
+ "score": 1.0
822
+ }
823
+
824
+ history = history + [
825
+ {
826
+ "role": "assistant",
827
+ "content": "\n".join(lines).strip(),
828
+ "confidence_html": confidence_html(conf)
829
+ }
830
+ ]
831
+
832
+ quiz_state = {
833
+ "active": False,
834
+ "quiz_data": None,
835
+ "language_mode": language_mode
836
+ }
837
+
838
+ return "", history, render_chat(history), quiz_state, render_dashboard()
839
+
840
+ # ---------------------------------------------
841
+ # Retrieval
842
+ # ---------------------------------------------
843
+ records = search_hybrid(text, shortlist_k=20, final_k=4)
844
+ records = prioritize_professor_handouts(records)
845
+ context = build_context(records)
846
+
847
+ # ---------------------------------------------
848
+ # Quiz generation
849
+ # ---------------------------------------------
850
+ if mode == "Quiz Me":
851
+ n_questions = choose_quiz_count(text, quiz_count_mode)
852
+
853
+ quiz_data = oai_json(
854
+ build_quiz_generation_prompt(
855
+ language_mode,
856
+ text,
857
+ context,
858
+ n_questions
859
+ )
860
+ )
861
+
862
+ conf = compute_confidence(records, "quiz generated")
863
+
864
+ lines = []
865
+ lines.append(f"**{quiz_data.get('title', 'Quiz')}**")
866
+ lines.append(f"\n**Total questions:** {len(quiz_data.get('questions', []))}\n")
867
+ lines.append("Reply in one message using numbered answers.")
868
+ lines.append("Example: 1. ... 2. ...\n")
869
+
870
+ for i, q in enumerate(quiz_data.get("questions", []), start=1):
871
+ lines.append(f"**Q{i}.** {q.get('q','')}")
872
+
873
+ if show_sources and conf["level"] != "red":
874
+ lines.append("\n\n**References used to create this quiz:**")
875
+ lines.append(make_sources(records))
876
+
877
+ log_event(
878
+ event_type="quiz_generated",
879
+ mode=mode,
880
+ language=language_mode,
881
+ confidence_level=conf["level"],
882
+ similarity=conf["score"],
883
+ query=text
884
+ )
885
+
886
+ history = history + [
887
+ {
888
+ "role": "assistant",
889
+ "content": "\n".join(lines).strip(),
890
+ "confidence_html": confidence_html(conf)
891
+ }
892
+ ]
893
+
894
+ quiz_state = {
895
+ "active": True,
896
+ "quiz_data": quiz_data,
897
+ "language_mode": language_mode
898
+ }
899
+
900
+ return "", history, render_chat(history), quiz_state, render_dashboard()
901
+
902
+ # ---------------------------------------------
903
+ # Normal answer
904
+ # ---------------------------------------------
905
+ answer = oai_text(
906
+ build_tutor_prompt(
907
+ mode,
908
+ language_mode,
909
+ text,
910
+ context
911
+ )
912
+ )
913
+
914
+ conf = compute_confidence(records, answer)
915
+
916
+ if conf["level"] == "red":
917
+ if language_mode == "English":
918
+ final_answer = "Not found in the course material."
919
+ else:
920
+ final_answer = "No encontrado en el material del curso."
921
+ else:
922
+ final_answer = answer.strip()
923
+
924
+ if show_sources:
925
+ final_answer += "\n\n**References used:**\n" + make_sources(records)
926
+
927
+ log_event(
928
+ event_type="answer",
929
+ mode=mode,
930
+ language=language_mode,
931
+ confidence_level=conf["level"],
932
+ similarity=conf["score"],
933
+ query=text
934
+ )
935
+
936
+ history = history + [
937
+ {
938
+ "role": "assistant",
939
+ "content": final_answer.strip(),
940
+ "confidence_html": confidence_html(conf)
941
+ }
942
+ ]
943
+
944
+ return "", history, render_chat(history), quiz_state, render_dashboard()
945
+
946
+ except Exception as e:
947
+ history = history + [{"role": "assistant", "content": f"Error: {str(e)}"}]
948
+
949
+ quiz_state = {
950
+ "active": False,
951
+ "quiz_data": None,
952
+ "language_mode": language_mode
953
+ }
954
+
955
+ return "", history, render_chat(history), quiz_state, render_dashboard()
956
+
957
+
958
+ def clear_all():
959
+ empty_history = []
960
+ empty_quiz = {
961
+ "active": False,
962
+ "quiz_data": None,
963
+ "language_mode": "Auto"
964
+ }
965
+
966
+ return "", empty_history, render_chat(empty_history), empty_quiz, render_dashboard()
967
+
968
+
969
+ # =====================================================
970
+ # CSS
971
+ # =====================================================
972
+ CSS = """
973
+ :root{
974
+ --page-bg: #d9d9dd;
975
+ --panel-bg: #555765;
976
+ --chat-bg: #4a4c59;
977
+ --grad-top: #e8c7d4;
978
+ --grad-mid: #a55ca2;
979
+ --grad-bot: #5a2d77;
980
+ --accent: #f4eb4b;
981
+ --accent-soft: #f5ef9a;
982
+ --user-bubble: #ffffff;
983
+ --bot-bubble: #f5efad;
984
+ --text-dark: #241336;
985
+ --text-dark-strong: #170c25;
986
+ --text-light: #ffffff;
987
+ --shadow: rgba(30,20,50,0.18);
988
+ }
989
+
990
+ html, body, .gradio-container{
991
+ background: var(--page-bg) !important;
992
+ font-family: Arial, Helvetica, sans-serif !important;
993
+ }
994
+
995
+ footer{
996
+ display:none !important;
997
+ }
998
+
999
+ #bc_app{
1000
+ max-width: 1100px;
1001
+ margin: 18px auto;
1002
+ }
1003
+
1004
+ /* SETTINGS BOX - UVa inspired */
1005
+ .bc-settings{
1006
+ background: linear-gradient(135deg, #5a2d77 0%, #7b3f98 100%);
1007
+ border-radius: 22px;
1008
+ padding: 18px;
1009
+ box-shadow: 0 12px 28px rgba(0,0,0,0.22);
1010
+ margin-bottom: 16px;
1011
+ border-top: 6px solid #c7a008;
1012
+ }
1013
+
1014
+ .bc-settings label{
1015
+ color: #ffffff !important;
1016
+ font-weight: 700 !important;
1017
+ }
1018
+
1019
+ .bc-settings .wrap{
1020
+ color: #ffffff !important;
1021
+ }
1022
+
1023
+ .bc-settings input,
1024
+ .bc-settings textarea,
1025
+ .bc-settings select{
1026
+ color:#241336 !important;
1027
+ }
1028
+
1029
+ .bc-howto{
1030
+ margin-top: 10px;
1031
+ padding: 14px 16px;
1032
+ border-radius: 16px;
1033
+ background: rgba(255,255,255,0.14);
1034
+ color: white;
1035
+ font-size: 14px;
1036
+ line-height: 1.55;
1037
+ border-left: 5px solid #c7a008;
1038
+ }
1039
+
1040
+ .bc-phone{
1041
+ position: relative;
1042
+ background: linear-gradient(180deg, var(--grad-top) 0%, var(--grad-mid) 48%, var(--grad-bot) 100%);
1043
+ border-radius: 30px;
1044
+ padding: 92px 14px 14px 14px;
1045
+ box-shadow: 0 16px 34px var(--shadow);
1046
+ min-height: 620px;
1047
+ }
1048
+
1049
+ .bc-logo-holder{
1050
+ position: absolute;
1051
+ top: 16px;
1052
+ left: 50%;
1053
+ transform: translateX(-50%);
1054
+ width: 104px;
1055
+ height: 104px;
1056
+ border-radius: 999px;
1057
+ background: var(--accent);
1058
+ display: flex;
1059
+ align-items: center;
1060
+ justify-content: center;
1061
+ box-shadow: 0 10px 22px rgba(0,0,0,0.18);
1062
+ }
1063
+
1064
+ .bc-logo-img{
1065
+ width: 88px;
1066
+ height: 88px;
1067
+ object-fit: contain;
1068
+ display:block;
1069
+ }
1070
+
1071
+ .bc-logo-fallback{
1072
+ width: 88px;
1073
+ height: 88px;
1074
+ border-radius: 999px;
1075
+ display:flex;
1076
+ align-items:center;
1077
+ justify-content:center;
1078
+ text-align:center;
1079
+ font-size: 13px;
1080
+ font-weight: 900;
1081
+ color: var(--text-dark-strong);
1082
+ background: rgba(255,255,255,0.40);
1083
+ line-height: 1.05;
1084
+ }
1085
+
1086
+ .bc-chat-shell{
1087
+ background: rgba(74,76,89,0.92);
1088
+ border-radius: 20px;
1089
+ padding: 16px;
1090
+ min-height: 460px;
1091
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
1092
+ }
1093
+
1094
+ .bc-chat-wrap{
1095
+ display: flex;
1096
+ flex-direction: column;
1097
+ gap: 14px;
1098
+ max-height: 460px;
1099
+ overflow-y: auto;
1100
+ padding-right: 4px;
1101
+ }
1102
+
1103
+ .bc-chat-wrap::-webkit-scrollbar{
1104
+ width: 8px;
1105
+ }
1106
+
1107
+ .bc-chat-wrap::-webkit-scrollbar-thumb{
1108
+ background: rgba(255,255,255,0.28);
1109
+ border-radius: 999px;
1110
+ }
1111
+
1112
+ .bc-row{
1113
+ display:flex;
1114
+ width:100%;
1115
+ }
1116
+
1117
+ .bc-user-row{
1118
+ justify-content: flex-start;
1119
+ }
1120
+
1121
+ .bc-bot-row{
1122
+ justify-content: flex-end;
1123
+ }
1124
+
1125
+ .bc-bubble{
1126
+ max-width: 82%;
1127
+ padding: 15px 18px;
1128
+ border-radius: 22px;
1129
+ line-height: 1.6;
1130
+ font-size: 15px;
1131
+ box-shadow: 0 10px 18px rgba(0,0,0,0.10);
1132
+ word-wrap: break-word;
1133
+ font-weight: 500;
1134
+ }
1135
+
1136
+ .bc-user-bubble{
1137
+ background: var(--user-bubble);
1138
+ color: var(--text-dark-strong) !important;
1139
+ border-bottom-left-radius: 8px;
1140
+ }
1141
+
1142
+ .bc-bot-bubble{
1143
+ background: var(--bot-bubble);
1144
+ color: var(--text-dark-strong) !important;
1145
+ border-bottom-right-radius: 8px;
1146
+ }
1147
+
1148
+ .bc-bubble strong{
1149
+ color: var(--text-dark-strong) !important;
1150
+ }
1151
+
1152
+ .bc-confidence{
1153
+ display:flex;
1154
+ align-items:center;
1155
+ gap:8px;
1156
+ margin-bottom:10px;
1157
+ padding:7px 10px;
1158
+ background:rgba(255,255,255,0.65);
1159
+ border-radius:999px;
1160
+ font-size:13px;
1161
+ color:#111827;
1162
+ }
1163
+
1164
+ .bc-dot{
1165
+ width:15px;
1166
+ height:15px;
1167
+ border-radius:999px;
1168
+ display:inline-block;
1169
+ box-shadow:0 0 0 3px rgba(255,255,255,0.75);
1170
+ }
1171
+
1172
+ .bc-empty{
1173
+ display:flex;
1174
+ justify-content:center;
1175
+ align-items:center;
1176
+ min-height: 400px;
1177
+ }
1178
+
1179
+ .bc-empty-text{
1180
+ color: white;
1181
+ text-align:center;
1182
+ opacity: 0.96;
1183
+ font-size: 16px;
1184
+ line-height: 1.6;
1185
+ }
1186
+
1187
+ .bc-input-bar{
1188
+ margin-top: 12px;
1189
+ background: var(--accent);
1190
+ border-radius: 999px;
1191
+ padding: 8px 10px;
1192
+ display:flex;
1193
+ align-items:center;
1194
+ gap: 10px;
1195
+ box-shadow: 0 10px 22px rgba(0,0,0,0.14);
1196
+ }
1197
+
1198
+ .bc-plus{
1199
+ width: 38px;
1200
+ height: 38px;
1201
+ border-radius: 999px;
1202
+ background: rgba(255,255,255,0.34);
1203
+ display:flex;
1204
+ align-items:center;
1205
+ justify-content:center;
1206
+ font-size: 30px;
1207
+ font-weight: 900;
1208
+ color: var(--text-dark-strong);
1209
+ user-select:none;
1210
+ }
1211
+
1212
+ #bc_msg textarea{
1213
+ background: rgba(255,255,255,0.42) !important;
1214
+ border: none !important;
1215
+ box-shadow: none !important;
1216
+ border-radius: 999px !important;
1217
+ color: var(--text-dark-strong) !important;
1218
+ padding: 11px 14px !important;
1219
+ min-height: 42px !important;
1220
+ }
1221
+
1222
+ #bc_msg textarea::placeholder{
1223
+ color: rgba(34,23,53,0.72) !important;
1224
+ }
1225
+
1226
+ #bc_send button{
1227
+ min-width: 48px !important;
1228
+ height: 42px !important;
1229
+ border-radius: 999px !important;
1230
+ border: none !important;
1231
+ background: rgba(255,255,255,0.34) !important;
1232
+ color: var(--text-dark-strong) !important;
1233
+ font-size: 20px !important;
1234
+ font-weight: 900 !important;
1235
+ box-shadow: none !important;
1236
+ }
1237
+
1238
+ #bc_send button:hover{
1239
+ background: rgba(255,255,255,0.52) !important;
1240
+ }
1241
+
1242
+ #bc_clear button, #bc_refresh button, #bc_clear_analytics button{
1243
+ border-radius: 14px !important;
1244
+ }
1245
+
1246
+ /* DASHBOARD - UVa inspired visible design */
1247
+ .bc-dashboard{
1248
+ background:#ffffff;
1249
+ border-radius:22px;
1250
+ padding:22px;
1251
+ box-shadow:0 12px 28px rgba(0,0,0,0.22);
1252
+ margin-top:18px;
1253
+ color:#241336 !important;
1254
+ border-top:8px solid #5a2d77;
1255
+ }
1256
+
1257
+ .bc-dashboard h3{
1258
+ color:#5a2d77 !important;
1259
+ font-size:22px;
1260
+ font-weight:800;
1261
+ margin-bottom:10px;
1262
+ }
1263
+
1264
+ .bc-dashboard h4{
1265
+ color:#5a2d77 !important;
1266
+ font-size:17px;
1267
+ font-weight:800;
1268
+ }
1269
+
1270
+ .bc-dashboard p{
1271
+ color:#241336 !important;
1272
+ font-size:14px;
1273
+ line-height:1.5;
1274
+ }
1275
+
1276
+ .bc-dashboard-grid{
1277
+ display:grid;
1278
+ grid-template-columns: 2fr 1fr;
1279
+ gap:20px;
1280
+ align-items:start;
1281
+ }
1282
+
1283
+ .bc-dashboard-help{
1284
+ background:#f4edf7;
1285
+ border-left:6px solid #c7a008;
1286
+ border-radius:16px;
1287
+ padding:16px;
1288
+ color:#241336 !important;
1289
+ }
1290
+
1291
+ .bc-dashboard-help strong{
1292
+ color:#5a2d77 !important;
1293
+ }
1294
+
1295
+ .bc-metrics{
1296
+ display:grid;
1297
+ grid-template-columns: repeat(3, 1fr);
1298
+ gap:14px;
1299
+ margin:16px 0;
1300
+ }
1301
+
1302
+ .bc-card{
1303
+ border-radius:16px;
1304
+ padding:16px;
1305
+ text-align:center;
1306
+ font-size:14px;
1307
+ color:#241336 !important;
1308
+ border:2px solid #e5d8ef;
1309
+ font-weight:600;
1310
+ }
1311
+
1312
+ .bc-card strong{
1313
+ display:block;
1314
+ font-size:28px;
1315
+ color:#5a2d77 !important;
1316
+ margin-bottom:4px;
1317
+ }
1318
+
1319
+ .bc-card.total{ background:#efe7f6; }
1320
+ .bc-card.green{ background:#dff7e7; border-color:#22c55e; }
1321
+ .bc-card.orange{ background:#fff1d6; border-color:#f59e0b; }
1322
+ .bc-card.red{ background:#ffe1e1; border-color:#dc2626; }
1323
+ .bc-card.quiz{ background:#f7edff; border-color:#8b5cf6; }
1324
+ .bc-card.avg{ background:#fff8cc; border-color:#c7a008; }
1325
+
1326
+ .bc-table{
1327
+ width:100%;
1328
+ border-collapse:collapse;
1329
+ font-size:13px;
1330
+ background:#ffffff;
1331
+ color:#241336 !important;
1332
+ margin-top:12px;
1333
+ }
1334
+
1335
+ .bc-table th{
1336
+ background:#5a2d77;
1337
+ color:#ffffff !important;
1338
+ padding:10px;
1339
+ border:1px solid #ddd;
1340
+ font-weight:700;
1341
+ }
1342
+
1343
+ .bc-table td{
1344
+ border:1px solid #ddd;
1345
+ padding:9px;
1346
+ vertical-align:top;
1347
+ color:#241336 !important;
1348
+ background:#ffffff;
1349
+ }
1350
+
1351
+ .bc-table tr:nth-child(even) td{
1352
+ background:#faf7fc;
1353
+ }
1354
+
1355
+ .bc-pill{
1356
+ padding:5px 10px;
1357
+ border-radius:999px;
1358
+ font-weight:800;
1359
+ color:#241336 !important;
1360
+ }
1361
+
1362
+ .bc-green{ background:#86efac; }
1363
+ .bc-orange{ background:#fdba74; }
1364
+ .bc-red{ background:#fca5a5; }
1365
+
1366
+ @media (max-width: 768px){
1367
+ #bc_app{
1368
+ max-width: 96vw;
1369
+ }
1370
+
1371
+ .bc-bubble{
1372
+ max-width: 90%;
1373
+ }
1374
+
1375
+ .bc-dashboard-grid{
1376
+ grid-template-columns: 1fr;
1377
+ }
1378
+
1379
+ .bc-metrics{
1380
+ grid-template-columns: 1fr;
1381
+ }
1382
+ }
1383
+ """
1384
+
1385
+
1386
+ # =====================================================
1387
+ # UI
1388
+ # =====================================================
1389
+ with gr.Blocks() as demo:
1390
+ history_state = gr.State([])
1391
+ quiz_state = gr.State({
1392
+ "active": False,
1393
+ "quiz_data": None,
1394
+ "language_mode": "Auto"
1395
+ })
1396
+
1397
+ with gr.Column(elem_id="bc_app"):
1398
+
1399
+ with gr.Group(elem_classes="bc-settings"):
1400
+ with gr.Row():
1401
+ mode = gr.Dropdown(
1402
+ choices=[
1403
+ "Explain",
1404
+ "Detailed",
1405
+ "Short Notes",
1406
+ "Flashcards",
1407
+ "Case-Based",
1408
+ "Quiz Me"
1409
+ ],
1410
+ value="Explain",
1411
+ label="Tutor Mode"
1412
+ )
1413
+
1414
+ language_mode = gr.Dropdown(
1415
+ choices=[
1416
+ "Auto",
1417
+ "Spanish",
1418
+ "English",
1419
+ "Bilingual"
1420
+ ],
1421
+ value="Spanish",
1422
+ label="Answer Language"
1423
+ )
1424
+
1425
+ with gr.Row():
1426
+ quiz_count_mode = gr.Dropdown(
1427
+ choices=[
1428
+ "Auto",
1429
+ "3",
1430
+ "5",
1431
+ "7"
1432
+ ],
1433
+ value="Auto",
1434
+ label="Quiz Questions"
1435
+ )
1436
+
1437
+ show_sources = gr.Checkbox(
1438
+ value=True,
1439
+ label="Show References"
1440
+ )
1441
+
1442
+ gr.HTML("""
1443
+ <div class="bc-howto">
1444
+ <strong>Welcome to BrainChat</strong><br>
1445
+ BrainChat is an AI-based neurology tutor designed to support PMQSN learning.<br>
1446
+ It first searches <strong>Professor Handouts</strong>, and then uses other textbooks only when needed.<br><br>
1447
+
1448
+ <strong>Confidence indicator</strong><br>
1449
+ 🟢 Strong support from course material &nbsp; | &nbsp;
1450
+ 🟠 Partial support &nbsp; | &nbsp;
1451
+ 🔴 Not found / weak evidence<br><br>
1452
+
1453
+ <strong>How to use</strong><br>
1454
+ 1. Choose a tutor mode: Explain, Detailed, Short Notes, Flashcards, Case-Based, or Quiz Me.<br>
1455
+ 2. Select the answer language: Spanish, English, Bilingual, or Auto.<br>
1456
+ 3. Type your question in the message box below.<br>
1457
+ 4. Use Quiz Me to practise questions and receive automatic feedback.<br><br>
1458
+
1459
+ <strong>Example prompts</strong><br>
1460
+ • Explícame la afasia de Broca según los apuntes.<br>
1461
+ • Ponme 3 preguntas tipo test sobre ictus.<br>
1462
+ • Explícame la diferencia diagnóstica entre EM y NMOSD.<br>
1463
+ • Dame un caso clínico sobre epilepsia.
1464
+ </div>
1465
+ """)
1466
+
1467
+ with gr.Group(elem_classes="bc-phone"):
1468
+ gr.HTML(f'<div class="bc-logo-holder">{render_logo()}</div>')
1469
+
1470
+ chat_html = gr.HTML(
1471
+ f'<div class="bc-chat-shell">{render_chat([])}</div>'
1472
+ )
1473
+
1474
+ with gr.Row(elem_classes="bc-input-bar"):
1475
+ gr.HTML('<div class="bc-plus">+</div>')
1476
+
1477
+ msg = gr.Textbox(
1478
+ placeholder="Type a message...",
1479
+ show_label=False,
1480
+ container=False,
1481
+ scale=8,
1482
+ elem_id="bc_msg"
1483
+ )
1484
+
1485
+ send_btn = gr.Button(
1486
+ "➤",
1487
+ elem_id="bc_send",
1488
+ scale=1
1489
+ )
1490
+
1491
+ with gr.Row():
1492
+ clear_btn = gr.Button("Clear Chat", elem_id="bc_clear")
1493
+ refresh_btn = gr.Button("Refresh Dashboard", elem_id="bc_refresh")
1494
+ clear_analytics_btn = gr.Button("Clear Analytics", elem_id="bc_clear_analytics")
1495
+
1496
+ dashboard_html = gr.HTML(render_dashboard())
1497
+
1498
+ msg.submit(
1499
+ respond,
1500
+ inputs=[
1501
+ msg,
1502
+ history_state,
1503
+ mode,
1504
+ language_mode,
1505
+ quiz_count_mode,
1506
+ show_sources,
1507
+ quiz_state
1508
+ ],
1509
+ outputs=[
1510
+ msg,
1511
+ history_state,
1512
+ chat_html,
1513
+ quiz_state,
1514
+ dashboard_html
1515
+ ]
1516
+ )
1517
+
1518
+ send_btn.click(
1519
+ respond,
1520
+ inputs=[
1521
+ msg,
1522
+ history_state,
1523
+ mode,
1524
+ language_mode,
1525
+ quiz_count_mode,
1526
+ show_sources,
1527
+ quiz_state
1528
+ ],
1529
+ outputs=[
1530
+ msg,
1531
+ history_state,
1532
+ chat_html,
1533
+ quiz_state,
1534
+ dashboard_html
1535
+ ]
1536
+ )
1537
+
1538
+ clear_btn.click(
1539
+ clear_all,
1540
+ inputs=[],
1541
+ outputs=[
1542
+ msg,
1543
+ history_state,
1544
+ chat_html,
1545
+ quiz_state,
1546
+ dashboard_html
1547
+ ],
1548
+ queue=False
1549
+ )
1550
+
1551
+ refresh_btn.click(
1552
+ refresh_dashboard,
1553
+ inputs=[],
1554
+ outputs=[dashboard_html],
1555
+ queue=False
1556
+ )
1557
+
1558
+ clear_analytics_btn.click(
1559
+ clear_analytics,
1560
+ inputs=[],
1561
+ outputs=[dashboard_html],
1562
+ queue=False
1563
+ )
1564
+
1565
+
1566
+ if __name__ == "__main__":
1567
+ demo.queue()
1568
+ demo.launch(css=CSS)