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 861a021e..a58865a4 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-start; + gap: 0.5rem; +} + +.asyncPeerGroup { + display: flex; + flex-direction: column; align-items: center; - justify-content: flex-start; + gap: 0.2rem; + padding-top: 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 ce76e96d..7eb3cda4 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 @@ -132,6 +132,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 handleVisibilityChange = async ( assignment: Assignment, data: { visible: boolean; visible_on: string | null; hidden_on: string | null } @@ -199,6 +211,7 @@ export const AssignmentBuilder = () => { onDuplicate={handleDuplicate} onReleasedChange={handleReleasedChange} onEnforceDueChange={handleEnforceDueChange} + onPeerAsyncChange={handlePeerAsyncChange} onVisibilityChange={handleVisibilityChange} onRemove={onRemove} /> 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 }} + /> +
+ )} + /> + )} void; onReleasedChange: (assignment: Assignment, released: boolean) => void; onEnforceDueChange: (assignment: Assignment, enforce_due: boolean) => void; + onPeerAsyncChange: (assignment: Assignment, peer_async_visible: boolean) => void; onVisibilityChange: ( assignment: Assignment, data: { visible: boolean; visible_on: string | null; hidden_on: string | null } @@ -41,6 +43,7 @@ export const AssignmentList = ({ onDuplicate, onReleasedChange, onEnforceDueChange, + onPeerAsyncChange, onVisibilityChange, onRemove }: AssignmentListProps) => { @@ -120,6 +123,17 @@ export const AssignmentList = ({ > {rowData.kind || "Unknown"} + {rowData.kind === "Peer" && ( + + )} ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts index fc6ae308..9226e9f6 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts @@ -167,6 +167,14 @@ export const assignmentExerciseApi = createApi({ body }) }), + hasApiKey: build.query({ + 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 5a7bb62f..67414541 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/book_server_api/routers/assessment.py b/bases/rsptx/book_server_api/routers/assessment.py index 3063eb87..19fbd223 100644 --- a/bases/rsptx/book_server_api/routers/assessment.py +++ b/bases/rsptx/book_server_api/routers/assessment.py @@ -321,8 +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: - if user_res.isnumeric(): - my_vote = int(user_res) + my_vote = int(user_res) if user_res.isnumeric() else -1 my_comment = "" else: my_vote = -1 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 6279f89f..7944a273 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -681,6 +681,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 e1cbabe1..4da6f20e 100644 --- a/components/rsptx/validation/schemas.py +++ b/components/rsptx/validation/schemas.py @@ -344,6 +344,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]