Spaces:
Sleeping
Sleeping
| import os | |
| import sys | |
| import re | |
| import json | |
| import gradio as gr | |
| from langchain_openai import ChatOpenAI | |
| from langgraph.prebuilt import create_react_agent | |
| from mcp import ClientSession, StdioServerParameters | |
| from mcp.client.stdio import stdio_client | |
| from langchain_mcp_adapters.tools import load_mcp_tools | |
| from langchain_core.messages import HumanMessage, SystemMessage | |
| # --- Configuration --- | |
| # Ensure your API key is set in your environment variables | |
| NEBIUS_API_KEY = os.getenv("NEBIUS_API_KEY") | |
| NEBIUS_BASE_URL = "https://api.studio.nebius.ai/v1/" | |
| # Model Options | |
| AVAILABLE_MODELS = [ | |
| "openai/gpt-oss-20b", | |
| "openai/gpt-oss-120b" | |
| ] | |
| # --- System Prompt --- | |
| SYSTEM_PROMPT = """You are a 'Vibe Coding' Python Tutor. | |
| Your goal is to teach by DOING and then providing resources. | |
| BEHAVIOR GUIDELINES: | |
| 1. **Greetings & Small Talk**: If the user says "hello", "hi", or asks non-coding questions, respond conversationally and politely. Ask them what they want to learn today. | |
| - DO NOT generate the lesson structure, files, or resources for simple greetings. | |
| 2. **Teaching Mode**: ONLY when the user asks a coding question or requests a topic (e.g., "dictionaries", "how do loops work"): | |
| - **The Lesson**: Explain the concept clearly. | |
| - **The Code**: ALWAYS create a Python file, run it, and show the output using tools ('write_file', 'run_python_script'). | |
| - **The Context**: Use 'list_directory' to see the student's workspace. | |
| CRITICAL FORMATTING INSTRUCTIONS: | |
| When in "Teaching Mode", you MUST end your response by strictly following this format. | |
| Do not add extra text between the sections. | |
| (End of your main lesson text) | |
| ---SECTION: SUMMARY--- | |
| (Provide 3-4 concise bullet points summarizing the key syntax, functions, or concepts learned in this lesson.) | |
| ---SECTION: VIDEOS--- | |
| (List 2-3 YouTube search queries or URLs relevant to the topic) | |
| ---SECTION: ARTICLES--- | |
| (List 2-3 documentation links or course names, e.g., RealPython, FreeCodeCamp) | |
| ---SECTION: QUIZ--- | |
| Provide a valid JSON list of exactly 3 objects. Do not use Markdown code blocks. | |
| [ | |
| {"question": "Question text here?", "options": ["Option A", "Option B", "Option C"], "correct_answer": "Option A", "explanation": "Brief explanation why."} | |
| ] | |
| """ | |
| def parse_agent_response(full_text): | |
| """ | |
| Robust parsing using Regex to handle LLM formatting inconsistencies. | |
| Splits the single LLM response into UI components. | |
| Returns: chat_content, summary, videos, articles, quiz_data (list of dicts) | |
| """ | |
| chat_content = full_text | |
| # Default values | |
| summary = "### π Key Takeaways\n*Ask a coding question to get a cheat sheet!*" | |
| videos = "### πΊ Recommended Videos\n*Ask a coding question to get recommendations!*" | |
| articles = "### π Articles & Courses\n*Ask a coding question to get resources!*" | |
| quiz_data = [] # Empty list for interactive quiz | |
| # Regex patterns | |
| summary_pattern = r"---SECTION:\s*SUMMARY\s*---" | |
| video_pattern = r"---SECTION:\s*VIDEOS\s*---" | |
| article_pattern = r"---SECTION:\s*ARTICLES\s*---" | |
| quiz_pattern = r"---SECTION:\s*QUIZ\s*---" | |
| try: | |
| # 1. Extract Chat vs Summary | |
| split_summary = re.split(summary_pattern, full_text, flags=re.IGNORECASE, maxsplit=1) | |
| if len(split_summary) > 1: | |
| chat_content = split_summary[0].strip() | |
| remaining_after_chat = split_summary[1] | |
| # 2. Extract Summary vs Videos | |
| split_video = re.split(video_pattern, remaining_after_chat, flags=re.IGNORECASE, maxsplit=1) | |
| if len(split_video) > 0: | |
| summary_content = split_video[0].strip() | |
| if summary_content: | |
| summary = f"### π Key Takeaways\n{summary_content}" | |
| if len(split_video) > 1: | |
| remaining_after_video = split_video[1] | |
| # 3. Extract Videos vs Articles | |
| split_article = re.split(article_pattern, remaining_after_video, flags=re.IGNORECASE, maxsplit=1) | |
| if len(split_article) > 0: | |
| video_content = split_article[0].strip() | |
| if video_content: | |
| videos = f"### πΊ Recommended Videos\n{video_content}" | |
| if len(split_article) > 1: | |
| remaining_after_article = split_article[1] | |
| # 4. Extract Articles vs Quiz | |
| split_quiz = re.split(quiz_pattern, remaining_after_article, flags=re.IGNORECASE, maxsplit=1) | |
| if len(split_quiz) > 0: | |
| article_content = split_quiz[0].strip() | |
| if article_content: | |
| articles = f"### π Articles & Courses\n{article_content}" | |
| if len(split_quiz) > 1: | |
| quiz_raw_json = split_quiz[1].strip() | |
| # Attempt to parse JSON | |
| try: | |
| # Clean up potential markdown code blocks if the LLM ignored instructions | |
| clean_json = quiz_raw_json.replace("```json", "").replace("```", "").strip() | |
| quiz_data = json.loads(clean_json) | |
| except json.JSONDecodeError as e: | |
| print(f"Quiz JSON Error: {e}") | |
| quiz_data = [] # Fallback | |
| elif "---SECTION: VIDEOS---" in full_text: | |
| # Fallback parsing | |
| split_video_fallback = re.split(video_pattern, full_text, flags=re.IGNORECASE, maxsplit=1) | |
| chat_content = split_video_fallback[0].strip() | |
| except Exception as e: | |
| print(f"Parsing error: {e}") | |
| chat_content = full_text | |
| return chat_content, summary, videos, articles, quiz_data | |
| async def run_tutor_dashboard(user_message, model_id): | |
| server_params = StdioServerParameters( | |
| command=sys.executable, | |
| args=["server.py"], | |
| env=os.environ.copy() | |
| ) | |
| async with stdio_client(server_params) as (read, write): | |
| async with ClientSession(read, write) as session: | |
| await session.initialize() | |
| tools = await load_mcp_tools(session) | |
| llm = ChatOpenAI( | |
| api_key=NEBIUS_API_KEY, | |
| base_url=NEBIUS_BASE_URL, | |
| model=model_id, | |
| temperature=0.7 | |
| ) | |
| agent_executor = create_react_agent(llm, tools) | |
| inputs = { | |
| "messages": [ | |
| SystemMessage(content=SYSTEM_PROMPT), | |
| HumanMessage(content=user_message) | |
| ] | |
| } | |
| response = await agent_executor.ainvoke(inputs) | |
| final_text = response["messages"][-1].content | |
| return parse_agent_response(final_text) | |
| def check_quiz(ans1, ans2, quiz_data): | |
| """Checks user answers against the quiz data.""" | |
| if not quiz_data or len(quiz_data) < 2: | |
| return "β οΈ Quiz not loaded.", "β οΈ Quiz not loaded." | |
| # Check Q1 | |
| q1 = quiz_data[0] | |
| if ans1 == q1["correct_answer"]: | |
| res1 = f"β Correct! {q1['explanation']}" | |
| else: | |
| res1 = f"β Incorrect. The correct answer was **{q1['correct_answer']}**.\n\n{q1['explanation']}" | |
| # Check Q2 | |
| q2 = quiz_data[1] | |
| if ans2 == q2["correct_answer"]: | |
| res2 = f"β Correct! {q2['explanation']}" | |
| else: | |
| res2 = f"β Incorrect. The correct answer was **{q2['correct_answer']}**.\n\n{q2['explanation']}" | |
| return gr.update(value=res1, visible=True), gr.update(value=res2, visible=True) | |
| # --- Gradio Dashboard UI --- | |
| theme = gr.themes.Soft( | |
| primary_hue="slate", | |
| secondary_hue="indigo", | |
| text_size="lg", | |
| spacing_size="md", | |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"], | |
| ).set( | |
| body_background_fill="*neutral_50", | |
| block_background_fill="white", | |
| block_border_width="1px", | |
| block_title_text_weight="600" | |
| ) | |
| # Custom CSS | |
| custom_css = """ | |
| .tight-header { | |
| margin-bottom: -40px !important; | |
| padding-bottom: 0px !important; | |
| } | |
| .tight-content { | |
| margin-top: 0px !important; | |
| padding-top: 0px !important; | |
| } | |
| .scrollable-right-col { | |
| max-height: 680px; | |
| overflow-y: auto !important; | |
| overflow-x: hidden !important; | |
| padding-right: 10px; | |
| } | |
| """ | |
| with gr.Blocks(title="AI Python Tutor", theme=theme, fill_height=True, css=custom_css) as demo: | |
| # State to hold quiz data | |
| quiz_state = gr.State([]) | |
| # --- Header with Model Selector --- | |
| with gr.Row(variant="compact", elem_classes="header-row"): | |
| with gr.Column(scale=1): | |
| gr.Markdown("## π AI Python Tutor") | |
| with gr.Column(scale=0, min_width=250): | |
| model_selector = gr.Dropdown( | |
| choices=AVAILABLE_MODELS, | |
| value=AVAILABLE_MODELS[0], | |
| label="Select Model", | |
| show_label=False, | |
| container=True, | |
| scale=1 | |
| ) | |
| with gr.Row(equal_height=True): | |
| # Left Column: Chat & Input | |
| with gr.Column(scale=3, variant="panel"): | |
| with gr.Row(): | |
| gr.Markdown("### π¬ Interactive Session") | |
| fullscreen_btn = gr.Button("βΆ Focus Mode", size="sm", variant="secondary", scale=0, min_width=120) | |
| chatbot = gr.Chatbot( | |
| height=600, | |
| show_label=False, | |
| type="messages", | |
| bubble_full_width=False, | |
| show_copy_button=True, | |
| avatar_images=(None, "https://api.dicebear.com/9.x/bottts-neutral/svg?seed=vibe") | |
| ) | |
| with gr.Row(equal_height=True): | |
| msg = gr.Textbox( | |
| label="What's your goal?", | |
| placeholder="Type 'Hello' to start, or ask: 'How do lists work?'", | |
| lines=1, | |
| scale=5, | |
| container=False, | |
| autofocus=True | |
| ) | |
| submit_btn = gr.Button("π Start", variant="primary", scale=1) | |
| gr.Examples( | |
| examples=[ | |
| "Hello! I'm new to Python.", | |
| "How do for-loops work?", | |
| "Explain dictionaries with an example.", | |
| "Write a script to calculate Fibonacci numbers." | |
| ], | |
| inputs=msg | |
| ) | |
| # Right Column: Resources Dashboard (Side View) | |
| with gr.Column(scale=2, elem_classes="scrollable-right-col") as right_col: | |
| # Moved Key Takeaways INSIDE the tabs to prevent vertical overflow | |
| with gr.Tabs(): | |
| with gr.TabItem("π Takeaways"): | |
| summary_box_side = gr.Markdown(value="### π Key Takeaways\n*Ask a topic to get a cheat sheet!*", elem_classes="tight-content") | |
| with gr.TabItem("πΊ Videos"): | |
| video_box_side = gr.Markdown(value="### Recommended Videos\n*Ask a topic to see video suggestions!*") | |
| with gr.TabItem("π Reading"): | |
| article_box_side = gr.Markdown(value="### Articles & Docs\n*Ask a topic to see reading materials!*") | |
| with gr.TabItem("π§ Quiz"): | |
| # Interactive Quiz UI (Side) | |
| q1_text_side = gr.Markdown("*Quiz will appear here...*") | |
| q1_radio_side = gr.Radio(choices=[], label="Select Answer") | |
| q1_result_side = gr.Markdown(visible=False) | |
| gr.Markdown("---") | |
| q2_text_side = gr.Markdown("") | |
| q2_radio_side = gr.Radio(choices=[], label="Select Answer") | |
| q2_result_side = gr.Markdown(visible=False) | |
| check_btn_side = gr.Button("β Check Answers", variant="secondary") | |
| # Bottom Row: Resources Dashboard (Focus Mode View) | |
| with gr.Row(visible=False) as bottom_dashboard: | |
| with gr.Column(): | |
| gr.Markdown("### π Learning Dashboard") | |
| # Moved Key Takeaways INSIDE the tabs for Bottom View too | |
| with gr.Tabs(): | |
| with gr.TabItem("π Takeaways"): | |
| summary_box_bottom = gr.Markdown(value="### π Key Takeaways\n*Ask a topic to get a cheat sheet!*") | |
| with gr.TabItem("πΊ Videos"): | |
| video_box_bottom = gr.Markdown(value="### Recommended Videos\n*Ask a topic to see video suggestions!*") | |
| with gr.TabItem("π Reading"): | |
| article_box_bottom = gr.Markdown(value="### Articles & Docs\n*Ask a topic to see reading materials!*") | |
| with gr.TabItem("π§ Quiz"): | |
| # Interactive Quiz UI (Bottom) - Mirroring functionality | |
| q1_text_bottom = gr.Markdown("*Quiz will appear here...*") | |
| q1_radio_bottom = gr.Radio(choices=[], label="Select Answer") | |
| q1_result_bottom = gr.Markdown(visible=False) | |
| gr.Markdown("---") | |
| q2_text_bottom = gr.Markdown("") | |
| q2_radio_bottom = gr.Radio(choices=[], label="Select Answer") | |
| q2_result_bottom = gr.Markdown(visible=False) | |
| check_btn_bottom = gr.Button("β Check Answers", variant="secondary") | |
| # --- Interaction Logic --- | |
| async def respond(user_message, history, model_id): | |
| if history is None: history = [] | |
| history.append({"role": "user", "content": user_message}) | |
| history.append({"role": "assistant", "content": f"Thinking (using {model_id})..."}) | |
| # Yield placeholders | |
| # Outputs: History, Chatbox, Side boxes, Bottom boxes, QUIZ STATE, Quiz Inputs (Side x2, Bottom x2), Quiz Texts (Side x2, Bottom x2) | |
| # We need to yield updates for EVERYTHING. | |
| # Simplified: We yield empty strings/defaults for now. | |
| yield history, "", "", "", "", [], \ | |
| gr.update(), gr.update(), gr.update(), gr.update(), \ | |
| gr.update(), gr.update(), gr.update(), gr.update(), \ | |
| "", "", "", "", \ | |
| gr.update(), gr.update(), gr.update(), gr.update(), \ | |
| gr.update(), gr.update(), gr.update(), gr.update() | |
| chat_text, summary_text, video_text, article_text, quiz_data = await run_tutor_dashboard(user_message, model_id) | |
| history[-1]["content"] = chat_text | |
| # Prepare Quiz UI updates | |
| if quiz_data and len(quiz_data) >= 2: | |
| q1 = quiz_data[0] | |
| q2 = quiz_data[1] | |
| # Common updates for both views | |
| q1_t = f"### 1. {q1['question']}" | |
| q1_c = q1['options'] | |
| q2_t = f"### 2. {q2['question']}" | |
| q2_c = q2['options'] | |
| # Reset results and set values | |
| q_upd = [ | |
| gr.update(value=q1_t), gr.update(choices=q1_c, value=None, interactive=True), gr.update(value="", visible=False), | |
| gr.update(value=q2_t), gr.update(choices=q2_c, value=None, interactive=True), gr.update(value="", visible=False) | |
| ] | |
| else: | |
| # Empty updates if no quiz | |
| q_upd = [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()] | |
| # Yield final content | |
| # Note: We duplicate the quiz updates for Side and Bottom views | |
| yield history, "", summary_text, video_text, article_text, quiz_data, \ | |
| q_upd[0], q_upd[1], q_upd[2], q_upd[3], q_upd[4], q_upd[5], \ | |
| summary_text, video_text, article_text, \ | |
| q_upd[0], q_upd[1], q_upd[2], q_upd[3], q_upd[4], q_upd[5] | |
| # --- Focus Mode Logic --- | |
| is_fullscreen = gr.State(False) | |
| def toggle_fullscreen(current_state): | |
| new_state = not current_state | |
| side_visible = not new_state | |
| bottom_visible = new_state | |
| btn_text = "β© Exit Focus" if new_state else "βΆ Focus Mode" | |
| return new_state, gr.Column(visible=side_visible), gr.Row(visible=bottom_visible), btn_text | |
| fullscreen_btn.click( | |
| toggle_fullscreen, | |
| inputs=[is_fullscreen], | |
| outputs=[is_fullscreen, right_col, bottom_dashboard, fullscreen_btn] | |
| ) | |
| # --- Quiz Button Logic --- | |
| # Side View Check | |
| check_btn_side.click( | |
| check_quiz, | |
| inputs=[q1_radio_side, q2_radio_side, quiz_state], | |
| outputs=[q1_result_side, q2_result_side] | |
| ) | |
| # Bottom View Check | |
| check_btn_bottom.click( | |
| check_quiz, | |
| inputs=[q1_radio_bottom, q2_radio_bottom, quiz_state], | |
| outputs=[q1_result_bottom, q2_result_bottom] | |
| ) | |
| outputs_list = [ | |
| chatbot, msg, | |
| summary_box_side, video_box_side, article_box_side, | |
| quiz_state, | |
| q1_text_side, q1_radio_side, q1_result_side, q2_text_side, q2_radio_side, q2_result_side, | |
| summary_box_bottom, video_box_bottom, article_box_bottom, | |
| q1_text_bottom, q1_radio_bottom, q1_result_bottom, q2_text_bottom, q2_radio_bottom, q2_result_bottom | |
| ] | |
| submit_btn.click( | |
| respond, | |
| [msg, chatbot, model_selector], | |
| outputs_list | |
| ) | |
| msg.submit( | |
| respond, | |
| [msg, chatbot, model_selector], | |
| outputs_list | |
| ) | |
| # --- Launch --- | |
| if __name__ == "__main__": | |
| demo.queue().launch() |