diff --git a/bases/rsptx/admin_server_api/routers/instructor.py b/bases/rsptx/admin_server_api/routers/instructor.py index 49fb68fe9..3228cb780 100644 --- a/bases/rsptx/admin_server_api/routers/instructor.py +++ b/bases/rsptx/admin_server_api/routers/instructor.py @@ -378,6 +378,7 @@ async def get_course_settings( "enable_compare_me": course_attrs.get("enable_compare_me", "false"), "show_points": course_attrs.get("show_points") == "true", "groupsize": course_attrs.get("groupsize", "3"), + "enable_async_llm_modes": course_attrs.get("enable_async_llm_modes", "false"), } return templates.TemplateResponse("admin/instructor/course_settings.html", context) 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 861a021e7..a58865a40 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/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx index d8e3604eb..217f81456 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, asyncLlmModesEnabled = 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 && asyncLlmModesEnabled && ( + } + bodyStyle={{ padding: 0 }} + body={(data: Exercise) => ( +
+ updateAssignmentQuestions([{ ...data, question_json: JSON.stringify(data.question_json), 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; async_llm_modes_enabled: boolean }> + ) => ({ + hasApiKey: response.detail.has_api_key, + asyncLlmModesEnabled: response.detail.async_llm_modes_enabled + }) + }), copyQuestion: build.mutation< DetailResponse<{ status: string; question_id: number; message: string }>, { @@ -218,5 +230,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 488793790..5d5129080 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 b5dc7154a..82230c292 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -1387,6 +1387,25 @@ 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 and whether async LLM modes are enabled.""" + tokens = await fetch_all_api_tokens(course.id) + course_attrs = await fetch_all_course_attributes(course.id) + async_llm_modes_enabled = ( + course_attrs.get("enable_async_llm_modes", "false") == "true" + ) + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "has_api_key": len(tokens) > 0, + "async_llm_modes_enabled": async_llm_modes_enabled, + }, + ) + + @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 3063eb87a..19fbd223b 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 6a6daedd7..dc2e70ec3 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/peer.py @@ -143,10 +143,32 @@ def dashboard(): is_last=done, lti=is_lti, has_vote1=has_vote1, + peer_async_visible=assignment.peer_async_visible or False, **course_attrs, ) +@auth.requires( + lambda: verifyInstructorStatus(auth.user.course_id, auth.user), + requires_login=True, +) +def toggle_async(): + response.headers["content-type"] = "application/json" + assignment_id = request.vars.assignment_id + if not assignment_id: + return json.dumps({"ok": False, "error": "missing assignment_id"}) + assignment = db(db.assignments.id == assignment_id).select().first() + if not assignment: + return json.dumps({"ok": False, "error": "assignment not found"}) + course = db(db.courses.course_name == auth.user.course_name).select().first() + if not course or assignment.course != course.id: + return json.dumps({"ok": False, "error": "assignment does not belong to your course"}) + new_value = not (assignment.peer_async_visible or False) + db(db.assignments.id == assignment_id).update(peer_async_visible=new_value) + db.commit() + return json.dumps({"peer_async_visible": new_value}) + + def extra(): assignment_id = request.vars.assignment_id current_question, done, idx = _get_current_question(assignment_id, False) @@ -174,7 +196,9 @@ def _get_current_question(assignment_id, get_next): idx = 0 db(db.assignments.id == assignment_id).update(current_index=idx) elif get_next is True: - idx = assignment.current_index + 1 + all_questions = _get_assignment_questions(assignment_id) + total_questions = len(all_questions) + idx = min(assignment.current_index + 1, max(total_questions - 1, 0)) db(db.assignments.id == assignment_id).update(current_index=idx) else: idx = assignment.current_index @@ -743,7 +767,18 @@ 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() + async_llm_modes_enabled = course_attrs.get("enable_async_llm_modes", "false") == "true" + if async_llm_modes_enabled: + question_use_llm = bool(aq.use_llm) if aq else False + llm_enabled = _llm_enabled() and question_use_llm + else: + llm_enabled = _llm_enabled() 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 b520a0269..fe87c73d0 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/dashboard.html b/bases/rsptx/web2py_server/applications/runestone/views/peer/dashboard.html index dea920732..083882b69 100644 --- a/bases/rsptx/web2py_server/applications/runestone/views/peer/dashboard.html +++ b/bases/rsptx/web2py_server/applications/runestone/views/peer/dashboard.html @@ -61,6 +61,7 @@

Question {{ =current_qnum }} of {{ =num_questions }}

+ {{ if current_qnum < num_questions: }} + {{ else: }} +
+ +
+ {{ pass }} + + `; + } + + function cancelAsyncConfirm() { + var area = document.getElementById("asyncBtnArea"); + var label = asyncReleased ? "Undo After-Class Release" : "Release After-Class PI"; + var extraStyle = asyncReleased ? 'style="background-color:#a3d4ec; border-color:#a3d4ec; color:#fff; margin-right:4px;"' : 'style="margin-right:4px;"'; + area.innerHTML = ``; + } + + async function confirmToggleAsync() { + var resp = await fetch("/runestone/peer/toggle_async?assignment_id={{=assignment_id}}", { method: "POST" }); + var data = await resp.json(); + asyncReleased = data.peer_async_visible; + cancelAsyncConfirm(); } {{ end }} \ No newline at end of file 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 c5a864cbd..bb59887e8 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 @@ -53,7 +53,11 @@

Peer Instruction Question (After Class)

  1. Answer the question as best you can.
  2. Then, in the space provided write a justification for your answer.
  3. -
  4. Read the dialog between two of your peers on why they answered the question the way they did.
  5. + {{ if llm_enabled: }} +
  6. Discuss the question with an LLM peer — explain your reasoning and respond to their questions.
  7. + {{ else: }} +
  8. Read the justification or discussion.
  9. + {{ pass }}
  10. Answer the question again. Even if you are not changing your answer from the first time.

@@ -115,6 +119,9 @@

Congratulations, you have completed this assignment!

+

@@ -139,6 +146,7 @@

Congratulations, you have completed this assignment!

{{ pass }}