Skip to content

Commit 8fafec4

Browse files
Merge pull request #7382 from christianbeeznest/GH-7307
Assignment: Add AI task grader - refs #7307
2 parents 85bee39 + 2bb44ad commit 8fafec4

10 files changed

Lines changed: 2083 additions & 155 deletions

assets/vue/components/assignments/CorrectAndRateModal.vue

Lines changed: 489 additions & 27 deletions
Large diffs are not rendered by default.

assets/vue/components/assignments/TeacherSubmissionList.vue

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
@page="onPage"
1212
@sort="onSort"
1313
>
14-
<!-- Full name -->
1514
<Column
1615
field="user.fullName"
1716
:header="t('Full name')"
@@ -126,13 +125,23 @@
126125
@click="saveCorrection(data)"
127126
type="primary"
128127
/>
128+
<BaseButton
129+
v-if="canUseAiTaskGrader"
130+
icon="robot"
131+
size="normal"
132+
only-icon
133+
:label="t('AI grade')"
134+
:class="actionBtnClass"
135+
@click="openCorrectAndRate(data)"
136+
type="black"
137+
/>
129138
<BaseButton
130139
icon="reply-all"
131140
size="normal"
132141
only-icon
133142
:label="t('Correct and rate')"
134143
:class="actionBtnClass"
135-
@click="correctAndRate(data)"
144+
@click="openCorrectAndRate(data)"
136145
type="success"
137146
/>
138147
<BaseButton
@@ -212,7 +221,7 @@
212221
</template>
213222

214223
<script setup>
215-
import { nextTick, watch, reactive, ref } from "vue"
224+
import { nextTick, watch, reactive, ref, computed, onMounted } from "vue"
216225
import { useI18n } from "vue-i18n"
217226
import Column from "primevue/column"
218227
import BaseButton from "../basecomponents/BaseButton.vue"
@@ -226,6 +235,11 @@ import { RESOURCE_LINK_DRAFT, RESOURCE_LINK_PUBLISHED } from "../../constants/en
226235
import EditStudentSubmissionForm from "./EditStudentSubmissionForm.vue"
227236
import CorrectAndRateModal from "./CorrectAndRateModal.vue"
228237
import MoveSubmissionModal from "./MoveSubmissionModal.vue"
238+
import { useCidReqStore } from "../../store/cidReq"
239+
import { storeToRefs } from "pinia"
240+
import { usePlatformConfig } from "../../store/platformConfig"
241+
import { useCourseSettings } from "../../store/courseSettingStore"
242+
import { useSecurityStore } from "../../store/securityStore"
229243
230244
const props = defineProps({
231245
assignmentId: {
@@ -254,12 +268,54 @@ const editingItem = ref(null)
254268
const showCorrectAndRateDialog = ref(false)
255269
const correctingItem = ref(null)
256270
const showMoveDialog = ref(false)
257-
258-
/**
259-
* Make action buttons visually bigger without relying on unknown "size" enums.
260-
* This keeps current BaseButton behavior and only increases hit area.
261-
*/
262271
const actionBtnClass = "w-10 h-10 !p-2"
272+
const cidReqStore = useCidReqStore()
273+
const { course, session } = storeToRefs(cidReqStore)
274+
275+
const platform = usePlatformConfig()
276+
const courseSettingsStore = useCourseSettings()
277+
const securityStore = useSecurityStore()
278+
279+
async function loadCourseSettingsIfPossible() {
280+
const courseId = course.value?.id
281+
const sessionId = session.value?.id
282+
283+
if (!courseId) return
284+
285+
try {
286+
await courseSettingsStore.loadCourseSettings(courseId, sessionId)
287+
} catch (err) {
288+
console.error("[Assignments] loadCourseSettings FAILED:", err)
289+
}
290+
}
291+
292+
onMounted(async () => {
293+
await loadCourseSettingsIfPossible()
294+
})
295+
296+
watch(
297+
() => [course.value?.id, session.value?.id],
298+
async () => {
299+
await loadCourseSettingsIfPossible()
300+
},
301+
)
302+
303+
const aiHelpersEnabled = computed(() => {
304+
const v = String(platform.getSetting("ai_helpers.enable_ai_helpers"))
305+
return v === "true"
306+
})
307+
308+
const taskGraderEnabled = computed(() => {
309+
const v = courseSettingsStore?.getSetting?.("task_grader")
310+
return String(v) === "true"
311+
})
312+
313+
const canUseAiTaskGrader = computed(() => {
314+
// Only teachers/admins and not in student view
315+
const canEdit = !!(securityStore.isTeacher || securityStore.isCourseAdmin || securityStore.isAdmin)
316+
const notStudentView = !platform.isStudentViewActive
317+
return !!(canEdit && notStudentView && aiHelpersEnabled.value && taskGraderEnabled.value)
318+
})
263319
264320
watch(
265321
loadParams,
@@ -270,14 +326,28 @@ watch(
270326
{ deep: true, immediate: true },
271327
)
272328
329+
function buildOrderFromSort() {
330+
// PrimeVue multi sort meta => API expects { field: "asc|desc" }
331+
const order = {}
332+
const meta = Array.isArray(sortFields.value) ? sortFields.value : []
333+
meta.forEach((s) => {
334+
if (!s?.field) return
335+
order[s.field] = s.order === 1 ? "asc" : "desc"
336+
})
337+
if (!Object.keys(order).length) {
338+
order.sentDate = "desc"
339+
}
340+
return order
341+
}
342+
273343
async function loadData() {
274344
loading.value = true
275345
try {
276346
const response = await cStudentPublicationService.getAssignmentDetailForTeacher({
277347
assignmentId: props.assignmentId,
278348
page: loadParams.page,
279349
itemsPerPage: loadParams.itemsPerPage,
280-
order: { sentDate: "desc" },
350+
order: buildOrderFromSort(),
281351
})
282352
283353
submissions.value = response["hydra:member"]
@@ -295,13 +365,9 @@ function onPage(event) {
295365
}
296366
297367
function onSort(event) {
298-
Object.keys(loadParams)
299-
.filter((key) => key.startsWith("order["))
300-
.forEach((key) => delete loadParams[key])
301-
302-
event.multiSortMeta.forEach((sortItem) => {
303-
loadParams[`order[${sortItem.field}]`] = sortItem.order === 1 ? "asc" : "desc"
304-
})
368+
if (event?.multiSortMeta) {
369+
sortFields.value = event.multiSortMeta
370+
}
305371
}
306372
307373
/**
@@ -438,7 +504,7 @@ async function deleteSubmission(item) {
438504
}
439505
}
440506
441-
function correctAndRate(item) {
507+
function openCorrectAndRate(item) {
442508
correctingItem.value = null
443509
nextTick(() => {
444510
correctingItem.value = item
@@ -447,11 +513,7 @@ function correctAndRate(item) {
447513
}
448514
449515
function openCommentDialog(item) {
450-
correctingItem.value = null
451-
nextTick(() => {
452-
correctingItem.value = item
453-
showCorrectAndRateDialog.value = true
454-
})
516+
openCorrectAndRate(item)
455517
}
456518
457519
function moveSubmission(item) {

assets/vue/services/cstudentpublication.js

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async function getAssignmentMetadata(assignmentId, cid, sid = 0, gid = 0) {
4646

4747
async function getAssignmentDetail({ assignmentId, page = 1, itemsPerPage = 10, order = {} }) {
4848
const params = {
49+
...buildCidParams(),
4950
page,
5051
itemsPerPage,
5152
...Object.fromEntries(Object.entries(order).map(([key, val]) => [`order[${key}]`, val])),
@@ -56,6 +57,7 @@ async function getAssignmentDetail({ assignmentId, page = 1, itemsPerPage = 10,
5657

5758
async function getAssignmentDetailForTeacher({ assignmentId, page = 1, itemsPerPage = 10, order = {} }) {
5859
const params = {
60+
...buildCidParams(),
5961
page,
6062
itemsPerPage,
6163
...Object.fromEntries(Object.entries(order).map(([key, val]) => [`order[${key}]`, val])),
@@ -72,18 +74,23 @@ async function uploadStudentAssignment(formData, queryParams) {
7274
}
7375

7476
async function getStudentProgress(queryParams = {}) {
75-
const params = new URLSearchParams(queryParams).toString()
77+
const merged = { ...buildCidParams(), ...queryParams }
78+
const params = new URLSearchParams(merged).toString()
7679
const url = params ? `/assignments/progress?${params}` : `/assignments/progress`
7780
const response = await axios.get(url)
7881
return response.data
7982
}
8083

8184
async function deleteAssignmentSubmission(submissionId) {
82-
await axios.delete(`/assignments/submissions/${submissionId}`)
85+
await axios.delete(`/assignments/submissions/${submissionId}`, {
86+
params: buildCidParams(),
87+
})
8388
}
8489

8590
async function updateSubmission(id, data) {
86-
await axios.patch(`/assignments/submissions/${id}/edit`, data)
91+
await axios.patch(`/assignments/submissions/${id}/edit`, data, {
92+
params: buildCidParams(),
93+
})
8794
}
8895

8996
async function uploadComment(submissionId, parentResourceNodeId, formData, sendMail = false) {
@@ -113,13 +120,17 @@ async function loadComments(submissionId) {
113120
})
114121
return response.data["hydra:member"] || []
115122
} catch (error) {
116-
console.error("Failed to load comments", error)
123+
console.error("[Assignments] Failed to load comments", error)
117124
return []
118125
}
119126
}
120127

121128
async function moveSubmission(submissionId, newAssignmentId) {
122-
const response = await axios.patch(`/assignments/submissions/${submissionId}/move`, { newAssignmentId })
129+
const response = await axios.patch(
130+
`/assignments/submissions/${submissionId}/move`,
131+
{ newAssignmentId },
132+
{ params: buildCidParams() },
133+
)
123134
return response.data
124135
}
125136

@@ -130,18 +141,19 @@ async function getUnsubmittedUsers(assignmentId) {
130141
}
131142

132143
async function sendEmailToUnsubmitted(assignmentId, queryParams = {}) {
133-
const params = new URLSearchParams(queryParams).toString()
144+
const merged = { ...buildCidParams(), ...queryParams }
145+
const params = new URLSearchParams(merged).toString()
134146
const response = await axios.post(`/assignments/${assignmentId}/unsubmitted-users/email?${params}`)
135147
return response.data
136148
}
137149

138150
async function deleteAllCorrections(assignmentId, cid, sid = 0) {
139-
const params = { cid, ...(sid && { sid }) }
151+
const params = { ...buildCidParams(), cid, ...(sid && { sid }) }
140152
await axios.delete(`/assignments/${assignmentId}/corrections/delete`, { params })
141153
}
142154

143155
async function exportAssignmentPdf(assignmentId, cid, sid = 0, gid = 0) {
144-
const params = { cid, ...(sid && { sid }), ...(gid && { gid }) }
156+
const params = { ...buildCidParams(), cid, ...(sid && { sid }), ...(gid && { gid }) }
145157
const response = await axios.get(`/assignments/${assignmentId}/export/pdf`, {
146158
params,
147159
responseType: "blob",
@@ -151,6 +163,7 @@ async function exportAssignmentPdf(assignmentId, cid, sid = 0, gid = 0) {
151163

152164
async function downloadAssignments(assignmentId) {
153165
const response = await axios.get(`/assignments/${assignmentId}/download-package`, {
166+
params: buildCidParams(),
154167
responseType: "blob",
155168
})
156169
return response.data
@@ -161,6 +174,7 @@ async function uploadCorrectionsPackage(assignmentId, file) {
161174
formData.append("file", file)
162175

163176
const response = await axios.post(`/assignments/${assignmentId}/upload-corrections-package`, formData, {
177+
params: buildCidParams(),
164178
headers: { "Content-Type": "multipart/form-data" },
165179
})
166180

@@ -171,6 +185,36 @@ async function updateScore(iid, qualification) {
171185
return axios.put(`${ENTRYPOINT}c_student_publications/${iid}`, { qualification }, { params: buildCidParams() })
172186
}
173187

188+
async function aiGradeSubmission(submissionId, payload = {}) {
189+
const response = await axios.post(`/assignments/submissions/${submissionId}/ai-grade`, payload, {
190+
headers: { "Content-Type": "application/json" },
191+
params: buildCidParams(),
192+
})
193+
return response.data
194+
}
195+
196+
async function getAiTextProviders() {
197+
const { data } = await axios.get("/assignments/ai/text-providers")
198+
return data
199+
}
200+
201+
async function getAiTaskGraderDefaultPrompt(submissionId, params = {}) {
202+
const { data } = await axios.get(`/assignments/submissions/${submissionId}/ai-task-grader-default-prompt`, {
203+
params,
204+
})
205+
return data
206+
}
207+
208+
async function aiTaskGrade(submissionId, payload) {
209+
const { data } = await axios.post(`/assignments/submissions/${submissionId}/ai-task-grade`, payload)
210+
return data
211+
}
212+
213+
async function getAiTaskGradeCapabilities(submissionId) {
214+
const { data } = await this.api.get(`/assignments/submissions/${submissionId}/ai-task-grade-capabilities`)
215+
return data
216+
}
217+
174218
export default {
175219
...makeService("c_student_publications"),
176220
findStudentAssignments,
@@ -192,4 +236,9 @@ export default {
192236
uploadCorrectionsPackage,
193237
updateScore,
194238
updatePublication,
239+
aiGradeSubmission,
240+
getAiTextProviders,
241+
getAiTaskGraderDefaultPrompt,
242+
aiTaskGrade,
243+
getAiTaskGradeCapabilities,
195244
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\AiProvider;
8+
9+
interface AiDocumentProcessProviderInterface
10+
{
11+
/**
12+
* Process a document and return feedback text.
13+
*
14+
* @param string $prompt Prompt that includes context and grading instructions
15+
* @param string $toolName Tag, e.g. 'task_grader'
16+
* @param string $filename Original file name, e.g. "submission.pdf"
17+
* @param string $mimeType e.g. "application/pdf"
18+
* @param string $binaryContent Raw file bytes (NOT base64)
19+
* @param array $options Optional overrides (model, temperature, max_output_tokens, etc.)
20+
*/
21+
public function processDocument(
22+
string $prompt,
23+
string $toolName,
24+
string $filename,
25+
string $mimeType,
26+
string $binaryContent,
27+
array $options = []
28+
): ?string;
29+
}

src/CoreBundle/AiProvider/AiProviderFactory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ public function __construct(
163163
}
164164
}
165165

166+
public function create(string $provider, string $serviceType = 'text'): object
167+
{
168+
return $this->getProvider($provider, $serviceType);
169+
}
170+
166171
public function hasProvidersForType(string $serviceType): bool
167172
{
168173
return !empty($this->providersByType[$serviceType] ?? []);

0 commit comments

Comments
 (0)