Deevyankar commited on
Commit
2fead40
·
verified ·
1 Parent(s): 1cf85b1

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +679 -0
app.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import pickle
5
+ from urllib.parse import quote
6
+
7
+ import numpy as np
8
+ import gradio as gr
9
+ from rank_bm25 import BM25Okapi
10
+ from sentence_transformers import SentenceTransformer
11
+ from openai import OpenAI
12
+
13
+ # ============================================================
14
+ # Configuration
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")
19
+ EMBED_PATH = os.path.join(BUILD_DIR, "embeddings.npy")
20
+ CONFIG_PATH = os.path.join(BUILD_DIR, "config.json")
21
+
22
+ # Put ONE of these logo files in your Space repo root (same folder as app.py)
23
+ LOGO_CANDIDATES = [
24
+ "Brain chat-09.png",
25
+ "brainchat_logo.png.png",
26
+ "Brain Chat Imagen.svg",
27
+ "ebcbb9f5-022f-473a-bf51-7e7974f794b4.png",
28
+ ]
29
+
30
+ MODEL_NAME_TEXT = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
31
+
32
+ # ============================================================
33
+ # Globals (lazy loaded)
34
+ # ============================================================
35
+ BM25 = None
36
+ CHUNKS = None
37
+ EMBEDDINGS = None
38
+ EMBED_MODEL = None
39
+ CLIENT = None
40
+
41
+
42
+ # ============================================================
43
+ # Utilities
44
+ # ============================================================
45
+ def tokenize(text: str):
46
+ return re.findall(r"\w+", text.lower(), flags=re.UNICODE)
47
+
48
+
49
+ def ensure_loaded():
50
+ global BM25, CHUNKS, EMBEDDINGS, EMBED_MODEL, CLIENT
51
+
52
+ if CHUNKS is None:
53
+ missing = [p for p in [CHUNKS_PATH, TOKENS_PATH, EMBED_PATH, CONFIG_PATH] if not os.path.exists(p)]
54
+ if missing:
55
+ raise FileNotFoundError(
56
+ "Missing build files. Make sure you ran the build step and committed brainchat_build/.\n"
57
+ + "\n".join(missing)
58
+ )
59
+
60
+ with open(CHUNKS_PATH, "rb") as f:
61
+ CHUNKS = pickle.load(f)
62
+
63
+ with open(TOKENS_PATH, "rb") as f:
64
+ tokenized_chunks = pickle.load(f)
65
+
66
+ EMBEDDINGS = np.load(EMBED_PATH)
67
+
68
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
69
+ cfg = json.load(f)
70
+
71
+ BM25 = BM25Okapi(tokenized_chunks)
72
+ EMBED_MODEL = SentenceTransformer(cfg["embedding_model"])
73
+
74
+ if CLIENT is None:
75
+ api_key = os.getenv("OPENAI_API_KEY")
76
+ if not api_key:
77
+ raise ValueError("OPENAI_API_KEY is missing. Add it in your Space Secrets.")
78
+ CLIENT = OpenAI(api_key=api_key)
79
+
80
+
81
+ def search_hybrid(query: str, shortlist_k: int = 30, final_k: int = 5):
82
+ ensure_loaded()
83
+
84
+ q_tokens = tokenize(query)
85
+ bm25_scores = BM25.get_scores(q_tokens)
86
+ shortlist_idx = np.argsort(bm25_scores)[::-1][:shortlist_k]
87
+
88
+ qvec = EMBED_MODEL.encode([query], normalize_embeddings=True).astype("float32")[0]
89
+ shortlist_emb = EMBEDDINGS[shortlist_idx]
90
+ dense_scores = shortlist_emb @ qvec
91
+
92
+ rerank = np.argsort(dense_scores)[::-1][:final_k]
93
+ final_idx = shortlist_idx[rerank]
94
+ return [CHUNKS[int(i)] for i in final_idx]
95
+
96
+
97
+ def build_context(records):
98
+ blocks = []
99
+ for i, r in enumerate(records, start=1):
100
+ blocks.append(
101
+ f"""[Source {i}]
102
+ Book: {r.get('book','')}
103
+ Section: {r.get('section_title','')}
104
+ Pages: {r.get('page_start','')}-{r.get('page_end','')}
105
+ Text:
106
+ {r.get('text','')}"""
107
+ )
108
+ return "\n\n".join(blocks)
109
+
110
+
111
+ def make_sources(records):
112
+ seen = set()
113
+ lines = []
114
+ for r in records:
115
+ key = (r.get("book"), r.get("section_title"), r.get("page_start"), r.get("page_end"))
116
+ if key in seen:
117
+ continue
118
+ seen.add(key)
119
+ lines.append(
120
+ f"• {r.get('book','')} | {r.get('section_title','')} | pp. {r.get('page_start','')}-{r.get('page_end','')}"
121
+ )
122
+ return "\n".join(lines)
123
+
124
+
125
+ def choose_quiz_count(user_text: str, selector: str) -> int:
126
+ if selector in {"3", "5", "7"}:
127
+ return int(selector)
128
+
129
+ t = user_text.lower()
130
+ if any(k in t for k in ["mock test", "final exam", "exam practice", "full test"]):
131
+ return 7
132
+ if any(k in t for k in ["detailed", "revision", "comprehensive", "study"]):
133
+ return 5
134
+ return 3
135
+
136
+
137
+ def language_instruction(language_mode: str) -> str:
138
+ if language_mode == "English":
139
+ return "Answer only in English."
140
+ if language_mode == "Spanish":
141
+ return "Answer only in Spanish."
142
+ if language_mode == "Bilingual":
143
+ return "Answer first in English, then provide a Spanish version under the heading 'Español:'."
144
+ return "If the user writes in Spanish, answer in Spanish; otherwise answer in English."
145
+
146
+
147
+ def build_tutor_prompt(mode: str, language_mode: str, question: str, context: str) -> str:
148
+ mode_map = {
149
+ "Explain": (
150
+ "Explain clearly like a friendly tutor using simple language. "
151
+ "Use short headings if helpful."
152
+ ),
153
+ "Detailed": (
154
+ "Give a detailed explanation. Include key terms and clinical relevance only if supported by the context."
155
+ ),
156
+ "Short Notes": "Write concise revision notes using bullet points.",
157
+ "Flashcards": "Create 6 flashcards in Q/A format.",
158
+ "Case-Based": (
159
+ "Create a short clinical scenario (2–4 lines) and then explain the underlying concept using the context."
160
+ ),
161
+ }
162
+
163
+ return f"""
164
+ You are BrainChat, an interactive neurology and neuroanatomy tutor.
165
+
166
+ Rules:
167
+ - Use ONLY the provided context from the books.
168
+ - If the answer is not supported by the context, say exactly:
169
+ Not found in the course material.
170
+ - Do not invent facts outside the context.
171
+ - {language_instruction(language_mode)}
172
+
173
+ Teaching style:
174
+ {mode_map.get(mode, mode_map['Explain'])}
175
+
176
+ Context:
177
+ {context}
178
+
179
+ Student question:
180
+ {question}
181
+ """.strip()
182
+
183
+
184
+ def build_quiz_generation_prompt(language_mode: str, topic: str, context: str, n_questions: int) -> str:
185
+ return f"""
186
+ You are BrainChat, an interactive tutor.
187
+
188
+ Rules:
189
+ - Use ONLY the provided context.
190
+ - Create exactly {n_questions} quiz questions.
191
+ - Questions should be short, clear, and course-aligned.
192
+ - Provide a short answer key per question.
193
+ - Return VALID JSON only.
194
+ - {language_instruction(language_mode)}
195
+
196
+ Required JSON format:
197
+ {{
198
+ "title": "short quiz title",
199
+ "questions": [
200
+ {{"q": "question 1", "answer_key": "expected short answer"}},
201
+ {{"q": "question 2", "answer_key": "expected short answer"}}
202
+ ]
203
+ }}
204
+
205
+ Context:
206
+ {context}
207
+
208
+ Topic:
209
+ {topic}
210
+ """.strip()
211
+
212
+
213
+ def build_quiz_evaluation_prompt(language_mode: str, quiz_data: dict, user_answers: str) -> str:
214
+ quiz_json = json.dumps(quiz_data, ensure_ascii=False)
215
+ return f"""
216
+ You are BrainChat, an interactive tutor.
217
+
218
+ Task:
219
+ Evaluate the student's answers fairly against the answer keys.
220
+ Accept semantically correct answers even if wording differs.
221
+
222
+ Return VALID JSON only.
223
+
224
+ Required JSON format:
225
+ {{
226
+ "score_obtained": 0,
227
+ "score_total": 0,
228
+ "summary": "short overall feedback",
229
+ "results": [
230
+ {{
231
+ "question": "question text",
232
+ "answer_key": "expected short answer",
233
+ "student_answer": "student answer",
234
+ "result": "Correct / Partially Correct / Incorrect",
235
+ "feedback": "short explanation"
236
+ }}
237
+ ],
238
+ "improvement_tip": "one short study suggestion"
239
+ }}
240
+
241
+ Quiz:
242
+ {quiz_json}
243
+
244
+ Student answers:
245
+ {user_answers}
246
+
247
+ Language:
248
+ {language_instruction(language_mode)}
249
+ """.strip()
250
+
251
+
252
+ def chat_text(prompt: str) -> str:
253
+ ensure_loaded()
254
+ resp = CLIENT.chat.completions.create(
255
+ model=MODEL_NAME_TEXT,
256
+ temperature=0.2,
257
+ messages=[
258
+ {"role": "system", "content": "You are a helpful educational assistant."},
259
+ {"role": "user", "content": prompt},
260
+ ],
261
+ )
262
+ return resp.choices[0].message.content.strip()
263
+
264
+
265
+ def chat_json(prompt: str) -> dict:
266
+ ensure_loaded()
267
+ resp = CLIENT.chat.completions.create(
268
+ model=MODEL_NAME_TEXT,
269
+ temperature=0.2,
270
+ response_format={"type": "json_object"},
271
+ messages=[
272
+ {"role": "system", "content": "Return only valid JSON."},
273
+ {"role": "user", "content": prompt},
274
+ ],
275
+ )
276
+ return json.loads(resp.choices[0].message.content)
277
+
278
+
279
+ # ============================================================
280
+ # Logo + Header HTML
281
+ # ============================================================
282
+ def find_logo_file():
283
+ for name in LOGO_CANDIDATES:
284
+ if os.path.exists(name):
285
+ return name
286
+ return None
287
+
288
+
289
+ def logo_img_tag(size_px: int = 88) -> str:
290
+ logo_file = find_logo_file()
291
+ if logo_file:
292
+ url = f"/gradio_api/file={quote(logo_file)}"
293
+ return f'<img src="{url}" class="bc-logo-img" width="{size_px}" height="{size_px}" alt="BrainChat logo" />'
294
+ return '<div class="bc-logo-fallback">BRAIN<br>CHAT</div>'
295
+
296
+
297
+ def render_top_banner() -> str:
298
+ return f"""
299
+ <div class="bc-banner">
300
+ <div class="bc-banner-inner">
301
+ <div class="bc-banner-logo">{logo_img_tag(64)}</div>
302
+ <div class="bc-banner-text">
303
+ <div class="bc-banner-title">BrainChat</div>
304
+ <div class="bc-banner-subtitle">Neurology & neuroanatomy tutor (book-based)</div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ """.strip()
309
+
310
+
311
+ def render_phone_logo() -> str:
312
+ return f"""
313
+ <div class="bc-phone-logo">
314
+ {logo_img_tag(84)}
315
+ </div>
316
+ """.strip()
317
+
318
+
319
+ # ============================================================
320
+ # Chat logic (with quiz state)
321
+ # ============================================================
322
+ def respond(message, history, mode, language_mode, quiz_count_mode, show_sources, quiz_state):
323
+ if history is None:
324
+ history = []
325
+ if quiz_state is None:
326
+ quiz_state = {"active": False, "quiz_data": None, "language_mode": "Auto"}
327
+
328
+ user_text = (message or "").strip()
329
+ if not user_text:
330
+ return "", history, quiz_state
331
+
332
+ try:
333
+ history = history + [{"role": "user", "content": user_text}]
334
+
335
+ # Quiz evaluation step
336
+ if quiz_state.get("active", False):
337
+ evaluation_prompt = build_quiz_evaluation_prompt(
338
+ quiz_state.get("language_mode", language_mode),
339
+ quiz_state.get("quiz_data", {}),
340
+ user_text,
341
+ )
342
+ evaluation = chat_json(evaluation_prompt)
343
+
344
+ lines = []
345
+ lines.append(f"**Score:** {evaluation.get('score_obtained', 0)}/{evaluation.get('score_total', 0)}")
346
+ if evaluation.get("summary"):
347
+ lines.append(f"\n**Overall:** {evaluation['summary']}")
348
+ if evaluation.get("improvement_tip"):
349
+ lines.append(f"\n**Tip:** {evaluation['improvement_tip']}\n")
350
+
351
+ results = evaluation.get("results", [])
352
+ if results:
353
+ lines.append("**Question-wise feedback:**")
354
+ for item in results:
355
+ lines.append("")
356
+ lines.append(f"**Q:** {item.get('question','')}")
357
+ lines.append(f"**Your answer:** {item.get('student_answer','')}")
358
+ lines.append(f"**Expected:** {item.get('answer_key','')}")
359
+ lines.append(f"**Result:** {item.get('result','')}")
360
+ lines.append(f"**Feedback:** {item.get('feedback','')}")
361
+
362
+ assistant_text = "\n".join(lines).strip()
363
+ history = history + [{"role": "assistant", "content": assistant_text}]
364
+
365
+ quiz_state = {"active": False, "quiz_data": None, "language_mode": language_mode}
366
+ return "", history, quiz_state
367
+
368
+ # Normal retrieval
369
+ records = search_hybrid(user_text, shortlist_k=30, final_k=5)
370
+ context = build_context(records)
371
+
372
+ # Quiz generation
373
+ if mode == "Quiz Me":
374
+ n_questions = choose_quiz_count(user_text, quiz_count_mode)
375
+ quiz_prompt = build_quiz_generation_prompt(language_mode, user_text, context, n_questions)
376
+ quiz_data = chat_json(quiz_prompt)
377
+
378
+ lines = []
379
+ lines.append(f"**{quiz_data.get('title','Quiz')}**")
380
+ lines.append(f"\n**Total questions:** {len(quiz_data.get('questions', []))}\n")
381
+ lines.append("Reply in ONE message with numbered answers, like:")
382
+ lines.append("1. ...")
383
+ lines.append("2. ...\n")
384
+
385
+ for i, q in enumerate(quiz_data.get("questions", []), start=1):
386
+ lines.append(f"**Q{i}.** {q.get('q','')}")
387
+
388
+ if show_sources:
389
+ lines.append("\n\n**Sources used to create this quiz:**")
390
+ lines.append(make_sources(records))
391
+
392
+ assistant_text = "\n".join(lines).strip()
393
+ history = history + [{"role": "assistant", "content": assistant_text}]
394
+
395
+ quiz_state = {"active": True, "quiz_data": quiz_data, "language_mode": language_mode}
396
+ return "", history, quiz_state
397
+
398
+ # Other modes
399
+ tutor_prompt = build_tutor_prompt(mode, language_mode, user_text, context)
400
+ answer = chat_text(tutor_prompt)
401
+
402
+ if show_sources:
403
+ answer = (answer or "").strip() + "\n\n**Sources:**\n" + make_sources(records)
404
+
405
+ history = history + [{"role": "assistant", "content": answer.strip()}]
406
+ return "", history, quiz_state
407
+
408
+ except Exception as e:
409
+ history = history + [{"role": "assistant", "content": f"Error: {str(e)}"}]
410
+ quiz_state = {"active": False, "quiz_data": None, "language_mode": language_mode}
411
+ return "", history, quiz_state
412
+
413
+
414
+ def clear_all():
415
+ return "", [], {"active": False, "quiz_data": None, "language_mode": "Auto"}
416
+
417
+
418
+ # ============================================================
419
+ # CSS (Instagram-style phone mock)
420
+ # ============================================================
421
+ CSS = r"""
422
+ :root{
423
+ --bc-page-bg: #dcdcdc;
424
+ --bc-grad-top: #E8C7D4;
425
+ --bc-grad-mid: #A55CA2;
426
+ --bc-grad-bot: #2B0C46;
427
+ --bc-yellow: #FFF34A;
428
+ --bc-bot-bubble: #FAF7B4;
429
+ --bc-user-bubble: #FFFFFF;
430
+ --bc-ink: #141414;
431
+ }
432
+
433
+ body, .gradio-container{
434
+ background: var(--bc-page-bg) !important;
435
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
436
+ }
437
+ footer{ display:none !important; }
438
+
439
+ /* Banner */
440
+ #bc_banner{ max-width: 980px; margin: 18px auto 8px auto; }
441
+ .bc-banner{
442
+ background: linear-gradient(180deg, var(--bc-grad-top) 0%, var(--bc-grad-mid) 52%, var(--bc-grad-bot) 100%);
443
+ border-radius: 26px;
444
+ padding: 14px 16px;
445
+ box-shadow: 0 10px 26px rgba(0,0,0,.12);
446
+ }
447
+ .bc-banner-inner{ display:flex; align-items:center; gap: 12px; color: white; }
448
+ .bc-banner-title{ font-size: 20px; font-weight: 800; line-height:1.1; }
449
+ .bc-banner-subtitle{ font-size: 13px; opacity:.92; margin-top:2px; }
450
+ .bc-banner-logo .bc-logo-img{ border-radius: 999px; background: var(--bc-yellow); padding: 6px; display:block; }
451
+ .bc-logo-fallback{
452
+ width: 64px; height: 64px;
453
+ border-radius: 999px;
454
+ background: var(--bc-yellow);
455
+ display:flex; align-items:center; justify-content:center;
456
+ color: #111; font-weight: 900; font-size: 12px; text-align:center;
457
+ }
458
+
459
+ /* Settings */
460
+ #bc_settings{ max-width: 980px; margin: 0 auto 10px auto; }
461
+ #bc_settings .label{ font-weight: 700; }
462
+
463
+ /* Phone */
464
+ #bc_phone{
465
+ max-width: 420px;
466
+ margin: 0 auto 18px auto;
467
+ border-radius: 38px;
468
+ background: linear-gradient(180deg, var(--bc-grad-top) 0%, var(--bc-grad-mid) 45%, var(--bc-grad-bot) 100%);
469
+ box-shadow: 0 18px 40px rgba(0,0,0,.18);
470
+ border: 1px solid rgba(255,255,255,.22);
471
+ padding: 14px 14px 12px 14px;
472
+ position: relative;
473
+ }
474
+
475
+ /* Floating logo in phone */
476
+ #bc_phone_logo{
477
+ position: absolute;
478
+ top: 12px;
479
+ left: 50%;
480
+ transform: translateX(-50%);
481
+ z-index: 10;
482
+ }
483
+ .bc-phone-logo{
484
+ width: 92px; height: 92px;
485
+ border-radius: 999px;
486
+ background: var(--bc-yellow);
487
+ display:flex; align-items:center; justify-content:center;
488
+ box-shadow: 0 10px 22px rgba(0,0,0,.18);
489
+ }
490
+ .bc-phone-logo .bc-logo-img{
491
+ width: 84px !important; height: 84px !important; object-fit: contain;
492
+ }
493
+
494
+ /* Push chat down under logo */
495
+ #bc_chatbot{ margin-top: 92px; }
496
+
497
+ /* Chatbot transparent */
498
+ #bc_chatbot, #bc_chatbot > div{
499
+ background: transparent !important;
500
+ border: none !important;
501
+ box-shadow: none !important;
502
+ }
503
+ #bc_chatbot .toolbar{ display:none !important; }
504
+
505
+ /* Bubble styling via internal testid markers */
506
+ #bc_chatbot button[data-testid="user"],
507
+ #bc_chatbot button[data-testid="bot"]{
508
+ max-width: 82%;
509
+ border-radius: 18px !important;
510
+ padding: 12px 14px !important;
511
+ color: var(--bc-ink) !important;
512
+ box-shadow: 0 8px 18px rgba(0,0,0,.10);
513
+ border: 0 !important;
514
+ line-height: 1.35;
515
+ font-size: 14px;
516
+ }
517
+
518
+ /* User bubble white */
519
+ #bc_chatbot button[data-testid="user"]{
520
+ background: var(--bc-user-bubble) !important;
521
+ }
522
+
523
+ /* Bot bubble pale yellow */
524
+ #bc_chatbot button[data-testid="bot"]{
525
+ background: var(--bc-bot-bubble) !important;
526
+ }
527
+
528
+ /* Bubble tails */
529
+ #bc_chatbot button[data-testid="user"]::after{
530
+ content:"";
531
+ position:absolute;
532
+ right:-7px;
533
+ bottom: 12px;
534
+ width:0; height:0;
535
+ border-left: 10px solid var(--bc-user-bubble);
536
+ border-top: 8px solid transparent;
537
+ border-bottom: 8px solid transparent;
538
+ }
539
+ #bc_chatbot button[data-testid="bot"]::before{
540
+ content:"";
541
+ position:absolute;
542
+ left:-7px;
543
+ bottom: 12px;
544
+ width:0; height:0;
545
+ border-right: 10px solid var(--bc-bot-bubble);
546
+ border-top: 8px solid transparent;
547
+ border-bottom: 8px solid transparent;
548
+ }
549
+
550
+ /* Input bar */
551
+ #bc_input_row{
552
+ margin-top: 10px;
553
+ background: rgba(255,243,74,.96);
554
+ border-radius: 999px;
555
+ padding: 10px 10px;
556
+ box-shadow: 0 10px 22px rgba(0,0,0,.14);
557
+ align-items: center;
558
+ }
559
+ #bc_plus{
560
+ width: 34px; height: 34px;
561
+ border-radius: 999px;
562
+ display:flex;
563
+ align-items:center;
564
+ justify-content:center;
565
+ font-weight: 900;
566
+ color: var(--bc-grad-bot);
567
+ background: rgba(255,255,255,.35);
568
+ user-select: none;
569
+ }
570
+ #bc_msg textarea{
571
+ background: rgba(255,255,255,.35) !important;
572
+ border-radius: 999px !important;
573
+ border: none !important;
574
+ padding: 10px 12px !important;
575
+ color: var(--bc-grad-bot) !important;
576
+ box-shadow: none !important;
577
+ }
578
+ #bc_send{
579
+ min-width: 42px !important;
580
+ height: 38px !important;
581
+ border-radius: 999px !important;
582
+ border: none !important;
583
+ background: rgba(255,255,255,.35) !important;
584
+ color: var(--bc-grad-bot) !important;
585
+ font-size: 18px !important;
586
+ font-weight: 900 !important;
587
+ }
588
+ #bc_send:hover{ background: rgba(255,255,255,.55) !important; }
589
+
590
+ /* Clear */
591
+ #bc_clear{
592
+ max-width: 420px;
593
+ margin: 10px auto 0 auto;
594
+ border-radius: 14px !important;
595
+ }
596
+
597
+ @media (max-width: 480px){
598
+ #bc_phone{ max-width: 95vw; }
599
+ #bc_chatbot button[data-testid="user"],
600
+ #bc_chatbot button[data-testid="bot"]{
601
+ max-width: 88%;
602
+ font-size: 14px;
603
+ }
604
+ }
605
+ """
606
+
607
+ # ============================================================
608
+ # UI
609
+ # ============================================================
610
+ with gr.Blocks() as demo:
611
+ quiz_state = gr.State({"active": False, "quiz_data": None, "language_mode": "Auto"})
612
+
613
+ gr.HTML(render_top_banner(), elem_id="bc_banner")
614
+
615
+ with gr.Accordion("Settings", open=False, elem_id="bc_settings"):
616
+ mode = gr.Dropdown(
617
+ choices=["Explain", "Detailed", "Short Notes", "Flashcards", "Case-Based", "Quiz Me"],
618
+ value="Explain",
619
+ label="Tutor Mode",
620
+ )
621
+ language_mode = gr.Dropdown(
622
+ choices=["Auto", "English", "Spanish", "Bilingual"],
623
+ value="Auto",
624
+ label="Answer Language",
625
+ )
626
+ quiz_count_mode = gr.Dropdown(
627
+ choices=["Auto", "3", "5", "7"],
628
+ value="Auto",
629
+ label="Quiz Questions",
630
+ )
631
+ show_sources = gr.Checkbox(value=True, label="Show Sources")
632
+
633
+ with gr.Group(elem_id="bc_phone"):
634
+ gr.HTML(render_phone_logo(), elem_id="bc_phone_logo")
635
+
636
+ chatbot = gr.Chatbot(
637
+ value=[],
638
+ elem_id="bc_chatbot",
639
+ height=560,
640
+ layout="bubble",
641
+ container=False,
642
+ show_label=False,
643
+ autoscroll=True,
644
+ buttons=[],
645
+ placeholder="Ask a question or type a topic…",
646
+ )
647
+
648
+ with gr.Row(elem_id="bc_input_row"):
649
+ gr.HTML("<div>+</div>", elem_id="bc_plus")
650
+ msg = gr.Textbox(
651
+ placeholder="Type a message…",
652
+ show_label=False,
653
+ container=False,
654
+ scale=8,
655
+ elem_id="bc_msg",
656
+ )
657
+ send_btn = gr.Button("➤", elem_id="bc_send", scale=1)
658
+
659
+ clear_btn = gr.Button("Clear chat", elem_id="bc_clear")
660
+
661
+ msg.submit(
662
+ respond,
663
+ inputs=[msg, chatbot, mode, language_mode, quiz_count_mode, show_sources, quiz_state],
664
+ outputs=[msg, chatbot, quiz_state],
665
+ )
666
+ send_btn.click(
667
+ respond,
668
+ inputs=[msg, chatbot, mode, language_mode, quiz_count_mode, show_sources, quiz_state],
669
+ outputs=[msg, chatbot, quiz_state],
670
+ )
671
+ clear_btn.click(
672
+ clear_all,
673
+ inputs=None,
674
+ outputs=[msg, chatbot, quiz_state],
675
+ queue=False,
676
+ )
677
+
678
+ if __name__ == "__main__":
679
+ demo.launch(css=CSS)