From d065a694ad9d356a96eb593a74970d6d951a5e8d Mon Sep 17 00:00:00 2001 From: Rashi Date: Wed, 29 Apr 2026 17:55:24 +1000 Subject: [PATCH 1/3] feat: add task prioritisation service --- app/api/api_root.rb | 1 + app/api/task_prioritization_api.rb | 108 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 app/api/task_prioritization_api.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index e36e21226..e502f036f 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -105,6 +105,7 @@ class ApiRoot < Grape::API mount MarkingSessionsApi mount DiscussionPromptsApi mount OverseerStepsApi + mount TaskPrioritizationApi mount Feedback::FeedbackChipApi diff --git a/app/api/task_prioritization_api.rb b/app/api/task_prioritization_api.rb new file mode 100644 index 000000000..4d25285fd --- /dev/null +++ b/app/api/task_prioritization_api.rb @@ -0,0 +1,108 @@ +require 'grape' + +class TaskPrioritizationApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers DbHelpers + + before do + authenticated? + end + + desc 'Get prioritized task recommendations for a student', + detail: 'Returns a ranked list of tasks across all active enrolled units based on deadline, effort, and workload scoring.' + + get '/tasks/recommended' do + tasks = fetch_active_tasks + workload_score = calculate_workload_score(tasks) + + results = tasks.map { |task| build_task_response(task, workload_score) } + + sorted = results.sort_by { |t| -t[:priority_score] } + present sorted + rescue StandardError => e + Rails.logger.error "TaskPrioritizationApi Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + error!({ error: 'An unexpected error occurred' }, 500) + end + + helpers do + # Fetch Active Tasks + def fetch_active_tasks + Task + .joins(project: :unit) + .joins(:task_definition) + .where(projects: { user_id: current_user&.id, enrolled: true }) + .where(units: { active: true }) + .where.not(task_status_id: 2) # completed + end + + # Build Response Object + def build_task_response(task, workload_score) + deadline = deadline_score(task) + effort = effort_score(task) + + priority = (0.5 * deadline) + (0.3 * effort) + (0.2 * workload_score) + + { + task_id: task.id, + task_name: task.task_definition.name, + unit_id: task.project.unit_id, + priority_score: priority.round(2) + } + end + + # Deadline Score + def deadline_score(task) + due_date = task.task_definition.due_date + return 0 unless due_date + + days_left = (due_date.to_date - Time.zone.today).to_i + + return 100 if days_left <= 1 + return 80 if days_left <= 3 + return 60 if days_left <= 7 + return 40 if days_left <= 14 + + 20 + end + + # Effort Score (Temporary - to be replaced by AI Task Prioritisation Service) + def effort_score(task) + weighting = task.task_definition.weighting.to_f + + return 30 if weighting <= 10 + return 50 if weighting <= 20 + return 70 if weighting <= 40 + + 90 + end + + # Workload Score + def calculate_workload_score(tasks) + total_tasks = tasks.count + + avg_target_grade = Project + .where(user_id: current_user&.id, enrolled: true) + .average(:target_grade) + .to_f + + task_pressure_score = + case total_tasks + when 0..4 then 30 + when 5..9 then 60 + else 90 + end + + target_grade_score = + case avg_target_grade.round + when 3 then 90 # High Distinction + when 2 then 75 # Distinction + when 1 then 60 # Credit + else 40 # Pass + end + + ((0.6 * task_pressure_score) + (0.4 * target_grade_score)).round + end + end +end From f4196cb3cfeda2e7047bd73a8af9683a4426cb39 Mon Sep 17 00:00:00 2001 From: NIETHIN SHREEDHARAN Date: Sun, 10 May 2026 14:45:16 +1000 Subject: [PATCH 2/3] refactor(task-prioritization): extract service object, fix bugs, add tests Stacks on top of #94. Pulls scoring logic into TaskPrioritizationService, fixes status filter to resolve completed statuses by name (not magic ID 2), fixes N+1 with includes, handles nil edge cases in target_grade and weighting, and adds unit + API tests covering scoring, filtering, and edge cases. Refs #94 --- app/api/task_prioritization_api.rb | 101 +--------- app/services/task_prioritization_service.rb | 154 +++++++++++++++ test/api/task_prioritization_api_test.rb | 74 +++++++ test/api/task_prioritization_service_test.rb | 197 +++++++++++++++++++ 4 files changed, 431 insertions(+), 95 deletions(-) create mode 100644 app/services/task_prioritization_service.rb create mode 100644 test/api/task_prioritization_api_test.rb create mode 100644 test/api/task_prioritization_service_test.rb diff --git a/app/api/task_prioritization_api.rb b/app/api/task_prioritization_api.rb index 4d25285fd..92edafeb9 100644 --- a/app/api/task_prioritization_api.rb +++ b/app/api/task_prioritization_api.rb @@ -3,106 +3,17 @@ class TaskPrioritizationApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers - helpers DbHelpers before do authenticated? end - desc 'Get prioritized task recommendations for a student', - detail: 'Returns a ranked list of tasks across all active enrolled units based on deadline, effort, and workload scoring.' - - get '/tasks/recommended' do - tasks = fetch_active_tasks - workload_score = calculate_workload_score(tasks) - - results = tasks.map { |task| build_task_response(task, workload_score) } - - sorted = results.sort_by { |t| -t[:priority_score] } - present sorted - rescue StandardError => e - Rails.logger.error "TaskPrioritizationApi Error: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - error!({ error: 'An unexpected error occurred' }, 500) + desc 'Get prioritised task recommendations for the current student' do + detail 'Returns a ranked list of tasks across all active enrolled units, ' \ + 'scored by deadline urgency, estimated effort, and overall workload.' end - - helpers do - # Fetch Active Tasks - def fetch_active_tasks - Task - .joins(project: :unit) - .joins(:task_definition) - .where(projects: { user_id: current_user&.id, enrolled: true }) - .where(units: { active: true }) - .where.not(task_status_id: 2) # completed - end - - # Build Response Object - def build_task_response(task, workload_score) - deadline = deadline_score(task) - effort = effort_score(task) - - priority = (0.5 * deadline) + (0.3 * effort) + (0.2 * workload_score) - - { - task_id: task.id, - task_name: task.task_definition.name, - unit_id: task.project.unit_id, - priority_score: priority.round(2) - } - end - - # Deadline Score - def deadline_score(task) - due_date = task.task_definition.due_date - return 0 unless due_date - - days_left = (due_date.to_date - Time.zone.today).to_i - - return 100 if days_left <= 1 - return 80 if days_left <= 3 - return 60 if days_left <= 7 - return 40 if days_left <= 14 - - 20 - end - - # Effort Score (Temporary - to be replaced by AI Task Prioritisation Service) - def effort_score(task) - weighting = task.task_definition.weighting.to_f - - return 30 if weighting <= 10 - return 50 if weighting <= 20 - return 70 if weighting <= 40 - - 90 - end - - # Workload Score - def calculate_workload_score(tasks) - total_tasks = tasks.count - - avg_target_grade = Project - .where(user_id: current_user&.id, enrolled: true) - .average(:target_grade) - .to_f - - task_pressure_score = - case total_tasks - when 0..4 then 30 - when 5..9 then 60 - else 90 - end - - target_grade_score = - case avg_target_grade.round - when 3 then 90 # High Distinction - when 2 then 75 # Distinction - when 1 then 60 # Credit - else 40 # Pass - end - - ((0.6 * task_pressure_score) + (0.4 * target_grade_score)).round - end + get '/tasks/recommended' do + results = TaskPrioritizationService.new(current_user).call + present results end end diff --git a/app/services/task_prioritization_service.rb b/app/services/task_prioritization_service.rb new file mode 100644 index 000000000..e9285649f --- /dev/null +++ b/app/services/task_prioritization_service.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# +# Computes a prioritised list of tasks for a student across their +# active enrolled units. +# +# Score formula: +# priority = (W_DEADLINE * deadline_score) +# + (W_EFFORT * effort_score) +# + (W_WORKLOAD * workload_score) +# +# The effort_score is a heuristic placeholder based on task weighting. +# It is intended to be replaced by an AI-driven estimator; keep the +# `effort_score_for(task)` method as the integration seam. +# +class TaskPrioritizationService + # --- Scoring weights ---------------------------------------------------- + W_DEADLINE = 0.5 + W_EFFORT = 0.3 + W_WORKLOAD = 0.2 + + # --- Deadline buckets (days remaining => score) ------------------------- + DEADLINE_BUCKETS = [ + [1, 100], + [3, 80], + [7, 60], + [14, 40] + ].freeze + DEADLINE_DEFAULT = 20 + DEADLINE_NONE = 0 # task has no due_date + + # --- Effort buckets (weighting => score) -------------------------------- + EFFORT_BUCKETS = [ + [10, 30], + [20, 50], + [40, 70] + ].freeze + EFFORT_DEFAULT = 90 + + # --- Workload sub-scoring ------------------------------------------------ + TASK_PRESSURE = { light: 30, medium: 60, heavy: 90 }.freeze + TASK_PRESSURE_THRESHOLDS = { light: 4, medium: 9 }.freeze # <=4 light, <=9 medium, else heavy + + # target_grade enum: 0=Pass, 1=Credit, 2=Distinction, 3=HD + TARGET_GRADE_SCORE = { 0 => 40, 1 => 60, 2 => 75, 3 => 90 }.freeze + TARGET_GRADE_DEFAULT = 40 + + WORKLOAD_PRESSURE_WEIGHT = 0.6 + WORKLOAD_GRADE_WEIGHT = 0.4 + + def initialize(user) + @user = user + end + + # Public: returns an Array of Hashes sorted by descending priority_score. + def call + tasks = active_tasks_for_user + return [] if tasks.empty? + + workload = workload_score(tasks) + + tasks + .map { |t| score_task(t, workload) } + .sort_by { |t| -t[:priority_score] } + end + + # Exposed for testing and for downstream replacement by AI service. + def effort_score_for(task) + weighting = task.task_definition&.weighting.to_f + bucket_lookup(weighting, EFFORT_BUCKETS, EFFORT_DEFAULT) + end + + def deadline_score_for(task) + due_date = task.task_definition&.due_date + return DEADLINE_NONE unless due_date + + days_left = (due_date.to_date - Time.zone.today).to_i + bucket_lookup(days_left, DEADLINE_BUCKETS, DEADLINE_DEFAULT) + end + + private + + attr_reader :user + + # Tasks that require *student action* across active enrolments. + # Excludes statuses that are complete or awaiting staff assessment. + def active_tasks_for_user + Task + .joins(project: :unit) + .includes(:task_definition, project: :unit) + .where(projects: { user_id: user.id, enrolled: true }) + .where(units: { active: true }) + .where.not(task_status_id: completed_status_ids) + .to_a + end + + # Status IDs we consider "no further student action needed". + # Resolved by name from the TaskStatus table — robust against seed reordering. + def completed_status_ids + @completed_status_ids ||= TaskStatus + .where(name: %w[complete discuss demonstrate]) + .pluck(:id) + end + + def score_task(task, workload) + deadline = deadline_score_for(task) + effort = effort_score_for(task) + + priority = (W_DEADLINE * deadline) + + (W_EFFORT * effort) + + (W_WORKLOAD * workload) + + { + task_id: task.id, + task_name: task.task_definition&.name, + unit_id: task.project.unit_id, + project_id: task.project_id, + deadline_score: deadline, + effort_score: effort, + workload_score: workload, + priority_score: priority.round(2) + } + end + + def workload_score(tasks) + pressure = task_pressure_score(tasks.length) + grade = target_grade_score + + ((WORKLOAD_PRESSURE_WEIGHT * pressure) + + (WORKLOAD_GRADE_WEIGHT * grade)).round + end + + def task_pressure_score(total_tasks) + return TASK_PRESSURE[:light] if total_tasks <= TASK_PRESSURE_THRESHOLDS[:light] + return TASK_PRESSURE[:medium] if total_tasks <= TASK_PRESSURE_THRESHOLDS[:medium] + + TASK_PRESSURE[:heavy] + end + + def target_grade_score + avg = Project + .where(user_id: user.id, enrolled: true) + .average(:target_grade) + return TARGET_GRADE_DEFAULT if avg.nil? + + TARGET_GRADE_SCORE.fetch(avg.round, TARGET_GRADE_DEFAULT) + end + + # Walks ascending [threshold, score] pairs and returns the first matching score. + def bucket_lookup(value, buckets, default) + buckets.each { |threshold, score| return score if value <= threshold } + default + end +end diff --git a/test/api/task_prioritization_api_test.rb b/test/api/task_prioritization_api_test.rb new file mode 100644 index 000000000..65e5e365a --- /dev/null +++ b/test/api/task_prioritization_api_test.rb @@ -0,0 +1,74 @@ +require 'test_helper' + +class TaskPrioritizationApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + + def app + Rails.application + end + + setup do + @user = FactoryBot.create(:user, :student) + @unit = FactoryBot.create(:unit, active: true) + @project = FactoryBot.create( + :project, user: @user, unit: @unit, enrolled: true, target_grade: 1 + ) + end + + test 'returns 401 when unauthenticated' do + get '/api/tasks/recommended' + assert_equal 401, last_response.status + end + + test 'returns 200 and an empty array when student has no tasks' do + get_with_auth '/api/tasks/recommended', user: @user + + assert_equal 200, last_response.status + assert_equal [], JSON.parse(last_response.body) + end + + test 'returns prioritised tasks for current student only' do + create_task(@project, weighting: 10, due_in_days: 2) + create_task(@project, weighting: 30, due_in_days: 10) + + get_with_auth '/api/tasks/recommended', user: @user + + assert_equal 200, last_response.status + body = JSON.parse(last_response.body) + assert_equal 2, body.length + + # Sorted descending by priority_score + scores = body.map { |t| t['priority_score'] } + assert_equal scores.sort.reverse, scores + end + + test 'response items have expected schema' do + create_task(@project, weighting: 10, due_in_days: 5) + + get_with_auth '/api/tasks/recommended', user: @user + item = JSON.parse(last_response.body).first + + %w[task_id task_name unit_id project_id deadline_score + effort_score workload_score priority_score].each do |key| + assert item.key?(key), "response missing key #{key}" + end + end + + private + + def get_with_auth(path, user:) + token = user.auth_token # whatever the project uses; adjust if needed + header 'auth_token', token + get path + end + + def create_task(project, weighting:, due_in_days:) + task_definition = FactoryBot.create( + :task_definition, + unit: project.unit, + weighting: weighting, + due_date: Time.zone.today + due_in_days.days + ) + FactoryBot.create(:task, project: project, task_definition: task_definition) + end +end diff --git a/test/api/task_prioritization_service_test.rb b/test/api/task_prioritization_service_test.rb new file mode 100644 index 000000000..1f42954b9 --- /dev/null +++ b/test/api/task_prioritization_service_test.rb @@ -0,0 +1,197 @@ +require 'test_helper' + +class TaskPrioritizationServiceTest < ActiveSupport::TestCase + setup do + @user = FactoryBot.create(:user, :student) + @unit = FactoryBot.create(:unit, active: true) + @project = FactoryBot.create( + :project, + user: @user, + unit: @unit, + enrolled: true, + target_grade: 2 # Distinction + ) + end + + # --------------------------------------------------------------------------- + # deadline_score_for + # --------------------------------------------------------------------------- + + test 'deadline_score returns 100 when due tomorrow' do + task = build_task_with_due_date(Time.zone.today + 1.day) + service = TaskPrioritizationService.new(@user) + assert_equal 100, service.deadline_score_for(task) + end + + test 'deadline_score returns 80 when due in 3 days' do + task = build_task_with_due_date(Time.zone.today + 3.days) + service = TaskPrioritizationService.new(@user) + assert_equal 80, service.deadline_score_for(task) + end + + test 'deadline_score returns 60 when due in a week' do + task = build_task_with_due_date(Time.zone.today + 7.days) + service = TaskPrioritizationService.new(@user) + assert_equal 60, service.deadline_score_for(task) + end + + test 'deadline_score returns 40 when due in 2 weeks' do + task = build_task_with_due_date(Time.zone.today + 14.days) + service = TaskPrioritizationService.new(@user) + assert_equal 40, service.deadline_score_for(task) + end + + test 'deadline_score returns 20 when far in the future' do + task = build_task_with_due_date(Time.zone.today + 90.days) + service = TaskPrioritizationService.new(@user) + assert_equal 20, service.deadline_score_for(task) + end + + test 'deadline_score returns 0 when due_date is nil' do + task = build_task_with_due_date(nil) + service = TaskPrioritizationService.new(@user) + assert_equal 0, service.deadline_score_for(task) + end + + test 'deadline_score returns 100 for an overdue task' do + task = build_task_with_due_date(Time.zone.today - 5.days) + service = TaskPrioritizationService.new(@user) + assert_equal 100, service.deadline_score_for(task) + end + + # --------------------------------------------------------------------------- + # effort_score_for + # --------------------------------------------------------------------------- + + test 'effort_score buckets weighting correctly' do + service = TaskPrioritizationService.new(@user) + + assert_equal 30, service.effort_score_for(build_task_with_weighting(5)) + assert_equal 30, service.effort_score_for(build_task_with_weighting(10)) + assert_equal 50, service.effort_score_for(build_task_with_weighting(20)) + assert_equal 70, service.effort_score_for(build_task_with_weighting(40)) + assert_equal 90, service.effort_score_for(build_task_with_weighting(60)) + end + + test 'effort_score handles nil weighting as zero' do + service = TaskPrioritizationService.new(@user) + assert_equal 30, service.effort_score_for(build_task_with_weighting(nil)) + end + + # --------------------------------------------------------------------------- + # call — integration + # --------------------------------------------------------------------------- + + test 'returns empty array when user has no tasks' do + assert_equal [], TaskPrioritizationService.new(@user).call + end + + test 'returns tasks sorted by descending priority_score' do + create_task(@project, weighting: 5, due_in_days: 30) # low effort, far deadline + create_task(@project, weighting: 50, due_in_days: 1) # high effort, urgent + + results = TaskPrioritizationService.new(@user).call + + assert_equal 2, results.length + assert results.first[:priority_score] >= results.last[:priority_score] + end + + test 'excludes tasks belonging to other students' do + create_task(@project, weighting: 10, due_in_days: 5) + + other_user = FactoryBot.create(:user, :student) + other_project = FactoryBot.create(:project, user: other_user, unit: @unit, enrolled: true) + create_task(other_project, weighting: 10, due_in_days: 5) + + results = TaskPrioritizationService.new(@user).call + assert_equal 1, results.length + end + + test 'excludes tasks from inactive units' do + inactive_unit = FactoryBot.create(:unit, active: false) + inactive_project = FactoryBot.create( + :project, user: @user, unit: inactive_unit, enrolled: true + ) + create_task(inactive_project, weighting: 10, due_in_days: 5) + + assert_equal [], TaskPrioritizationService.new(@user).call + end + + test 'excludes tasks from unenrolled projects' do + unenrolled_project = FactoryBot.create( + :project, user: @user, unit: @unit, enrolled: false + ) + create_task(unenrolled_project, weighting: 10, due_in_days: 5) + + assert_equal [], TaskPrioritizationService.new(@user).call + end + + test 'excludes completed and staff-assessed tasks' do + create_task(@project, weighting: 10, due_in_days: 5, + status_name: 'complete') + create_task(@project, weighting: 10, due_in_days: 5, + status_name: 'discuss') + create_task(@project, weighting: 10, due_in_days: 5, + status_name: 'demonstrate') + actionable = create_task(@project, weighting: 10, due_in_days: 5, + status_name: 'fix_and_resubmit') + + results = TaskPrioritizationService.new(@user).call + assert_equal 1, results.length + assert_equal actionable.id, results.first[:task_id] + end + + test 'response shape includes all expected keys' do + create_task(@project, weighting: 10, due_in_days: 5) + result = TaskPrioritizationService.new(@user).call.first + + %i[task_id task_name unit_id project_id deadline_score + effort_score workload_score priority_score].each do |key| + assert result.key?(key), "missing key #{key}" + end + end + + test 'workload score reflects task pressure and target grade' do + # 5 tasks => medium pressure (60), HD target_grade => 90 + # workload = 0.6*60 + 0.4*90 = 36 + 36 = 72 + @project.update!(target_grade: 3) + 5.times { create_task(@project, weighting: 10, due_in_days: 10) } + + results = TaskPrioritizationService.new(@user).call + assert_equal 72, results.first[:workload_score] + end + + # --------------------------------------------------------------------------- + # helpers + # --------------------------------------------------------------------------- + + private + + def build_task_with_due_date(due_date) + task_definition = FactoryBot.build(:task_definition, due_date: due_date, weighting: 10) + FactoryBot.build(:task, project: @project, task_definition: task_definition) + end + + def build_task_with_weighting(weighting) + task_definition = FactoryBot.build(:task_definition, + weighting: weighting, + due_date: Time.zone.today + 5.days) + FactoryBot.build(:task, project: @project, task_definition: task_definition) + end + + def create_task(project, weighting:, due_in_days:, status_name: 'not_started') + task_definition = FactoryBot.create( + :task_definition, + unit: project.unit, + weighting: weighting, + due_date: Time.zone.today + due_in_days.days + ) + status = TaskStatus.find_by(name: status_name) || TaskStatus.first + FactoryBot.create( + :task, + project: project, + task_definition: task_definition, + task_status: status + ) + end +end From 017057845331d32a818fbcfda73cc70786b33809 Mon Sep 17 00:00:00 2001 From: NIETHIN SHREEDHARAN Date: Sun, 17 May 2026 15:58:46 +1000 Subject: [PATCH 3/3] chore(task-prioritization): register endpoint with Swagger auth helpers Adds the missing AuthenticationHelpers.add_auth_to call so the new /tasks/recommended endpoint surfaces correctly in /api/docs. Refs #94 --- app/api/api_root.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/api_root.rb b/app/api/api_root.rb index e502f036f..ceb19bce6 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -157,6 +157,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to DiscussionPromptsApi AuthenticationHelpers.add_auth_to OverseerStepsApi AuthenticationHelpers.add_auth_to TutorNotesApi + AuthenticationHelpers.add_auth_to TaskPrioritizationApi add_swagger_documentation \ base_path: nil,