Skip to content

Commit 0398715

Browse files
authored
Merge pull request #1173 from sethbern/async-toggle
Add per-question LLM/Standard mode toggle for async peer instruction (discussion)
2 parents e3564f2 + 2d1f4f5 commit 0398715

15 files changed

Lines changed: 322 additions & 18 deletions

File tree

bases/rsptx/admin_server_api/routers/instructor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ async def get_course_settings(
378378
"enable_compare_me": course_attrs.get("enable_compare_me", "false"),
379379
"show_points": course_attrs.get("show_points") == "true",
380380
"groupsize": course_attrs.get("groupsize", "3"),
381+
"enable_async_llm_modes": course_attrs.get("enable_async_llm_modes", "false"),
381382
}
382383

383384
return templates.TemplateResponse("admin/instructor/course_settings.html", context)

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.module.css

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,34 @@
6868
}
6969

7070
.typeColumn {
71-
width: 120px;
71+
width: 160px;
7272
}
7373

7474
.typeCell {
7575
display: flex;
76+
flex-direction: row;
77+
align-items: flex-start;
78+
gap: 0.5rem;
79+
}
80+
81+
.asyncPeerGroup {
82+
display: flex;
83+
flex-direction: column;
7684
align-items: center;
77-
justify-content: flex-start;
85+
gap: 0.2rem;
86+
padding-top: 0.25rem;
87+
cursor: pointer;
88+
user-select: none;
89+
}
90+
91+
.asyncPeerText {
92+
font-size: 0.7rem;
93+
color: var(--surface-500);
94+
white-space: nowrap;
95+
}
96+
97+
.asyncPeerGroup:hover .asyncPeerText {
98+
color: var(--surface-700);
7899
}
79100

80101
.typeTag {

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesTable.tsx

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import { EditableCellFactory } from "@components/ui/EditableTable/EditableCellFactory";
22
import { TableSelectionOverlay } from "@components/ui/EditableTable/TableOverlay";
33
import { ExerciseTypeTag } from "@components/ui/ExerciseTypeTag";
4-
import { useReorderAssignmentExercisesMutation } from "@store/assignmentExercise/assignmentExercise.logic.api";
4+
import { useToastContext } from "@components/ui/ToastContext";
5+
import {
6+
useHasApiKeyQuery,
7+
useReorderAssignmentExercisesMutation,
8+
useUpdateAssignmentQuestionsMutation
9+
} from "@store/assignmentExercise/assignmentExercise.logic.api";
10+
import { Button } from "primereact/button";
511
import { Column } from "primereact/column";
612
import { DataTable, DataTableSelectionMultipleChangeEvent } from "primereact/datatable";
13+
import { Dropdown } from "primereact/dropdown";
14+
import { OverlayPanel } from "primereact/overlaypanel";
715
import { Tooltip } from "primereact/tooltip";
816
import { useRef, useState } from "react";
917

18+
import { useExercisesSelector } from "@/hooks/useExercisesSelector";
19+
1020
import { difficultyOptions } from "@/config/exerciseTypes";
1121
import { useJwtUser } from "@/hooks/useJwtUser";
22+
import { useSelectedAssignment } from "@/hooks/useSelectedAssignment";
1223
import { DraggingExerciseColumns } from "@/types/components/editableTableCell";
1324
import { Exercise, supportedExerciseTypesToEdit } from "@/types/exercises";
1425

@@ -19,6 +30,68 @@ import { ExercisePreviewModal } from "../components/ExercisePreview/ExercisePrev
1930

2031
import { SetCurrentEditExercise, ViewModeSetter, MouseUpHandler } from "./types";
2132

33+
const AsyncModeHeader = ({ hasApiKey }: { hasApiKey: boolean }) => {
34+
const { showToast } = useToastContext();
35+
const [updateExercises] = useUpdateAssignmentQuestionsMutation();
36+
const { assignmentExercises = [] } = useExercisesSelector();
37+
const overlayRef = useRef<OverlayPanel>(null);
38+
const [value, setValue] = useState("Standard");
39+
40+
const handleSubmit = async () => {
41+
const exercises = assignmentExercises.map((ex) => ({
42+
...ex,
43+
question_json: JSON.stringify(ex.question_json),
44+
use_llm: value === "LLM"
45+
}));
46+
const { error } = await updateExercises(exercises);
47+
if (!error) {
48+
overlayRef.current?.hide();
49+
showToast({ severity: "success", summary: "Success", detail: "Exercises updated successfully" });
50+
} else {
51+
showToast({ severity: "error", summary: "Error", detail: "Failed to update exercises" });
52+
}
53+
};
54+
55+
return (
56+
<div className="flex align-items-center gap-2">
57+
<span>Async Mode</span>
58+
<Button
59+
className="icon-button-sm"
60+
tooltip='Edit "Async Mode" for all exercises'
61+
rounded
62+
text
63+
severity="secondary"
64+
size="small"
65+
icon="pi pi-pencil"
66+
onClick={(e) => overlayRef.current?.toggle(e)}
67+
/>
68+
<OverlayPanel closeIcon ref={overlayRef} style={{ width: "17rem" }}>
69+
<div className="p-1 flex gap-2 flex-column align-items-center justify-content-around">
70+
<div><span>Edit "Async Mode" for all exercises</span></div>
71+
<div style={{ width: "100%" }}>
72+
<Dropdown
73+
style={{ width: "100%" }}
74+
value={value}
75+
onChange={(e) => setValue(e.value)}
76+
options={[
77+
{ label: "Standard", value: "Standard" },
78+
{ label: "LLM", value: "LLM", disabled: !hasApiKey }
79+
]}
80+
optionLabel="label"
81+
optionDisabled="disabled"
82+
scrollHeight="auto"
83+
/>
84+
</div>
85+
<div className="flex flex-row justify-content-around align-items-center w-full">
86+
<Button size="small" severity="danger" onClick={() => overlayRef.current?.hide()}>Cancel</Button>
87+
<Button size="small" onClick={handleSubmit}>Submit</Button>
88+
</div>
89+
</div>
90+
</OverlayPanel>
91+
</div>
92+
);
93+
};
94+
2295
interface AssignmentExercisesTableProps {
2396
assignmentExercises: Exercise[];
2497
selectedExercises: Exercise[];
@@ -48,6 +121,11 @@ export const AssignmentExercisesTable = ({
48121
}: AssignmentExercisesTableProps) => {
49122
const { username } = useJwtUser();
50123
const [reorderExercises] = useReorderAssignmentExercisesMutation();
124+
const [updateAssignmentQuestions] = useUpdateAssignmentQuestionsMutation();
125+
const { selectedAssignment } = useSelectedAssignment();
126+
const { data: { hasApiKey = false, asyncLlmModesEnabled = false } = {} } = useHasApiKeyQuery();
127+
const isPeerAsync =
128+
selectedAssignment?.kind === "Peer" && selectedAssignment?.peer_async_visible === true;
51129
const dataTableRef = useRef<DataTable<Exercise[]>>(null);
52130
const [copyModalVisible, setCopyModalVisible] = useState(false);
53131
const [selectedExerciseForCopy, setSelectedExerciseForCopy] = useState<Exercise | null>(null);
@@ -276,6 +354,32 @@ export const AssignmentExercisesTable = ({
276354
/>
277355
)}
278356
/>
357+
{isPeerAsync && asyncLlmModesEnabled && (
358+
<Column
359+
resizeable={false}
360+
style={{ width: "12rem" }}
361+
header={() => <AsyncModeHeader hasApiKey={hasApiKey} />}
362+
bodyStyle={{ padding: 0 }}
363+
body={(data: Exercise) => (
364+
<div className="editable-table-cell" style={{ position: "relative" }}>
365+
<Dropdown
366+
className="editable-table-dropdown"
367+
value={data.use_llm && hasApiKey ? "LLM" : "Standard"}
368+
onChange={(e) => updateAssignmentQuestions([{ ...data, question_json: JSON.stringify(data.question_json), use_llm: e.value === "LLM" }])}
369+
options={[
370+
{ label: "Standard", value: "Standard" },
371+
{ label: "LLM", value: "LLM", disabled: !hasApiKey }
372+
]}
373+
optionLabel="label"
374+
optionDisabled="disabled"
375+
scrollHeight="auto"
376+
tooltip={!hasApiKey ? "Add an API key to enable LLM mode" : undefined}
377+
tooltipOptions={{ showOnDisabled: true }}
378+
/>
379+
</div>
380+
)}
381+
/>
382+
)}
279383
<Column resizeable={false} rowReorder style={{ width: "3rem" }} />
280384
</DataTable>
281385
<TableSelectionOverlay

bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,18 @@ export const assignmentExerciseApi = createApi({
167167
body
168168
})
169169
}),
170+
hasApiKey: build.query<{ hasApiKey: boolean; asyncLlmModesEnabled: boolean }, void>({
171+
query: () => ({
172+
method: "GET",
173+
url: "/assignment/instructor/has_api_key"
174+
}),
175+
transformResponse: (
176+
response: DetailResponse<{ has_api_key: boolean; async_llm_modes_enabled: boolean }>
177+
) => ({
178+
hasApiKey: response.detail.has_api_key,
179+
asyncLlmModesEnabled: response.detail.async_llm_modes_enabled
180+
})
181+
}),
170182
copyQuestion: build.mutation<
171183
DetailResponse<{ status: string; question_id: number; message: string }>,
172184
{
@@ -218,5 +230,6 @@ export const {
218230
useReorderAssignmentExercisesMutation,
219231
useUpdateAssignmentExercisesMutation,
220232
useValidateQuestionNameMutation,
221-
useCopyQuestionMutation
233+
useCopyQuestionMutation,
234+
useHasApiKeyQuery
222235
} = assignmentExerciseApi;

bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type Exercise = {
5656
reading_assignment: boolean;
5757
sorting_priority: number;
5858
activities_required: number;
59+
use_llm: boolean;
5960
qnumber: string;
6061
name: string;
6162
subchapter: string;

bases/rsptx/assignment_server_api/routers/instructor.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,25 @@ async def add_api_token(
13871387
)
13881388

13891389

1390+
@router.get("/has_api_key")
1391+
@instructor_role_required()
1392+
@with_course()
1393+
async def has_api_key(request: Request, user=Depends(auth_manager), course=None):
1394+
"""Return whether the course has at least one API token configured and whether async LLM modes are enabled."""
1395+
tokens = await fetch_all_api_tokens(course.id)
1396+
course_attrs = await fetch_all_course_attributes(course.id)
1397+
async_llm_modes_enabled = (
1398+
course_attrs.get("enable_async_llm_modes", "false") == "true"
1399+
)
1400+
return make_json_response(
1401+
status=status.HTTP_200_OK,
1402+
detail={
1403+
"has_api_key": len(tokens) > 0,
1404+
"async_llm_modes_enabled": async_llm_modes_enabled,
1405+
},
1406+
)
1407+
1408+
13901409
@router.get("/add_token")
13911410
@instructor_role_required()
13921411
@with_course()

bases/rsptx/book_server_api/routers/assessment.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,7 @@ async def getpollresults(request: Request, course: str, div_id: str):
321321
my_vote = int(user_res.split(":")[0])
322322
my_comment = user_res.split(":")[1]
323323
else:
324-
if user_res.isnumeric():
325-
my_vote = int(user_res)
324+
my_vote = int(user_res) if user_res.isnumeric() else -1
326325
my_comment = ""
327326
else:
328327
my_vote = -1

bases/rsptx/web2py_server/applications/runestone/controllers/peer.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,32 @@ def dashboard():
143143
is_last=done,
144144
lti=is_lti,
145145
has_vote1=has_vote1,
146+
peer_async_visible=assignment.peer_async_visible or False,
146147
**course_attrs,
147148
)
148149

149150

151+
@auth.requires(
152+
lambda: verifyInstructorStatus(auth.user.course_id, auth.user),
153+
requires_login=True,
154+
)
155+
def toggle_async():
156+
response.headers["content-type"] = "application/json"
157+
assignment_id = request.vars.assignment_id
158+
if not assignment_id:
159+
return json.dumps({"ok": False, "error": "missing assignment_id"})
160+
assignment = db(db.assignments.id == assignment_id).select().first()
161+
if not assignment:
162+
return json.dumps({"ok": False, "error": "assignment not found"})
163+
course = db(db.courses.course_name == auth.user.course_name).select().first()
164+
if not course or assignment.course != course.id:
165+
return json.dumps({"ok": False, "error": "assignment does not belong to your course"})
166+
new_value = not (assignment.peer_async_visible or False)
167+
db(db.assignments.id == assignment_id).update(peer_async_visible=new_value)
168+
db.commit()
169+
return json.dumps({"peer_async_visible": new_value})
170+
171+
150172
def extra():
151173
assignment_id = request.vars.assignment_id
152174
current_question, done, idx = _get_current_question(assignment_id, False)
@@ -174,7 +196,9 @@ def _get_current_question(assignment_id, get_next):
174196
idx = 0
175197
db(db.assignments.id == assignment_id).update(current_index=idx)
176198
elif get_next is True:
177-
idx = assignment.current_index + 1
199+
all_questions = _get_assignment_questions(assignment_id)
200+
total_questions = len(all_questions)
201+
idx = min(assignment.current_index + 1, max(total_questions - 1, 0))
178202
db(db.assignments.id == assignment_id).update(current_index=idx)
179203
else:
180204
idx = assignment.current_index
@@ -743,7 +767,18 @@ def peer_async():
743767
if "latex_macros" not in course_attrs:
744768
course_attrs["latex_macros"] = ""
745769

746-
llm_enabled = _llm_enabled()
770+
aq = None
771+
if current_question:
772+
aq = db(
773+
(db.assignment_questions.assignment_id == assignment_id)
774+
& (db.assignment_questions.question_id == current_question.id)
775+
).select().first()
776+
async_llm_modes_enabled = course_attrs.get("enable_async_llm_modes", "false") == "true"
777+
if async_llm_modes_enabled:
778+
question_use_llm = bool(aq.use_llm) if aq else False
779+
llm_enabled = _llm_enabled() and question_use_llm
780+
else:
781+
llm_enabled = _llm_enabled()
747782
try:
748783
db.useinfo.insert(
749784
course_id=auth.user.course_name,

bases/rsptx/web2py_server/applications/runestone/models/questions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,6 @@
7373
Field(
7474
"activities_required", type="integer"
7575
), # specifies how many activities in a sub chapter a student must perform in order to receive credit
76+
Field("use_llm", type="boolean", default=False),
7677
migrate=bookserver_owned("assignment_questions"),
7778
)

bases/rsptx/web2py_server/applications/runestone/views/peer/dashboard.html

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
6161
</div>
6262

6363
<div id="pi-assignment-navigation">
64+
{{ if current_qnum < num_questions: }}
6465
<button
6566
type="submit"
6667
id="nextq"
@@ -70,6 +71,19 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
7071
>
7172
Next Question
7273
</button>
74+
{{ else: }}
75+
<div id="asyncBtnArea" style="display:inline-block;">
76+
<button
77+
type="button"
78+
id="toggleAsyncBtn"
79+
class="btn btn-info"
80+
onclick="showAsyncConfirm()"
81+
style="margin-right: 4px;{{ if peer_async_visible: }} background-color:#a3d4ec; border-color:#a3d4ec; color:#fff;{{ pass }}"
82+
>
83+
{{ if peer_async_visible: }}Undo After-Class Release{{ else: }}Release After-Class PI{{ pass }}
84+
</button>
85+
</div>
86+
{{ pass }}
7387
<button
7488
type="submit"
7589
id="restart"
@@ -349,8 +363,33 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
349363
var mess_count = 0;
350364
var answerCount = 0;
351365
var done = {{=is_last }}
352-
if (done) {
353-
document.getElementById("nextq").disabled = true;
366+
367+
var asyncReleased = {{=peer_async_visible}};
368+
369+
function showAsyncConfirm() {
370+
var area = document.getElementById("asyncBtnArea");
371+
var msg = asyncReleased
372+
? "Undo the after-class PI release?"
373+
: "Release after-class PI questions to students?";
374+
area.innerHTML = `
375+
<span style="margin-right:6px; font-weight:bold;">${msg}</span>
376+
<button type="button" class="btn btn-sm btn-default" onclick="confirmToggleAsync()" style="margin-right:4px;">Yes</button>
377+
<button type="button" class="btn btn-sm btn-default" onclick="cancelAsyncConfirm()">Cancel</button>
378+
`;
379+
}
380+
381+
function cancelAsyncConfirm() {
382+
var area = document.getElementById("asyncBtnArea");
383+
var label = asyncReleased ? "Undo After-Class Release" : "Release After-Class PI";
384+
var extraStyle = asyncReleased ? 'style="background-color:#a3d4ec; border-color:#a3d4ec; color:#fff; margin-right:4px;"' : 'style="margin-right:4px;"';
385+
area.innerHTML = `<button type="button" id="toggleAsyncBtn" class="btn btn-info" onclick="showAsyncConfirm()" ${extraStyle}>${label}</button>`;
386+
}
387+
388+
async function confirmToggleAsync() {
389+
var resp = await fetch("/runestone/peer/toggle_async?assignment_id={{=assignment_id}}", { method: "POST" });
390+
var data = await resp.json();
391+
asyncReleased = data.peer_async_visible;
392+
cancelAsyncConfirm();
354393
}
355394
</script>
356395
{{ end }}

0 commit comments

Comments
 (0)