import gradio as gr import os import sys import json import re # Ensure the current directory is in the path current_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.append(current_dir) from smart_tutor_core import crew # ---------------------------------------------------------------------- # Helper: Parse Output # ---------------------------------------------------------------------- def parse_agent_output(raw_output: str): """ Tries to parse JSON from the raw string output. Returns (data_dict, is_json). """ data = None try: data = json.loads(raw_output) return data, True except json.JSONDecodeError: # Try finding JSON block match = re.search(r"(\{.*\})", raw_output, re.DOTALL) if match: try: data = json.loads(match.group(1)) return data, True except: pass return raw_output, False def clean_text(text: str) -> str: """ Aggressively removes markdown formatting to ensure clean text display. Removes: **bold**, __bold__, *italic*, _italic_, `code` """ if not text: return "" text = str(text) # Remove bold/italic markers text = re.sub(r"\*\*|__|`", "", text) text = re.sub(r"^\s*\*\s+", "", text) # Remove leading list asterisks if any return text.strip() # ---------------------------------------------------------------------- # Helper: Format Text Output for Display # ---------------------------------------------------------------------- def format_text_output(raw_text): """ Converts raw agent text (markdown-ish) into beautifully styled HTML inside a summary-box. """ if not raw_text: return "" text = str(raw_text).strip() # Convert markdown headings to HTML text = re.sub(r"^### (.+)$", r"

\1

", text, flags=re.MULTILINE) text = re.sub(r"^## (.+)$", r"

\1

", text, flags=re.MULTILINE) text = re.sub(r"^# (.+)$", r"

\1

", text, flags=re.MULTILINE) # Convert **bold** to text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # Convert bullet lists (- item or * item) lines = text.split("\n") result = [] in_list = False for line in lines: stripped = line.strip() is_bullet = ( stripped.startswith("- ") or stripped.startswith("* ") or re.match(r"^\d+\.\s", stripped) ) if is_bullet: if not in_list: tag = "ul" # Always use bullets as requested result.append(f"<{tag}>") in_list = tag # Remove both -/* and 1. from the start of the line content = re.sub(r"^[-*]\s+|^\d+\.\s+", "", stripped) result.append(f"
  • {content}
  • ") else: if in_list: result.append(f"") in_list = False if stripped.startswith("{stripped}

    ") if in_list: result.append(f"") html = "\n".join(result) return f"
    {html}
    " # ---------------------------------------------------------------------- # Logic: Run Agent # ---------------------------------------------------------------------- def run_agent(file, user_text): if not user_text and not file: return ( gr.update( visible=True, value="
    ⚠️ Please enter a request or upload a file.
    ", ), gr.update(visible=False), # Quiz Group None, # State ) full_request = user_text # Check if user wants a quiz but didn't upload a file (common error) if "quiz" in user_text.lower() and not file: return ( gr.update( visible=True, value="
    ⚠️ To generate a quiz, please upload a document first.
    ", ), gr.update(visible=False), None, ) if file: # file is a filepath string because type='filepath' full_request = f"""USER REQUEST: {user_text} IMPORTANT: The file to process is located at this EXACT path: {file} You MUST use this exact path when calling tools (process_file, store_quiz, etc.).""" # SYSTEM PROMPT INJECTION to force JSON format from the agent system_instruction = "\n\n(SYSTEM NOTE: If generating a quiz, you MUST call the store_quiz tool and return its VALID JSON output including 'quiz_id'. Do NOT return just the questions text.)" try: inputs = {"user_request": full_request + system_instruction} result = crew.kickoff(inputs=inputs) raw_output = str(result) print(f"\n{'='*60}") print(f"[DEBUG] raw_output (first 500 chars):") print(raw_output[:500]) print(f"{'='*60}") data, is_json = parse_agent_output(raw_output) print(f"[DEBUG] is_json={is_json}") if is_json: print( f"[DEBUG] keys={list(data.keys()) if isinstance(data, dict) else 'not a dict'}" ) if isinstance(data, dict) and "questions" in data: print(f"[DEBUG] num questions={len(data['questions'])}") # Case 1: Quiz Output (Success) if is_json and "questions" in data: # We accept it even if quiz_id is missing, but grading might fail. return ( gr.update(visible=False), # Hide Summary gr.update(visible=True), # Show Quiz data, # Store Data ) # Case 2: Grade Result (Standard JSON from grade_quiz) - Handled nicely if is_json and "score" in data: markdown = format_grade_result(data) return ( gr.update(visible=True, value=markdown), gr.update(visible=False), None, ) # Case 3: Normal Text / Summary / Explanation html_content = format_text_output(raw_output) return ( gr.update(visible=True, value=html_content), gr.update(visible=False), None, ) except Exception as e: error_msg = f"
    ❌ Error: {str(e)}
    " return ( gr.update(visible=True, value=error_msg), gr.update(visible=False), None, ) # ---------------------------------------------------------------------- # Logic: Quiz Render & Grading # ---------------------------------------------------------------------- def render_quiz(quiz_data): """ Renders the quiz questions dynamically. Returns updates for: [Radios x10] + [Feedbacks x10] + [CheckBtn] (Total 21) """ updates = [] if not quiz_data: # Hide everything return [gr.update(visible=False)] * 21 questions = quiz_data.get("questions", []) # 1. Update Radios (10 slots) for i in range(10): if i < len(questions): q = questions[i] q_txt = clean_text(q.get("question", "Question text missing")) question_text = f"{i+1}. {q_txt}" # Ensure options are a dict and sorted raw_options = q.get("options", {}) if not isinstance(raw_options, dict): # Fallback if options came as a list or string raw_options = {"A": "Error loading options"} # Sort by key A, B, C, D... # We strictly enforce the "Key. Value" format choices = [] for key in sorted(raw_options.keys()): val = clean_text(raw_options[key]) choices.append(f"{key}. {val}") updates.append( gr.update( visible=True, label=question_text, choices=choices, value=None, interactive=True, ) ) else: updates.append(gr.update(visible=False, choices=[], value=None)) # 2. Update Feedbacks (10 slots) - Hide them initially for i in range(10): updates.append(gr.update(visible=False, value="")) # 3. Show Grid/Check Button updates.append(gr.update(visible=True)) return updates def grade_quiz_ui(quiz_data, *args): """ Collects answers, calls agent (or tool), and returns graded results designed for UI. Input args: [Radio1_Val, Radio2_Val, ..., Radio10_Val] (Length 10) Output: [Radios x10] + [Feedbacks x10] + [ResultMsg] (Total 21) """ # args tuple contains the values of the 10 radios answers_list = args[0:10] updates = [] # Validation if not quiz_data or "quiz_id" not in quiz_data: # Fallback if ID is missing error_updates = [gr.update()] * 10 + [gr.update()] * 10 error_updates.append( gr.update( visible=True, value="
    ⚠️ Error: Quiz ID not found. Cannot grade this quiz.
    ", ) ) return error_updates quiz_id = quiz_data["quiz_id"] # Construct answer map {"1": "A", ...} user_answers = {} for i, ans in enumerate(answers_list): if ans: # ans is like "A. Option Text" -> extract "A" selected_opt = ans.split(".")[0] # Use qid from data if available, else i+1 qid = str(i + 1) # Try to match qid from quiz_data if possible if i < len(quiz_data.get("questions", [])): q = quiz_data["questions"][i] qid = str(q.get("qid", i + 1)) user_answers[qid] = selected_opt # Construct the JSON for the agent answers_json = json.dumps(user_answers) grading_request = f"Grade quiz {quiz_id} with answers {answers_json}\n(SYSTEM: Return valid JSON matching GradeQuizResult schema.)" try: inputs = {"user_request": grading_request} result = crew.kickoff(inputs=inputs) raw_output = str(result) data, is_json = parse_agent_output(raw_output) if is_json and "score" in data: return format_grade_result_interactive(data, answers_list) else: # Fallback error in result box error_updates = [gr.update()] * 10 + [gr.update()] * 10 error_updates.append( gr.update( visible=True, value=f"
    Error parsing grading result: {raw_output}
    ", ) ) return error_updates except Exception as e: error_updates = [gr.update()] * 10 + [gr.update()] * 10 error_updates.append( gr.update( visible=True, value=f"
    Error: {str(e)}
    " ) ) return error_updates def format_grade_result_interactive(data, user_answers_list): """ Updates the UI with colors and correctness. Returns 21 updates. """ details = data.get("details", []) # Map details by QID or index for safety details_map = {} for det in details: details_map[str(det.get("qid"))] = det radio_updates = [] feedback_updates = [] # Iterate 10 slots for i in range(10): # Find corresponding detail # We assume strict ordering i=0 -> Q1 # But let's try to be smart with QID if possible qid = ( str(data.get("details", [])[i].get("qid")) if i < len(data.get("details", [])) else str(i + 1) ) det = details_map.get(qid) if det: # Clean feedback text correct_raw = det.get("correct_answer", "?") correct = clean_text(correct_raw) explanation_raw = det.get("explanation", "") explanation = clean_text(explanation_raw) is_correct = det.get("is_correct", False) # 1. Lock Radio radio_updates.append(gr.update(interactive=False)) # 2. Show Feedback Box css_class = ( "feedback-box-correct" if is_correct else "feedback-box-incorrect" ) # Title title_text = "Correct Answer!" if is_correct else "Incorrect Answer." title_icon = "✅" if is_correct else "❌" html_content = f"""
    """ feedback_updates.append(gr.update(visible=True, value=html_content)) else: # No detail (maybe question didn't exist) radio_updates.append(gr.update(visible=False)) feedback_updates.append(gr.update(visible=False)) # 3. Final Score Msg percentage = data.get("percentage", 0) emoji = "🏆" if percentage >= 80 else "📊" # Create a nice result card score_html = f"""
    {emoji} Quiz Completed!
    Your Score: {data.get('score')} / {data.get('total')}
    ({percentage}%)
    """ return ( radio_updates + feedback_updates + [gr.update(visible=True, value=score_html)] ) def format_grade_result(data): """Standard markdown formatter for standalone grade result""" score = data.get("percentage", 0) emoji = "🎉" if score > 70 else "📚" md = f"# {emoji} Score: {data.get('score')}/{data.get('total')}\n\n" for cx in data.get("details", []): md += f"- **Q{cx['qid']}**: {cx['is_correct'] and '✅' or '❌'} (Correct: {cx.get('correct_answer')})\n" return md # ---------------------------------------------------------------------- # CSS Styling # ---------------------------------------------------------------------- custom_css = """ @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); body { font-family: 'Poppins', sans-serif !important; background: #f8fafc; /* Lighter background */ color: #334155; font-weight: 400; /* Regular weight by default */ } .gradio-container { max-width: 900px !important; margin: 40px auto !important; background: #ffffff; border-radius: 24px; box-shadow: 0 20px 40px -10px rgba(0,0,0,0.1); padding: 0 !important; overflow: hidden; border: 1px solid rgba(255,255,255,0.8); } /* ================= HEADER ================= */ .header-box { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); color: white; padding: 60px 40px; text-align: center; position: relative; overflow: hidden; margin-bottom: 30px; } .header-box::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%); animation: rotate 20s linear infinite; } .header-box h1 { color: white !important; margin: 0; font-size: 3em !important; font-weight: 700; letter-spacing: -1px; text-shadow: 0 4px 10px rgba(0,0,0,0.2); position: relative; z-index: 1; } .header-box p { color: #e0e7ff !important; font-size: 1.25em !important; margin-top: 15px; font-weight: 300; position: relative; z-index: 1; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* ================= INPUT PANEL ================= */ .gradio-row { gap: 30px !important; padding: 0 40px 40px 40px; } /* Logic to remove padding from internal rows if needed, simplified here */ /* Buttons */ button.primary { background: linear-gradient(90deg, #4f46e5 0%, #6366f1 100%) !important; border: none !important; color: white !important; font-weight: 600 !important; padding: 12px 24px !important; border-radius: 12px !important; box-shadow: 0 4px 15px rgba(79, 70, 229, 0.4) !important; transition: all 0.3s ease !important; } button.primary:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(79, 70, 229, 0.5) !important; } button.secondary { background: #f3f4f6 !important; color: #4b5563 !important; border: 1px solid #e5e7eb !important; border-radius: 12px !important; } button.secondary:hover { background: #e5e7eb !important; } /* ================= QUIZ CARDS ================= */ .quiz-question { background: #ffffff; border-radius: 16px; padding: 25px; margin-bottom: 30px !important; border: 1px solid #e5e7eb; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.03), 0 4px 6px -2px rgba(0, 0, 0, 0.02); transition: transform 0.2s ease, box-shadow 0.2s ease; } .quiz-question:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02); } .quiz-question span { /* Label/Title */ font-size: 1.15em !important; font-weight: 600 !important; color: #111827; margin-bottom: 20px; display: block; line-height: 1.5; } /* Options Wrapper (The Radio Group) */ .quiz-question .wrap { display: flex !important; flex-direction: column !important; gap: 12px !important; } /* Individual Option Label */ .quiz-question .wrap label { display: flex !important; align-items: center !important; background: #f9fafb; border: 2px solid #e5e7eb !important; /* Thick border */ padding: 15px 20px !important; border-radius: 12px !important; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); font-size: 1.05em; color: #4b5563; } .quiz-question .wrap label:hover { background: #f3f4f6; border-color: #6366f1 !important; color: #4f46e5; } .quiz-question .wrap label.selected { background: #eef2ff !important; border-color: #4f46e5 !important; color: #4338ca !important; font-weight: 600; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.1); } /* Hide default circle if possible, or style it. Gradio's radio inputs are tricky to hide fully without breaking accessibility, but we can style the container enough. */ /* ================= RESULTS & FEEDBACK ================= */ /* Success/Error Cards */ .feedback-box-correct, .feedback-box-incorrect { margin-top: 20px; padding: 20px; border-radius: 12px; animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); position: relative; overflow: hidden; } .feedback-box-correct { background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border: 1px solid #10b981; color: #065f46; } .feedback-box-incorrect { background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #ef4444; color: #991b1b; } .feedback-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; font-size: 1.2em; font-weight: 700; } .feedback-icon { font-size: 1.4em; background: rgba(255,255,255,0.5); border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .feedback-body { background: rgba(255,255,255,0.4); padding: 15px; border-radius: 8px; font-size: 1em; line-height: 1.6; } .feedback-correct-answer { font-weight: 600; margin-bottom: 8px; color: #064e3b; /* darker green */ } .feedback-box-incorrect .feedback-correct-answer { color: #7f1d1d; /* darker red */ } /* Summary / Explanation Box */ .summary-box { background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%); border-radius: 20px; padding: 35px 40px; border: 1px solid #e0e7ff; box-shadow: 0 8px 30px rgba(79, 70, 229, 0.06); font-size: 1.05em; line-height: 1.9; color: #374151; position: relative; overflow: hidden; } .summary-box::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: linear-gradient(90deg, #4f46e5, #7c3aed, #a78bfa); } .summary-box h2 { font-size: 1.4em; font-weight: 700; color: #312e81; margin: 0 0 18px 0; padding-bottom: 12px; border-bottom: 2px solid #e0e7ff; display: flex; align-items: center; gap: 10px; } .summary-box h3 { font-size: 1.15em; font-weight: 600; color: #4338ca; margin: 20px 0 10px 0; } .summary-box p { margin: 0 0 14px 0; text-align: justify; } .summary-box ul, .summary-box ol { margin: 10px 0 16px 0; padding-left: 24px; } .summary-box li { margin-bottom: 8px; position: relative; } .summary-box strong { color: #312e81; font-weight: 600; } .summary-box .summary-footer { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e0e7ff; font-size: 0.85em; color: #9ca3af; text-align: left; } /* Example Buttons */ #examples-container { margin: 15px 0; padding: 10px; background: #f3f4f6; border-radius: 12px; } .example-btn { background: #ffffff !important; border: 1px solid #e5e7eb !important; color: #6366f1 !important; /* Indigo text */ font-size: 0.85em !important; padding: 2px 10px !important; border-radius: 20px !important; /* Pill shape */ transition: all 0.2s ease !important; font-weight: 500 !important; box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; } .example-btn:hover { background: #f5f7ff !important; border-color: #6366f1 !important; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.1) !important; } /* Result Card */ .result-card { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); border-radius: 20px; padding: 40px; text-align: center; color: white; box-shadow: 0 20px 25px -5px rgba(79, 70, 229, 0.3); margin-top: 40px; animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); } .result-header { font-size: 2em; font-weight: 800; margin-bottom: 15px; text-shadow: 0 2px 4px rgba(0,0,0,0.1); } .result-score { font-size: 3.5em; font-weight: 800; margin: 10px 0; background: -webkit-linear-gradient(#ffffff, #e0e7ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .result-percentage { font-size: 1.5em; opacity: 0.9; font-weight: 500; } /* Keyframes */ @keyframes popIn { from { opacity: 0; transform: scale(0.95) translateY(-5px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes slideUp { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } } /* Hide Gradio Footer */ footer { display: none !important; } .gradio-container .prose.footer-content { display: none !important; } """ # ---------------------------------------------------------------------- # Main App # ---------------------------------------------------------------------- with gr.Blocks(css=custom_css, title="SmartTutor AI") as demo: # State quiz_state = gr.State() with gr.Column(elem_classes="header-box"): gr.HTML( """

    🧠 SmartTutor AI

    Your intelligent companion for learning and assessment

    """ ) with gr.Row(): # Left Panel: Controls with gr.Column(scale=1, variant="panel"): file_input = gr.File( label="📄 Upload Document", file_types=[".pdf", ".txt"], type="filepath" ) user_input = gr.Textbox( label="✍️ Request", placeholder="e.g. 'Summarize this' or 'Create a quiz'", lines=3, ) # Quick Examples with gr.Column(elem_id="examples-container"): gr.Markdown("✨ **Quick Actions:**") with gr.Row(): ex_summarize = gr.Button( "📝 Summary (3 lines)", size="sm", elem_classes="example-btn" ) ex_quiz = gr.Button( "🧪 3 Questions", size="sm", elem_classes="example-btn" ) with gr.Row(): ex_explain = gr.Button( "💡 Main Concepts", size="sm", elem_classes="example-btn" ) with gr.Row(): submit_btn = gr.Button("🚀 Run", variant="primary") clear_btn = gr.Button("🧹 Clear") # Right Panel: Results with gr.Column(scale=2): # 1. Summary / Text Output summary_output = gr.HTML(visible=True) # 2. Quiz Group (Hidden initially) with gr.Group(visible=False) as quiz_group: gr.Markdown("## 📝 Quiz Time") gr.Markdown("Select the correct answer for each question.") # Create 10 Questions + Feedback slots q_radios = [] q_feedbacks = [] for i in range(10): # Radio r = gr.Radio( label=f"Question {i+1}", visible=False, elem_classes="quiz-question", ) q_radios.append(r) # Feedback (Markdown/HTML) fb = gr.HTML(visible=False) q_feedbacks.append(fb) check_btn = gr.Button( "✅ Check Answers", variant="primary", visible=False ) # Final Result Message quiz_result_msg = gr.Markdown(visible=False) # ------------------------------------------------------------------ # Events # ------------------------------------------------------------------ # 1. Run Agent # Returns: [Summary, QuizGroup, QuizState] submit_btn.click( fn=run_agent, inputs=[file_input, user_input], outputs=[summary_output, quiz_group, quiz_state], ).success( # On success, update the quiz UI components (21 items) fn=render_quiz, inputs=[quiz_state], outputs=q_radios + q_feedbacks + [check_btn], ) # Example Buttons Handling # These will ONLY fill the text box. User must click 'Run' manually. ex_summarize.click( fn=lambda: "Summarize this document strictly in exactly 3 lines.", outputs=[user_input], ) ex_quiz.click( fn=lambda: "Generate a quiz with exactly 3 multiple-choice questions.", outputs=[user_input], ) ex_explain.click( fn=lambda: "Explain the 5 most important core concepts in this document clearly.", outputs=[user_input], ) # 2. Check Answers # Inputs: State + 10 Radios # Outputs: 10 Radios (Lock) + 10 Feedbacks (Show) + ResultMsg check_btn.click( fn=grade_quiz_ui, inputs=[quiz_state] + q_radios, outputs=q_radios + q_feedbacks + [quiz_result_msg], ) # 3. Clear def reset_ui(): # Reset everything to default updates = [ gr.update(value=None, interactive=True, visible=False) ] * 10 # Radios fb_updates = [gr.update(value="", visible=False)] * 10 # Feedbacks return ( None, "", # Inputs gr.update(value="", visible=True), # Summary gr.update(visible=False), # Quiz Group None, # State *updates, *fb_updates, # Radios + Feedbacks gr.update(visible=False), # CheckBtn gr.update(visible=False), # ResultMsg ) clear_btn.click( fn=reset_ui, inputs=[], outputs=[file_input, user_input, summary_output, quiz_group, quiz_state] + q_radios + q_feedbacks + [check_btn, quiz_result_msg], ) if __name__ == "__main__": print("Starting SmartTutor AI...") demo.queue() demo.launch( server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), share=False, show_error=True )