From c56199e88ecf86416b674e1dd19ecbea45fd2a00 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 23 Jul 2026 15:26:22 +0200 Subject: [PATCH 1/4] Add initial_source metadata tag --- examples/matplotlib_exercices.ipynb | 3 + examples/matplotlib_exercices_teacher.ipynb | 216 ++++++++++++++++++++ jupyter_ai_tutor/TUTOR.md | 3 + src/index.ts | 6 + 4 files changed, 228 insertions(+) create mode 100644 examples/matplotlib_exercices_teacher.ipynb diff --git a/examples/matplotlib_exercices.ipynb b/examples/matplotlib_exercices.ipynb index 2aff21d..9ba3fec 100644 --- a/examples/matplotlib_exercices.ipynb +++ b/examples/matplotlib_exercices.ipynb @@ -50,6 +50,7 @@ "deletable": true, "editable": true, "evaluation_criteria": "Typo Correction: Correct 'np.linsapce' typo to 'np.linspace'.\nPlot Output: Ensure the Sine and Cosine waves plot is correctly rendered without runtime errors.", + "initial_source": "# 🐞 There is a bug in this cell — find and fix it!\n\nx = np.linsapce(0, 2 * np.pi, 100) # <-- look carefully at this line\n\nfig, ax = plt.subplots(figsize=(8, 4))\nax.plot(x, np.sin(x), label='sin(x)', color='royalblue')\nax.plot(x, np.cos(x), label='cos(x)', color='tomato', linestyle='--')\nax.set_title('Sine and Cosine Waves')\nax.set_xlabel('x')\nax.set_ylabel('y')\nax.legend()\nax.grid(True, alpha=0.3)\nplt.tight_layout()\nplt.show()", "reference_solution": "import matplotlib.pyplot as plt\nimport numpy as np\nx = np.linspace(0, 2 * np.pi, 100)\nfig, ax = plt.subplots(figsize=(8, 4))\nax.plot(x, np.sin(x), label='sin(x)', color='royalblue')\nax.plot(x, np.cos(x), label='cos(x)', color='tomato', linestyle='--')\nax.set_title('Sine and Cosine Waves')\nax.set_xlabel('x')\nax.set_ylabel('y')\nax.legend()\nax.grid(True, alpha=0.3)\nplt.tight_layout()\nplt.show()", "slideshow": { "slide_type": "" @@ -99,6 +100,7 @@ "deletable": true, "editable": true, "evaluation_criteria": "Bar chart creation: Uses ax.bar with categories and values.\nAesthetics & Colors: Applies a different color to each bar.\nValue Labels: Correctly loops over the bars to add text scores above each bar.", + "initial_source": "# Data is provided — write your bar chart code below!\ncategories = ['Python', 'JavaScript', 'Rust', 'Go', 'Julia']\nvalues = [85, 72, 58, 45, 30]\n\n# YOUR CODE HERE\nfig, ax = plt.subplots(figsize=(8, 4))\n\n\n# Intentional syntax error\nax.bar(categories, values\n\n# 1. Create the bar chart with different colors for each bar\n# ...\n\n# 2. Add a title and y-axis label\n# ...\n\n# 3. Add value labels on top of each bar\n# ...\n\nplt.tight_layout()\nplt.show()", "reference_solution": "categories = ['Python', 'JavaScript', 'Rust', 'Go', 'Julia']\nvalues = [85, 72, 58, 45, 30]\nfig, ax = plt.subplots(figsize=(8, 4))\ncolors = ['royalblue', 'tomato', 'mediumseagreen', 'orange', 'mediumpurple']\nbars = ax.bar(categories, values, color=colors)\nax.set_title('Programming Language Scores')\nax.set_ylabel('Score')\nfor bar in bars:\n yval = bar.get_height()\n ax.text(bar.get_x() + bar.get_width()/2.0, yval + 1, str(yval), ha='center', va='bottom')\nplt.tight_layout()\nplt.show()", "slideshow": { "slide_type": "" @@ -157,6 +159,7 @@ "deletable": true, "editable": true, "evaluation_criteria": "Random data seed: Fails to set random seed 42, generating incorrect data coordinates.\nColorbar & Labels: Missing colorbar, title, grid, or x/y labels on the axes.\nScatter aesthetic parameters: Fails to set alpha=0.6, colormap='viridis', size mapping, or color mapping.", + "initial_source": "# YOUR CODE HERE\nnp.random.seed(42)\nn = 150\n\n# 1. Generate random x and y coordinates\n# x = ...\n# y = ...\n\n# 2. Generate random sizes and colors\n# sizes = ...\n# colors = ...\n\nfig, ax = plt.subplots(figsize=(8, 6))\n\n# 3. Create the scatter plot\n# scatter = ax.scatter(...)\n\n# 4. Add colorbar\n# plt.colorbar(...)\n\n# 5. Add title, axis labels, and grid\n# ...\n\nplt.tight_layout()\nplt.show()", "reference_solution": "np.random.seed(42)\nn = 150\nx = np.random.randn(n)\ny = np.random.randn(n)\nsizes = np.random.uniform(20, 200, n)\ncolors = np.random.rand(n)\nfig, ax = plt.subplots(figsize=(8, 6))\nscatter = ax.scatter(x, y, c=colors, s=sizes, cmap='viridis', alpha=0.6)\nplt.colorbar(scatter, ax=ax, label='Color value')\nax.set_title('Random Scatter Plot')\nax.set_xlabel('X values')\nax.set_ylabel('Y values')\nax.grid(True, alpha=0.3)\nplt.tight_layout()\nplt.show()", "slideshow": { "slide_type": "" diff --git a/examples/matplotlib_exercices_teacher.ipynb b/examples/matplotlib_exercices_teacher.ipynb new file mode 100644 index 0000000..09eb31a --- /dev/null +++ b/examples/matplotlib_exercices_teacher.ipynb @@ -0,0 +1,216 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "43e1ba2a-fda4-4f9f-b70c-6f3035c571ae", + "metadata": {}, + "source": [ + "# 📊 Matplotlib Exercises\n", + "\n", + "In this notebook, you'll practice common matplotlib plots.\n", + "\n", + "> **Instructions:** Work through each exercise below. Fix any bugs you find and complete the missing code cells.\n", + "\n", + "---" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bad3907c-9b3e-446c-aa52-71d63a1d72cb", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "# Enable inline plotting\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "884b0e47-1e1c-4945-908c-8fdbc5f9fcd9", + "metadata": {}, + "source": [ + "## ✅ Exercise 1 — Fix the Bug: Line Plot\n", + "\n", + "The cell below tries to plot sine and cosine waves, but it contains a **bug**.\n", + "\n", + "- Run the cell to see the error.\n", + "- Find and fix the typo in the NumPy function call.\n", + "- Re-run the cell to verify your fix produces the correct plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15a20896-1822-4b7f-89db-bf5ef6682aaf", + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# 🐞 There is a bug in this cell — find and fix it!\n", + "\n", + "### BEGIN SOLUTION\n", + "x = np.linspace(0, 2 * np.pi, 100)\n", + "### END SOLUTION\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "ax.plot(x, np.sin(x), label='sin(x)', color='royalblue')\n", + "ax.plot(x, np.cos(x), label='cos(x)', color='tomato', linestyle='--')\n", + "ax.set_title('Sine and Cosine Waves')\n", + "ax.set_xlabel('x')\n", + "ax.set_ylabel('y')\n", + "ax.legend()\n", + "ax.grid(True, alpha=0.3)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "raw", + "id": "02c2b142-1fbd-4da6-8229-b441d445730b", + "metadata": {}, + "source": [ + "## 📊 Exercise 2 — Create a Bar Chart\n", + "\n", + "Using the data provided below, create a **bar chart** that meets these requirements:\n", + "\n", + "1. Use the `categories` and `values` variables already defined.\n", + "2. Apply a different color to each bar (feel free to pick your own colors).\n", + "3. Add a title, a y-axis label (`'Score'`), and value labels on top of each bar.\n", + "4. Call `plt.tight_layout()` and `plt.show()` at the end.\n", + "\n", + "> **Hint:** Use `ax.bar(...)` and loop over the bars to add text labels with `ax.text(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a984913-5581-4ca5-a7ad-c16dc21c108d", + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# Data is provided — write your bar chart code below!\n", + "categories = ['Python', 'JavaScript', 'Rust', 'Go', 'Julia']\n", + "values = [85, 72, 58, 45, 30]\n", + "\n", + "### BEGIN SOLUTION\n", + "fig, ax = plt.subplots(figsize=(8, 4))\n", + "colors = ['royalblue', 'tomato', 'mediumseagreen', 'orange', 'mediumpurple']\n", + "bars = ax.bar(categories, values, color=colors)\n", + "ax.set_title('Programming Language Scores')\n", + "ax.set_ylabel('Score')\n", + "for bar in bars:\n", + " yval = bar.get_height()\n", + " ax.text(bar.get_x() + bar.get_width()/2.0, yval + 1, str(yval), ha='center', va='bottom')\n", + "### END SOLUTION\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54e87b2a-cff2-4943-8505-2a0ee3f4d95f", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "a696a0ac-a513-4988-8e3d-65040ca64d88", + "metadata": {}, + "source": [ + "## ⭐ Exercise 3 — Create a Scatter Plot with a Colorbar\n", + "\n", + "Using NumPy's random number generator, create a **scatter plot** that meets these requirements:\n", + "\n", + "1. Generate `150` random 2D points (`x` and `y` from a standard normal distribution, seed `42`).\n", + "2. Give each point a random size (between 20 and 200) and a random color value (between 0 and 1).\n", + "3. Use the `'viridis'` colormap and set `alpha=0.6`.\n", + "4. Add a colorbar with the label `'Color value'`.\n", + "5. Add a title (`'Random Scatter Plot'`), axis labels (`'X values'`, `'Y values'`), and a light grid (`alpha=0.3`).\n", + "\n", + "> **Hint:** Use `ax.scatter(x, y, c=colors, s=sizes, ...)` and `plt.colorbar(scatter, ax=ax, label=...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "957d292f-e46d-4e7d-ba1a-4adafce9a820", + "metadata": { + "deletable": true, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "# YOUR CODE HERE\n", + "np.random.seed(42)\n", + "n = 150\n", + "\n", + "# 1. Generate random x and y coordinates\n", + "# x = ...\n", + "# y = ...\n", + "\n", + "# 2. Generate random sizes and colors\n", + "# sizes = ...\n", + "# colors = ...\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, 6))\n", + "\n", + "# 3. Create the scatter plot\n", + "# scatter = ax.scatter(...)\n", + "\n", + "# 4. Add colorbar\n", + "# plt.colorbar(...)\n", + "\n", + "# 5. Add title, axis labels, and grid\n", + "# ...\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyter_ai_tutor/TUTOR.md b/jupyter_ai_tutor/TUTOR.md index 287dd00..19bdc6c 100644 --- a/jupyter_ai_tutor/TUTOR.md +++ b/jupyter_ai_tutor/TUTOR.md @@ -25,6 +25,9 @@ understand code — never to write code for them. cells immediately preceding the code cell in the notebook. Use it to understand what the student is expected to accomplish, and tailor your guidance to that goal. The block is not visible to the student. +- An `` block may also be embedded. It contains the starter code + originally provided to the student before any edits. Use it to compare against + `` and understand what the student has changed or attempted. - A `` block can also be embedded. You should use it to help guiding the student, without exposing its content. - A `` block may also be embedded. You should use this to help the diff --git a/src/index.ts b/src/index.ts index 9a5f0c3..b82ed16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,6 +286,9 @@ const plugin: JupyterFrontEndPlugin = { 'evaluation_criteria' ); + // Retrieve initial_source from metadata + const initialSource = cell.model.getMetadata('initial_source'); + const question = errorSection ? 'Explain code and error' : 'Explain code'; @@ -297,6 +300,9 @@ const plugin: JupyterFrontEndPlugin = { } formattedBody += `\n${studentAnswer}\n`; + if (initialSource && typeof initialSource === 'string') { + formattedBody += `\n\n\n${initialSource}\n`; + } if (referenceSolution) { formattedBody += `\n\n\n${referenceSolution}\n`; } From 3bc465c9c97f32830bba886d981b02996ecf3fc7 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 24 Jul 2026 12:50:06 +0200 Subject: [PATCH 2/4] Implement review command --- jupyter_ai_tutor/TUTOR.md | 29 ++-- jupyter_ai_tutor/handlers.py | 46 ++++- schema/plugin.json | 5 + src/api.ts | 3 +- src/index.ts | 314 +++++++++++++++++++---------------- src/model.ts | 4 +- 6 files changed, 239 insertions(+), 162 deletions(-) diff --git a/jupyter_ai_tutor/TUTOR.md b/jupyter_ai_tutor/TUTOR.md index 19bdc6c..a3b07e4 100644 --- a/jupyter_ai_tutor/TUTOR.md +++ b/jupyter_ai_tutor/TUTOR.md @@ -9,18 +9,9 @@ understand code — never to write code for them. redirect them to think through the problem themselves. - Do not end with a question, user won't be able to answer. -## How to Help - -- Ask guiding questions that lead the student toward the answer themselves. -- Explain the underlying concept or principle at play. -- Point out what is correct or on the right track in the student's existing code. -- Identify the specific part that is wrong or missing, without fixing it. -- Suggest what to search for or which documentation to read. -- Break a complex problem into smaller steps and ask the student to tackle one at a time. - ## Request formatting -- The cell to explain is in a `` block. +- The cell to work on is in a `` block. - When the message contains an `` block, that content comes from the markdown cells immediately preceding the code cell in the notebook. Use it to understand what the student is expected to accomplish, and tailor your @@ -33,6 +24,24 @@ understand code — never to write code for them. - A `` block may also be embedded. You should use this to help the student in accordance with the teacher's expectations. +## Mode: Explain + +- Ask guiding questions that lead the student toward the answer themselves. +- Explain the underlying concept or principle at play. +- Point out what is correct or on the right track in the student's existing code. +- Identify the specific part that is wrong or missing, without fixing it. +- Suggest what to search for or which documentation to read. +- Break a complex problem into smaller steps and ask the student to tackle one at a time. + +## Mode: Review + +- Thoroughly review and evaluate the student's current code in `` (do not report bugs from `` if the student has already fixed them in ``). +- If an `` block is present, check the student's implementation in `` against each criterion and state whether it meets expectations. +- If an `` block is present, compare it against `` to analyze what progress or changes the student has made relative to the starter code. +- If a `` block is present, compare the student's logic against it to spot logical or architectural flaws, without revealing the solution code. +- Clearly highlight any remaining syntax errors, runtime exceptions, logic bugs, or API misuse in ``. +- Provide clear, actionable, and constructive feedback on how the student can improve their submission while adhering to the Core Rules (never writing code for them). + ## Tone - Be encouraging and patient. diff --git a/jupyter_ai_tutor/handlers.py b/jupyter_ai_tutor/handlers.py index 0aa56de..76341e4 100644 --- a/jupyter_ai_tutor/handlers.py +++ b/jupyter_ai_tutor/handlers.py @@ -18,6 +18,7 @@ async def post(self): raise tornado.web.HTTPError(400, "Missing 'body' field in request") message_body = body["body"] + action = body.get("action", "explain") config_manager = self.settings.get("jupyternaut.config_manager") if not config_manager: @@ -29,7 +30,7 @@ async def post(self): 503, "No chat model is configured. Set one in 'Settings > AI Settings'.", ) - model_used_string = f"/* MODEL USED: {config_manager.chat_model} */\n\n" + model_used_string = f"/* MODEL USED: {config_manager.chat_model} (ACTION: {action}) */\n\n" self.set_header("Content-Type", "text/event-stream") self.set_header("Cache-Control", "no-cache") @@ -37,19 +38,21 @@ async def post(self): notebook_path = body.get("notebookPath", "") server_root = self.settings.get("server_root_dir", "") - system_prompt = None + raw_system_prompt = None if self.settings.get("jupyter_ai_tutor.discover_tutor_md", True) and notebook_path and server_root: - system_prompt = self._find_tutor_md(notebook_path, server_root) - if system_prompt: + raw_system_prompt = self._find_tutor_md(notebook_path, server_root) + if raw_system_prompt: self.log.info( "jupyter_ai_tutor: using TUTOR.md resolved from notebook path %s", notebook_path, ) - if system_prompt is None: - system_prompt = self.settings.get( + if raw_system_prompt is None: + raw_system_prompt = self.settings.get( "jupyter_ai_tutor.default_system_prompt", "" ) + system_prompt = self._filter_prompt_for_action(raw_system_prompt, action) + debug_mode = self.settings.get("jupyter_ai_tutor.debug", False) prompt_file = None answer_file = None @@ -62,8 +65,8 @@ async def post(self): except Exception as e: self.log.error(f"Failed to create debug directory {debug_dir}: {e}") timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - prompt_file = debug_dir / f"{timestamp}_jupyter_tutor_prompt.txt" - answer_file = debug_dir / f"{timestamp}_jupyter_tutor_answer.txt" + prompt_file = debug_dir / f"{timestamp}_jupyter_tutor_{action}_prompt.txt" + answer_file = debug_dir / f"{timestamp}_jupyter_tutor_{action}_answer.txt" try: with prompt_file.open("w", encoding="utf-8") as f: f.write(model_used_string) @@ -137,6 +140,33 @@ async def post(self): except StreamClosedError: pass + def _filter_prompt_for_action(self, raw_prompt: str, action: str) -> str: + """Filters system prompt sections based on action ('explain' vs 'review').""" + lines = raw_prompt.splitlines() + filtered_lines = [] + current_mode = "common" + target_header = f"## Mode: {action.capitalize()}" + other_headers = [ + "## Mode: Explain", + "## Mode: Review", + ] + + for line in lines: + stripped = line.strip() + if stripped in other_headers: + if stripped == target_header: + current_mode = "include" + else: + current_mode = "exclude" + continue + elif stripped.startswith("## ") and current_mode != "common": + current_mode = "common" + + if current_mode != "exclude": + filtered_lines.append(line) + + return "\n".join(filtered_lines).strip() + def _find_tutor_md(self, notebook_path: str, server_root: str) -> str | None: """Walk up the directory tree from the notebook's directory toward the server root, returning the content of the first TUTOR.md found, or None.""" diff --git a/schema/plugin.json b/schema/plugin.json index ec1efa6..bb60935 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -6,6 +6,11 @@ "name": "jupyter-ai-tutor:explain-code", "command": "jupyter-ai-tutor:explain-code", "rank": 10 + }, + { + "name": "jupyter-ai-tutor:review-code", + "command": "jupyter-ai-tutor:review-code", + "rank": 11 } ] }, diff --git a/src/api.ts b/src/api.ts index 9c8b8d1..24b9d34 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,7 @@ import { ServerConnection } from '@jupyterlab/services'; export async function* streamExplanation( body: string, notebookPath?: string, + action?: 'explain' | 'review', signal?: AbortSignal ): AsyncGenerator { const settings = ServerConnection.makeSettings(); @@ -20,7 +21,7 @@ export async function* streamExplanation( url, { method: 'POST', - body: JSON.stringify({ body, notebookPath }), + body: JSON.stringify({ body, notebookPath, action: action ?? 'explain' }), headers: { 'Content-Type': 'application/json' }, signal }, diff --git a/src/index.ts b/src/index.ts index b82ed16..0eed0b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,13 +15,14 @@ import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; -import { infoIcon } from '@jupyterlab/ui-components'; +import { codeCheckIcon, infoIcon } from '@jupyterlab/ui-components'; import { clearItem, stopItem } from './components'; import { TUTOR_USER, TutorChatModel } from './model'; import { decodeSolution, isContinuous } from './utils'; const INFO_ICON_BASE_64 = btoa(infoIcon.svgstr); +const CHECK_ICON_BASE_64 = btoa(codeCheckIcon.svgstr); // Matches ANSI escape sequences used for terminal colors in tracebacks. const ANSI_ESCAPE = new RegExp( @@ -34,6 +35,7 @@ const ANSI_ESCAPE = new RegExp( */ namespace CommandIDs { export const explainCode = 'jupyter-ai-tutor:explain-code'; + export const reviewCode = 'jupyter-ai-tutor:review-code'; } /** @@ -118,7 +120,7 @@ const plugin: JupyterFrontEndPlugin = { rmRegistry, translator: translator ?? undefined, welcomeMessage: trans.__( - `## Select a code cell and click **Explain Code** to get started.` + `## Select a code cell and click **Explain Code** or **Review Code** to get started.` ), attachmentOpenerRegistry, inputToolbarRegistry @@ -132,6 +134,7 @@ const plugin: JupyterFrontEndPlugin = { // Keep the enabled state in sync when the active cell changes. notebookTracker?.activeCellChanged.connect(() => { commands.notifyCommandChanged(CommandIDs.explainCode); + commands.notifyCommandChanged(CommandIDs.reviewCode); }); // Listen for writers change to display the stop button. @@ -160,169 +163,196 @@ const plugin: JupyterFrontEndPlugin = { tutorModel.messagesUpdated.connect(messagesChanged); - // the command to ask for explanation. - commands.addCommand(CommandIDs.explainCode, { - label: trans.__('Explain Code'), - caption: trans.__('Send cell content to AI tutor for explanation'), - icon: infoIcon, - isEnabled: () => { - const cell = notebookTracker?.activeCell; - return !!cell && cell.model.type === 'code'; - }, - isVisible: () => true, - execute: async () => { - const cell = notebookTracker?.activeCell; - if (!cell || cell.model.type !== 'code') return; - - const source = cell.model.sharedModel.source.trim(); - if (!source) return; - - const language = - notebookTracker?.currentWidget?.model?.defaultKernelLanguage ?? ''; - - // Collect the first error output from the cell, if any. - const codeModel = cell.model as ICodeCellModel; - const outputs = codeModel.outputs; - let errorSection = ''; - let jsonError: { - ename: string; - evalue: string; - traceback: string[]; - } | null = null; - - for (let i = 0; i < outputs.length; i++) { - const output = outputs.get(i); - if (output.type === 'error') { - const json = output.toJSON() as { - ename: string; - evalue: string; - traceback: string[]; - }; - jsonError = json; - const traceback = json.traceback - .map(line => line.replace(ANSI_ESCAPE, '')) - .join('\n'); - errorSection = - `\n\n**Error:**\n\`\`\`\n${json.ename}: ${json.evalue}\n` + - `${traceback}\n\`\`\``; - break; - } + async function executeAction(action: 'explain' | 'review') { + const cell = notebookTracker?.activeCell; + if (!cell || cell.model.type !== 'code') return; + + const source = cell.model.sharedModel.source.trim(); + if (!source) return; + + const language = + notebookTracker?.currentWidget?.model?.defaultKernelLanguage ?? ''; + + // Collect the first error output from the cell, if any. + const codeModel = cell.model as ICodeCellModel; + const outputs = codeModel.outputs; + let errorSection = ''; + let jsonError: { + ename: string; + evalue: string; + traceback: string[]; + } | null = null; + + for (let i = 0; i < outputs.length; i++) { + const output = outputs.get(i); + if (output.type === 'error') { + const json = output.toJSON() as { + ename: string; + evalue: string; + traceback: string[]; + }; + jsonError = json; + const traceback = json.traceback + .map(line => line.replace(ANSI_ESCAPE, '')) + .join('\n'); + errorSection = + `\n\n**Error:**\n\`\`\`\n${json.ename}: ${json.evalue}\n` + + `${traceback}\n\`\`\``; + break; } + } - // Collect preceding cells context. - const notebook = notebookTracker?.currentWidget?.content; - const notebookPath = notebookTracker?.currentWidget?.context.path ?? ''; - let studentContext = ''; - let attachment: INotebookAttachment | undefined; - - if (notebook) { - const activeCellIndex = notebook.activeCellIndex; - let lastMdIdx = -1; - - // Find the index of the most recent markdown cell above the active cell - for (let i = activeCellIndex - 1; i >= 0; i--) { - const precedingCell = notebook.widgets[i]; - if (precedingCell.model.type === 'markdown') { - lastMdIdx = i; - break; - } - } - - // Gather all cells from that markdown cell up to activeCellIndex - 1 - const startIdx = lastMdIdx !== -1 ? lastMdIdx : 0; - - const contextCells = []; - for (let i = startIdx; i < activeCellIndex; i++) { - contextCells.push(notebook.widgets[i]); + // Collect preceding cells context. + const notebook = notebookTracker?.currentWidget?.content; + const notebookPath = notebookTracker?.currentWidget?.context.path ?? ''; + let studentContext = ''; + let attachment: INotebookAttachment | undefined; + + if (notebook) { + const activeCellIndex = notebook.activeCellIndex; + let lastMdIdx = -1; + + // Find the index of the most recent markdown cell above the active cell + for (let i = activeCellIndex - 1; i >= 0; i--) { + const precedingCell = notebook.widgets[i]; + if (precedingCell.model.type === 'markdown') { + lastMdIdx = i; + break; } + } - let contextStr = ''; - const cellsForAttachment = []; + // Gather all cells from that markdown cell up to activeCellIndex - 1 + const startIdx = lastMdIdx !== -1 ? lastMdIdx : 0; - for (const cCell of contextCells) { - const cSource = cCell.model.sharedModel.source.trim(); - if (!cSource) { - continue; - } + const contextCells = []; + for (let i = startIdx; i < activeCellIndex; i++) { + contextCells.push(notebook.widgets[i]); + } - cellsForAttachment.push({ - id: cCell.model.id, - input_type: cCell.model.type as 'raw' | 'markdown' | 'code' - }); + let contextStr = ''; + const cellsForAttachment = []; - if (cCell.model.type === 'markdown') { - contextStr += `${cSource}\n\n`; - } else if (cCell.model.type === 'code') { - contextStr += `Preceding Code:\n\`\`\`${language}\n${cSource}\n\`\`\`\n\n`; - } + for (const cCell of contextCells) { + const cSource = cCell.model.sharedModel.source.trim(); + if (!cSource) { + continue; } - studentContext = contextStr.trim(); + cellsForAttachment.push({ + id: cCell.model.id, + input_type: cCell.model.type as 'raw' | 'markdown' | 'code' + }); - if (cellsForAttachment.length > 0) { - attachment = { - type: 'notebook', - value: notebookPath, - cells: cellsForAttachment - }; + if (cCell.model.type === 'markdown') { + contextStr += `${cSource}\n\n`; + } else if (cCell.model.type === 'code') { + contextStr += `Preceding Code:\n\`\`\`${language}\n${cSource}\n\`\`\`\n\n`; } } - // Format student answer - let studentAnswer = source; - if (jsonError) { - const traceback = jsonError.traceback - .map(line => line.replace(ANSI_ESCAPE, '')) - .join('\n'); - studentAnswer += `\n\nError:\n${jsonError.ename}: ${jsonError.evalue}\n${traceback}`; + + studentContext = contextStr.trim(); + + if (cellsForAttachment.length > 0) { + attachment = { + type: 'notebook', + value: notebookPath, + cells: cellsForAttachment + }; } + } + // Format student answer + let studentAnswer = source; + if (jsonError) { + const traceback = jsonError.traceback + .map(line => line.replace(ANSI_ESCAPE, '')) + .join('\n'); + studentAnswer += `\n\nError:\n${jsonError.ename}: ${jsonError.evalue}\n${traceback}`; + } - // Retrieve and decode reference_solution from metadata - const rawSolution = cell.model.getMetadata('reference_solution'); - const referenceSolution = - typeof rawSolution === 'string' ? decodeSolution(rawSolution) : ''; + // Retrieve and decode reference_solution from metadata + const rawSolution = cell.model.getMetadata('reference_solution'); + const referenceSolution = + typeof rawSolution === 'string' ? decodeSolution(rawSolution) : ''; - // Retrieve evaluation_criteria from metadata - const evaluationCriteria = cell.model.getMetadata( - 'evaluation_criteria' - ); + // Retrieve evaluation_criteria from metadata + const evaluationCriteria = cell.model.getMetadata( + 'evaluation_criteria' + ); - // Retrieve initial_source from metadata - const initialSource = cell.model.getMetadata('initial_source'); + // Retrieve initial_source from metadata + const initialSource = cell.model.getMetadata('initial_source'); - const question = errorSection - ? 'Explain code and error' - : 'Explain code'; - const bodyContent = `${question}\n\n\`\`\`${language}\n${source}\n\`\`\`${errorSection}\n`; + const actionTitle = action === 'review' ? 'Review code' : 'Explain code'; + const question = errorSection + ? `${actionTitle} and error` + : actionTitle; + const bodyContent = `${question}\n\n\`\`\`${language}\n${source}\n\`\`\`${errorSection}\n`; - let formattedBody = ''; - if (studentContext) { - formattedBody += `\n${studentContext}\n\n\n`; - } - formattedBody += `\n${studentAnswer}\n`; + let formattedBody = ''; + if (studentContext) { + formattedBody += `\n${studentContext}\n\n\n`; + } + formattedBody += `\n${studentAnswer}\n`; - if (initialSource && typeof initialSource === 'string') { - formattedBody += `\n\n\n${initialSource}\n`; - } - if (referenceSolution) { - formattedBody += `\n\n\n${referenceSolution}\n`; - } - if (evaluationCriteria && typeof evaluationCriteria === 'string') { - formattedBody += `\n\n\n${evaluationCriteria}\n`; - } + if (initialSource && typeof initialSource === 'string') { + formattedBody += `\n\n\n${initialSource}\n`; + } + if (referenceSolution) { + formattedBody += `\n\n\n${referenceSolution}\n`; + } + if (evaluationCriteria && typeof evaluationCriteria === 'string') { + formattedBody += `\n\n\n${evaluationCriteria}\n`; + } + + formattedBody += '\n'; - formattedBody += '\n'; + if (!chatWidget.isAttached) { + app.shell.add(chatWidget, 'right'); + } + app.shell.activateById(chatWidget.id); + + await tutorModel.sendMessageToAI({ + body: bodyContent, + formattedBody: formattedBody, + notebookPath, + action, + attachments: attachment ? [attachment] : undefined + }); + } - if (!chatWidget.isAttached) { - app.shell.add(chatWidget, 'right'); + // Register Explain Code command + commands.addCommand(CommandIDs.explainCode, { + label: trans.__('Explain Code'), + caption: trans.__('Send cell content to AI tutor for explanation'), + icon: infoIcon, + isEnabled: () => { + const cell = notebookTracker?.activeCell; + return !!cell && cell.model.type === 'code'; + }, + isVisible: () => true, + execute: async () => { + await executeAction('explain'); + }, + describedBy: { + args: { + type: 'object', + properties: {} } - app.shell.activateById(chatWidget.id); + } + }); - await tutorModel.sendMessageToAI({ - body: bodyContent, - formattedBody: formattedBody, - notebookPath, - attachments: attachment ? [attachment] : undefined - }); + // Register Review Code command + commands.addCommand(CommandIDs.reviewCode, { + label: trans.__('Review Code'), + caption: trans.__('Send cell content to AI tutor for review'), + icon: codeCheckIcon, + isEnabled: () => { + const cell = notebookTracker?.activeCell; + return !!cell && cell.model.type === 'code'; + }, + isVisible: () => true, + execute: async () => { + await executeAction('review'); }, describedBy: { args: { diff --git a/src/model.ts b/src/model.ts index 02c012e..2741a42 100644 --- a/src/model.ts +++ b/src/model.ts @@ -12,10 +12,11 @@ import { UUID } from '@lumino/coreutils'; import { streamExplanation } from './api'; import { AI_AVATAR } from './icons'; -interface ITutorNewMessage extends INewMessage { +export interface ITutorNewMessage extends INewMessage { attachments?: IAttachment[]; notebookPath?: string; formattedBody?: string; + action?: 'explain' | 'review'; } export const TUTOR_USER: IUser = { @@ -91,6 +92,7 @@ export class TutorChatModel extends AbstractChatModel { for await (const chunk of streamExplanation( message.formattedBody ?? message.body, message.notebookPath, + message.action, this._abortController.signal )) { accumulated += chunk; From b0aa67d285ddecddb5e0785fb760f327d0b5ac65 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 24 Jul 2026 14:16:12 +0200 Subject: [PATCH 3/4] Change TUTOR.md to jinja template --- jupyter_ai_tutor/TUTOR.md | 20 +++++++++------- jupyter_ai_tutor/handlers.py | 46 +++++++++++++++--------------------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/jupyter_ai_tutor/TUTOR.md b/jupyter_ai_tutor/TUTOR.md index a3b07e4..70667f4 100644 --- a/jupyter_ai_tutor/TUTOR.md +++ b/jupyter_ai_tutor/TUTOR.md @@ -24,15 +24,7 @@ understand code — never to write code for them. - A `` block may also be embedded. You should use this to help the student in accordance with the teacher's expectations. -## Mode: Explain - -- Ask guiding questions that lead the student toward the answer themselves. -- Explain the underlying concept or principle at play. -- Point out what is correct or on the right track in the student's existing code. -- Identify the specific part that is wrong or missing, without fixing it. -- Suggest what to search for or which documentation to read. -- Break a complex problem into smaller steps and ask the student to tackle one at a time. - +{% if action == 'review' %} ## Mode: Review - Thoroughly review and evaluate the student's current code in `` (do not report bugs from `` if the student has already fixed them in ``). @@ -41,6 +33,16 @@ understand code — never to write code for them. - If a `` block is present, compare the student's logic against it to spot logical or architectural flaws, without revealing the solution code. - Clearly highlight any remaining syntax errors, runtime exceptions, logic bugs, or API misuse in ``. - Provide clear, actionable, and constructive feedback on how the student can improve their submission while adhering to the Core Rules (never writing code for them). +{% else %} +## Mode: Explain + +- Ask guiding questions that lead the student toward the answer themselves. +- Explain the underlying concept or principle at play. +- Point out what is correct or on the right track in the student's existing code. +- Identify the specific part that is wrong or missing, without fixing it. +- Suggest what to search for or which documentation to read. +- Break a complex problem into smaller steps and ask the student to tackle one at a time. +{% endif %} ## Tone diff --git a/jupyter_ai_tutor/handlers.py b/jupyter_ai_tutor/handlers.py index 76341e4..c71d918 100644 --- a/jupyter_ai_tutor/handlers.py +++ b/jupyter_ai_tutor/handlers.py @@ -51,7 +51,9 @@ async def post(self): "jupyter_ai_tutor.default_system_prompt", "" ) - system_prompt = self._filter_prompt_for_action(raw_system_prompt, action) + system_prompt = self._render_prompt_template( + raw_system_prompt, action, notebook_path, body + ) debug_mode = self.settings.get("jupyter_ai_tutor.debug", False) prompt_file = None @@ -140,32 +142,22 @@ async def post(self): except StreamClosedError: pass - def _filter_prompt_for_action(self, raw_prompt: str, action: str) -> str: - """Filters system prompt sections based on action ('explain' vs 'review').""" - lines = raw_prompt.splitlines() - filtered_lines = [] - current_mode = "common" - target_header = f"## Mode: {action.capitalize()}" - other_headers = [ - "## Mode: Explain", - "## Mode: Review", - ] - - for line in lines: - stripped = line.strip() - if stripped in other_headers: - if stripped == target_header: - current_mode = "include" - else: - current_mode = "exclude" - continue - elif stripped.startswith("## ") and current_mode != "common": - current_mode = "common" - - if current_mode != "exclude": - filtered_lines.append(line) - - return "\n".join(filtered_lines).strip() + def _render_prompt_template( + self, raw_prompt: str, action: str, notebook_path: str, body: dict + ) -> str: + """Renders the system prompt using Jinja2 template rendering.""" + try: + from jinja2 import Template + + template = Template(raw_prompt) + return template.render( + action=action, + notebook_path=notebook_path, + **body, + ).strip() + except Exception as e: + self.log.error(f"jupyter_ai_tutor: Jinja template rendering error: {e}") + return raw_prompt.strip() def _find_tutor_md(self, notebook_path: str, server_root: str) -> str | None: """Walk up the directory tree from the notebook's directory toward the From c7dd4b09c2041fac120ddd1fd89daab85dedaac4 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 24 Jul 2026 14:27:32 +0200 Subject: [PATCH 4/4] Extend TUTOR.md jinja template --- jupyter_ai_tutor/TUTOR.md | 31 +++++++++++++++++++++++++++++++ src/api.ts | 10 ++++++++-- src/index.ts | 9 +++++++++ src/model.ts | 4 +++- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/jupyter_ai_tutor/TUTOR.md b/jupyter_ai_tutor/TUTOR.md index 70667f4..e326ae9 100644 --- a/jupyter_ai_tutor/TUTOR.md +++ b/jupyter_ai_tutor/TUTOR.md @@ -24,6 +24,37 @@ understand code — never to write code for them. - A `` block may also be embedded. You should use this to help the student in accordance with the teacher's expectations. +{% if context %} + +{{ context }} + + +{% endif %} +{% if source %} + +{{ source }} + + +{% endif %} +{% if initial_source %} + +{{ initial_source }} + + +{% endif %} +{% if reference_solution %} + +{{ reference_solution }} + + +{% endif %} +{% if evaluation_criteria %} + +{{ evaluation_criteria }} + + +{% endif %} + {% if action == 'review' %} ## Mode: Review diff --git a/src/api.ts b/src/api.ts index 24b9d34..e44927c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,7 +12,8 @@ export async function* streamExplanation( body: string, notebookPath?: string, action?: 'explain' | 'review', - signal?: AbortSignal + signal?: AbortSignal, + payload?: Record ): AsyncGenerator { const settings = ServerConnection.makeSettings(); const url = URLExt.join(settings.baseUrl, 'api/jupyter-ai-tutor/explain'); @@ -21,7 +22,12 @@ export async function* streamExplanation( url, { method: 'POST', - body: JSON.stringify({ body, notebookPath, action: action ?? 'explain' }), + body: JSON.stringify({ + body, + notebookPath, + action: action ?? 'explain', + ...(payload ?? {}) + }), headers: { 'Content-Type': 'application/json' }, signal }, diff --git a/src/index.ts b/src/index.ts index 0eed0b2..1b2931e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -316,6 +316,15 @@ const plugin: JupyterFrontEndPlugin = { formattedBody: formattedBody, notebookPath, action, + payload: { + source: studentAnswer, + context: studentContext, + initial_source: + typeof initialSource === 'string' ? initialSource : '', + reference_solution: referenceSolution, + evaluation_criteria: + typeof evaluationCriteria === 'string' ? evaluationCriteria : '' + }, attachments: attachment ? [attachment] : undefined }); } diff --git a/src/model.ts b/src/model.ts index 2741a42..7b77cd7 100644 --- a/src/model.ts +++ b/src/model.ts @@ -17,6 +17,7 @@ export interface ITutorNewMessage extends INewMessage { notebookPath?: string; formattedBody?: string; action?: 'explain' | 'review'; + payload?: Record; } export const TUTOR_USER: IUser = { @@ -93,7 +94,8 @@ export class TutorChatModel extends AbstractChatModel { message.formattedBody ?? message.body, message.notebookPath, message.action, - this._abortController.signal + this._abortController.signal, + message.payload )) { accumulated += chunk; streamingMsg.update({ body: accumulated });