Babajaan commited on
Commit
28ad690
Β·
verified Β·
1 Parent(s): efd19a4

Fix: ChatInterface examples must be list-of-lists matching additional_inputs count

Browse files
Files changed (1) hide show
  1. app.py +92 -243
app.py CHANGED
@@ -12,10 +12,7 @@ Architecture:
12
 
13
  import gradio as gr
14
  import numpy as np
15
- import json
16
  import os
17
- import re
18
- import time
19
  from pathlib import Path
20
 
21
  # ── Conditional imports with fallbacks ────────────────────────────────────────
@@ -60,6 +57,9 @@ CHUNK_SIZE = 400
60
  CHUNK_OVERLAP = 60
61
  TOP_K_RETRIEVAL = 3
62
 
 
 
 
63
 
64
  # ============================================================================
65
  # BACKEND SERVICES β€” Singleton Pattern
@@ -104,30 +104,11 @@ class LLMService:
104
  def is_available(self):
105
  return self.client is not None
106
 
107
- def _format_messages(self, messages, system_prompt=None, rag_context=None):
108
- """Build message list with optional system prompt and RAG context."""
109
- formatted = []
110
- if system_prompt:
111
- formatted.append({"role": "system", "content": system_prompt})
112
- if rag_context:
113
- formatted.append({"role": "system", "content": rag_context})
114
- # Add conversation history (already formatted)
115
- for m in messages:
116
- if isinstance(m, dict) and "role" in m:
117
- formatted.append(m)
118
- elif isinstance(m, (list, tuple)) and len(m) >= 2:
119
- # Handle tuple format (text, response)
120
- formatted.append({"role": "user", "content": str(m[0])})
121
- if len(m) > 1 and m[1]:
122
- formatted.append({"role": "assistant", "content": str(m[1])})
123
- return formatted
124
-
125
  def stream_chat(self, messages, temperature=0.7, max_tokens=1024):
126
  """Stream chat completion. Yields partial response strings."""
127
  if not self.is_available():
128
  yield self._fallback_response(messages)
129
  return
130
-
131
  try:
132
  partial = ""
133
  for chunk in self.client.chat_completion(
@@ -152,7 +133,6 @@ class LLMService:
152
  """Non-streaming generation. Returns complete response."""
153
  if not self.is_available():
154
  return self._fallback_response(messages)
155
-
156
  try:
157
  response = self.client.chat_completion(
158
  messages=messages,
@@ -164,30 +144,25 @@ class LLMService:
164
  return response.choices[0].message.content
165
  except Exception as e:
166
  print(f"LLM generate error: {e}")
167
- return f"⚠️ LLM API error: {str(e)}\n\nThe tutor can still answer from its knowledge base. Try asking about specific concepts like 'DESeq2 normalization' or 'variant calling pipeline'!"
168
 
169
  def _fallback_response(self, messages):
170
  """Knowledge-base fallback when LLM unavailable."""
171
- # Extract user query
172
  user_msg = ""
173
  for m in reversed(messages):
174
  if isinstance(m, dict) and m.get("role") == "user":
175
  user_msg = m.get("content", "").lower()
176
  break
177
-
178
  if not user_msg:
179
  return "⚠️ **LLM not available.** Add HF_TOKEN in Space settings to enable AI responses.\n\nMeanwhile, the knowledge base covers: DESeq2, variant calling, microbiome diversity, scRNA-seq clustering, and more. Try asking a specific question!"
180
 
181
  response_parts = []
182
-
183
- # Glossary match
184
  for term, definition in GLOSSARY.items():
185
  if term.lower() in user_msg:
186
  response_parts.append(f"**{term}**: {definition}")
187
  if len(response_parts) >= 3:
188
  break
189
 
190
- # Workflow match
191
  for wf_key, wf in WORKFLOWS.items():
192
  if any(kw in user_msg for kw in wf["name"].lower().split()):
193
  response_parts.append(f"\n### {wf['name']}")
@@ -195,7 +170,6 @@ class LLMService:
195
  response_parts.append(f"**Step {step['step']}: {step['name']}**\n{step['description']}")
196
  break
197
 
198
- # Misconception match
199
  for misc in COMMON_MISCONCEPTIONS:
200
  if misc["domain"].replace("_", " ") in user_msg or any(w in user_msg for w in misc["misconception"].lower().split()[:5]):
201
  response_parts.append(f"\n⚠️ **Common Misconception**: {misc['misconception']}\n\nβœ… **Correction**: {misc['correction']}")
@@ -235,16 +209,14 @@ class RAGService:
235
  self._build_kb_index()
236
 
237
  def _ensure_embedder(self):
238
- """Lazy load the embedding model."""
239
  if self.embedder is not None:
240
  return True
241
  if not HAS_ST:
242
  return False
243
  try:
244
- print("RAGService: Loading embedding model (this may take a moment)...")
245
  self.embedder = SentenceTransformer(EMBED_MODEL)
246
  print("RAGService: Embedding model loaded")
247
- # Now embed the KB
248
  if self.kb_chunks:
249
  self.kb_embeddings = self.embedder.encode(
250
  self.kb_chunks,
@@ -260,16 +232,11 @@ class RAGService:
260
  return False
261
 
262
  def _build_kb_index(self):
263
- """Build searchable chunks from knowledge base."""
264
  chunks = []
265
  metadata = []
266
-
267
- # Glossary
268
  for term, definition in GLOSSARY.items():
269
  chunks.append(f"{term}: {definition}")
270
  metadata.append({"source": "glossary", "topic": term, "type": "definition"})
271
-
272
- # Workflows
273
  for wf_key, wf in WORKFLOWS.items():
274
  for step in wf["steps"]:
275
  step_text = f"{wf['name']} - Step {step['step']}: {step['name']}. {step['description']}"
@@ -285,8 +252,6 @@ class RAGService:
285
  "step": step["step"],
286
  "workflow": wf_key
287
  })
288
-
289
- # Misconceptions
290
  for misc in COMMON_MISCONCEPTIONS:
291
  text = f"Misconception: {misc['misconception']} Correction: {misc['correction']}"
292
  chunks.append(text)
@@ -296,32 +261,24 @@ class RAGService:
296
  "type": "misconception",
297
  "severity": misc["severity"]
298
  })
299
-
300
- # Taxonomy
301
  for key, domain in DOMAIN_TAXONOMY.items():
302
  text = f"{domain['name']} covers: {', '.join(domain['subtopics'][:10])}"
303
  chunks.append(text)
304
  metadata.append({"source": "taxonomy", "topic": key, "type": "domain_overview"})
305
-
306
  self.kb_chunks = chunks
307
  self.kb_metadata = metadata
308
  print(f"RAGService: Built KB with {len(chunks)} chunks")
309
 
310
  def search(self, query, top_k=TOP_K_RETRIEVAL, user_chunks=None, user_embeddings=None):
311
- """Search KB and optionally user-uploaded content."""
312
  if not self._ensure_embedder():
313
  return self._keyword_search(query, top_k)
314
-
315
  try:
316
  query_embedding = self.embedder.encode(
317
  [query],
318
  convert_to_numpy=True,
319
  normalize_embeddings=True,
320
  )
321
-
322
  results = []
323
-
324
- # Search KB
325
  if self.kb_embeddings is not None and len(self.kb_embeddings) > 0:
326
  kb_scores = np.dot(query_embedding, self.kb_embeddings.T)[0]
327
  top_indices = np.argsort(kb_scores)[::-1][:top_k]
@@ -332,8 +289,6 @@ class RAGService:
332
  "score": float(kb_scores[idx]),
333
  "metadata": self.kb_metadata[idx]
334
  })
335
-
336
- # Search user content
337
  if user_chunks and user_embeddings is not None and len(user_embeddings) > 0:
338
  user_scores = np.dot(query_embedding, user_embeddings.T)[0]
339
  top_user = np.argsort(user_scores)[::-1][:top_k]
@@ -344,7 +299,6 @@ class RAGService:
344
  "score": float(user_scores[idx]),
345
  "metadata": {"source": "uploaded", "type": "user_content"}
346
  })
347
-
348
  results.sort(key=lambda x: x["score"], reverse=True)
349
  return results[:top_k]
350
  except Exception as e:
@@ -352,7 +306,6 @@ class RAGService:
352
  return self._keyword_search(query, top_k)
353
 
354
  def _keyword_search(self, query, top_k=3):
355
- """Fallback keyword search."""
356
  query_words = set(query.lower().split())
357
  scored = []
358
  for i, chunk in enumerate(self.kb_chunks):
@@ -368,7 +321,6 @@ class RAGService:
368
  return scored[:top_k]
369
 
370
  def embed_chunks(self, chunks):
371
- """Embed text chunks. Returns numpy array or None."""
372
  if not self._ensure_embedder() or not chunks:
373
  return None
374
  try:
@@ -385,16 +337,12 @@ class RAGService:
385
 
386
 
387
  class DocumentParser:
388
- """Parse uploaded documents."""
389
-
390
  @staticmethod
391
  def parse_file(filepath):
392
- """Extract text from uploaded file."""
393
  if filepath is None:
394
  return "", []
395
  filepath = str(filepath)
396
  ext = Path(filepath).suffix.lower()
397
-
398
  try:
399
  if ext == ".pdf" and HAS_FITZ:
400
  return DocumentParser._parse_pdf(filepath)
@@ -438,7 +386,6 @@ class DocumentParser:
438
  return chunks
439
 
440
 
441
- # ── Initialize services (lightweight, no heavy downloads) ───────────────────────
442
  llm_service = LLMService()
443
  rag_service = RAGService()
444
  doc_parser = DocumentParser()
@@ -446,11 +393,10 @@ print(f"🧬 BB Tutor initialized. LLM: {llm_service.is_available()}, Embeddings
446
 
447
 
448
  # ============================================================================
449
- # HANDLER FUNCTIONS β€” All receive/return plain Python values (not components)
450
  # ============================================================================
451
 
452
  def _rag_context(query, user_chunks=None, user_embeddings=None):
453
- """Retrieve RAG context as formatted string."""
454
  results = rag_service.search(query, top_k=TOP_K_RETRIEVAL,
455
  user_chunks=user_chunks, user_embeddings=user_embeddings)
456
  if not results:
@@ -463,7 +409,6 @@ def _rag_context(query, user_chunks=None, user_embeddings=None):
463
 
464
 
465
  def _format_history(history):
466
- """Convert Gradio history to OpenAI-style messages."""
467
  messages = []
468
  for h in history:
469
  if isinstance(h, dict):
@@ -476,140 +421,107 @@ def _format_history(history):
476
  return messages
477
 
478
 
479
- # ── Module 1: Ask the Tutor ───────────────────────────────────────────────────
 
 
480
 
481
  def tutor_respond(message, history, system_prompt, temperature, max_tokens, rag_state):
482
- """Ask the Tutor handler β€” streaming."""
483
  if not message or not message.strip():
484
  yield ""
485
  return
486
-
487
- rag_state = rag_state or {"chunks": [], "embeddings": None}
488
  user_chunks = rag_state.get("chunks", [])
489
  user_embeddings = rag_state.get("embeddings")
490
-
491
  rag_ctx = _rag_context(message, user_chunks, user_embeddings)
492
-
493
  messages = [{"role": "system", "content": system_prompt}]
494
  if rag_ctx:
495
  messages.append({"role": "system", "content": rag_ctx})
496
  messages.extend(_format_history(history))
497
  messages.append({"role": "user", "content": message})
498
-
499
  for partial in llm_service.stream_chat(messages, temperature, max_tokens):
500
  yield partial
501
 
502
 
503
- # ── Module 2: Upload & Explain ──────────────────────────────────────────────
504
-
505
  def process_upload(file, rag_state):
506
- """Process uploaded file. Returns (explanation_markdown, raw_text, new_rag_state)."""
507
- rag_state = rag_state or {"chunks": [], "embeddings": None}
508
-
509
  if file is None:
510
  return "πŸ“ Please upload a file first.", "", rag_state
511
-
512
  full_text, chunks = doc_parser.parse_file(file)
513
-
514
  if not chunks:
515
  return "⚠️ Could not extract text from the uploaded file.", full_text[:2000] if full_text else "", rag_state
516
-
517
  embeddings = rag_service.embed_chunks(chunks)
518
  new_state = {"chunks": chunks, "embeddings": embeddings}
519
-
520
  preview = full_text[:2500] if len(full_text) > 2500 else full_text
521
  msgs = [
522
  {"role": "system", "content": SYSTEM_PROMPTS["upload_explain"]},
523
  {"role": "user", "content": f"Analyze and explain this bioinformatics document:\n\n{preview}"}
524
  ]
525
  explanation = llm_service.generate(msgs, temperature=0.5, max_tokens=1500)
526
-
527
  stats = f"πŸ“Š **Document Stats:** {len(chunks)} chunks, ~{len(full_text.split())} words | "
528
  stats += f"File type: {Path(str(file)).suffix} | "
529
  stats += "πŸ€– AI-powered" if llm_service.is_available() else "πŸ“š Knowledge-base mode"
530
  stats += f"\n\n---\n\n"
531
-
532
  return stats + explanation, full_text[:5000], new_state
533
 
534
 
535
  def upload_chat_respond(message, history, rag_state):
536
- """Chat about uploaded documents."""
537
  if not message or not message.strip():
538
  yield ""
539
  return
540
-
541
- rag_state = rag_state or {"chunks": [], "embeddings": None}
542
  user_chunks = rag_state.get("chunks", [])
543
  user_embeddings = rag_state.get("embeddings")
544
-
545
  if not user_chunks:
546
  yield "πŸ“ Please upload a document in the panel above, then ask questions about it.\n\nYour uploaded document will be indexed and searchable across all modules!"
547
  return
548
-
549
  rag_results = rag_service.search(message, top_k=4, user_chunks=user_chunks, user_embeddings=user_embeddings)
550
-
551
  ctx = "CONTEXT FROM UPLOADED DOCUMENT:\n"
552
  if rag_results:
553
  for r in rag_results:
554
  ctx += f"\n{r['text'][:600]}\n"
555
  else:
556
  ctx += "(No highly relevant passages found β€” answering from general knowledge)\n"
557
-
558
  messages = [
559
  {"role": "system", "content": SYSTEM_PROMPTS["upload_explain"]},
560
  {"role": "system", "content": ctx},
561
  ]
562
  messages.extend(_format_history(history))
563
  messages.append({"role": "user", "content": message})
564
-
565
  for partial in llm_service.stream_chat(messages, temperature=0.5, max_tokens=1024):
566
  yield partial
567
 
568
 
569
- # ── Module 3: Quiz Me ─────────────────────────────────────────────────────────
570
-
571
  def generate_quiz(topic, quiz_type, num_questions, difficulty, rag_state):
572
- """Generate quiz. Returns (quiz_markdown, answer_key_string)."""
573
  if not topic:
574
  return "❓ Please select or enter a topic first.", ""
575
-
576
- rag_state = rag_state or {"chunks": [], "embeddings": None}
577
  rag_results = rag_service.search(topic, top_k=3)
578
  context = ""
579
  if rag_results:
580
  context = "Reference material:\n" + "\n".join(r["text"][:500] for r in rag_results)
581
-
582
  template_key = {
583
  "Multiple Choice (MCQ)": "mcq",
584
  "True/False": "true_false",
585
  "Short Answer": "short_answer"
586
  }.get(quiz_type, "mcq")
587
-
588
  quiz_prompt = QUIZ_TEMPLATES[template_key].format(
589
  n=int(num_questions), topic=topic, difficulty=difficulty
590
  )
591
-
592
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["quiz_me"]}]
593
  if context:
594
  messages.append({"role": "system", "content": context})
595
  messages.append({"role": "user", "content": quiz_prompt})
596
-
597
  response = llm_service.generate(messages, temperature=0.8, max_tokens=2000)
598
-
599
  formatted = f"## 🧠 {topic} Quiz β€” {difficulty}\n\n"
600
  formatted += f"*Format: {quiz_type} | Questions: {int(num_questions)}*\n\n---\n\n"
601
  formatted += response
602
-
603
  return formatted, response
604
 
605
 
606
  def check_quiz_answers(user_answers, answer_key):
607
- """Grade quiz answers."""
608
  if not user_answers or not user_answers.strip():
609
  return "✍️ Please enter your answers above before checking."
610
  if not answer_key:
611
  return "⚠️ Please generate a quiz first (use the panel above)."
612
-
613
  messages = [
614
  {"role": "system", "content": "You are a bioinformatics tutor grading a quiz. Compare student answers to correct answers. For each: mark βœ… or ❌, explain briefly, provide correct answer if wrong. Be encouraging. Give final score."},
615
  {"role": "user", "content": f"QUIZ AND ANSWERS:\n{answer_key}\n\nSTUDENT ANSWERS:\n{user_answers}\n\nGrade each:"}
@@ -617,40 +529,29 @@ def check_quiz_answers(user_answers, answer_key):
617
  return llm_service.generate(messages, temperature=0.3, max_tokens=1500)
618
 
619
 
620
- # ── Module 4: Build a Lesson ──────────────────────────────────────────────────
621
-
622
  def generate_lesson(topic, level, include_exercises, include_quiz):
623
- """Generate structured lesson."""
624
  if not topic:
625
  return "πŸ“š Please select or enter a topic."
626
-
627
  rag_results = rag_service.search(topic, top_k=4)
628
  context = ""
629
  if rag_results:
630
  context = "Reference:\n" + "\n".join(r["text"][:500] for r in rag_results)
631
-
632
  prompt = LESSON_TEMPLATE.format(topic=topic, level=level)
633
  if include_exercises:
634
  prompt += "\n\nInclude 2-3 practical exercises with clear instructions."
635
  if include_quiz:
636
  prompt += "\n\nInclude a 5-question self-assessment quiz (with answers)."
637
-
638
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["build_lesson"]}]
639
  if context:
640
  messages.append({"role": "system", "content": context})
641
  messages.append({"role": "user", "content": prompt})
642
-
643
  return llm_service.generate(messages, temperature=0.7, max_tokens=3000)
644
 
645
 
646
- # ── Module 5: Workflow Coach ──────────────────────────────────────────────────
647
-
648
  def workflow_respond(message, history, selected_workflow, temperature):
649
- """Workflow Coach handler."""
650
  if not message or not message.strip():
651
  yield ""
652
  return
653
-
654
  workflow_context = ""
655
  for wf_key, wf in WORKFLOWS.items():
656
  if wf["name"] in selected_workflow or selected_workflow.lower() in wf["name"].lower():
@@ -664,78 +565,60 @@ def workflow_respond(message, history, selected_workflow, temperature):
664
  workflow_context += f" ⚠️ Common mistakes: {'; '.join(step['common_mistakes'])}\n"
665
  workflow_context += "\n"
666
  break
667
-
668
  rag_results = rag_service.search(message, top_k=2)
669
  if rag_results:
670
  workflow_context += "\nADDITIONAL CONTEXT:\n" + "\n".join(r["text"][:500] for r in rag_results)
671
-
672
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["workflow_coach"]}]
673
  if workflow_context:
674
  messages.append({"role": "system", "content": workflow_context})
675
  messages.extend(_format_history(history))
676
  messages.append({"role": "user", "content": message})
677
-
678
  for partial in llm_service.stream_chat(messages, temperature, 1500):
679
  yield partial
680
 
681
 
682
- # ── Module 6: Paper to Lesson ─────────────────────────────────────────────────
683
-
684
  def paper_to_lesson_respond(message, history, output_format, rag_state):
685
- """Convert papers to teaching material."""
686
  if not message or not message.strip():
687
  yield ""
688
  return
689
-
690
- rag_state = rag_state or {"chunks": [], "embeddings": None}
691
  user_chunks = rag_state.get("chunks", [])
692
  user_embeddings = rag_state.get("embeddings")
693
-
694
  context = ""
695
  if user_chunks:
696
  rag_results = rag_service.search(message, top_k=4, user_chunks=user_chunks, user_embeddings=user_embeddings)
697
  if rag_results:
698
  context = "PAPER CONTENT:\n" + "\n".join(r["text"][:600] for r in rag_results)
699
-
700
  format_instruction = {
701
  "Lesson Plan": "Create a structured lesson plan with learning objectives, sections, and exercises.",
702
  "Slide Outline": "Create a slide-by-slide outline with key points for each slide.",
703
  "Study Notes": "Create concise study notes highlighting key methods, tools, and findings.",
704
  "Quiz Questions": "Generate 5-10 quiz questions based on the paper's methods and findings.",
705
  }.get(output_format, "Create a structured lesson plan.")
706
-
707
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["paper_to_lesson"]}]
708
  if context:
709
  messages.append({"role": "system", "content": context})
710
  messages.extend(_format_history(history))
711
-
712
  full_msg = f"{message}\n\nOUTPUT FORMAT: {format_instruction}"
713
  messages.append({"role": "user", "content": full_msg})
714
-
715
  for partial in llm_service.stream_chat(messages, temperature=0.7, max_tokens=2500):
716
  yield partial
717
 
718
 
719
- # ── Module 7: Viva Practice ───────────────────────────────────────────────────
720
-
721
  def viva_respond(message, history, topic, difficulty):
722
- """Viva practice handler."""
723
  if not message or not message.strip():
724
  yield ""
725
  return
726
-
727
  rag_results = rag_service.search(f"{topic} {message}", top_k=3)
728
  context = ""
729
  if rag_results:
730
  context = "Reference:\n" + "\n".join(r["text"][:500] for r in rag_results)
731
-
732
  messages = [
733
  {"role": "system", "content": SYSTEM_PROMPTS["viva_practice"]},
734
  {"role": "system", "content": f"VIVA TOPIC: {topic}\nDIFFICULTY: {difficulty}\n\n{context}"},
735
  ]
736
  messages.extend(_format_history(history))
737
  messages.append({"role": "user", "content": message})
738
-
739
  for partial in llm_service.stream_chat(messages, temperature=0.7, max_tokens=1000):
740
  yield partial
741
 
@@ -746,36 +629,23 @@ def viva_respond(message, history, topic, difficulty):
746
 
747
  CUSTOM_CSS = """
748
  .main-header {
749
- text-align: center;
750
- padding: 20px;
751
  background: linear-gradient(135deg, #1a5276 0%, #2e86c1 50%, #48c9b0 100%);
752
- border-radius: 12px;
753
- margin-bottom: 20px;
754
- color: white;
755
  }
756
  .main-header h1 { color: white; font-size: 2em; margin: 0; }
757
  .main-header p { color: #ecf0f1; margin: 5px 0; }
758
  .module-info {
759
- background: #f0f9ff;
760
- border-left: 4px solid #2e86c1;
761
- padding: 12px 16px;
762
- margin-bottom: 16px;
763
- border-radius: 0 8px 8px 0;
764
  }
765
  .safety-notice {
766
- background: #fff3e0;
767
- border-left: 4px solid #f39c12;
768
- padding: 10px 14px;
769
- margin-top: 10px;
770
- border-radius: 0 8px 8px 0;
771
- font-size: 0.9em;
772
  }
773
  .status-badge {
774
- display: inline-block;
775
- padding: 4px 12px;
776
- border-radius: 12px;
777
- font-size: 0.85em;
778
- font-weight: bold;
779
  }
780
  .status-on { background: #d4edda; color: #155724; }
781
  .status-off { background: #f8d7da; color: #721c24; }
@@ -786,7 +656,7 @@ def build_app():
786
  with gr.Blocks(title="Bioinformatics with BB Tutor", css=CUSTOM_CSS) as demo:
787
 
788
  # ── Global shared state ─────────────────────────────────────────
789
- rag_store = gr.State({"chunks": [], "embeddings": None})
790
 
791
  # ── Status indicator ────────────────────────────────────────────
792
  llm_status = "🟒 AI Enabled" if llm_service.is_available() else "πŸ”΄ AI Offline (Knowledge Base Active)"
@@ -813,31 +683,30 @@ def build_app():
813
  with gr.Tab("🧬 Ask the Tutor", id="ask"):
814
  gr.HTML('<div class="module-info">πŸ’‘ Ask any bioinformatics question. RAG-augmented responses from a curated knowledge base covering 15+ domains.</div>')
815
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  gr.ChatInterface(
817
  fn=tutor_respond,
818
  type="messages",
819
  additional_inputs=[
820
- gr.Textbox(
821
- value=SYSTEM_PROMPTS["ask_tutor"],
822
- label="System Prompt",
823
- lines=2,
824
- visible=False,
825
- ),
826
  gr.Slider(0.1, 1.5, 0.7, step=0.1, label="Temperature", visible=False),
827
  gr.Slider(256, 4096, 1024, step=256, label="Max Tokens", visible=False),
828
  rag_store,
829
  ],
830
  additional_inputs_accordion=gr.Accordion("βš™οΈ Advanced", open=False, visible=False),
831
- examples=[
832
- "What is the difference between DESeq2 and edgeR?",
833
- "Explain the GATK variant calling pipeline step by step.",
834
- "What is the difference between alpha and beta diversity?",
835
- "Why should I use adjusted p-values instead of raw p-values?",
836
- "Explain the single-cell RNA-seq analysis workflow.",
837
- "What is BQSR and why is it important?",
838
- "How do I choose between STAR and HISAT2 for alignment?",
839
- "What common mistakes do students make with DESeq2?",
840
- ],
841
  )
842
  gr.HTML('<div class="safety-notice">⚠️ <strong>Educational use only.</strong> Not for clinical interpretation. Always consult qualified professionals for clinical genomics.</div>')
843
 
@@ -854,12 +723,10 @@ def build_app():
854
  file_types=[".pdf", ".txt", ".md", ".csv", ".tsv",
855
  ".fasta", ".fa", ".fastq", ".vcf", ".bed",
856
  ".gff", ".gtf", ".sam", ".bam"],
857
- file_count="single",
858
- type="filepath",
859
  )
860
  process_btn = gr.Button("πŸ” Analyze Document", variant="primary")
861
  gr.Markdown("**Supported:** PDF, text, FASTA/FASTQ, VCF, BED, GFF/GTF, SAM/BAM, CSV/TSV")
862
-
863
  with gr.Column(scale=2):
864
  explanation_output = gr.Markdown(label="Analysis & Explanation")
865
 
@@ -873,17 +740,20 @@ def build_app():
873
  )
874
 
875
  gr.Markdown("### πŸ’¬ Chat About Your Document")
 
 
 
 
 
 
 
 
 
876
  gr.ChatInterface(
877
  fn=upload_chat_respond,
878
  type="messages",
879
  additional_inputs=[rag_store],
880
- additional_inputs_accordion=gr.Accordion("", open=False, visible=False),
881
- examples=[
882
- "Summarize the key methods in this paper.",
883
- "What bioinformatics tools are mentioned?",
884
- "Explain the main findings in simple terms.",
885
- "What are the limitations of this analysis?",
886
- ],
887
  )
888
 
889
  # ══════════════════════════════════════════════════════════════
@@ -894,22 +764,16 @@ def build_app():
894
 
895
  with gr.Row():
896
  quiz_topic = gr.Dropdown(
897
- choices=TOPIC_CHOICES,
898
- label="Select Topic",
899
- allow_custom_value=True,
900
- value="RNA-seq: Differential Expression (DESeq2)"
901
  )
902
  quiz_type = gr.Radio(
903
  choices=["Multiple Choice (MCQ)", "True/False", "Short Answer"],
904
- value="Multiple Choice (MCQ)",
905
- label="Format"
906
  )
907
-
908
  with gr.Row():
909
  quiz_difficulty = gr.Radio(
910
- choices=DIFFICULTY_LEVELS,
911
- value="Intermediate",
912
- label="Difficulty"
913
  )
914
  num_questions = gr.Slider(1, 10, 5, step=1, label="# Questions")
915
  generate_quiz_btn = gr.Button("🎲 Generate Quiz", variant="primary")
@@ -928,9 +792,7 @@ def build_app():
928
  with gr.Row():
929
  user_answers = gr.Textbox(
930
  label="Your Answers (e.g., '1: A, 2: B')",
931
- lines=5,
932
- placeholder="Type your answers here...",
933
- scale=3
934
  )
935
  check_btn = gr.Button("βœ… Check", variant="primary", scale=1)
936
  feedback_output = gr.Markdown(label="Feedback")
@@ -949,17 +811,12 @@ def build_app():
949
 
950
  with gr.Row():
951
  lesson_topic = gr.Dropdown(
952
- choices=TOPIC_CHOICES,
953
- label="Lesson Topic",
954
- allow_custom_value=True,
955
- value="RNA-seq: Differential Expression (DESeq2)"
956
  )
957
  lesson_level = gr.Radio(
958
- choices=DIFFICULTY_LEVELS,
959
- value="Intermediate",
960
- label="Level"
961
  )
962
-
963
  with gr.Row():
964
  include_exercises = gr.Checkbox(label="Include Exercises", value=True)
965
  include_quiz = gr.Checkbox(label="Include Quiz", value=True)
@@ -980,12 +837,20 @@ def build_app():
980
  gr.HTML('<div class="module-info">πŸ”¬ Step-by-step guidance through bioinformatics analysis pipelines. Select a workflow and ask about any step.</div>')
981
 
982
  workflow_selector = gr.Dropdown(
983
- choices=WORKFLOW_CHOICES,
984
- label="Select Workflow",
985
- value="Bulk RNA-seq: Full DE Analysis Pipeline",
986
- allow_custom_value=True,
987
  )
988
 
 
 
 
 
 
 
 
 
 
 
989
  gr.ChatInterface(
990
  fn=workflow_respond,
991
  type="messages",
@@ -994,14 +859,7 @@ def build_app():
994
  gr.Slider(0.1, 1.5, 0.7, step=0.1, label="Temperature", visible=False),
995
  ],
996
  additional_inputs_accordion=gr.Accordion("βš™οΈ", open=False, visible=False),
997
- examples=[
998
- "Walk me through the complete pipeline from raw FASTQ to DE results.",
999
- "I'm at alignment. What should I check before counting?",
1000
- "My mapping rate is only 45%. What could be wrong?",
1001
- "How do I choose between STAR and HISAT2?",
1002
- "What parameters for GATK HaplotypeCaller on exome data?",
1003
- "How do I set DADA2 truncation parameters?",
1004
- ],
1005
  )
1006
 
1007
  # ══════════════════════════════════════════════════════════════
@@ -1012,21 +870,22 @@ def build_app():
1012
 
1013
  output_format = gr.Radio(
1014
  choices=["Lesson Plan", "Slide Outline", "Study Notes", "Quiz Questions"],
1015
- value="Lesson Plan",
1016
- label="Output Format"
1017
  )
1018
 
 
 
 
 
 
 
 
 
1019
  gr.ChatInterface(
1020
  fn=paper_to_lesson_respond,
1021
  type="messages",
1022
  additional_inputs=[output_format, rag_store],
1023
- additional_inputs_accordion=gr.Accordion("", open=False, visible=False),
1024
- examples=[
1025
- "Convert this paper into a 45-minute lecture plan.",
1026
- "Create a slide outline covering the key methods.",
1027
- "Generate study notes on the bioinformatics methods.",
1028
- "Create quiz questions on this paper's methodology.",
1029
- ],
1030
  )
1031
 
1032
  # ══════════════════════════════════════════════════════════════
@@ -1037,28 +896,26 @@ def build_app():
1037
 
1038
  with gr.Row():
1039
  viva_topic = gr.Dropdown(
1040
- choices=TOPIC_CHOICES,
1041
- label="Viva Topic",
1042
- allow_custom_value=True,
1043
- value="RNA-seq: Differential Expression (DESeq2)"
1044
  )
1045
  viva_difficulty = gr.Radio(
1046
- choices=DIFFICULTY_LEVELS,
1047
- value="Intermediate",
1048
- label="Difficulty"
1049
  )
1050
 
 
 
 
 
 
 
 
 
1051
  gr.ChatInterface(
1052
  fn=viva_respond,
1053
  type="messages",
1054
  additional_inputs=[viva_topic, viva_difficulty],
1055
- additional_inputs_accordion=gr.Accordion("βš™οΈ", open=False, visible=False),
1056
- examples=[
1057
- "I'm ready for my viva. Start with your first question.",
1058
- "Focus on the statistical aspects of RNA-seq.",
1059
- "Ask me about variant calling and interpretation.",
1060
- "Test my understanding of microbiome diversity.",
1061
- ],
1062
  )
1063
 
1064
  # ── Footer ─────────────────────────────────────────────────────
@@ -1073,14 +930,6 @@ def build_app():
1073
  return demo
1074
 
1075
 
1076
- # ============================================================================
1077
- # LAUNCH
1078
- # ============================================================================
1079
-
1080
  if __name__ == "__main__":
1081
  demo = build_app()
1082
- demo.launch(
1083
- server_name="0.0.0.0",
1084
- server_port=7860,
1085
- share=False,
1086
- )
 
12
 
13
  import gradio as gr
14
  import numpy as np
 
15
  import os
 
 
16
  from pathlib import Path
17
 
18
  # ── Conditional imports with fallbacks ────────────────────────────────────────
 
57
  CHUNK_OVERLAP = 60
58
  TOP_K_RETRIEVAL = 3
59
 
60
+ DEFAULT_SYSTEM_PROMPT = SYSTEM_PROMPTS["ask_tutor"]
61
+ DEFAULT_RAG_STATE = {"chunks": [], "embeddings": None}
62
+
63
 
64
  # ============================================================================
65
  # BACKEND SERVICES β€” Singleton Pattern
 
104
  def is_available(self):
105
  return self.client is not None
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def stream_chat(self, messages, temperature=0.7, max_tokens=1024):
108
  """Stream chat completion. Yields partial response strings."""
109
  if not self.is_available():
110
  yield self._fallback_response(messages)
111
  return
 
112
  try:
113
  partial = ""
114
  for chunk in self.client.chat_completion(
 
133
  """Non-streaming generation. Returns complete response."""
134
  if not self.is_available():
135
  return self._fallback_response(messages)
 
136
  try:
137
  response = self.client.chat_completion(
138
  messages=messages,
 
144
  return response.choices[0].message.content
145
  except Exception as e:
146
  print(f"LLM generate error: {e}")
147
+ return f"⚠️ LLM API error: {str(e)}\n\nThe tutor can still answer from its knowledge base. Try asking about specific concepts!"
148
 
149
  def _fallback_response(self, messages):
150
  """Knowledge-base fallback when LLM unavailable."""
 
151
  user_msg = ""
152
  for m in reversed(messages):
153
  if isinstance(m, dict) and m.get("role") == "user":
154
  user_msg = m.get("content", "").lower()
155
  break
 
156
  if not user_msg:
157
  return "⚠️ **LLM not available.** Add HF_TOKEN in Space settings to enable AI responses.\n\nMeanwhile, the knowledge base covers: DESeq2, variant calling, microbiome diversity, scRNA-seq clustering, and more. Try asking a specific question!"
158
 
159
  response_parts = []
 
 
160
  for term, definition in GLOSSARY.items():
161
  if term.lower() in user_msg:
162
  response_parts.append(f"**{term}**: {definition}")
163
  if len(response_parts) >= 3:
164
  break
165
 
 
166
  for wf_key, wf in WORKFLOWS.items():
167
  if any(kw in user_msg for kw in wf["name"].lower().split()):
168
  response_parts.append(f"\n### {wf['name']}")
 
170
  response_parts.append(f"**Step {step['step']}: {step['name']}**\n{step['description']}")
171
  break
172
 
 
173
  for misc in COMMON_MISCONCEPTIONS:
174
  if misc["domain"].replace("_", " ") in user_msg or any(w in user_msg for w in misc["misconception"].lower().split()[:5]):
175
  response_parts.append(f"\n⚠️ **Common Misconception**: {misc['misconception']}\n\nβœ… **Correction**: {misc['correction']}")
 
209
  self._build_kb_index()
210
 
211
  def _ensure_embedder(self):
 
212
  if self.embedder is not None:
213
  return True
214
  if not HAS_ST:
215
  return False
216
  try:
217
+ print("RAGService: Loading embedding model...")
218
  self.embedder = SentenceTransformer(EMBED_MODEL)
219
  print("RAGService: Embedding model loaded")
 
220
  if self.kb_chunks:
221
  self.kb_embeddings = self.embedder.encode(
222
  self.kb_chunks,
 
232
  return False
233
 
234
  def _build_kb_index(self):
 
235
  chunks = []
236
  metadata = []
 
 
237
  for term, definition in GLOSSARY.items():
238
  chunks.append(f"{term}: {definition}")
239
  metadata.append({"source": "glossary", "topic": term, "type": "definition"})
 
 
240
  for wf_key, wf in WORKFLOWS.items():
241
  for step in wf["steps"]:
242
  step_text = f"{wf['name']} - Step {step['step']}: {step['name']}. {step['description']}"
 
252
  "step": step["step"],
253
  "workflow": wf_key
254
  })
 
 
255
  for misc in COMMON_MISCONCEPTIONS:
256
  text = f"Misconception: {misc['misconception']} Correction: {misc['correction']}"
257
  chunks.append(text)
 
261
  "type": "misconception",
262
  "severity": misc["severity"]
263
  })
 
 
264
  for key, domain in DOMAIN_TAXONOMY.items():
265
  text = f"{domain['name']} covers: {', '.join(domain['subtopics'][:10])}"
266
  chunks.append(text)
267
  metadata.append({"source": "taxonomy", "topic": key, "type": "domain_overview"})
 
268
  self.kb_chunks = chunks
269
  self.kb_metadata = metadata
270
  print(f"RAGService: Built KB with {len(chunks)} chunks")
271
 
272
  def search(self, query, top_k=TOP_K_RETRIEVAL, user_chunks=None, user_embeddings=None):
 
273
  if not self._ensure_embedder():
274
  return self._keyword_search(query, top_k)
 
275
  try:
276
  query_embedding = self.embedder.encode(
277
  [query],
278
  convert_to_numpy=True,
279
  normalize_embeddings=True,
280
  )
 
281
  results = []
 
 
282
  if self.kb_embeddings is not None and len(self.kb_embeddings) > 0:
283
  kb_scores = np.dot(query_embedding, self.kb_embeddings.T)[0]
284
  top_indices = np.argsort(kb_scores)[::-1][:top_k]
 
289
  "score": float(kb_scores[idx]),
290
  "metadata": self.kb_metadata[idx]
291
  })
 
 
292
  if user_chunks and user_embeddings is not None and len(user_embeddings) > 0:
293
  user_scores = np.dot(query_embedding, user_embeddings.T)[0]
294
  top_user = np.argsort(user_scores)[::-1][:top_k]
 
299
  "score": float(user_scores[idx]),
300
  "metadata": {"source": "uploaded", "type": "user_content"}
301
  })
 
302
  results.sort(key=lambda x: x["score"], reverse=True)
303
  return results[:top_k]
304
  except Exception as e:
 
306
  return self._keyword_search(query, top_k)
307
 
308
  def _keyword_search(self, query, top_k=3):
 
309
  query_words = set(query.lower().split())
310
  scored = []
311
  for i, chunk in enumerate(self.kb_chunks):
 
321
  return scored[:top_k]
322
 
323
  def embed_chunks(self, chunks):
 
324
  if not self._ensure_embedder() or not chunks:
325
  return None
326
  try:
 
337
 
338
 
339
  class DocumentParser:
 
 
340
  @staticmethod
341
  def parse_file(filepath):
 
342
  if filepath is None:
343
  return "", []
344
  filepath = str(filepath)
345
  ext = Path(filepath).suffix.lower()
 
346
  try:
347
  if ext == ".pdf" and HAS_FITZ:
348
  return DocumentParser._parse_pdf(filepath)
 
386
  return chunks
387
 
388
 
 
389
  llm_service = LLMService()
390
  rag_service = RAGService()
391
  doc_parser = DocumentParser()
 
393
 
394
 
395
  # ============================================================================
396
+ # HELPER FUNCTIONS
397
  # ============================================================================
398
 
399
  def _rag_context(query, user_chunks=None, user_embeddings=None):
 
400
  results = rag_service.search(query, top_k=TOP_K_RETRIEVAL,
401
  user_chunks=user_chunks, user_embeddings=user_embeddings)
402
  if not results:
 
409
 
410
 
411
  def _format_history(history):
 
412
  messages = []
413
  for h in history:
414
  if isinstance(h, dict):
 
421
  return messages
422
 
423
 
424
+ # ============================================================================
425
+ # MODULE HANDLERS
426
+ # ============================================================================
427
 
428
  def tutor_respond(message, history, system_prompt, temperature, max_tokens, rag_state):
 
429
  if not message or not message.strip():
430
  yield ""
431
  return
432
+ rag_state = rag_state or DEFAULT_RAG_STATE
 
433
  user_chunks = rag_state.get("chunks", [])
434
  user_embeddings = rag_state.get("embeddings")
 
435
  rag_ctx = _rag_context(message, user_chunks, user_embeddings)
 
436
  messages = [{"role": "system", "content": system_prompt}]
437
  if rag_ctx:
438
  messages.append({"role": "system", "content": rag_ctx})
439
  messages.extend(_format_history(history))
440
  messages.append({"role": "user", "content": message})
 
441
  for partial in llm_service.stream_chat(messages, temperature, max_tokens):
442
  yield partial
443
 
444
 
 
 
445
  def process_upload(file, rag_state):
446
+ rag_state = rag_state or DEFAULT_RAG_STATE
 
 
447
  if file is None:
448
  return "πŸ“ Please upload a file first.", "", rag_state
 
449
  full_text, chunks = doc_parser.parse_file(file)
 
450
  if not chunks:
451
  return "⚠️ Could not extract text from the uploaded file.", full_text[:2000] if full_text else "", rag_state
 
452
  embeddings = rag_service.embed_chunks(chunks)
453
  new_state = {"chunks": chunks, "embeddings": embeddings}
 
454
  preview = full_text[:2500] if len(full_text) > 2500 else full_text
455
  msgs = [
456
  {"role": "system", "content": SYSTEM_PROMPTS["upload_explain"]},
457
  {"role": "user", "content": f"Analyze and explain this bioinformatics document:\n\n{preview}"}
458
  ]
459
  explanation = llm_service.generate(msgs, temperature=0.5, max_tokens=1500)
 
460
  stats = f"πŸ“Š **Document Stats:** {len(chunks)} chunks, ~{len(full_text.split())} words | "
461
  stats += f"File type: {Path(str(file)).suffix} | "
462
  stats += "πŸ€– AI-powered" if llm_service.is_available() else "πŸ“š Knowledge-base mode"
463
  stats += f"\n\n---\n\n"
 
464
  return stats + explanation, full_text[:5000], new_state
465
 
466
 
467
  def upload_chat_respond(message, history, rag_state):
 
468
  if not message or not message.strip():
469
  yield ""
470
  return
471
+ rag_state = rag_state or DEFAULT_RAG_STATE
 
472
  user_chunks = rag_state.get("chunks", [])
473
  user_embeddings = rag_state.get("embeddings")
 
474
  if not user_chunks:
475
  yield "πŸ“ Please upload a document in the panel above, then ask questions about it.\n\nYour uploaded document will be indexed and searchable across all modules!"
476
  return
 
477
  rag_results = rag_service.search(message, top_k=4, user_chunks=user_chunks, user_embeddings=user_embeddings)
 
478
  ctx = "CONTEXT FROM UPLOADED DOCUMENT:\n"
479
  if rag_results:
480
  for r in rag_results:
481
  ctx += f"\n{r['text'][:600]}\n"
482
  else:
483
  ctx += "(No highly relevant passages found β€” answering from general knowledge)\n"
 
484
  messages = [
485
  {"role": "system", "content": SYSTEM_PROMPTS["upload_explain"]},
486
  {"role": "system", "content": ctx},
487
  ]
488
  messages.extend(_format_history(history))
489
  messages.append({"role": "user", "content": message})
 
490
  for partial in llm_service.stream_chat(messages, temperature=0.5, max_tokens=1024):
491
  yield partial
492
 
493
 
 
 
494
  def generate_quiz(topic, quiz_type, num_questions, difficulty, rag_state):
 
495
  if not topic:
496
  return "❓ Please select or enter a topic first.", ""
 
 
497
  rag_results = rag_service.search(topic, top_k=3)
498
  context = ""
499
  if rag_results:
500
  context = "Reference material:\n" + "\n".join(r["text"][:500] for r in rag_results)
 
501
  template_key = {
502
  "Multiple Choice (MCQ)": "mcq",
503
  "True/False": "true_false",
504
  "Short Answer": "short_answer"
505
  }.get(quiz_type, "mcq")
 
506
  quiz_prompt = QUIZ_TEMPLATES[template_key].format(
507
  n=int(num_questions), topic=topic, difficulty=difficulty
508
  )
 
509
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["quiz_me"]}]
510
  if context:
511
  messages.append({"role": "system", "content": context})
512
  messages.append({"role": "user", "content": quiz_prompt})
 
513
  response = llm_service.generate(messages, temperature=0.8, max_tokens=2000)
 
514
  formatted = f"## 🧠 {topic} Quiz β€” {difficulty}\n\n"
515
  formatted += f"*Format: {quiz_type} | Questions: {int(num_questions)}*\n\n---\n\n"
516
  formatted += response
 
517
  return formatted, response
518
 
519
 
520
  def check_quiz_answers(user_answers, answer_key):
 
521
  if not user_answers or not user_answers.strip():
522
  return "✍️ Please enter your answers above before checking."
523
  if not answer_key:
524
  return "⚠️ Please generate a quiz first (use the panel above)."
 
525
  messages = [
526
  {"role": "system", "content": "You are a bioinformatics tutor grading a quiz. Compare student answers to correct answers. For each: mark βœ… or ❌, explain briefly, provide correct answer if wrong. Be encouraging. Give final score."},
527
  {"role": "user", "content": f"QUIZ AND ANSWERS:\n{answer_key}\n\nSTUDENT ANSWERS:\n{user_answers}\n\nGrade each:"}
 
529
  return llm_service.generate(messages, temperature=0.3, max_tokens=1500)
530
 
531
 
 
 
532
  def generate_lesson(topic, level, include_exercises, include_quiz):
 
533
  if not topic:
534
  return "πŸ“š Please select or enter a topic."
 
535
  rag_results = rag_service.search(topic, top_k=4)
536
  context = ""
537
  if rag_results:
538
  context = "Reference:\n" + "\n".join(r["text"][:500] for r in rag_results)
 
539
  prompt = LESSON_TEMPLATE.format(topic=topic, level=level)
540
  if include_exercises:
541
  prompt += "\n\nInclude 2-3 practical exercises with clear instructions."
542
  if include_quiz:
543
  prompt += "\n\nInclude a 5-question self-assessment quiz (with answers)."
 
544
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["build_lesson"]}]
545
  if context:
546
  messages.append({"role": "system", "content": context})
547
  messages.append({"role": "user", "content": prompt})
 
548
  return llm_service.generate(messages, temperature=0.7, max_tokens=3000)
549
 
550
 
 
 
551
  def workflow_respond(message, history, selected_workflow, temperature):
 
552
  if not message or not message.strip():
553
  yield ""
554
  return
 
555
  workflow_context = ""
556
  for wf_key, wf in WORKFLOWS.items():
557
  if wf["name"] in selected_workflow or selected_workflow.lower() in wf["name"].lower():
 
565
  workflow_context += f" ⚠️ Common mistakes: {'; '.join(step['common_mistakes'])}\n"
566
  workflow_context += "\n"
567
  break
 
568
  rag_results = rag_service.search(message, top_k=2)
569
  if rag_results:
570
  workflow_context += "\nADDITIONAL CONTEXT:\n" + "\n".join(r["text"][:500] for r in rag_results)
 
571
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["workflow_coach"]}]
572
  if workflow_context:
573
  messages.append({"role": "system", "content": workflow_context})
574
  messages.extend(_format_history(history))
575
  messages.append({"role": "user", "content": message})
 
576
  for partial in llm_service.stream_chat(messages, temperature, 1500):
577
  yield partial
578
 
579
 
 
 
580
  def paper_to_lesson_respond(message, history, output_format, rag_state):
 
581
  if not message or not message.strip():
582
  yield ""
583
  return
584
+ rag_state = rag_state or DEFAULT_RAG_STATE
 
585
  user_chunks = rag_state.get("chunks", [])
586
  user_embeddings = rag_state.get("embeddings")
 
587
  context = ""
588
  if user_chunks:
589
  rag_results = rag_service.search(message, top_k=4, user_chunks=user_chunks, user_embeddings=user_embeddings)
590
  if rag_results:
591
  context = "PAPER CONTENT:\n" + "\n".join(r["text"][:600] for r in rag_results)
 
592
  format_instruction = {
593
  "Lesson Plan": "Create a structured lesson plan with learning objectives, sections, and exercises.",
594
  "Slide Outline": "Create a slide-by-slide outline with key points for each slide.",
595
  "Study Notes": "Create concise study notes highlighting key methods, tools, and findings.",
596
  "Quiz Questions": "Generate 5-10 quiz questions based on the paper's methods and findings.",
597
  }.get(output_format, "Create a structured lesson plan.")
 
598
  messages = [{"role": "system", "content": SYSTEM_PROMPTS["paper_to_lesson"]}]
599
  if context:
600
  messages.append({"role": "system", "content": context})
601
  messages.extend(_format_history(history))
 
602
  full_msg = f"{message}\n\nOUTPUT FORMAT: {format_instruction}"
603
  messages.append({"role": "user", "content": full_msg})
 
604
  for partial in llm_service.stream_chat(messages, temperature=0.7, max_tokens=2500):
605
  yield partial
606
 
607
 
 
 
608
  def viva_respond(message, history, topic, difficulty):
 
609
  if not message or not message.strip():
610
  yield ""
611
  return
 
612
  rag_results = rag_service.search(f"{topic} {message}", top_k=3)
613
  context = ""
614
  if rag_results:
615
  context = "Reference:\n" + "\n".join(r["text"][:500] for r in rag_results)
 
616
  messages = [
617
  {"role": "system", "content": SYSTEM_PROMPTS["viva_practice"]},
618
  {"role": "system", "content": f"VIVA TOPIC: {topic}\nDIFFICULTY: {difficulty}\n\n{context}"},
619
  ]
620
  messages.extend(_format_history(history))
621
  messages.append({"role": "user", "content": message})
 
622
  for partial in llm_service.stream_chat(messages, temperature=0.7, max_tokens=1000):
623
  yield partial
624
 
 
629
 
630
  CUSTOM_CSS = """
631
  .main-header {
632
+ text-align: center; padding: 20px;
 
633
  background: linear-gradient(135deg, #1a5276 0%, #2e86c1 50%, #48c9b0 100%);
634
+ border-radius: 12px; margin-bottom: 20px; color: white;
 
 
635
  }
636
  .main-header h1 { color: white; font-size: 2em; margin: 0; }
637
  .main-header p { color: #ecf0f1; margin: 5px 0; }
638
  .module-info {
639
+ background: #f0f9ff; border-left: 4px solid #2e86c1;
640
+ padding: 12px 16px; margin-bottom: 16px; border-radius: 0 8px 8px 0;
 
 
 
641
  }
642
  .safety-notice {
643
+ background: #fff3e0; border-left: 4px solid #f39c12;
644
+ padding: 10px 14px; margin-top: 10px; border-radius: 0 8px 8px 0; font-size: 0.9em;
 
 
 
 
645
  }
646
  .status-badge {
647
+ display: inline-block; padding: 4px 12px; border-radius: 12px;
648
+ font-size: 0.85em; font-weight: bold;
 
 
 
649
  }
650
  .status-on { background: #d4edda; color: #155724; }
651
  .status-off { background: #f8d7da; color: #721c24; }
 
656
  with gr.Blocks(title="Bioinformatics with BB Tutor", css=CUSTOM_CSS) as demo:
657
 
658
  # ── Global shared state ─────────────────────────────────────────
659
+ rag_store = gr.State(DEFAULT_RAG_STATE)
660
 
661
  # ── Status indicator ────────────────────────────────────────────
662
  llm_status = "🟒 AI Enabled" if llm_service.is_available() else "πŸ”΄ AI Offline (Knowledge Base Active)"
 
683
  with gr.Tab("🧬 Ask the Tutor", id="ask"):
684
  gr.HTML('<div class="module-info">πŸ’‘ Ask any bioinformatics question. RAG-augmented responses from a curated knowledge base covering 15+ domains.</div>')
685
 
686
+ # Examples must be list-of-lists matching fn signature: (message, history, system_prompt, temperature, max_tokens, rag_state)
687
+ # Additional inputs: [system_prompt, temperature, max_tokens, rag_store] β†’ 4 values per example
688
+ ask_examples = [
689
+ ["What is the difference between DESeq2 and edgeR?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
690
+ ["Explain the GATK variant calling pipeline step by step.", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
691
+ ["What is the difference between alpha and beta diversity?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
692
+ ["Why should I use adjusted p-values instead of raw p-values?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
693
+ ["Explain the single-cell RNA-seq analysis workflow.", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
694
+ ["What is BQSR and why is it important?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
695
+ ["How do I choose between STAR and HISAT2 for alignment?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
696
+ ["What common mistakes do students make with DESeq2?", DEFAULT_SYSTEM_PROMPT, 0.7, 1024, DEFAULT_RAG_STATE],
697
+ ]
698
+
699
  gr.ChatInterface(
700
  fn=tutor_respond,
701
  type="messages",
702
  additional_inputs=[
703
+ gr.Textbox(value=DEFAULT_SYSTEM_PROMPT, label="System Prompt", lines=2, visible=False),
 
 
 
 
 
704
  gr.Slider(0.1, 1.5, 0.7, step=0.1, label="Temperature", visible=False),
705
  gr.Slider(256, 4096, 1024, step=256, label="Max Tokens", visible=False),
706
  rag_store,
707
  ],
708
  additional_inputs_accordion=gr.Accordion("βš™οΈ Advanced", open=False, visible=False),
709
+ examples=ask_examples,
 
 
 
 
 
 
 
 
 
710
  )
711
  gr.HTML('<div class="safety-notice">⚠️ <strong>Educational use only.</strong> Not for clinical interpretation. Always consult qualified professionals for clinical genomics.</div>')
712
 
 
723
  file_types=[".pdf", ".txt", ".md", ".csv", ".tsv",
724
  ".fasta", ".fa", ".fastq", ".vcf", ".bed",
725
  ".gff", ".gtf", ".sam", ".bam"],
726
+ file_count="single", type="filepath",
 
727
  )
728
  process_btn = gr.Button("πŸ” Analyze Document", variant="primary")
729
  gr.Markdown("**Supported:** PDF, text, FASTA/FASTQ, VCF, BED, GFF/GTF, SAM/BAM, CSV/TSV")
 
730
  with gr.Column(scale=2):
731
  explanation_output = gr.Markdown(label="Analysis & Explanation")
732
 
 
740
  )
741
 
742
  gr.Markdown("### πŸ’¬ Chat About Your Document")
743
+
744
+ # fn signature: (message, history, rag_state) β†’ 1 additional input
745
+ upload_chat_examples = [
746
+ ["Summarize the key methods in this paper.", DEFAULT_RAG_STATE],
747
+ ["What bioinformatics tools are mentioned?", DEFAULT_RAG_STATE],
748
+ ["Explain the main findings in simple terms.", DEFAULT_RAG_STATE],
749
+ ["What are the limitations of this analysis?", DEFAULT_RAG_STATE],
750
+ ]
751
+
752
  gr.ChatInterface(
753
  fn=upload_chat_respond,
754
  type="messages",
755
  additional_inputs=[rag_store],
756
+ examples=upload_chat_examples,
 
 
 
 
 
 
757
  )
758
 
759
  # ══════════════════════════════════════════════════════════════
 
764
 
765
  with gr.Row():
766
  quiz_topic = gr.Dropdown(
767
+ choices=TOPIC_CHOICES, label="Select Topic",
768
+ allow_custom_value=True, value="RNA-seq: Differential Expression (DESeq2)"
 
 
769
  )
770
  quiz_type = gr.Radio(
771
  choices=["Multiple Choice (MCQ)", "True/False", "Short Answer"],
772
+ value="Multiple Choice (MCQ)", label="Format"
 
773
  )
 
774
  with gr.Row():
775
  quiz_difficulty = gr.Radio(
776
+ choices=DIFFICULTY_LEVELS, value="Intermediate", label="Difficulty"
 
 
777
  )
778
  num_questions = gr.Slider(1, 10, 5, step=1, label="# Questions")
779
  generate_quiz_btn = gr.Button("🎲 Generate Quiz", variant="primary")
 
792
  with gr.Row():
793
  user_answers = gr.Textbox(
794
  label="Your Answers (e.g., '1: A, 2: B')",
795
+ lines=5, placeholder="Type your answers here...", scale=3
 
 
796
  )
797
  check_btn = gr.Button("βœ… Check", variant="primary", scale=1)
798
  feedback_output = gr.Markdown(label="Feedback")
 
811
 
812
  with gr.Row():
813
  lesson_topic = gr.Dropdown(
814
+ choices=TOPIC_CHOICES, label="Lesson Topic",
815
+ allow_custom_value=True, value="RNA-seq: Differential Expression (DESeq2)"
 
 
816
  )
817
  lesson_level = gr.Radio(
818
+ choices=DIFFICULTY_LEVELS, value="Intermediate", label="Level"
 
 
819
  )
 
820
  with gr.Row():
821
  include_exercises = gr.Checkbox(label="Include Exercises", value=True)
822
  include_quiz = gr.Checkbox(label="Include Quiz", value=True)
 
837
  gr.HTML('<div class="module-info">πŸ”¬ Step-by-step guidance through bioinformatics analysis pipelines. Select a workflow and ask about any step.</div>')
838
 
839
  workflow_selector = gr.Dropdown(
840
+ choices=WORKFLOW_CHOICES, label="Select Workflow",
841
+ value="Bulk RNA-seq: Full DE Analysis Pipeline", allow_custom_value=True,
 
 
842
  )
843
 
844
+ # fn signature: (message, history, selected_workflow, temperature) β†’ 2 additional inputs
845
+ workflow_examples = [
846
+ ["Walk me through the complete pipeline from raw FASTQ to DE results.", "Bulk RNA-seq: Full DE Analysis Pipeline", 0.7],
847
+ ["I'm at alignment. What should I check before counting?", "Bulk RNA-seq: Full DE Analysis Pipeline", 0.7],
848
+ ["My mapping rate is only 45%. What could be wrong?", "Bulk RNA-seq: Full DE Analysis Pipeline", 0.7],
849
+ ["How do I choose between STAR and HISAT2?", "Bulk RNA-seq: Full DE Analysis Pipeline", 0.7],
850
+ ["What parameters for GATK HaplotypeCaller on exome data?", "Exome Sequencing: Variant Calling Pipeline", 0.7],
851
+ ["How do I set DADA2 truncation parameters?", "Microbiome: 16S Amplicon Analysis (QIIME2)", 0.7],
852
+ ]
853
+
854
  gr.ChatInterface(
855
  fn=workflow_respond,
856
  type="messages",
 
859
  gr.Slider(0.1, 1.5, 0.7, step=0.1, label="Temperature", visible=False),
860
  ],
861
  additional_inputs_accordion=gr.Accordion("βš™οΈ", open=False, visible=False),
862
+ examples=workflow_examples,
 
 
 
 
 
 
 
863
  )
864
 
865
  # ══════════════════════════════════════════════════════════════
 
870
 
871
  output_format = gr.Radio(
872
  choices=["Lesson Plan", "Slide Outline", "Study Notes", "Quiz Questions"],
873
+ value="Lesson Plan", label="Output Format"
 
874
  )
875
 
876
+ # fn signature: (message, history, output_format, rag_state) β†’ 2 additional inputs
877
+ paper_examples = [
878
+ ["Convert this paper into a 45-minute lecture plan.", "Lesson Plan", DEFAULT_RAG_STATE],
879
+ ["Create a slide outline covering the key methods.", "Slide Outline", DEFAULT_RAG_STATE],
880
+ ["Generate study notes on the bioinformatics methods.", "Study Notes", DEFAULT_RAG_STATE],
881
+ ["Create quiz questions on this paper's methodology.", "Quiz Questions", DEFAULT_RAG_STATE],
882
+ ]
883
+
884
  gr.ChatInterface(
885
  fn=paper_to_lesson_respond,
886
  type="messages",
887
  additional_inputs=[output_format, rag_store],
888
+ examples=paper_examples,
 
 
 
 
 
 
889
  )
890
 
891
  # ══════════════════════════════════════════════════════════════
 
896
 
897
  with gr.Row():
898
  viva_topic = gr.Dropdown(
899
+ choices=TOPIC_CHOICES, label="Viva Topic",
900
+ allow_custom_value=True, value="RNA-seq: Differential Expression (DESeq2)"
 
 
901
  )
902
  viva_difficulty = gr.Radio(
903
+ choices=DIFFICULTY_LEVELS, value="Intermediate", label="Difficulty"
 
 
904
  )
905
 
906
+ # fn signature: (message, history, topic, difficulty) β†’ 2 additional inputs
907
+ viva_examples = [
908
+ ["I'm ready for my viva. Start with your first question.", "RNA-seq: Differential Expression (DESeq2)", "Intermediate"],
909
+ ["Focus on the statistical aspects of RNA-seq.", "RNA-seq: Differential Expression (DESeq2)", "Intermediate"],
910
+ ["Ask me about variant calling and interpretation.", "Variant Interpretation: ACMG Guidelines", "Intermediate"],
911
+ ["Test my understanding of microbiome diversity.", "Microbiome: Alpha & Beta Diversity", "Intermediate"],
912
+ ]
913
+
914
  gr.ChatInterface(
915
  fn=viva_respond,
916
  type="messages",
917
  additional_inputs=[viva_topic, viva_difficulty],
918
+ examples=viva_examples,
 
 
 
 
 
 
919
  )
920
 
921
  # ── Footer ─────────────────────────────────────────────────────
 
930
  return demo
931
 
932
 
 
 
 
 
933
  if __name__ == "__main__":
934
  demo = build_app()
935
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)