From fe8424773ebe6ae55da025555eb3d7336431b79b Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Mon, 6 Jul 2026 12:53:17 +0200 Subject: [PATCH 1/9] improve context collection --- jupyter_ai_tutor/handlers.py | 30 ++++++++++----- src/api.ts | 31 ++++++++++++---- src/index.ts | 72 ++++++++++++++++++++++++++---------- src/model.ts | 18 ++++++--- 4 files changed, 111 insertions(+), 40 deletions(-) diff --git a/jupyter_ai_tutor/handlers.py b/jupyter_ai_tutor/handlers.py index 65e966a..ede2323 100644 --- a/jupyter_ai_tutor/handlers.py +++ b/jupyter_ai_tutor/handlers.py @@ -9,16 +9,28 @@ class ExplainHandler(APIHandler): @tornado.web.authenticated async def post(self): body = self.get_json_body() - if not body or "body" not in body: - raise tornado.web.HTTPError(400, "Missing 'body' field in request") + if not body: + raise tornado.web.HTTPError(400, "Missing request body") - message_body = body["body"] - description = body.get("description", "") - if description: - message_body = ( - f"\n{description}\n\n\n" - f"{message_body}" - ) + student_context = body.get("student_context", "") + student_answer = body.get("student_answer", "") + + if student_context or student_answer: + message_body = "" + if student_context: + message_body += f"\n{student_context}\n\n\n" + if student_answer: + message_body += f"\n{student_answer}\n\n\n" + else: + if "body" not in body: + raise tornado.web.HTTPError(400, "Missing 'body' field in request") + message_body = body["body"] + description = body.get("description", "") + if description: + message_body = ( + f"\n{description}\n\n\n" + f"{message_body}" + ) config_manager = self.settings.get("jupyternaut.config_manager") if not config_manager: diff --git a/src/api.ts b/src/api.ts index 5f6c01d..ebc791c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,16 +1,26 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; +/** + * Options for streaming the explanation request. + */ +export interface IStreamExplanationOptions { + body: string; + description?: string; + signal?: AbortSignal; + studentContext?: string; + studentAnswer?: string; + referenceSolution?: string; + evaluationCriteria?: string; +} + /** * Streams the tutor explanation for the given message body via SSE. * Yields text chunks as they arrive from the backend. - * @param body - The user message (code + question) - * @param description - Optional exercise description from preceding markdown cells + * @param options - The request options containing body and structured contexts */ export async function* streamExplanation( - body: string, - description?: string, - signal?: AbortSignal + options: IStreamExplanationOptions ): AsyncGenerator { const settings = ServerConnection.makeSettings(); const url = URLExt.join(settings.baseUrl, 'api/jupyter-ai-tutor/explain'); @@ -19,9 +29,16 @@ export async function* streamExplanation( url, { method: 'POST', - body: JSON.stringify({ body, description }), + body: JSON.stringify({ + body: options.body, + description: options.description, + student_context: options.studentContext, + student_answer: options.studentAnswer, + reference_solution: options.referenceSolution, + evaluation_criteria: options.evaluationCriteria + }), headers: { 'Content-Type': 'application/json' }, - signal + signal: options.signal }, settings ); diff --git a/src/index.ts b/src/index.ts index 82bd3d1..68e436e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,6 +184,11 @@ const plugin: JupyterFrontEndPlugin = { 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); @@ -193,6 +198,7 @@ const plugin: JupyterFrontEndPlugin = { evalue: string; traceback: string[]; }; + jsonError = json; const traceback = json.traceback .map(line => line.replace(ANSI_ESCAPE, '')) .join('\n'); @@ -203,45 +209,72 @@ const plugin: JupyterFrontEndPlugin = { } } - // Collect preceding markdown cells as exercise description context. + // Collect preceding cells context. const notebook = notebookTracker?.currentWidget?.content; const notebookPath = notebookTracker?.currentWidget?.context.path ?? ''; - let description: string | undefined; + let studentContext = ''; let attachment: INotebookAttachment | undefined; if (notebook) { const activeCellIndex = notebook.activeCellIndex; - const markdownCells: { id: string; source: string }[] = []; + 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 === 'code') { + if (precedingCell.model.type === 'markdown') { + lastMdIdx = i; break; } - if (precedingCell.model.type === 'markdown') { - const mdSource = precedingCell.model.sharedModel.source.trim(); - if (mdSource) { - markdownCells.unshift({ - id: precedingCell.model.id, - source: mdSource - }); - } + } + + // 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]); + } + + let contextStr = ''; + const markdownCellsForAttachment = []; + + for (const cCell of contextCells) { + const cSource = cCell.model.sharedModel.source.trim(); + if (!cSource) { + continue; + } + + if (cCell.model.type === 'markdown') { + contextStr += `${cSource}\n\n`; + markdownCellsForAttachment.push({ + id: cCell.model.id, + input_type: 'markdown' as const + }); + } else if (cCell.model.type === 'code') { + contextStr += `Preceding Code:\n\`\`\`${language}\n${cSource}\n\`\`\`\n\n`; } } - if (markdownCells.length > 0) { - description = markdownCells.map(c => c.source).join('\n\n'); + studentContext = contextStr.trim(); + + if (markdownCellsForAttachment.length > 0) { attachment = { type: 'notebook', value: notebookPath, - cells: markdownCells.map(c => ({ - id: c.id, - input_type: 'markdown' as const - })) + cells: markdownCellsForAttachment }; } } + // 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}`; + } + const question = errorSection ? 'Can you explain this code and the error it produced?' : 'Can you explain this code?'; @@ -254,7 +287,8 @@ const plugin: JupyterFrontEndPlugin = { await tutorModel.sendMessageToAI({ body, - description, + studentContext, + studentAnswer, attachments: attachment ? [attachment] : undefined }); }, diff --git a/src/model.ts b/src/model.ts index fb1aa42..2cb2205 100644 --- a/src/model.ts +++ b/src/model.ts @@ -15,6 +15,10 @@ import { AI_AVATAR } from './icons'; interface ITutorNewMessage extends INewMessage { attachments?: IAttachment[]; description?: string; + studentContext?: string; + studentAnswer?: string; + referenceSolution?: string; + evaluationCriteria?: string; } export const TUTOR_USER: IUser = { @@ -87,11 +91,15 @@ export class TutorChatModel extends AbstractChatModel { try { let accumulated = ''; - for await (const chunk of streamExplanation( - message.body, - message.description, - this._abortController.signal - )) { + for await (const chunk of streamExplanation({ + body: message.body, + description: message.description, + signal: this._abortController.signal, + studentContext: message.studentContext, + studentAnswer: message.studentAnswer, + referenceSolution: message.referenceSolution, + evaluationCriteria: message.evaluationCriteria + })) { accumulated += chunk; streamingMsg.update({ body: accumulated }); } From a95d05e579f7b317a0cac744d742130ff1fb2c00 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Mon, 6 Jul 2026 16:19:14 +0200 Subject: [PATCH 2/9] remove criteria and solution keys from context branch --- src/api.ts | 6 +----- src/model.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/api.ts b/src/api.ts index ebc791c..a39472b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,8 +10,6 @@ export interface IStreamExplanationOptions { signal?: AbortSignal; studentContext?: string; studentAnswer?: string; - referenceSolution?: string; - evaluationCriteria?: string; } /** @@ -33,9 +31,7 @@ export async function* streamExplanation( body: options.body, description: options.description, student_context: options.studentContext, - student_answer: options.studentAnswer, - reference_solution: options.referenceSolution, - evaluation_criteria: options.evaluationCriteria + student_answer: options.studentAnswer }), headers: { 'Content-Type': 'application/json' }, signal: options.signal diff --git a/src/model.ts b/src/model.ts index 2cb2205..ef4f507 100644 --- a/src/model.ts +++ b/src/model.ts @@ -17,8 +17,6 @@ interface ITutorNewMessage extends INewMessage { description?: string; studentContext?: string; studentAnswer?: string; - referenceSolution?: string; - evaluationCriteria?: string; } export const TUTOR_USER: IUser = { @@ -96,9 +94,7 @@ export class TutorChatModel extends AbstractChatModel { description: message.description, signal: this._abortController.signal, studentContext: message.studentContext, - studentAnswer: message.studentAnswer, - referenceSolution: message.referenceSolution, - evaluationCriteria: message.evaluationCriteria + studentAnswer: message.studentAnswer })) { accumulated += chunk; streamingMsg.update({ body: accumulated }); From 2da40c5eaac677591a43648a06008a78f625b88a Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 9 Jul 2026 19:50:28 +0100 Subject: [PATCH 3/9] Merge main branch --- src/index.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index da372c4..ebb8fca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ import { infoIcon } from '@jupyterlab/ui-components'; import { clearItem, stopItem } from './components'; import { TUTOR_USER, TutorChatModel } from './model'; -import { isContinuous } from './utils'; +import { decodeRot13, formatEvaluationCriteria, isContinuous } from './utils'; const INFO_ICON_BASE_64 = btoa(infoIcon.svgstr); @@ -183,6 +183,7 @@ const plugin: JupyterFrontEndPlugin = { // 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; @@ -198,6 +199,12 @@ const plugin: JupyterFrontEndPlugin = { 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; } } @@ -267,6 +274,20 @@ const plugin: JupyterFrontEndPlugin = { 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' ? decodeRot13(rawSolution) : ''; + + // Retrieve and format evaluation_criteria from metadata + const rawCriteria = cell.model.getMetadata('evaluation_criteria'); + const evaluationCriteria = formatEvaluationCriteria(rawCriteria); + + const question = errorSection + ? 'Can you explain this code and the error it produced?' + : 'Can you explain this code?'; + const bodyContent = `${question}\n\n\`\`\`${language}\n${source}\n\`\`\`${errorSection}\n`; + let formattedBody = ''; if (studentContext) { formattedBody += `\n${studentContext}\n\n\n`; @@ -274,6 +295,13 @@ const plugin: JupyterFrontEndPlugin = { if (studentAnswer) { formattedBody += `\n${studentAnswer}\n\n\n`; } + formattedBody += bodyContent; + if (referenceSolution) { + formattedBody += `\n\n\n${referenceSolution}\n`; + } + if (evaluationCriteria) { + formattedBody += `\n\n\n${evaluationCriteria}\n`; + } if (!chatWidget.isAttached) { app.shell.add(chatWidget, 'right'); From 2254c0b8869b5f82d79677c45e45d778b2b98938 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Thu, 9 Jul 2026 20:20:15 +0100 Subject: [PATCH 4/9] Fix chat UI prompt formatting --- src/index.ts | 3 ++- src/model.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index ebb8fca..ac6a73e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -309,7 +309,8 @@ const plugin: JupyterFrontEndPlugin = { app.shell.activateById(chatWidget.id); await tutorModel.sendMessageToAI({ - body: formattedBody, + body: bodyContent, + formattedBody: formattedBody, notebookPath, attachments: attachment ? [attachment] : undefined }); diff --git a/src/model.ts b/src/model.ts index 7caa72d..02c012e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -15,6 +15,7 @@ import { AI_AVATAR } from './icons'; interface ITutorNewMessage extends INewMessage { attachments?: IAttachment[]; notebookPath?: string; + formattedBody?: string; } export const TUTOR_USER: IUser = { @@ -88,7 +89,7 @@ export class TutorChatModel extends AbstractChatModel { try { let accumulated = ''; for await (const chunk of streamExplanation( - message.body, + message.formattedBody ?? message.body, message.notebookPath, this._abortController.signal )) { From 02de870a580fdbd5b33f3b52b713950c3fb38493 Mon Sep 17 00:00:00 2001 From: Yahiewi Date: Fri, 10 Jul 2026 08:32:35 +0100 Subject: [PATCH 5/9] Fix context collection --- src/index.ts | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index ac6a73e..e72b77b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,26 +217,35 @@ const plugin: JupyterFrontEndPlugin = { if (notebook) { const activeCellIndex = notebook.activeCellIndex; - let lastMdIdx = -1; + let startIdx = activeCellIndex; + let hasSeenMarkdown = false; - // Find the index of the most recent markdown cell above the active cell + // Traverse backwards to find the start of the exercise context for (let i = activeCellIndex - 1; i >= 0; i--) { const precedingCell = notebook.widgets[i]; if (precedingCell.model.type === 'markdown') { - lastMdIdx = i; - break; + // We found an exercise description cell + hasSeenMarkdown = true; + startIdx = i; + } else if (precedingCell.model.type === 'code') { + // Stop if we hit a code cell before the exercise instructions (markdown) + if (hasSeenMarkdown) { + break; + } + // Code cells below the instructions are part of the exercise + startIdx = i; } } - // 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]); + if (hasSeenMarkdown) { + for (let i = startIdx; i < activeCellIndex; i++) { + contextCells.push(notebook.widgets[i]); + } } let contextStr = ''; - const markdownCellsForAttachment = []; + const cellsForAttachment = []; for (const cCell of contextCells) { const cSource = cCell.model.sharedModel.source.trim(); @@ -246,22 +255,26 @@ const plugin: JupyterFrontEndPlugin = { if (cCell.model.type === 'markdown') { contextStr += `${cSource}\n\n`; - markdownCellsForAttachment.push({ + cellsForAttachment.push({ id: cCell.model.id, input_type: 'markdown' as const }); } else if (cCell.model.type === 'code') { contextStr += `Preceding Code:\n\`\`\`${language}\n${cSource}\n\`\`\`\n\n`; + cellsForAttachment.push({ + id: cCell.model.id, + input_type: 'code' as const + }); } } studentContext = contextStr.trim(); - if (markdownCellsForAttachment.length > 0) { + if (cellsForAttachment.length > 0) { attachment = { type: 'notebook', value: notebookPath, - cells: markdownCellsForAttachment + cells: cellsForAttachment }; } } @@ -293,9 +306,8 @@ const plugin: JupyterFrontEndPlugin = { formattedBody += `\n${studentContext}\n\n\n`; } if (studentAnswer) { - formattedBody += `\n${studentAnswer}\n\n\n`; + formattedBody += `\n${studentAnswer}\n`; } - formattedBody += bodyContent; if (referenceSolution) { formattedBody += `\n\n\n${referenceSolution}\n`; } From 407a29a052dd84656fd7d06fad651a3d5f6b2c88 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 10 Jul 2026 09:59:33 +0200 Subject: [PATCH 6/9] Always attach code cells and add student answer --- src/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index e72b77b..f15b941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -238,10 +238,8 @@ const plugin: JupyterFrontEndPlugin = { } const contextCells = []; - if (hasSeenMarkdown) { - for (let i = startIdx; i < activeCellIndex; i++) { - contextCells.push(notebook.widgets[i]); - } + for (let i = startIdx; i < activeCellIndex; i++) { + contextCells.push(notebook.widgets[i]); } let contextStr = ''; @@ -305,9 +303,8 @@ const plugin: JupyterFrontEndPlugin = { if (studentContext) { formattedBody += `\n${studentContext}\n\n\n`; } - if (studentAnswer) { - formattedBody += `\n${studentAnswer}\n`; - } + formattedBody += `\n${studentAnswer}\n`; + if (referenceSolution) { formattedBody += `\n\n\n${referenceSolution}\n`; } @@ -315,6 +312,8 @@ const plugin: JupyterFrontEndPlugin = { formattedBody += `\n\n\n${evaluationCriteria}\n`; } + formattedBody += '\n'; + if (!chatWidget.isAttached) { app.shell.add(chatWidget, 'right'); } From 267eaca487bfdddc775ae25c48ec771d5d173bbf Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 10 Jul 2026 12:01:55 +0200 Subject: [PATCH 7/9] Keep only one MD cell in context --- src/index.ts | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index f15b941..e046da3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,26 +217,20 @@ const plugin: JupyterFrontEndPlugin = { if (notebook) { const activeCellIndex = notebook.activeCellIndex; - let startIdx = activeCellIndex; - let hasSeenMarkdown = false; + let lastMdIdx = -1; - // Traverse backwards to find the start of the exercise context + // 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') { - // We found an exercise description cell - hasSeenMarkdown = true; - startIdx = i; - } else if (precedingCell.model.type === 'code') { - // Stop if we hit a code cell before the exercise instructions (markdown) - if (hasSeenMarkdown) { - break; - } - // Code cells below the instructions are part of the exercise - startIdx = i; + 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]); @@ -251,18 +245,15 @@ const plugin: JupyterFrontEndPlugin = { continue; } + cellsForAttachment.push({ + id: cCell.model.id, + input_type: cCell.model.type as 'raw' | 'markdown' | 'code' + }); + if (cCell.model.type === 'markdown') { contextStr += `${cSource}\n\n`; - cellsForAttachment.push({ - id: cCell.model.id, - input_type: 'markdown' as const - }); } else if (cCell.model.type === 'code') { contextStr += `Preceding Code:\n\`\`\`${language}\n${cSource}\n\`\`\`\n\n`; - cellsForAttachment.push({ - id: cCell.model.id, - input_type: 'code' as const - }); } } From 1cba91d2dd7cb87aaee8cde5372a614ba5ccdc35 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 10 Jul 2026 12:11:05 +0200 Subject: [PATCH 8/9] Update the block tags and add some description in TUTOR.md --- jupyter_ai_tutor/TUTOR.md | 17 +++++++++++------ src/index.ts | 8 ++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/jupyter_ai_tutor/TUTOR.md b/jupyter_ai_tutor/TUTOR.md index be693c1..287dd00 100644 --- a/jupyter_ai_tutor/TUTOR.md +++ b/jupyter_ai_tutor/TUTOR.md @@ -18,12 +18,17 @@ understand code — never to write code for them. - 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. -## Exercise Context - -When the student's 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 -guidance to that goal. The block is not visible to the student. +## Request formatting + +- The cell to explain 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 + guidance to that goal. The block is not visible to the student. +- 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 + student in accordance with the teacher's expectations. ## Tone diff --git a/src/index.ts b/src/index.ts index e046da3..11c1ba8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,15 +286,15 @@ const plugin: JupyterFrontEndPlugin = { const evaluationCriteria = formatEvaluationCriteria(rawCriteria); const question = errorSection - ? 'Can you explain this code and the error it produced?' - : 'Can you explain this code?'; + ? 'Explain code and error' + : 'Explain code?'; const bodyContent = `${question}\n\n\`\`\`${language}\n${source}\n\`\`\`${errorSection}\n`; let formattedBody = ''; if (studentContext) { - formattedBody += `\n${studentContext}\n\n\n`; + formattedBody += `\n${studentContext}\n\n\n`; } - formattedBody += `\n${studentAnswer}\n`; + formattedBody += `\n${studentAnswer}\n`; if (referenceSolution) { formattedBody += `\n\n\n${referenceSolution}\n`; From b7335bb42a0ccde291f4a335a0921b1f6b946dfe Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 10 Jul 2026 12:12:36 +0200 Subject: [PATCH 9/9] lint --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 516f436..fb12595 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ Once Jupyterlab started, the model must be configured via the menu `Settings>Jup See Jupyternaut [documentation](https://jupyter-ai.readthedocs.io/en/v3/users/jupyternaut/index.html#model-selection) to set it up. - - ## Requirements - JupyterLab >= 4.0.0