feat: grade exercise with AI (#296)

* working on the UI of the grading

* working on the grading function of the radio and checkboxes

* ai grading fully implemented with dummy functions for generating scores

* working on the openai response

* working on the openai response

* finished with the ai_grading

* finished with the ai_grading

* creating new migration file

* working on reviews

* updated the translations

* improvements

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Em_Dev
2024-05-13 19:35:06 +01:00
committed by GitHub
co-authored by Rotimi Best
parent fc8b8e1898
commit e243564113
37 changed files with 688 additions and 150 deletions
@@ -161,7 +161,7 @@
class="w-[50px]"
bind:selected={question.question_type.id}
on:change={(e) => {
const id = parseInt(e.target.value);
const id = parseInt(e.target?.value);
question.question_type = QUESTION_TYPES.find((q) => q.id === id);
question.is_dirty = true;
}}
@@ -176,7 +176,7 @@
<div class="flex justify-between items-center my-3 w-3/5">
<TextArea
bind:value={question.code}
rows="2"
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton value="write-code" onClick={() => handleCode(question.id, false)}>
@@ -1,24 +1,43 @@
<script>
import { onMount } from 'svelte';
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import Select from '$lib/components/Form/Select.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Preview from './Preview.svelte';
import { SELECTABLE_STATUS } from './constants';
import { STATUS } from './constants';
import { snackbar } from '$lib/components/Snackbar/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { Dropdown, Tag } from 'carbon-components-svelte';
import { useCompletion } from 'ai/svelte';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { t } from '$lib/utils/functions/translations';
export let open = false;
export let onClose = () => {};
export let handleSave = () => {};
export let isGradeWithAI = false;
// export let submissionId;
export let data = {};
export let updateStatus = () => {};
let status = SELECTABLE_STATUS[0];
const SELECTABLE_STATUS = [
{
id: STATUS.SUBMITTED,
text: $t('course.navItem.submissions.submission_status.submitted')
},
{
id: STATUS.IN_PROGRESS,
text: $t('course.navItem.submissions.submission_status.in_progress')
},
{
id: STATUS.GRADED,
text: $t('course.navItem.submissions.submission_status.graded')
}
];
let status = SELECTABLE_STATUS[0];
let selectedId = status.id;
let reasons = {};
let isLoading = false;
let total = 0;
let maxPoints = 0;
@@ -26,44 +45,107 @@
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
function calculateTotal(grades) {
function calculateTotal(grades: Record<string, string>): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0);
}
function handleStatusChange() {
function handleStatusChange(event) {
const newSelectedId = event.detail.selectedId;
setStatus({
status_id: newSelectedId
});
updateStatus({
submissionId: data.id,
prevStatusId: data.status_id,
nextStatusId: status.value,
nextStatusId: status.id,
total
});
snackbar.success(`${$t('snackbar.exercise.submission_updated')} '${status.label}'`);
snackbar.success(`${$t('snackbar.exercise.submission_updated')} '${status.text}'`);
}
function setStatus(data) {
status = SELECTABLE_STATUS.find((status) => status.value === data.status_id);
const statusBySelectedId = SELECTABLE_STATUS.find((status) => status.id === data.status_id);
if (!statusBySelectedId) {
return;
}
status = statusBySelectedId;
if (data.status_id !== selectedId) {
selectedId = data.status_id;
}
}
function getStatusColor(status) {
// if (!status)
return '';
const { input, handleSubmit, completion } = useCompletion({
api: '/api/completion/gradingprompt',
onFinish: async () => {
try {
const responseData = $completion.replace('```json', '').replace('```', '');
// switch (status.value) {
// case STATUS.SUBMITTED:
// return '';
// case STATUS.IN_PROGRESS:
// return 'text-white bg-primary-700';
// case STATUS.GRADED:
// return 'text-white bg-green-700';
// }
}
let aiResponses = [];
try {
// Parse the modified response data as JSON
aiResponses = JSON.parse(responseData);
} catch (error) {
console.error('Error parsing AI response', error);
}
onMount(() => {
console.log('mounting');
data?.questions.forEach((question) => {
const { id, points, question_type_id } = question;
if (question_type_id !== QUESTION_TYPE.TEXTAREA) {
const answer = data.questionAnswers.find((q) => q.question_id === id);
reasons = {
...reasons,
[id]: `${$t('course.navItem.submissions.grading_modal.questions_tried')} ${
answer.answers.length
} `
};
data.questionAnswerByPoint[id] = points / answer.answers.length;
} else if (aiResponses.length) {
const graded = aiResponses.find((res) => res.id === id);
reasons = {
...reasons,
[id]: `${graded?.explanation}`
};
data.questionAnswerByPoint[id] = graded.score;
}
});
} catch (error) {
console.error('Error', error);
} finally {
isLoading = false;
}
}
});
function gradeWithAI() {
isGradeWithAI = true;
isLoading = true;
const paragraphAiInput = data.questions
.filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
.map((q) => {
const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
return {
id: q.id,
question: q.title,
answer: answer?.open_answer,
point: q.points
};
});
$input = JSON.stringify(paragraphAiInput);
handleSubmit({ preventDefault: () => {} });
}
$: total = calculateTotal(data.questionAnswerByPoint);
$: maxPoints = getMaxPoints(data.questions);
$: setStatus(data);
@@ -73,10 +155,12 @@
modalHeading={data.title}
bind:open
{onClose}
width="w-4/5"
containerClass="flex items-start"
width="w-4/5 h-[90%]"
containerClass="flex items-start !max-h-full h-[85%] py-0 px-4"
headerClass="py-2"
labelClass="text-base font-semibold"
>
<div class="w-full">
<div class="w-full h-full">
<Preview
questions={Array.isArray(data.questions)
? data.questions.sort((a, b) => a.order - b.order)
@@ -86,26 +170,41 @@
isFinished: true
}}
bind:grades={data.questionAnswerByPoint}
bind:reasons
bind:isGradeWithAI
bind:isLoading
disableGrading={false}
/>
</div>
<div class="ml-4 w-2/5 sticky top-0">
<div class="border border-gray-300 rounded-md mt-2">
<div class="border border-gray-300 rounded-md">
<div
class="hover:bg-gray-100 dark:bg-neutral-800 border-b border-t-0 border-l-0 border-r-0 border-gray-300 p-3"
>
<p class="dark:text-white font-bold text-lg">
Details
<p class="dark:text-white font-bold text-base">
{$t('course.navItem.submissions.grading_modal.details')}
{#if data.isEarly}
<span class="ml-2 text-sm badge rounded-sm px-2 bg-green-500 text-white"> early </span>
<span class="ml-2 text-sm badge rounded-sm px-2 bg-green-500 text-white">
{$t('course.navItem.submissions.grading_modal.early')}</span
>
{:else}
<span class="ml-2 badge text-sm rounded-sm px-2 bg-red-500 text-white"> late </span>
<span class="ml-2 badge text-sm rounded-sm px-2 bg-red-500 text-white">
{$t('course.navItem.submissions.grading_modal.late')}
</span>
{/if}
</p>
</div>
<div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-2/5">Total grade</p>
<p class="dark:text-white">{total} / {maxPoints}</p>
<div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">
{$t('course.navItem.submissions.grading_modal.total_grade')}:
</p>
<Tag
class="dark:text-white font-semibold text-black bg-gray-100 dark:bg-neutral-700 rounded-md w-fit"
>
{total}/{maxPoints}
</Tag>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-1/2">Status</p>
@@ -114,35 +213,75 @@
<p class="dark:text-white">Grading</p>
</div>
</div> -->
<div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-2/5">Student</p>
<div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">
{$t('course.navItem.submissions.grading_modal.student')}:
</p>
{#if data.student}
<img
alt="Student avatar"
class="block rounded-full h-6 w-6"
src={data.student.avatar_url}
/>
<p class="dark:text-white ml-2 text-sm">{data.student.fullname}</p>
<div
class="flex flex-row justify-center items-center bg-gray-100 dark:bg-neutral-700 rounded-md p-[6px]"
>
<img
alt="Student avatar"
class="flex rounded-full h-5 w-5"
src={data.student.avatar_url}
/>
<p class="dark:text-white font-semibold ml-2 text-sm line-clamp-1">
{data.student.fullname}
</p>
</div>
{/if}
</div>
<div class="flex items-center text-sm p-3">
<p class="dark:text-white font-bold w-2/5">Status</p>
<Select
bind:value={status}
options={SELECTABLE_STATUS}
selectClassName={getStatusColor(status)}
onChange={handleStatusChange}
className="w-full"
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
<Tag
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
>Paragraph</Tag
>
</div> -->
<div class="flex flex-col items-start text-sm px-3 py-2">
<p class="dark:text-white text-gray-500 font-semibold">
{$t('course.navItem.submissions.grading_modal.status')}:
</p>
<Dropdown
bind:selectedId
items={SELECTABLE_STATUS}
class="w-full"
on:select={handleStatusChange}
/>
</div>
<div class="flex items-center justify-center w-full p-3">
<div class="flex flex-col items-start text-sm px-3 py-2">
<p class="dark:text-white text-gray-500 font-semibold">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextArea
bgColor="bg-gray-100 dark:bg-neutral-700"
className="font-semibold"
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
bind:value={data.feedback}
/>
</div>
<div class="flex flex-col w-full space-y-3 px-3 py-2">
<PrimaryButton
onClick={gradeWithAI}
variant={VARIANTS.OUTLINED}
className="space-x-3 py-3 px-8 w-full "
>
<img src="/ai.svg" alt="ai" />
<p class="font-semibold text-sm">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p>
</PrimaryButton>
<PrimaryButton
onClick={() => {
handleSave(data);
onClose();
}}
label="Submit Grades"
variant={VARIANTS.CONTAINED_SUCCESS}
label={$t('course.navItem.submissions.grading_modal.submit_grades')}
variant={VARIANTS.CONTAINED}
className="py-3 px-8 w-full"
/>
</div>
@@ -11,7 +11,10 @@
export let onSubmit = () => {};
export let onPrevious = () => {};
export let handleGrade = () => {};
export let disableGrading = false;
export let disableGrading = true;
export let isGradeWithAI = false;
export let isLoading = false;
export let reasons = {};
</script>
{#each questions as currentQuestion, currentQuestionIndex}
@@ -27,6 +30,9 @@
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
@@ -44,6 +50,9 @@
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
@@ -61,6 +70,9 @@
true
)}
bind:grade={grades[currentQuestion.id]}
bind:isGradeWithAI
bind:reason={reasons[currentQuestion.id]}
bind:isLoading
gradeMax={currentQuestion.points}
handleGrade={handleGrade(currentQuestion.id)}
{disableGrading}
@@ -26,9 +26,12 @@
} from '$lib/utils/services/notification/notification';
import { lesson } from '../store/lessons';
import { browser } from '$app/environment';
import { CloseFilled } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
export let preview: false;
export let preview: boolean = false;
export let exerciseId = '';
export let isFetchingExercise = false;
let currentQuestion = {};
let renderProps = {};
@@ -36,6 +39,7 @@
let hasSubmission = false;
let isLoadingAutoSavedData = false;
let alreadyCheckedAutoSavedData = false;
let gradedComment = true;
function handleStart() {
$questionnaireMetaData.currentQuestionIndex += 1;
@@ -110,13 +114,14 @@
$questionnaireMetaData.status = 1;
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
$questionnaireMetaData.grades = {};
submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
($questionnaireMetaData.comment = ''),
submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
notifyEducator();
}
}
@@ -140,7 +145,7 @@
}, 0);
}
async function checkForSubmission(people, profileId: string, courseId: string) {
async function checkForSubmission(people, profileId?: string, courseId?: string) {
if (!Array.isArray(people) || !profileId || !courseId || !!submission) {
return;
}
@@ -157,6 +162,7 @@
if (Array.isArray(data) && data.length) {
submission = data[0];
$questionnaireMetaData.answers = formatAnswers({
questions: $questionnaire.questions,
answers: submission.answers
@@ -168,6 +174,7 @@
$questionnaireMetaData.isFinished = true;
$questionnaireMetaData.status = submission.status_id;
$questionnaireMetaData.finalTotalGrade = 0;
$questionnaireMetaData.comment = submission.feedback;
$questionnaireMetaData.grades = submission.answers.reduce((acc, answer) => {
acc[answer.question_id] = answer.point;
$questionnaireMetaData.finalTotalGrade += answer.point;
@@ -194,6 +201,10 @@
alreadyCheckedAutoSavedData = true;
}
const toggleComments = () => {
gradedComment = !gradedComment;
};
$: browser && !alreadyCheckedAutoSavedData && getAutoSavedData();
// Reactive code
@@ -218,7 +229,8 @@
$questionnaireMetaData.currentQuestionIndex
);
}
$: checkForSubmission($group.people, $profile.id, $course.id);
$: !isFetchingExercise && checkForSubmission($group.people, $profile.id, $course.id);
</script>
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
@@ -281,18 +293,18 @@
{:else if $questionnaireMetaData.isFinished}
{#if !isLoadingAutoSavedData}
<div class="flex items-center justify-between">
<div class="flex flex-col justify-between w-full">
<h2 class="text-xl mb-2 mt-0">{$questionnaire.title}</h2>
<div class="flex flex-col lg:flex-row items-start lg:items-center lg:space-x-4 w-full">
<h2 class="text-xl font-normal">{$questionnaire.title}</h2>
{#if STATUS.GRADED === $questionnaireMetaData.status}
<span
class="status-text bg-green-700 text-white rounded-full py-3 px-6 text-center"
class="status-text bg-green-700 text-white py-1 px-2 text-center"
title="Status: Pending Review"
>
Graded
</span>
{:else}
<span
class="status-text bg-yellow-600 text-white rounded-full py-3 px-6 text-center"
class="status-text bg-yellow-600 text-white py-1 px-2 text-center"
title="Status: Pending Review"
>
Pending
@@ -301,13 +313,21 @@
</div>
{#if STATUS.GRADED === $questionnaireMetaData.status}
<span
class="p-5 border-2 border-gray-700 rounded-full h-24 w-24 flex items-center justify-center text-2xl"
class="p-6 border-2 border-gray-300 bg-[#F5F8FE] rounded-full h-10 w-10 flex items-center justify-center text-[#2751DA] text-sm font-semibold"
title="Status: Pending Review"
>
{$questionnaireMetaData.finalTotalGrade}/{$questionnaireMetaData.totalPossibleGrade}
</span>
{/if}
</div>
{#if $questionnaireMetaData.status === STATUS.GRADED && $questionnaireMetaData.comment}
<div
class="flex items-center justify-between bg-primary-700 p-4 text-white font-semibold rounded-sm mt-3"
>
<span> {$questionnaireMetaData.comment}</span>
</div>
{/if}
<Preview
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
questionnaireMetaData={$questionnaireMetaData}
@@ -320,11 +340,11 @@
<!-- <div transition:fade id="question"> -->
<div in:fly={{ x: 500, duration: 1000 }} id="question">
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
<RadioQuestion {...renderProps} key={currentQuestion.id} />
<RadioQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
<CheckboxQuestion {...renderProps} key={currentQuestion.id} />
<CheckboxQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
<TextareaQuestion {...renderProps} key={currentQuestion.id} />
<TextareaQuestion {...renderProps} key={currentQuestion.id} hideGrading={true} />
{/if}
</div>
{/key}
@@ -1,20 +1,7 @@
import { t } from '$lib/utils/functions/translations';
export const STATUS = {
SUBMITTED: 1,
IN_PROGRESS: 2,
GRADED: 3
};
export const SELECTABLE_STATUS = [
{
label: 'Submitted',
value: STATUS.SUBMITTED
},
{
label: 'In progress',
value: STATUS.IN_PROGRESS
},
{
label: 'Graded',
value: STATUS.GRADED
}
];
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import AddAltIcon from 'carbon-icons-svelte/lib/AddAlt.svelte';
@@ -36,8 +36,9 @@
export let path = '';
export let goBack = () => {};
export let isStudent = true;
export let isFetching = false;
let preview = false;
let preview: boolean = false;
let shouldDeleteExercise = false;
let isSaving = false;
let selectedIndex = 0;
@@ -78,12 +79,11 @@
});
$: $questionnaire?.questions?.length < 1 && handleAddQuestion();
console.log('question', $questionnaire.questions);
</script>
<PageBody bind:isPageNavHidden={isStudent} padding="px-4 overflow-x-hidden">
<div class="bg-gray-100 dark:bg-neutral-800 top-0 z-10 sticky p-2 mb-3">
<Breadcrumb noTrailingSlash class="mb-5">
<Breadcrumb noTrailingSlash>
<BreadcrumbItem href={path}
>{$t('course.navItem.lessons.exercises.all_exercises.heading')}</BreadcrumbItem
>
@@ -157,7 +157,7 @@
{#if !isStudent && !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
{:else}
<ViewMode {preview} {exerciseId} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} />
{/if}
{:else if selectedIndex === 1}
<Analytics bind:exerciseId />
@@ -151,7 +151,7 @@
{/if}
{#if exerciseId}
<Exercise {exerciseId} {goBack} bind:path bind:isStudent />
<Exercise {exerciseId} {goBack} bind:path bind:isStudent {isFetching} />
{:else}
<NewExerciseModal
bind:open
@@ -19,7 +19,8 @@ const initAnswerState = {
currentQuestionIndex: 0,
isFinished: false,
progressValue: 100,
status: STATUS.PENDING
status: STATUS.PENDING,
comment: ''
};
export const questionnaireMetaData = writable(initAnswerState);