Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bases/rsptx/admin_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<OverlayPanel>(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 (
<div className="flex align-items-center gap-2">
<span>Async Mode</span>
<Button
className="icon-button-sm"
tooltip='Edit "Async Mode" for all exercises'
rounded
text
severity="secondary"
size="small"
icon="pi pi-pencil"
onClick={(e) => overlayRef.current?.toggle(e)}
/>
<OverlayPanel closeIcon ref={overlayRef} style={{ width: "17rem" }}>
<div className="p-1 flex gap-2 flex-column align-items-center justify-content-around">
<div><span>Edit "Async Mode" for all exercises</span></div>
<div style={{ width: "100%" }}>
<Dropdown
style={{ width: "100%" }}
value={value}
onChange={(e) => setValue(e.value)}
options={[
{ label: "Standard", value: "Standard" },
{ label: "LLM", value: "LLM", disabled: !hasApiKey }
]}
optionLabel="label"
optionDisabled="disabled"
scrollHeight="auto"
/>
</div>
<div className="flex flex-row justify-content-around align-items-center w-full">
<Button size="small" severity="danger" onClick={() => overlayRef.current?.hide()}>Cancel</Button>
<Button size="small" onClick={handleSubmit}>Submit</Button>
</div>
</div>
</OverlayPanel>
</div>
);
};

interface AssignmentExercisesTableProps {
assignmentExercises: Exercise[];
selectedExercises: Exercise[];
Expand Down Expand Up @@ -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<DataTable<Exercise[]>>(null);
const [copyModalVisible, setCopyModalVisible] = useState(false);
const [selectedExerciseForCopy, setSelectedExerciseForCopy] = useState<Exercise | null>(null);
Expand Down Expand Up @@ -276,6 +354,32 @@ export const AssignmentExercisesTable = ({
/>
)}
/>
{isPeerAsync && asyncLlmModesEnabled && (
<Column
resizeable={false}
style={{ width: "12rem" }}
header={() => <AsyncModeHeader hasApiKey={hasApiKey} />}
bodyStyle={{ padding: 0 }}
body={(data: Exercise) => (
<div className="editable-table-cell" style={{ position: "relative" }}>
<Dropdown
className="editable-table-dropdown"
value={data.use_llm && hasApiKey ? "LLM" : "Standard"}
onChange={(e) => 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 }}
/>
</div>
)}
/>
)}
<Column resizeable={false} rowReorder style={{ width: "3rem" }} />
</DataTable>
<TableSelectionOverlay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,18 @@ export const assignmentExerciseApi = createApi({
body
})
}),
hasApiKey: build.query<{ hasApiKey: boolean; asyncLlmModesEnabled: boolean }, void>({
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 }>,
{
Expand Down Expand Up @@ -218,5 +230,6 @@ export const {
useReorderAssignmentExercisesMutation,
useUpdateAssignmentExercisesMutation,
useValidateQuestionNameMutation,
useCopyQuestionMutation
useCopyQuestionMutation,
useHasApiKeyQuery
} = assignmentExerciseApi;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions bases/rsptx/assignment_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions bases/rsptx/book_server_api/routers/assessment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
</div>

<div id="pi-assignment-navigation">
{{ if current_qnum < num_questions: }}
<button
type="submit"
id="nextq"
Expand All @@ -70,6 +71,19 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
>
Next Question
</button>
{{ else: }}
<div id="asyncBtnArea" style="display:inline-block;">
<button
type="button"
id="toggleAsyncBtn"
class="btn btn-info"
onclick="showAsyncConfirm()"
style="margin-right: 4px;{{ if peer_async_visible: }} background-color:#a3d4ec; border-color:#a3d4ec; color:#fff;{{ pass }}"
>
{{ if peer_async_visible: }}Undo After-Class Release{{ else: }}Release After-Class PI{{ pass }}
</button>
</div>
{{ pass }}
<button
type="submit"
id="restart"
Expand Down Expand Up @@ -349,8 +363,33 @@ <h3>Question {{ =current_qnum }} of {{ =num_questions }}</h3>
var mess_count = 0;
var answerCount = 0;
var done = {{=is_last }}
if (done) {
document.getElementById("nextq").disabled = true;

var asyncReleased = {{=peer_async_visible}};

function showAsyncConfirm() {
var area = document.getElementById("asyncBtnArea");
var msg = asyncReleased
? "Undo the after-class PI release?"
: "Release after-class PI questions to students?";
area.innerHTML = `
<span style="margin-right:6px; font-weight:bold;">${msg}</span>
<button type="button" class="btn btn-sm btn-default" onclick="confirmToggleAsync()" style="margin-right:4px;">Yes</button>
<button type="button" class="btn btn-sm btn-default" onclick="cancelAsyncConfirm()">Cancel</button>
`;
}

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 = `<button type="button" id="toggleAsyncBtn" class="btn btn-info" onclick="showAsyncConfirm()" ${extraStyle}>${label}</button>`;
}

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();
}
</script>
{{ end }}
Loading
Loading