diff --git a/src/app/api/models/project.ts b/src/app/api/models/project.ts index b799ed01d6..a5c488ea23 100644 --- a/src/app/api/models/project.ts +++ b/src/app/api/models/project.ts @@ -404,7 +404,7 @@ export class Project extends Entity { const targetTasks = this.unit.taskDefinitionsForGrade(this.targetGrade); // get total value of all tasks assigned to this project - const total = targetTasks.map((td) => td.weighting).reduce((prev, current, idx, array) => prev + current, 0); + const total = targetTasks.map((td) => td.predicted_effort).reduce((prev, current, idx, array) => prev + current, 0); // exit if no tasks or no weights if (targetTasks.length === 0 || total === 0) { @@ -444,7 +444,7 @@ export class Project extends Entity { const weeksElapsed = MappingFunctions.weeksBetween(this.unit.startDate, today); if (weeksElapsed > 0) { const completedTasksWeight = readyOrCompleteTasks - .map((t) => t.definition.weighting) + .map((t) => t.definition.predicted_effort) .reduce((prev, current, idx, arr) => prev + current, 0); completionRate = completedTasksWeight / weeksElapsed; } @@ -465,7 +465,7 @@ export class Project extends Entity { date.getTime(), (targetTasks .filter((taskDef) => taskDef.targetDate >= date) - .map((td) => td.weighting) + .map((td) => td.predicted_effort) .reduce((prev, current) => prev + current, 0) || 0) / total, ]; @@ -475,7 +475,7 @@ export class Project extends Entity { (total - doneTasks .filter((task) => task.submissionDate && task.submissionDate <= date) - .map((task) => task.definition.weighting) + .map((task) => task.definition.predicted_effort) .reduce((prev, current) => prev + current, 0)) / total, ]; @@ -486,7 +486,7 @@ export class Project extends Entity { (total - completedTasks .filter((task) => task.completionDate <= date) - .map((task) => task.definition.weighting) + .map((task) => task.definition.predicted_effort) .reduce((prev, current) => prev + current, 0)) / total, ]; diff --git a/src/app/api/models/task-definition.ts b/src/app/api/models/task-definition.ts index c8f88d1e9e..42f4d804a8 100644 --- a/src/app/api/models/task-definition.ts +++ b/src/app/api/models/task-definition.ts @@ -27,7 +27,8 @@ export class TaskDefinition extends Entity { abbreviation: string; name: string; description: string; - weighting: number; + estimated_hours: number; + predicted_effort: number; targetGrade: number; targetDate: Date; dueDate: Date; @@ -330,4 +331,7 @@ export class TaskDefinition extends Entity { public projectTask(project?: Project): Task | undefined { return project?.tasks?.find((p) => p.definition.id === this.id); } + public get predictEffortUrl(): string { + return `${AppInjector.get(DoubtfireConstants).API_URL}/units/${this.unit.id}/task_definitions/${this.id}/predict_effort`; + } } diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index e9f48f02f9..61f0e4180e 100644 --- a/src/app/api/models/unit.ts +++ b/src/app/api/models/unit.ts @@ -81,6 +81,8 @@ export class Unit extends Entity { d2lMapping: D2lAssessmentMapping; + allowEffortPredictions: boolean; + public readonly learningOutcomesCache: EntityCache = new EntityCache(); public readonly tutorialStreamsCache: EntityCache = diff --git a/src/app/api/services/task-definition.service.ts b/src/app/api/services/task-definition.service.ts index d420f2c1fc..a673eac2f2 100644 --- a/src/app/api/services/task-definition.service.ts +++ b/src/app/api/services/task-definition.service.ts @@ -34,7 +34,8 @@ export class TaskDefinitionService extends CachedEntityService { 'abbreviation', 'name', 'description', - 'weighting', + 'estimated_hours', + 'predicted_effort', 'targetGrade', 'similarityLanguage', 'hasJplagReport', @@ -273,4 +274,10 @@ export class TaskDefinitionService extends CachedEntityService { const httpClient = AppInjector.get(HttpClient); return httpClient.get(url); } + + public predictEffort(taskDefinition: TaskDefinition): Observable { + const http = AppInjector.get(HttpClient); + + return http.post(taskDefinition.predictEffortUrl, {}); + } } diff --git a/src/app/api/services/unit.service.ts b/src/app/api/services/unit.service.ts index 1d6462727d..5493ef1874 100644 --- a/src/app/api/services/unit.service.ts +++ b/src/app/api/services/unit.service.ts @@ -257,6 +257,7 @@ export class UnitService extends CachedEntityService { // 'groupMemberships', - map to group memberships 'feedbackWarningThresholdDays', 'feedbackOverflowThresholdDays', + 'allowEffortPredictions', ); this.mapping.addJsonKey( @@ -288,6 +289,7 @@ export class UnitService extends CachedEntityService { 'allowStudentChangeTutorial', 'feedbackWarningThresholdDays', 'feedbackOverflowThresholdDays', + 'allowEffortPredictions', ); } diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index ae030bcc3e..0669758ac2 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -323,6 +323,7 @@ import {TutorNotesModalComponent} from './common/modals/tutor-notes-modal/tutor- import {FeedbackAppealModalComponent} from './tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component'; import {ConfirmModerationModalComponent} from './units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component'; import {TaskClaimComponent} from './units/states/tasks/inbox/directives/task-claim/task-claim.component'; +import { TaskDefinitionEffortComponent } from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component'; // See https://stackoverflow.com/questions/55721254/how-to-change-mat-datepicker-date-format-to-dd-mm-yyyy-in-simplest-way/58189036#58189036 const MY_DATE_FORMAT = { @@ -407,6 +408,7 @@ const GANTT_CHART_CONFIG = { TaskDefinitionResourcesComponent, TaskDefinitionOverseerComponent, TaskDefinitionScormComponent, + TaskDefinitionEffortComponent, UnitAnalyticsComponent, StudentTutorialSelectComponent, StudentCampusSelectComponent, diff --git a/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html b/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html index 073c69ce2a..21967465e2 100644 --- a/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html +++ b/src/app/units/states/edit/directives/unit-details-editor/unit-details-editor.component.html @@ -201,6 +201,11 @@

Unit Details

Active

Set to false to hide unit from students and tutors.

+ +
+ Allow AI Effort Prediction +

Set to false to disable running AI effort predictions on tasks belonging to this unit.

+
@if (overseerEnabled.value) { diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html index 567758a0ad..84c7fc8a7b 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component.html @@ -111,6 +111,20 @@

Prerequisite Tasks

+
+

Effort Prediction

+

+ Use a regression model to determine the effort required for a task based on task definition values +

+ + +
+ +
+
+ Enable effort prediction +
+ +
+
+ +
+ +
+ + + + + + {{ predictionStatus === 'queued' ? 'Queued...' : + predictionStatus === 'working' ? 'Running prediction...' : + 'Processing...' }} + + + + ✔ Prediction complete + + + + ✖ Prediction failed + +
+ + + +
+ + Predicted Effort + + + +
+
diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component.scss b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component.ts new file mode 100644 index 0000000000..c0742bf4a6 --- /dev/null +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-effort/task-definition-effort.component.ts @@ -0,0 +1,128 @@ +import { Component, Input, OnDestroy } from '@angular/core'; +import { TaskDefinition } from 'src/app/api/models/task-definition'; +import { Unit } from 'src/app/api/models/unit'; +import { SidekiqJobService } from 'src/app/api/services/sidekiq-job.service'; +import { TaskDefinitionService } from 'src/app/api/services/task-definition.service'; +import { Subject, interval, switchMap, takeWhile, Subscription } from 'rxjs'; + +@Component({ + selector: 'f-task-definition-effort', + templateUrl: 'task-definition-effort.component.html', + styleUrls: ['task-definition-effort.component.scss'], +}) +export class TaskDefinitionEffortComponent implements OnDestroy { + @Input() taskDefinition: TaskDefinition; + @Input() staffView: boolean; + + constructor( + private TaskDefinitionService: TaskDefinitionService, + private SidekiqJobService: SidekiqJobService, + ) {} + + enablePrediction = true; + + isPredicting = false; + predictionStatus: 'queued' | 'working' | 'retrying' | 'complete' | 'stopped' | 'failed' | 'interrupted'; + jobId: string | null = null; + + private pollSub?: Subscription; + + public get unit(): Unit { + return this.taskDefinition?.unit; + } + + runPrediction() { + if (!this.taskDefinition?.id) return; + + this.isPredicting = true; + this.predictionStatus = 'queued'; + + this.TaskDefinitionService.predictEffort(this.taskDefinition).subscribe({ + next: (res: any) => { + this.predictionStatus = 'working'; + const jobId = res.job_id; + this.jobId = jobId; + + const resultSubject = new Subject(); + + this.SidekiqJobService.setJob( + jobId, + 'Predicting effort', + resultSubject + ); + + this.pollJob(jobId, resultSubject); + }, + error: () => { + this.predictionStatus = 'failed'; + this.isPredicting = false; + }, + }); + } + + pollJob(jobId: string, resultSubject: Subject) { + this.pollSub = interval(2000).pipe( + switchMap(() => this.SidekiqJobService.getSidekiqJob(jobId)), + takeWhile(job => job.status !== 'complete' && job.status !== 'failed', true) + ).subscribe({ + next: (job) => { + this.predictionStatus = (job.status || 'working').toLowerCase() as any; + + this.SidekiqJobService.setJob( + jobId, + 'Predicting effort', + resultSubject, + job + ); + + if (job.status === 'complete') { + let result: any; + + try { + result = typeof job.result === 'string' + ? JSON.parse(job.result) + : job.result; + } catch { + result = null; + } + + this.taskDefinition.predicted_effort = Number(result?.predicted_effort ?? 0); + + this.predictionStatus = 'complete'; + this.stopPolling(); + } + + if (job.status === 'failed') { + this.predictionStatus = 'failed'; + console.error('Job failed:', job.message || job.result); + this.stopPolling(); + } + }, + error: () => { + this.predictionStatus = 'failed'; + this.cleanup(jobId); + } + }); + } + + stopPolling() { + if (this.pollSub) { + this.pollSub.unsubscribe(); + } + this.isPredicting = false; + } + + cleanup(jobId: string) { + this.isPredicting = false; + if (this.pollSub) { + this.pollSub.unsubscribe(); + } + this.SidekiqJobService.removeJob(jobId); + } + + ngOnDestroy() { + if (this.pollSub) { + this.pollSub.unsubscribe(); + } + } +} diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html index 5ec2551e03..382ca9943a 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html +++ b/src/app/units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-general/task-definition-general.component.html @@ -14,8 +14,8 @@ - Weight - Effort relative to other tasks. - + Estimated hours required for completion. + diff --git a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts index f40f563a7c..5c7640a44d 100644 --- a/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-tasks-editor/unit-task-editor.component.ts @@ -278,7 +278,7 @@ export class UnitTaskEditorComponent implements AfterViewInit { task.startDate = new Date(); task.targetDate = addWeeks(new Date(), 2); task.uploadRequirements = []; - task.weighting = 4; + task.estimated_hours = 4; task.targetGrade = 0; task.restrictStatusUpdates = false; task.plagiarismWarnPct = 80; diff --git a/src/app/visualisations/progress-burndown-chart.coffee b/src/app/visualisations/progress-burndown-chart.coffee index c9cf4bc620..dd4d47d5e2 100644 --- a/src/app/visualisations/progress-burndown-chart.coffee +++ b/src/app/visualisations/progress-burndown-chart.coffee @@ -96,7 +96,7 @@ angular.module('doubtfire.visualisations.progress-burndown-chart', []) tickFormat: xAxisTickFormatDateFormat ticks: 8 yAxis: - axisLabel: "Tasks Remaining" + axisLabel: "Effort Remaining" tickFormat: yAxisTickFormatPercentFormat color: colorFunction legendColor: colorFunction