|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
NEBIUS_API_KEY = os.getenv("NEBIUS_API_KEY") |
|
|
NEBIUS_BASE_URL = "https://api.studio.nebius.ai/v1/" |
|
|
|
|
|
|
|
|
AVAILABLE_MODELS = [ |
|
|
"openai/gpt-oss-20b", |
|
|
"openai/gpt-oss-120b" |
|
|
] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 = [] |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
try: |
|
|
|
|
|
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 = [] |
|
|
|
|
|
elif "---SECTION: VIDEOS---" in full_text: |
|
|
|
|
|
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." |
|
|
|
|
|
|
|
|
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']}" |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 = """ |
|
|
.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: |
|
|
|
|
|
quiz_state = gr.State([]) |
|
|
|
|
|
|
|
|
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): |
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Column(scale=2, elem_classes="scrollable-right-col") as right_col: |
|
|
|
|
|
|
|
|
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"): |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
with gr.Row(visible=False) as bottom_dashboard: |
|
|
with gr.Column(): |
|
|
gr.Markdown("### 🎒 Learning Dashboard") |
|
|
|
|
|
|
|
|
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"): |
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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 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 |
|
|
|
|
|
|
|
|
if quiz_data and len(quiz_data) >= 2: |
|
|
q1 = quiz_data[0] |
|
|
q2 = quiz_data[1] |
|
|
|
|
|
|
|
|
q1_t = f"### 1. {q1['question']}" |
|
|
q1_c = q1['options'] |
|
|
q2_t = f"### 2. {q2['question']}" |
|
|
q2_c = q2['options'] |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
q_upd = [gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()] |
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
check_btn_side.click( |
|
|
check_quiz, |
|
|
inputs=[q1_radio_side, q2_radio_side, quiz_state], |
|
|
outputs=[q1_result_side, q2_result_side] |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.queue().launch() |