From 54c337563988720dab4e815853cb05048cb43cd1 Mon Sep 17 00:00:00 2001 From: tzj04 <190485478+tzj04@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:11:09 +0800 Subject: [PATCH 01/45] feat(grading): add interactive AI comment selection and word-level diffing --- src/commons/sagas/RequestsSaga.ts | 24 +++ .../subcomponents/GradingCommentSelector.tsx | 191 ++++++++++++++++-- .../grading/subcomponents/GradingEditor.tsx | 74 ++++++- src/styles/GradingCommentSelector.module.scss | 73 ++++++- 4 files changed, 335 insertions(+), 27 deletions(-) diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 3f0bf79aeb..f6ea8c0531 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1585,6 +1585,30 @@ export const saveFinalComment = async ( return resp; }; +export const saveChosenComments = async ( + tokens: Tokens, + submissionId: number, + questionId: number, + answerId: number, + selectedIndices: number[], + edits: Record +): Promise => { + const resp = await request( + `${courseId()}/admin/save-chosen-comments/${submissionId}/${questionId}`, + 'POST', + { + body: { + answer_id: answerId, + selected_indices: selectedIndices, + edits: edits + }, + ...tokens + } + ); + + return resp; +}; + /** * GET /courses/{courseId}/admin/users */ diff --git a/src/pages/academy/grading/subcomponents/GradingCommentSelector.tsx b/src/pages/academy/grading/subcomponents/GradingCommentSelector.tsx index fb9a9cfc64..676baa5c02 100644 --- a/src/pages/academy/grading/subcomponents/GradingCommentSelector.tsx +++ b/src/pages/academy/grading/subcomponents/GradingCommentSelector.tsx @@ -1,14 +1,143 @@ -import { H5, NonIdealState, Spinner } from '@blueprintjs/core'; -import React from 'react'; +import { Button, Checkbox, H5, NonIdealState, Spinner, TextArea } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import React, { useCallback, useState } from 'react'; import styles from 'src/styles/GradingCommentSelector.module.scss'; +type DiffOp = { op: 'eq' | 'add' | 'del'; text: string }; + type Props = { comments: string[]; isLoading: boolean; - onSelect: (comment: string) => void; + selectedIndices: number[]; + edits: Record; + onToggle: (index: number) => void; + onEditChange: (index: number, text: string) => void; +}; + +/** + * Computes a word-level Myers diff between two strings. + * Uses a simple O(ND) implementation suitable for short comment texts. + */ +function computeWordDiff(original: string, edited: string): DiffOp[] { + const tokenize = (s: string): string[] => s.match(/\S+|\s+/g) || []; + const a = tokenize(original); + const b = tokenize(edited); + const ops = myersDiff(a, b); + return ops; +} + +function myersDiff(a: string[], b: string[]): DiffOp[] { + const n = a.length; + const m = b.length; + const max = n + m; + const v: Record = { 1: 0 }; + const trace: Array> = []; + + outer: for (let d = 0; d <= max; d++) { + const vSnap: Record = { ...v }; + trace.push(vSnap); + for (let k = -d; k <= d; k += 2) { + let x: number; + if (k === -d || (k !== d && (v[k - 1] ?? 0) < (v[k + 1] ?? 0))) { + x = v[k + 1] ?? 0; + } else { + x = (v[k - 1] ?? 0) + 1; + } + let y = x - k; + while (x < n && y < m && a[x] === b[y]) { + x++; + y++; + } + v[k] = x; + if (x >= n && y >= m) { + break outer; + } + } + } + + // Backtrack to build the edit script + const ops: DiffOp[] = []; + let x = n; + let y = m; + for (let d = trace.length - 1; d > 0; d--) { + const vPrev = trace[d]; + const k = x - y; + let prevK: number; + if (k === -d || (k !== d && (vPrev[k - 1] ?? 0) < (vPrev[k + 1] ?? 0))) { + prevK = k + 1; + } else { + prevK = k - 1; + } + const prevX = vPrev[prevK] ?? 0; + const prevY = prevX - prevK; + // Diagonal (equal) + while (x > prevX && y > prevY) { + x--; + y--; + ops.push({ op: 'eq', text: a[x] }); + } + if (x === prevX && y > prevY) { + y--; + ops.push({ op: 'add', text: b[y] }); + } else if (y === prevY && x > prevX) { + x--; + ops.push({ op: 'del', text: a[x] }); + } + } + // Any remaining diagonal at d=0 + while (x > 0 && y > 0) { + x--; + y--; + ops.push({ op: 'eq', text: a[x] }); + } + + return ops.reverse(); +} + +const InlineDiff: React.FC<{ original: string; edited: string }> = ({ original, edited }) => { + const ops = computeWordDiff(original, edited); + return ( +
+ {ops.map((op, i) => { + switch (op.op) { + case 'eq': + return {op.text}; + case 'del': + return ( + + {op.text} + + ); + case 'add': + return ( + + {op.text} + + ); + } + })} +
+ ); }; const GradingCommentSelector: React.FC = props => { + const [editingIndex, setEditingIndex] = useState(null); + + const handleToggleEdit = useCallback( + (index: number) => { + if (editingIndex === index) { + setEditingIndex(null); + } else { + // Initialise edit text with original if not already edited + if (props.edits[index] === undefined) { + props.onEditChange(index, props.comments[index]); + } + setEditingIndex(index); + } + }, + [editingIndex, props] + ); + return (
LLM Comment Suggestions:
@@ -17,24 +146,60 @@ const GradingCommentSelector: React.FC = props => { } /> ) : (
- {' '} {props.comments.length > 0 ? ( - props.comments.map((el, index) => { + props.comments.map((comment, index) => { + const isSelected = props.selectedIndices.includes(index); + const isEditing = editingIndex === index; + const editedText = props.edits[index]; + const hasEdits = editedText !== undefined && editedText !== comment; + return ( - +
+ props.onToggle(index)} + className={styles['comment-checkbox']} + /> +
props.onToggle(index)}> + {comment} +
+ {isSelected && ( +
+ + {isEditing && ( +
+