Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions src/app/api/models/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
];

Expand All @@ -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,
];
Expand All @@ -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,
];
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/models/task-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
}
}
2 changes: 2 additions & 0 deletions src/app/api/models/unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export class Unit extends Entity {

d2lMapping: D2lAssessmentMapping;

allowEffortPredictions: boolean;

public readonly learningOutcomesCache: EntityCache<LearningOutcome> =
new EntityCache<LearningOutcome>();
public readonly tutorialStreamsCache: EntityCache<TutorialStream> =
Expand Down
9 changes: 8 additions & 1 deletion src/app/api/services/task-definition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export class TaskDefinitionService extends CachedEntityService<TaskDefinition> {
'abbreviation',
'name',
'description',
'weighting',
'estimated_hours',
'predicted_effort',
'targetGrade',
'similarityLanguage',
'hasJplagReport',
Expand Down Expand Up @@ -273,4 +274,10 @@ export class TaskDefinitionService extends CachedEntityService<TaskDefinition> {
const httpClient = AppInjector.get(HttpClient);
return httpClient.get<SidekiqJob>(url);
}

public predictEffort(taskDefinition: TaskDefinition): Observable<SidekiqJob> {
const http = AppInjector.get(HttpClient);

return http.post<SidekiqJob>(taskDefinition.predictEffortUrl, {});
}
}
2 changes: 2 additions & 0 deletions src/app/api/services/unit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export class UnitService extends CachedEntityService<Unit> {
// 'groupMemberships', - map to group memberships
'feedbackWarningThresholdDays',
'feedbackOverflowThresholdDays',
'allowEffortPredictions',
);

this.mapping.addJsonKey(
Expand Down Expand Up @@ -288,6 +289,7 @@ export class UnitService extends CachedEntityService<Unit> {
'allowStudentChangeTutorial',
'feedbackWarningThresholdDays',
'feedbackOverflowThresholdDays',
'allowEffortPredictions',
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/doubtfire-angular.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -407,6 +408,7 @@ const GANTT_CHART_CONFIG = {
TaskDefinitionResourcesComponent,
TaskDefinitionOverseerComponent,
TaskDefinitionScormComponent,
TaskDefinitionEffortComponent,
UnitAnalyticsComponent,
StudentTutorialSelectComponent,
StudentCampusSelectComponent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ <h3>Unit Details</h3>
<mat-slide-toggle [(ngModel)]="unit.active">Active</mat-slide-toggle>
<p>Set to false to hide unit from students and tutors.</p>
</div>

<div>
<mat-slide-toggle [(ngModel)]="unit.allowEffortPredictions">Allow AI Effort Prediction</mat-slide-toggle>
<p>Set to false to disable running AI effort predictions on tasks belonging to this unit.</p>
</div>
</mat-card>

@if (overseerEnabled.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ <h3>Prerequisite Tasks</h3>
</section>
<mat-divider class="my-[25px]"></mat-divider>

<section
class="mb-0 rounded-xl bg-white p-4 scroll-mt-28"
#sectionElement
data-section-id="effort-prediction"
>
<h3>Effort Prediction</h3>
<p class="font-normal text-gray-500 pb-2">
Use a regression model to determine the effort required for a task based on task definition values
</p>
<f-task-definition-effort [taskDefinition]="taskDefinition" [staffView]="true">
</f-task-definition-effort>
</section>

<mat-divider class="my-[25px]"></mat-divider>
<section
class="mb-0 rounded-xl bg-white p-4 scroll-mt-28"
#sectionElement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type TaskDefinitionSectionId =
| 'upload-requirements'
| 'task-resources'
| 'prerequisite-tasks'
| 'effort-prediction'
| 'discussion-prompts'
| 'task-assessment-automation'
| 'scorm-test'
Expand Down Expand Up @@ -58,6 +59,7 @@ export class TaskDefinitionEditorComponent implements OnInit, AfterViewInit, OnC
{id: 'upload-requirements', label: 'Upload Requirements'},
{id: 'task-resources', label: 'Task Resources'},
{id: 'prerequisite-tasks', label: 'Prerequisite Tasks'},
{id: 'effort-prediction', label: 'Effort Prediction'},
{id: 'discussion-prompts', label: 'Discussion Prompts'},
{id: 'task-assessment-automation', label: 'Task Assessment Automation'},
{id: 'scorm-test', label: 'SCORM Test'},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!-- Row 1: Enable prediction -->
<div>
<mat-checkbox [(ngModel)]="enablePrediction">Enable effort prediction</mat-checkbox>
</div>

<div class="space-y-8">
<div class="flex gap-4 items-center">
<!-- Row 2: Button -->
<div>
<button
mat-raised-button
color="primary"
(click)="runPrediction()"
[disabled]="!enablePrediction || isPredicting"
>
Run prediction
</button>
</div>

<mat-progress-spinner
*ngIf="isPredicting"
mode="indeterminate"
diameter="24">
</mat-progress-spinner>

<span *ngIf="isPredicting">
{{ predictionStatus === 'queued' ? 'Queued...' :
predictionStatus === 'working' ? 'Running prediction...' :
'Processing...' }}
</span>

<span *ngIf="!isPredicting && predictionStatus === 'complete'" class="text-green-600">
✔ Prediction complete
</span>

<span *ngIf="!isPredicting && predictionStatus === 'failed'" class="text-red-600">
✖ Prediction failed
</span>
</div>


<!-- Row 3: Manual input -->
<div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Predicted Effort</mat-label>

<input
matInput
type="number"
[(ngModel)]="taskDefinition.predicted_effort"
[disabled]="enablePrediction || isPredicting"
min="1"
max="100"
/>
</mat-form-field>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -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<any>();

this.SidekiqJobService.setJob(
jobId,
'Predicting effort',
resultSubject
);

this.pollJob(jobId, resultSubject);
},
error: () => {
this.predictionStatus = 'failed';
this.isPredicting = false;
},
});
}

pollJob(jobId: string, resultSubject: Subject<any>) {
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
</mat-form-field>

<mat-form-field appearance="outline" class="flex-grow">
<mat-label>Weight - Effort relative to other tasks.</mat-label>
<input matInput type="number" [(ngModel)]="taskDefinition.weighting" required />
<mat-label>Estimated hours required for completion.</mat-label>
<input matInput type="number" [(ngModel)]="taskDefinition.estimated_hours" required />
</mat-form-field>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/app/visualisations/progress-burndown-chart.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading