Skip to content

Commit d35031c

Browse files
committed
add async checkbox, per question ability to set LLM for PI questions
1 parent cf083c0 commit d35031c

9 files changed

Lines changed: 159 additions & 8 deletions

File tree

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 } = 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 && (
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, 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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ export const assignmentExerciseApi = createApi({
167167
body
168168
})
169169
}),
170+
hasApiKey: build.query<boolean, void>({
171+
query: () => ({
172+
method: "GET",
173+
url: "/assignment/instructor/has_api_key"
174+
}),
175+
transformResponse: (response: DetailResponse<{ has_api_key: boolean }>) =>
176+
response.detail.has_api_key
177+
}),
170178
copyQuestion: build.mutation<
171179
DetailResponse<{ status: string; question_id: number; message: string }>,
172180
{
@@ -218,5 +226,6 @@ export const {
218226
useReorderAssignmentExercisesMutation,
219227
useUpdateAssignmentExercisesMutation,
220228
useValidateQuestionNameMutation,
221-
useCopyQuestionMutation
229+
useCopyQuestionMutation,
230+
useHasApiKeyQuery
222231
} = 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,17 @@ 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."""
1395+
tokens = await fetch_all_api_tokens(course.id)
1396+
return make_json_response(
1397+
status=status.HTTP_200_OK, detail={"has_api_key": len(tokens) > 0}
1398+
)
1399+
1400+
13901401
@router.get("/add_token")
13911402
@instructor_role_required()
13921403
@with_course()

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,14 @@ def peer_async():
742742
if "latex_macros" not in course_attrs:
743743
course_attrs["latex_macros"] = ""
744744

745-
llm_enabled = _llm_enabled()
745+
aq = None
746+
if current_question:
747+
aq = db(
748+
(db.assignment_questions.assignment_id == assignment_id)
749+
& (db.assignment_questions.question_id == current_question.id)
750+
).select().first()
751+
question_use_llm = bool(aq.use_llm) if aq else False
752+
llm_enabled = _llm_enabled() and question_use_llm
746753
try:
747754
db.useinfo.insert(
748755
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/peer_async.html

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ <h3>Congratulations, you have completed this assignment!</h3>
100100
</button>
101101
</div>
102102

103+
<p id="llmDisclaimer" style="display:none; font-size:0.8em; color:#888; margin-top:4px; margin-bottom:0;">
104+
You are chatting with an AI, not a real peer.
105+
</p>
103106
<p id="nextStep" style="margin-top:8px; font-style:italic;"></p>
104107
</div>
105108
</div>
@@ -298,9 +301,13 @@ <h3>Congratulations, you have completed this assignment!</h3>
298301
lockVote1AndReflection();
299302

300303
discussion.style.display = "block";
304+
if (window.PI_LLM_MODE === true) {
305+
const disclaimer = document.getElementById("llmDisclaimer");
306+
if (disclaimer) disclaimer.style.display = "block";
307+
}
301308
chat.innerHTML = "<p><em>Thinking about your explanation…</em></p>";
302309

303-
if (window.PI_LLM_MODE !== true) {
310+
async function showStandardDiscussion(chat, nextStep, showInstructorNote) {
304311
const resp = await fetch("/runestone/peer/get_async_explainer", {
305312
method: "POST",
306313
headers: { "Content-Type": "application/json" },
@@ -323,6 +330,11 @@ <h3>Congratulations, you have completed this assignment!</h3>
323330
)} <br />`;
324331
}
325332
chat.innerHTML = "";
333+
if (showInstructorNote) {
334+
chat.innerHTML += `<div style="background:#fff3cd; border:1px solid #ffc107; border-radius:4px; padding:8px; margin-bottom:8px;">
335+
<strong>Note:</strong> The AI peer is currently unavailable. Please contact your instructor.
336+
</div>`;
337+
}
326338
chat.innerHTML += `<p><strong>Other students said:</strong></p>`;
327339
if (res) {
328340
chat.innerHTML += `<p>${res}</p>`;
@@ -343,6 +355,10 @@ <h3>Congratulations, you have completed this assignment!</h3>
343355
readyBtn.title = "";
344356
}
345357
studentSubmittedVote2 = false;
358+
}
359+
360+
if (window.PI_LLM_MODE !== true) {
361+
await showStandardDiscussion(chat, nextStep, false);
346362
return;
347363
} else {
348364
const mcq = document.querySelector('.mchoice');
@@ -369,15 +385,15 @@ <h3>Congratulations, you have completed this assignment!</h3>
369385
})
370386
});
371387
} catch (e) {
372-
chat.innerHTML = "<p><em>LLM error. Please try again.</em></p>";
388+
await showStandardDiscussion(chat, nextStep, true);
373389
return;
374390
}
375391

376392
const data = await resp.json();
377393
chat.innerHTML = "";
378394

379395
if (!data.ok) {
380-
chat.innerHTML = "<p><em>LLM error. Please try again.</em></p>";
396+
await showStandardDiscussion(chat, nextStep, true);
381397
return;
382398
}
383399

@@ -487,15 +503,15 @@ <h3>Congratulations, you have completed this assignment!</h3>
487503
})
488504
});
489505
} catch (e) {
490-
appendMsg("assistant", "LLM error. Please try again.");
506+
appendMsg("assistant", "The AI peer is currently unavailable. Please contact your instructor.");
491507
btn.disabled = false;
492508
input.disabled = false;
493509
return;
494510
}
495511

496512
const data = await resp.json();
497513
if (!data.ok) {
498-
appendMsg("assistant", "LLM error. Please try again.");
514+
appendMsg("assistant", "The AI peer is currently unavailable. Please contact your instructor.");
499515
btn.disabled = false;
500516
input.disabled = false;
501517
return;

components/rsptx/db/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ class AssignmentQuestion(Base, IdMixin):
678678
activities_required = Column(
679679
Integer
680680
) # only reading assignments will have this populated
681+
use_llm = Column(Web2PyBoolean, default=False)
681682

682683

683684
AssignmentQuestionValidator: TypeAlias = sqlalchemy_to_pydantic(AssignmentQuestion) # type: ignore

components/rsptx/validation/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ class AssignmentQuestionUpdateDict(TypedDict, total=False):
341341
reading_assignment: Optional[bool]
342342
sorting_priority: int
343343
activities_required: Optional[int]
344+
use_llm: Optional[bool]
344345

345346
# Question fields
346347
name: Optional[str]

0 commit comments

Comments
 (0)