From fd834b781fffac58cbda7efe4ea3edc84e0401e3 Mon Sep 17 00:00:00 2001 From: Seth Bernstein Date: Wed, 25 Feb 2026 14:44:27 -0500 Subject: [PATCH 1/4] add async toggle to the instructor assignments page --- .../AssignmentBuilder.module.css | 25 +++++++++++++++++-- .../AssignmentBuilder/AssignmentBuilder.tsx | 13 ++++++++++ .../components/list/AssignmentList.tsx | 14 +++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css index a653d10d..514ea90c 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css @@ -68,13 +68,34 @@ } .typeColumn { - width: 120px; + width: 160px; } .typeCell { display: flex; + flex-direction: row; + align-items: flex-end; + gap: 0.5rem; +} + +.asyncPeerGroup { + display: flex; + flex-direction: column; align-items: center; - justify-content: flex-start; + gap: 0.2rem; + padding-bottom: 0.25rem; + cursor: pointer; + user-select: none; +} + +.asyncPeerText { + font-size: 0.7rem; + color: var(--surface-500); + white-space: nowrap; +} + +.asyncPeerGroup:hover .asyncPeerText { + color: var(--surface-700); } .typeTag { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx index 5d270662..8677f9c3 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx @@ -144,6 +144,18 @@ export const AssignmentBuilder = () => { } }; + const handlePeerAsyncChange = async (assignment: Assignment, peer_async_visible: boolean) => { + try { + await updateAssignment({ + ...assignment, + peer_async_visible + }); + toast.success(`Async peer ${peer_async_visible ? "enabled" : "disabled"}`); + } catch (error) { + toast.error("Failed to update async peer setting"); + } + }; + const handleWizardComplete = async () => { const formValues = getValues(); const payload: CreateAssignmentPayload = { @@ -195,6 +207,7 @@ export const AssignmentBuilder = () => { onVisibilityChange={handleVisibilityChange} onReleasedChange={handleReleasedChange} onEnforceDueChange={handleEnforceDueChange} + onPeerAsyncChange={handlePeerAsyncChange} onRemove={onRemove} /> )} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 43f39954..dd4008b7 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -1,5 +1,6 @@ import { SearchInput } from "@components/ui/SearchInput"; import { Button } from "primereact/button"; +import { Checkbox } from "primereact/checkbox"; import { Column } from "primereact/column"; import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog"; import { DataTable } from "primereact/datatable"; @@ -21,6 +22,7 @@ interface AssignmentListProps { onVisibilityChange: (assignment: Assignment, visible: boolean) => void; onReleasedChange: (assignment: Assignment, released: boolean) => void; onEnforceDueChange: (assignment: Assignment, enforce_due: boolean) => void; + onPeerAsyncChange: (assignment: Assignment, peer_async_visible: boolean) => void; onRemove: (assignment: Assignment) => void; } @@ -34,6 +36,7 @@ export const AssignmentList = ({ onVisibilityChange, onReleasedChange, onEnforceDueChange, + onPeerAsyncChange, onRemove }: AssignmentListProps) => { const visibilityBodyTemplate = (rowData: Assignment) => ( @@ -100,6 +103,17 @@ export const AssignmentList = ({ > {rowData.kind || "Unknown"} + {rowData.kind === "Peer" && ( + + )} ); From cf083c0a794db8c003ab0a20a328bada19a57167 Mon Sep 17 00:00:00 2001 From: Seth Bernstein Date: Wed, 25 Feb 2026 16:08:23 -0500 Subject: [PATCH 2/4] fix check box positioning --- .../routes/AssignmentBuilder/AssignmentBuilder.module.css | 4 ++-- .../AssignmentBuilder/components/list/AssignmentList.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css index 514ea90c..daffd232 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css @@ -74,7 +74,7 @@ .typeCell { display: flex; flex-direction: row; - align-items: flex-end; + align-items: flex-start; gap: 0.5rem; } @@ -83,7 +83,7 @@ flex-direction: column; align-items: center; gap: 0.2rem; - padding-bottom: 0.25rem; + padding-top: 0.25rem; cursor: pointer; user-select: none; } diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index dd4008b7..6b7d68ef 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -105,13 +105,13 @@ export const AssignmentList = ({ {rowData.kind === "Peer" && ( )} From d35031cbae4d8aee31546e194e72da05bfd4169c Mon Sep 17 00:00:00 2001 From: Seth Bernstein Date: Fri, 27 Feb 2026 09:58:23 -0500 Subject: [PATCH 3/4] add async checkbox, per question ability to set LLM for PI questions --- .../AssignmentExercisesTable.tsx | 106 +++++++++++++++++- .../assignmentExercise.logic.api.ts | 11 +- .../assignment_builder/src/types/exercises.ts | 1 + .../routers/instructor.py | 11 ++ .../runestone/controllers/peer.py | 9 +- .../runestone/models/questions.py | 1 + .../runestone/views/peer/peer_async.html | 26 ++++- components/rsptx/db/models.py | 1 + components/rsptx/validation/schemas.py | 1 + 9 files changed, 159 insertions(+), 8 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx index fa78aa4b..6fd440bc 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx @@ -1,14 +1,25 @@ import { EditableCellFactory } from "@components/ui/EditableTable/EditableCellFactory"; import { TableSelectionOverlay } from "@components/ui/EditableTable/TableOverlay"; import { ExerciseTypeTag } from "@components/ui/ExerciseTypeTag"; -import { useReorderAssignmentExercisesMutation } from "@store/assignmentExercise/assignmentExercise.logic.api"; +import { useToastContext } from "@components/ui/ToastContext"; +import { + useHasApiKeyQuery, + useReorderAssignmentExercisesMutation, + useUpdateAssignmentQuestionsMutation +} from "@store/assignmentExercise/assignmentExercise.logic.api"; +import { Button } from "primereact/button"; import { Column } from "primereact/column"; import { DataTable, DataTableSelectionMultipleChangeEvent } from "primereact/datatable"; +import { Dropdown } from "primereact/dropdown"; +import { OverlayPanel } from "primereact/overlaypanel"; import { Tooltip } from "primereact/tooltip"; import { useRef, useState } from "react"; +import { useExercisesSelector } from "@/hooks/useExercisesSelector"; + import { difficultyOptions } from "@/config/exerciseTypes"; import { useJwtUser } from "@/hooks/useJwtUser"; +import { useSelectedAssignment } from "@/hooks/useSelectedAssignment"; import { DraggingExerciseColumns } from "@/types/components/editableTableCell"; import { Exercise, supportedExerciseTypesToEdit } from "@/types/exercises"; @@ -19,6 +30,68 @@ import { ExercisePreviewModal } from "../components/ExercisePreview/ExercisePrev import { SetCurrentEditExercise, ViewModeSetter, MouseUpHandler } from "./types"; +const AsyncModeHeader = ({ hasApiKey }: { hasApiKey: boolean }) => { + const { showToast } = useToastContext(); + const [updateExercises] = useUpdateAssignmentQuestionsMutation(); + const { assignmentExercises = [] } = useExercisesSelector(); + const overlayRef = useRef(null); + const [value, setValue] = useState("Standard"); + + const handleSubmit = async () => { + const exercises = assignmentExercises.map((ex) => ({ + ...ex, + question_json: JSON.stringify(ex.question_json), + use_llm: value === "LLM" + })); + const { error } = await updateExercises(exercises); + if (!error) { + overlayRef.current?.hide(); + showToast({ severity: "success", summary: "Success", detail: "Exercises updated successfully" }); + } else { + showToast({ severity: "error", summary: "Error", detail: "Failed to update exercises" }); + } + }; + + return ( +
+ Async Mode + + +
+ +
+ + ); +}; + interface AssignmentExercisesTableProps { assignmentExercises: Exercise[]; selectedExercises: Exercise[]; @@ -48,6 +121,11 @@ export const AssignmentExercisesTable = ({ }: AssignmentExercisesTableProps) => { const { username } = useJwtUser(); const [reorderExercises] = useReorderAssignmentExercisesMutation(); + const [updateAssignmentQuestions] = useUpdateAssignmentQuestionsMutation(); + const { selectedAssignment } = useSelectedAssignment(); + const { data: hasApiKey = false } = useHasApiKeyQuery(); + const isPeerAsync = + selectedAssignment?.kind === "Peer" && selectedAssignment?.peer_async_visible === true; const dataTableRef = useRef>(null); const [copyModalVisible, setCopyModalVisible] = useState(false); const [selectedExerciseForCopy, setSelectedExerciseForCopy] = useState(null); @@ -276,6 +354,32 @@ export const AssignmentExercisesTable = ({ /> )} /> + {isPeerAsync && ( + } + bodyStyle={{ padding: 0 }} + body={(data: Exercise) => ( +
+ updateAssignmentQuestions([{ ...data, use_llm: e.value === "LLM" }])} + options={[ + { label: "Standard", value: "Standard" }, + { label: "LLM", value: "LLM", disabled: !hasApiKey } + ]} + optionLabel="label" + optionDisabled="disabled" + scrollHeight="auto" + tooltip={!hasApiKey ? "Add an API key to enable LLM mode" : undefined} + tooltipOptions={{ showOnDisabled: true }} + /> +
+ )} + /> + )} ({ + query: () => ({ + method: "GET", + url: "/assignment/instructor/has_api_key" + }), + transformResponse: (response: DetailResponse<{ has_api_key: boolean }>) => + response.detail.has_api_key + }), copyQuestion: build.mutation< DetailResponse<{ status: string; question_id: number; message: string }>, { @@ -218,5 +226,6 @@ export const { useReorderAssignmentExercisesMutation, useUpdateAssignmentExercisesMutation, useValidateQuestionNameMutation, - useCopyQuestionMutation + useCopyQuestionMutation, + useHasApiKeyQuery } = assignmentExerciseApi; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts index e9c5f9da..a38630ae 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts @@ -56,6 +56,7 @@ export type Exercise = { reading_assignment: boolean; sorting_priority: number; activities_required: number; + use_llm: boolean; qnumber: string; name: string; subchapter: string; diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 079e3140..8f01cbb1 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -1387,6 +1387,17 @@ async def add_api_token( ) +@router.get("/has_api_key") +@instructor_role_required() +@with_course() +async def has_api_key(request: Request, user=Depends(auth_manager), course=None): + """Return whether the course has at least one API token configured.""" + tokens = await fetch_all_api_tokens(course.id) + return make_json_response( + status=status.HTTP_200_OK, detail={"has_api_key": len(tokens) > 0} + ) + + @router.get("/add_token") @instructor_role_required() @with_course() diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py b/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py index eea019d6..5a8a5114 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py @@ -742,7 +742,14 @@ def peer_async(): if "latex_macros" not in course_attrs: course_attrs["latex_macros"] = "" - llm_enabled = _llm_enabled() + aq = None + if current_question: + aq = db( + (db.assignment_questions.assignment_id == assignment_id) + & (db.assignment_questions.question_id == current_question.id) + ).select().first() + question_use_llm = bool(aq.use_llm) if aq else False + llm_enabled = _llm_enabled() and question_use_llm try: db.useinfo.insert( course_id=auth.user.course_name, diff --git a/bases/rsptx/web2py_server/applications/runestone/models/questions.py b/bases/rsptx/web2py_server/applications/runestone/models/questions.py index b520a026..fe87c73d 100644 --- a/bases/rsptx/web2py_server/applications/runestone/models/questions.py +++ b/bases/rsptx/web2py_server/applications/runestone/models/questions.py @@ -73,5 +73,6 @@ Field( "activities_required", type="integer" ), # specifies how many activities in a sub chapter a student must perform in order to receive credit + Field("use_llm", type="boolean", default=False), migrate=bookserver_owned("assignment_questions"), ) diff --git a/bases/rsptx/web2py_server/applications/runestone/views/peer/peer_async.html b/bases/rsptx/web2py_server/applications/runestone/views/peer/peer_async.html index 764cb6bb..8e170d19 100644 --- a/bases/rsptx/web2py_server/applications/runestone/views/peer/peer_async.html +++ b/bases/rsptx/web2py_server/applications/runestone/views/peer/peer_async.html @@ -100,6 +100,9 @@

Congratulations, you have completed this assignment!

+

@@ -298,9 +301,13 @@

Congratulations, you have completed this assignment!

lockVote1AndReflection(); discussion.style.display = "block"; + if (window.PI_LLM_MODE === true) { + const disclaimer = document.getElementById("llmDisclaimer"); + if (disclaimer) disclaimer.style.display = "block"; + } chat.innerHTML = "

Thinking about your explanation…

"; - if (window.PI_LLM_MODE !== true) { + async function showStandardDiscussion(chat, nextStep, showInstructorNote) { const resp = await fetch("/runestone/peer/get_async_explainer", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -323,6 +330,11 @@

Congratulations, you have completed this assignment!

)}
`; } chat.innerHTML = ""; + if (showInstructorNote) { + chat.innerHTML += `
+ Note: The AI peer is currently unavailable. Please contact your instructor. +
`; + } chat.innerHTML += `

Other students said:

`; if (res) { chat.innerHTML += `

${res}

`; @@ -343,6 +355,10 @@

Congratulations, you have completed this assignment!

readyBtn.title = ""; } studentSubmittedVote2 = false; + } + + if (window.PI_LLM_MODE !== true) { + await showStandardDiscussion(chat, nextStep, false); return; } else { const mcq = document.querySelector('.mchoice'); @@ -369,7 +385,7 @@

Congratulations, you have completed this assignment!

}) }); } catch (e) { - chat.innerHTML = "

LLM error. Please try again.

"; + await showStandardDiscussion(chat, nextStep, true); return; } @@ -377,7 +393,7 @@

Congratulations, you have completed this assignment!

chat.innerHTML = ""; if (!data.ok) { - chat.innerHTML = "

LLM error. Please try again.

"; + await showStandardDiscussion(chat, nextStep, true); return; } @@ -487,7 +503,7 @@

Congratulations, you have completed this assignment!

}) }); } catch (e) { - appendMsg("assistant", "LLM error. Please try again."); + appendMsg("assistant", "The AI peer is currently unavailable. Please contact your instructor."); btn.disabled = false; input.disabled = false; return; @@ -495,7 +511,7 @@

Congratulations, you have completed this assignment!

const data = await resp.json(); if (!data.ok) { - appendMsg("assistant", "LLM error. Please try again."); + appendMsg("assistant", "The AI peer is currently unavailable. Please contact your instructor."); btn.disabled = false; input.disabled = false; return; diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index 84d8729e..5df47b2e 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -678,6 +678,7 @@ class AssignmentQuestion(Base, IdMixin): activities_required = Column( Integer ) # only reading assignments will have this populated + use_llm = Column(Web2PyBoolean, default=False) AssignmentQuestionValidator: TypeAlias = sqlalchemy_to_pydantic(AssignmentQuestion) # type: ignore diff --git a/components/rsptx/validation/schemas.py b/components/rsptx/validation/schemas.py index df6aa495..f45b0639 100644 --- a/components/rsptx/validation/schemas.py +++ b/components/rsptx/validation/schemas.py @@ -341,6 +341,7 @@ class AssignmentQuestionUpdateDict(TypedDict, total=False): reading_assignment: Optional[bool] sorting_priority: int activities_required: Optional[int] + use_llm: Optional[bool] # Question fields name: Optional[str] From 1a9127df40f30e9af640d52f5cdc65c635a0b820 Mon Sep 17 00:00:00 2001 From: Seth Bernstein Date: Wed, 4 Mar 2026 11:11:48 -0500 Subject: [PATCH 4/4] Fix poll results handler to handle non-numeric responses and prevent ValueError --- bases/rsptx/book_server_api/routers/assessment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bases/rsptx/book_server_api/routers/assessment.py b/bases/rsptx/book_server_api/routers/assessment.py index 6dcf62cd..19fbd223 100644 --- a/bases/rsptx/book_server_api/routers/assessment.py +++ b/bases/rsptx/book_server_api/routers/assessment.py @@ -321,7 +321,7 @@ async def getpollresults(request: Request, course: str, div_id: str): my_vote = int(user_res.split(":")[0]) my_comment = user_res.split(":")[1] else: - my_vote = int(user_res) + my_vote = int(user_res) if user_res.isnumeric() else -1 my_comment = "" else: my_vote = -1