Deevyankar commited on
Commit
d124dac
·
verified ·
1 Parent(s): d4689cf

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +592 -132
app.py CHANGED
@@ -10,9 +10,9 @@ from rank_bm25 import BM25Okapi
10
  from sentence_transformers import SentenceTransformer
11
  from openai import OpenAI
12
 
13
- # ==============================
14
  # PATHS
15
- # ==============================
16
  BUILD_DIR = "brainchat_build"
17
  CHUNKS_PATH = os.path.join(BUILD_DIR, "chunks.pkl")
18
  TOKENS_PATH = os.path.join(BUILD_DIR, "tokenized_chunks.pkl")
@@ -20,6 +20,9 @@ EMBED_PATH = os.path.join(BUILD_DIR, "embeddings.npy")
20
  CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
21
  LOGO_FILE = "Brain chat-09.png"
22
 
 
 
 
23
  EMBED_MODEL = None
24
  BM25 = None
25
  CHUNKS = None
@@ -27,183 +30,640 @@ EMBEDDINGS = None
27
  CLIENT = None
28
 
29
 
30
- # ==============================
31
- # LOAD
32
- # ==============================
33
- def tokenize(text):
34
- return re.findall(r"\w+", text.lower())
35
 
36
 
37
  def ensure_loaded():
38
  global EMBED_MODEL, BM25, CHUNKS, EMBEDDINGS, CLIENT
39
 
40
  if CHUNKS is None:
 
 
 
 
41
  with open(CHUNKS_PATH, "rb") as f:
42
  CHUNKS = pickle.load(f)
43
 
44
  with open(TOKENS_PATH, "rb") as f:
45
- tokenized = pickle.load(f)
46
 
47
  EMBEDDINGS = np.load(EMBED_PATH)
48
 
49
- with open(CONFIG_PATH) as f:
50
  cfg = json.load(f)
51
 
52
- BM25 = BM25Okapi(tokenized)
53
  EMBED_MODEL = SentenceTransformer(cfg["embedding_model"])
54
 
55
  if CLIENT is None:
56
- CLIENT = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
 
 
 
57
 
58
 
59
- # ==============================
60
- # SEARCH
61
- # ==============================
62
- def search(query):
63
  ensure_loaded()
64
 
65
- scores = BM25.get_scores(tokenize(query))
66
- idx = np.argsort(scores)[::-1][:3]
67
- return [CHUNKS[int(i)] for i in idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
 
70
- def context_text(records):
71
- return "\n\n".join([r["text"] for r in records])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- # ==============================
75
- # OPENAI
76
- # ==============================
77
- def chat(prompt):
78
- return CLIENT.chat.completions.create(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  model="gpt-4o-mini",
80
- messages=[{"role": "user", "content": prompt}],
81
  temperature=0.2,
82
- ).choices[0].message.content
 
 
 
 
 
83
 
84
 
85
- def chat_json(prompt):
86
- return json.loads(
87
- CLIENT.chat.completions.create(
88
- model="gpt-4o-mini",
89
- messages=[{"role": "user", "content": prompt}],
90
- response_format={"type": "json_object"},
91
- ).choices[0].message.content
 
 
92
  )
 
93
 
94
 
95
- # ==============================
96
- # CORE LOGIC
97
- # ==============================
98
- def answer_question(msg, history, mode, quiz_state):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  if history is None:
100
  history = []
101
  if quiz_state is None:
102
- quiz_state = {"active": False, "quiz": None}
103
-
104
- if not msg.strip():
105
- return history, quiz_state, ""
106
-
107
- history.append({"role": "user", "content": msg})
108
-
109
- records = search(msg)
110
- ctx = context_text(records)
111
-
112
- # =====================
113
- # QUIZ EVALUATION
114
- # =====================
115
- if quiz_state["active"]:
116
- eval_prompt = f"""
117
- Evaluate answers:
118
-
119
- Quiz: {json.dumps(quiz_state["quiz"])}
120
- Student: {msg}
121
-
122
- Return JSON:
123
- {{"score": "x/y", "feedback": "short"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  """
125
- res = chat_json(eval_prompt)
126
-
127
- out = f"Score: {res['score']}\n\n{res['feedback']}"
128
- history.append({"role": "assistant", "content": out})
129
 
130
- quiz_state = {"active": False, "quiz": None}
131
- return history, quiz_state, ""
132
-
133
- # =====================
134
- # QUIZ GENERATION
135
- # =====================
136
- if mode == "Quiz":
137
- quiz = chat_json(f"""
138
- Create 3 questions.
139
-
140
- Context:
141
- {ctx}
142
-
143
- Return:
144
- {{"questions":[{{"q":"","a":""}}]}}
145
- """)
146
 
147
- text = "Answer these:\n\n"
148
- for i, q in enumerate(quiz["questions"], 1):
149
- text += f"{i}. {q['q']}\n"
150
-
151
- history.append({"role": "assistant", "content": text})
152
-
153
- quiz_state = {"active": True, "quiz": quiz}
154
- return history, quiz_state, ""
155
-
156
- # =====================
157
- # NORMAL
158
- # =====================
159
- answer = chat(f"""
160
- Explain clearly:
161
-
162
- {ctx}
163
-
164
- Question: {msg}
165
- """)
166
-
167
- history.append({"role": "assistant", "content": answer})
168
- return history, quiz_state, ""
169
-
170
-
171
- def clear():
172
- return [], {"active": False, "quiz": None}, ""
173
-
174
-
175
- # ==============================
176
  # UI
177
- # ==============================
178
- def logo():
179
- if os.path.exists(LOGO_FILE):
180
- return f'<img src="/gradio_api/file={quote(LOGO_FILE)}" width="120">'
181
- return "<h2>BrainChat</h2>"
182
-
183
-
184
- CSS = """
185
- body {background:#dcdcdc;}
186
- """
187
-
188
  with gr.Blocks() as demo:
189
- quiz_state = gr.State({"active": False, "quiz": None})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- gr.HTML(logo())
192
 
193
- mode = gr.Dropdown(["Explain", "Quiz"], value="Explain")
 
 
 
 
 
 
 
194
 
195
- chatbot = gr.Chatbot(height=500)
196
- msg = gr.Textbox()
197
 
198
- btn = gr.Button("Send")
199
- clr = gr.Button("Clear")
 
 
 
200
 
201
- btn.click(
202
  answer_question,
203
- inputs=[msg, chatbot, mode, quiz_state],
204
- outputs=[chatbot, quiz_state, msg],
205
  )
206
 
207
- clr.click(clear, outputs=[chatbot, quiz_state, msg])
 
 
 
 
208
 
209
- demo.launch(css=CSS)
 
 
10
  from sentence_transformers import SentenceTransformer
11
  from openai import OpenAI
12
 
13
+ # =====================================================
14
  # PATHS
15
+ # =====================================================
16
  BUILD_DIR = "brainchat_build"
17
  CHUNKS_PATH = os.path.join(BUILD_DIR, "chunks.pkl")
18
  TOKENS_PATH = os.path.join(BUILD_DIR, "tokenized_chunks.pkl")
 
20
  CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
21
  LOGO_FILE = "Brain chat-09.png"
22
 
23
+ # =====================================================
24
+ # GLOBALS
25
+ # =====================================================
26
  EMBED_MODEL = None
27
  BM25 = None
28
  CHUNKS = None
 
30
  CLIENT = None
31
 
32
 
33
+ # =====================================================
34
+ # LOADERS
35
+ # =====================================================
36
+ def tokenize(text: str):
37
+ return re.findall(r"\w+", text.lower(), flags=re.UNICODE)
38
 
39
 
40
  def ensure_loaded():
41
  global EMBED_MODEL, BM25, CHUNKS, EMBEDDINGS, CLIENT
42
 
43
  if CHUNKS is None:
44
+ for path in [CHUNKS_PATH, TOKENS_PATH, EMBED_PATH, CONFIG_PATH]:
45
+ if not os.path.exists(path):
46
+ raise FileNotFoundError(f"Missing file: {path}")
47
+
48
  with open(CHUNKS_PATH, "rb") as f:
49
  CHUNKS = pickle.load(f)
50
 
51
  with open(TOKENS_PATH, "rb") as f:
52
+ tokenized_chunks = pickle.load(f)
53
 
54
  EMBEDDINGS = np.load(EMBED_PATH)
55
 
56
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
57
  cfg = json.load(f)
58
 
59
+ BM25 = BM25Okapi(tokenized_chunks)
60
  EMBED_MODEL = SentenceTransformer(cfg["embedding_model"])
61
 
62
  if CLIENT is None:
63
+ api_key = os.getenv("OPENAI_API_KEY")
64
+ if not api_key:
65
+ raise ValueError("OPENAI_API_KEY is missing in Hugging Face Space Secrets.")
66
+ CLIENT = OpenAI(api_key=api_key)
67
 
68
 
69
+ # =====================================================
70
+ # RETRIEVAL
71
+ # =====================================================
72
+ def search_hybrid(query: str, shortlist_k: int = 20, final_k: int = 3):
73
  ensure_loaded()
74
 
75
+ query_tokens = tokenize(query)
76
+ bm25_scores = BM25.get_scores(query_tokens)
77
+
78
+ shortlist_idx = np.argsort(bm25_scores)[::-1][:shortlist_k]
79
+ shortlist_embeddings = EMBEDDINGS[shortlist_idx]
80
+
81
+ qvec = EMBED_MODEL.encode([query], normalize_embeddings=True).astype("float32")[0]
82
+ dense_scores = shortlist_embeddings @ qvec
83
+
84
+ rerank_order = np.argsort(dense_scores)[::-1][:final_k]
85
+ final_idx = shortlist_idx[rerank_order]
86
+
87
+ return [CHUNKS[int(i)] for i in final_idx]
88
+
89
+
90
+ def build_context(records):
91
+ blocks = []
92
+ for i, r in enumerate(records, start=1):
93
+ blocks.append(
94
+ f"""[Source {i}]
95
+ Book: {r['book']}
96
+ Section: {r['section_title']}
97
+ Pages: {r['page_start']}-{r['page_end']}
98
+ Text:
99
+ {r['text']}"""
100
+ )
101
+ return "\n\n".join(blocks)
102
+
103
+
104
+ def make_sources(records):
105
+ seen = set()
106
+ lines = []
107
+ for r in records:
108
+ key = (r["book"], r["section_title"], r["page_start"], r["page_end"])
109
+ if key in seen:
110
+ continue
111
+ seen.add(key)
112
+ lines.append(
113
+ f"• {r['book']} | {r['section_title']} | pp. {r['page_start']}-{r['page_end']}"
114
+ )
115
+ return "\n".join(lines)
116
+
117
+
118
+ # =====================================================
119
+ # PROMPTS
120
+ # =====================================================
121
+ def language_instruction(language_mode: str) -> str:
122
+ if language_mode == "English":
123
+ return "Answer only in English."
124
+ if language_mode == "Spanish":
125
+ return "Answer only in Spanish."
126
+ if language_mode == "Bilingual":
127
+ return "Answer first in English, then provide a Spanish version under the heading 'Español:'."
128
+ return (
129
+ "If the user's message is in Spanish, answer in Spanish. "
130
+ "If the user's message is in English, answer in English."
131
+ )
132
 
133
 
134
+ def choose_quiz_count(user_text: str, selector: str) -> int:
135
+ if selector in {"3", "5", "7"}:
136
+ return int(selector)
137
+
138
+ t = user_text.lower()
139
+ if any(k in t for k in ["mock test", "final exam", "exam practice", "full test"]):
140
+ return 7
141
+ if any(k in t for k in ["detailed", "revision", "comprehensive", "study"]):
142
+ return 5
143
+ return 3
144
+
145
+
146
+ def build_tutor_prompt(mode: str, language_mode: str, question: str, context: str) -> str:
147
+ mode_map = {
148
+ "Explain": (
149
+ "Explain clearly like a friendly tutor using simple language. "
150
+ "Use short headings if useful."
151
+ ),
152
+ "Detailed": (
153
+ "Give a fuller and more detailed explanation. Include concept, key points, and clinical relevance when supported by context."
154
+ ),
155
+ "Short Notes": (
156
+ "Answer in concise revision-note format using short bullet points."
157
+ ),
158
+ "Flashcards": (
159
+ "Create 6 flashcards in Q/A format using only the provided context."
160
+ ),
161
+ "Case-Based": (
162
+ "Create a short clinical scenario and explain it clearly using the provided context."
163
+ )
164
+ }
165
+
166
+ return f"""
167
+ You are BrainChat, an interactive neurology and neuroanatomy tutor.
168
+
169
+ Rules:
170
+ - Use only the provided context from the books.
171
+ - If the answer is not supported by the context, say exactly:
172
+ Not found in the course material.
173
+ - Be accurate and student-friendly.
174
+ - Do not invent facts outside the context.
175
+ - {language_instruction(language_mode)}
176
+
177
+ Teaching style:
178
+ {mode_map[mode]}
179
 
180
+ Context:
181
+ {context}
182
+
183
+ Question:
184
+ {question}
185
+ """.strip()
186
+
187
+
188
+ def build_quiz_generation_prompt(language_mode: str, topic: str, context: str, n_questions: int) -> str:
189
+ return f"""
190
+ You are BrainChat, an interactive tutor.
191
+
192
+ Rules:
193
+ - Use only the provided context.
194
+ - Create exactly {n_questions} quiz questions.
195
+ - Questions should be short and clear.
196
+ - Also create a short answer key.
197
+ - Return valid JSON only.
198
+ - {language_instruction(language_mode)}
199
+
200
+ Required JSON format:
201
+ {{
202
+ "title": "short quiz title",
203
+ "questions": [
204
+ {{"q": "question 1", "answer_key": "expected short answer"}},
205
+ {{"q": "question 2", "answer_key": "expected short answer"}}
206
+ ]
207
+ }}
208
 
209
+ Context:
210
+ {context}
211
+
212
+ Topic:
213
+ {topic}
214
+ """.strip()
215
+
216
+
217
+ def build_quiz_evaluation_prompt(language_mode: str, quiz_data: dict, user_answers: str) -> str:
218
+ quiz_json = json.dumps(quiz_data, ensure_ascii=False)
219
+ return f"""
220
+ You are BrainChat, an interactive tutor.
221
+
222
+ Evaluate the student's answers fairly using the quiz answer key.
223
+ Give:
224
+ - total score
225
+ - per-question feedback
226
+ - one short improvement suggestion
227
+
228
+ Rules:
229
+ - Accept semantically correct answers even if wording differs.
230
+ - Return valid JSON only.
231
+ - {language_instruction(language_mode)}
232
+
233
+ Required JSON format:
234
+ {{
235
+ "score_obtained": 0,
236
+ "score_total": 0,
237
+ "summary": "short overall feedback",
238
+ "results": [
239
+ {{
240
+ "question": "question text",
241
+ "student_answer": "student answer",
242
+ "result": "Correct / Partially Correct / Incorrect",
243
+ "feedback": "short explanation"
244
+ }}
245
+ ]
246
+ }}
247
+
248
+ Quiz data:
249
+ {quiz_json}
250
+
251
+ Student answers:
252
+ {user_answers}
253
+ """.strip()
254
+
255
+
256
+ # =====================================================
257
+ # OPENAI HELPERS
258
+ # =====================================================
259
+ def chat_text(prompt: str) -> str:
260
+ resp = CLIENT.chat.completions.create(
261
  model="gpt-4o-mini",
 
262
  temperature=0.2,
263
+ messages=[
264
+ {"role": "system", "content": "You are a helpful educational assistant."},
265
+ {"role": "user", "content": prompt},
266
+ ],
267
+ )
268
+ return resp.choices[0].message.content.strip()
269
 
270
 
271
+ def chat_json(prompt: str) -> dict:
272
+ resp = CLIENT.chat.completions.create(
273
+ model="gpt-4o-mini",
274
+ temperature=0.2,
275
+ response_format={"type": "json_object"},
276
+ messages=[
277
+ {"role": "system", "content": "Return only valid JSON."},
278
+ {"role": "user", "content": prompt},
279
+ ],
280
  )
281
+ return json.loads(resp.choices[0].message.content)
282
 
283
 
284
+ # =====================================================
285
+ # HTML RENDERING
286
+ # =====================================================
287
+ def md_to_html(text: str) -> str:
288
+ safe = (
289
+ text.replace("&", "&amp;")
290
+ .replace("<", "&lt;")
291
+ .replace(">", "&gt;")
292
+ )
293
+ safe = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", safe)
294
+ safe = safe.replace("\n", "<br>")
295
+ return safe
296
+
297
+
298
+ def render_chat(history):
299
+ if not history:
300
+ return """
301
+ <div class="empty-chat">
302
+ <div class="empty-chat-text">
303
+ Ask a question, choose a tutor mode, or start a quiz.
304
+ </div>
305
+ </div>
306
+ """
307
+
308
+ rows = []
309
+ for msg in history:
310
+ role = msg["role"]
311
+ content = md_to_html(msg["content"])
312
+
313
+ if role == "user":
314
+ rows.append(
315
+ f'<div class="msg-row user-row"><div class="msg-bubble user-bubble">{content}</div></div>'
316
+ )
317
+ else:
318
+ rows.append(
319
+ f'<div class="msg-row bot-row"><div class="msg-bubble bot-bubble">{content}</div></div>'
320
+ )
321
+
322
+ return f'<div class="chat-wrap">{"".join(rows)}</div>'
323
+
324
+
325
+ def detect_logo_url():
326
+ if os.path.exists(LOGO_FILE):
327
+ return f"/gradio_api/file={quote(LOGO_FILE)}"
328
+ return None
329
+
330
+
331
+ def render_header():
332
+ logo_url = detect_logo_url()
333
+ if logo_url:
334
+ logo_html = f"""
335
+ <img src="{logo_url}" alt="BrainChat Logo"
336
+ style="width:120px;height:120px;object-fit:contain;display:block;margin:0 auto;">
337
+ """
338
+ else:
339
+ logo_html = """
340
+ <div style="
341
+ width:120px;height:120px;border-radius:50%;
342
+ background:#efe85a;display:flex;align-items:center;justify-content:center;
343
+ font-weight:700;text-align:center;margin:0 auto;">
344
+ BRAIN<br>CHAT
345
+ </div>
346
+ """
347
+
348
+ return f"""
349
+ <div class="hero-card">
350
+ <div class="hero-inner">
351
+ <div class="hero-logo">{logo_html}</div>
352
+ <div class="hero-title">BrainChat</div>
353
+ <div class="hero-subtitle">
354
+ Interactive neurology and neuroanatomy tutor based on your uploaded books
355
+ </div>
356
+ </div>
357
+ </div>
358
+ """
359
+
360
+
361
+ # =====================================================
362
+ # MAIN LOGIC
363
+ # =====================================================
364
+ def answer_question(message, history, mode, language_mode, quiz_count_mode, show_sources, quiz_state):
365
  if history is None:
366
  history = []
367
  if quiz_state is None:
368
+ quiz_state = {
369
+ "active": False,
370
+ "topic": None,
371
+ "quiz_data": None,
372
+ "language_mode": "Auto"
373
+ }
374
+
375
+ if not message or not message.strip():
376
+ return history, render_chat(history), quiz_state, ""
377
+
378
+ try:
379
+ ensure_loaded()
380
+ user_text = message.strip()
381
+ history = history + [{"role": "user", "content": user_text}]
382
+
383
+ # -------------------------------
384
+ # QUIZ EVALUATION
385
+ # -------------------------------
386
+ if quiz_state.get("active", False):
387
+ evaluation_prompt = build_quiz_evaluation_prompt(
388
+ quiz_state["language_mode"],
389
+ quiz_state["quiz_data"],
390
+ user_text
391
+ )
392
+ evaluation = chat_json(evaluation_prompt)
393
+
394
+ lines = []
395
+ lines.append(f"**Score:** {evaluation['score_obtained']}/{evaluation['score_total']}")
396
+ lines.append("")
397
+ lines.append(f"**Overall feedback:** {evaluation['summary']}")
398
+ lines.append("")
399
+ lines.append("**Question-wise evaluation:**")
400
+
401
+ for item in evaluation["results"]:
402
+ lines.append("")
403
+ lines.append(f"**Q:** {item['question']}")
404
+ lines.append(f"**Your answer:** {item['student_answer']}")
405
+ lines.append(f"**Result:** {item['result']}")
406
+ lines.append(f"**Feedback:** {item['feedback']}")
407
+
408
+ final_answer = "\n".join(lines)
409
+ history = history + [{"role": "assistant", "content": final_answer}]
410
+
411
+ quiz_state = {
412
+ "active": False,
413
+ "topic": None,
414
+ "quiz_data": None,
415
+ "language_mode": language_mode
416
+ }
417
+
418
+ return history, render_chat(history), quiz_state, ""
419
+
420
+ # -------------------------------
421
+ # NORMAL RETRIEVAL
422
+ # -------------------------------
423
+ records = search_hybrid(user_text, shortlist_k=20, final_k=3)
424
+ context = build_context(records)
425
+
426
+ # -------------------------------
427
+ # QUIZ GENERATION
428
+ # -------------------------------
429
+ if mode == "Quiz Me":
430
+ n_questions = choose_quiz_count(user_text, quiz_count_mode)
431
+ prompt = build_quiz_generation_prompt(language_mode, user_text, context, n_questions)
432
+ quiz_data = chat_json(prompt)
433
+
434
+ lines = []
435
+ lines.append(f"**{quiz_data.get('title', 'Quiz')}**")
436
+ lines.append("")
437
+ lines.append("Please answer the following questions in one message.")
438
+ lines.append("You can reply in numbered format, for example:")
439
+ lines.append("1. ...")
440
+ lines.append("2. ...")
441
+ lines.append("")
442
+ lines.append(f"**Total questions: {len(quiz_data['questions'])}**")
443
+ lines.append("")
444
+
445
+ for i, q in enumerate(quiz_data["questions"], start=1):
446
+ lines.append(f"**Q{i}.** {q['q']}")
447
+
448
+ if show_sources:
449
+ lines.append("\n---\n**Topic sources used to create the quiz:**")
450
+ lines.append(make_sources(records))
451
+
452
+ assistant_text = "\n".join(lines)
453
+ history = history + [{"role": "assistant", "content": assistant_text}]
454
+
455
+ quiz_state = {
456
+ "active": True,
457
+ "topic": user_text,
458
+ "quiz_data": quiz_data,
459
+ "language_mode": language_mode
460
+ }
461
+
462
+ return history, render_chat(history), quiz_state, ""
463
+
464
+ # -------------------------------
465
+ # OTHER MODES
466
+ # -------------------------------
467
+ prompt = build_tutor_prompt(mode, language_mode, user_text, context)
468
+ answer = chat_text(prompt)
469
+
470
+ if show_sources:
471
+ answer += "\n\n---\n**Sources used:**\n" + make_sources(records)
472
+
473
+ history = history + [{"role": "assistant", "content": answer}]
474
+ return history, render_chat(history), quiz_state, ""
475
+
476
+ except Exception as e:
477
+ history = history + [{"role": "assistant", "content": f"Error: {str(e)}"}]
478
+ quiz_state["active"] = False
479
+ return history, render_chat(history), quiz_state, ""
480
+
481
+
482
+ def clear_all():
483
+ empty_history = []
484
+ empty_quiz = {
485
+ "active": False,
486
+ "topic": None,
487
+ "quiz_data": None,
488
+ "language_mode": "Auto"
489
+ }
490
+ return empty_history, render_chat(empty_history), empty_quiz, ""
491
+
492
+
493
+ # =====================================================
494
+ # CSS
495
+ # =====================================================
496
+ CSS = """
497
+ body, .gradio-container {
498
+ background: #dcdcdc !important;
499
+ font-family: Arial, Helvetica, sans-serif !important;
500
+ }
501
+ footer { display: none !important; }
502
+
503
+ .hero-card {
504
+ max-width: 900px;
505
+ margin: 18px auto 14px auto;
506
+ border-radius: 28px;
507
+ background: linear-gradient(180deg, #e8c7d4 0%, #a55ca2 48%, #2b0c46 100%);
508
+ padding: 22px 22px 18px 22px;
509
+ }
510
+ .hero-inner { text-align: center; }
511
+ .hero-title {
512
+ color: white;
513
+ font-size: 34px;
514
+ font-weight: 800;
515
+ margin-top: 6px;
516
+ }
517
+ .hero-subtitle {
518
+ color: white;
519
+ opacity: 0.92;
520
+ font-size: 16px;
521
+ margin-top: 6px;
522
+ }
523
+
524
+ .chat-panel {
525
+ max-width: 900px;
526
+ margin: 0 auto;
527
+ background: white;
528
+ border-radius: 22px;
529
+ padding: 16px;
530
+ min-height: 420px;
531
+ box-shadow: 0 6px 18px rgba(0,0,0,0.08);
532
+ }
533
+
534
+ .chat-wrap {
535
+ display: flex;
536
+ flex-direction: column;
537
+ gap: 14px;
538
+ }
539
+
540
+ .msg-row {
541
+ display: flex;
542
+ width: 100%;
543
+ }
544
+
545
+ .user-row {
546
+ justify-content: flex-end;
547
+ }
548
+
549
+ .bot-row {
550
+ justify-content: flex-start;
551
+ }
552
+
553
+ .msg-bubble {
554
+ max-width: 80%;
555
+ padding: 14px 16px;
556
+ border-radius: 18px;
557
+ line-height: 1.5;
558
+ font-size: 15px;
559
+ word-wrap: break-word;
560
+ }
561
+
562
+ .user-bubble {
563
+ background: #e9d8ff;
564
+ color: #111;
565
+ border-bottom-right-radius: 6px;
566
+ }
567
+
568
+ .bot-bubble {
569
+ background: #f7f3a1;
570
+ color: #111;
571
+ border-bottom-left-radius: 6px;
572
+ }
573
+
574
+ .empty-chat {
575
+ display: flex;
576
+ justify-content: center;
577
+ align-items: center;
578
+ min-height: 360px;
579
+ }
580
+
581
+ .empty-chat-text {
582
+ color: #777;
583
+ font-size: 16px;
584
+ text-align: center;
585
+ }
586
+
587
+ .controls-wrap {
588
+ max-width: 900px;
589
+ margin: 0 auto;
590
+ }
591
  """
 
 
 
 
592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
 
594
+ # =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  # UI
596
+ # =====================================================
 
 
 
 
 
 
 
 
 
 
597
  with gr.Blocks() as demo:
598
+ history_state = gr.State([])
599
+ quiz_state = gr.State({
600
+ "active": False,
601
+ "topic": None,
602
+ "quiz_data": None,
603
+ "language_mode": "Auto"
604
+ })
605
+
606
+ gr.HTML(render_header())
607
+
608
+ with gr.Row(elem_classes="controls-wrap"):
609
+ mode = gr.Dropdown(
610
+ choices=["Explain", "Detailed", "Short Notes", "Flashcards", "Case-Based", "Quiz Me"],
611
+ value="Explain",
612
+ label="Tutor Mode"
613
+ )
614
+ language_mode = gr.Dropdown(
615
+ choices=["Auto", "English", "Spanish", "Bilingual"],
616
+ value="Auto",
617
+ label="Answer Language"
618
+ )
619
+
620
+ with gr.Row(elem_classes="controls-wrap"):
621
+ quiz_count_mode = gr.Dropdown(
622
+ choices=["Auto", "3", "5", "7"],
623
+ value="Auto",
624
+ label="Quiz Questions"
625
+ )
626
+ show_sources = gr.Checkbox(value=True, label="Show Sources")
627
+
628
+ gr.Markdown("""
629
+ **How to use**
630
+ - Choose a **Tutor Mode**
631
+ - Then type a topic or question
632
+ - For **Quiz Me**, type a topic such as: `cranial nerves`
633
+ - The system will ask questions, and your **next message will be evaluated automatically**
634
+ """)
635
 
636
+ chat_html = gr.HTML(render_chat([]), elem_classes="chat-panel")
637
 
638
+ with gr.Row(elem_classes="controls-wrap"):
639
+ msg = gr.Textbox(
640
+ placeholder="Ask a question or type a topic...",
641
+ lines=1,
642
+ show_label=False,
643
+ scale=8
644
+ )
645
+ send_btn = gr.Button("Send", scale=1)
646
 
647
+ with gr.Row(elem_classes="controls-wrap"):
648
+ clear_btn = gr.Button("Clear Chat")
649
 
650
+ msg.submit(
651
+ answer_question,
652
+ inputs=[msg, history_state, mode, language_mode, quiz_count_mode, show_sources, quiz_state],
653
+ outputs=[history_state, chat_html, quiz_state, msg]
654
+ )
655
 
656
+ send_btn.click(
657
  answer_question,
658
+ inputs=[msg, history_state, mode, language_mode, quiz_count_mode, show_sources, quiz_state],
659
+ outputs=[history_state, chat_html, quiz_state, msg]
660
  )
661
 
662
+ clear_btn.click(
663
+ clear_all,
664
+ inputs=[],
665
+ outputs=[history_state, chat_html, quiz_state, msg]
666
+ )
667
 
668
+ if __name__ == "__main__":
669
+ demo.launch(css=CSS)