Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7981de8
docs: fix typo in API root, add doc_version
SteveDala Nov 30, 2025
0e89378
Merge branch '10.0.x' of https://github.com/doubtfire-lms/doubtfire-a…
SteveDala Nov 30, 2025
069a023
Merge branch '10.0.x' of https://github.com/SteveDala/doubtfire-api i…
SteveDala Dec 13, 2025
835bf79
Merge branch '10.0.x' of https://github.com/SteveDala/doubtfire-api i…
SteveDala Jan 4, 2026
1e6a8df
Merge branch '10.0.x' of https://github.com/thoth-tech/doubtfire-api …
SteveDala Mar 17, 2026
f9b455d
Merge branch '10.0.x' of https://github.com/thoth-tech/doubtfire-api …
SteveDala Mar 23, 2026
524c2eb
feat(ai_eff): rename weighting in test files
SteveDala Apr 25, 2026
308b11f
feat(ai_eff): rename weighting to estimated hours
SteveDala Apr 25, 2026
d337b35
feat(ai_eff): prediction endpoint and Sidekiq job
SteveDala Apr 26, 2026
05b690d
feat(ai_eff): default value for predicted effort
SteveDala Apr 26, 2026
a1f5dbb
fix(ai_eff): mutated uri in the Sidekiq job
SteveDala Apr 26, 2026
3a0eef4
add allow prediction flag on units migration
joshtalev May 5, 2026
6f459a8
expose allow_effort_predictions in entity
joshtalev May 5, 2026
c0523da
add allow_effort_prediction to crud endpoints
joshtalev May 5, 2026
42ee620
return job ID from prediction endpoint
joshtalev May 5, 2026
96da485
error handling on prediction job enqueue
joshtalev May 5, 2026
158acbd
add initiator to sidekiq job
joshtalev May 5, 2026
bb93aec
More error handling
joshtalev May 5, 2026
749814a
removes package install that breaks build (#1)
jtalev May 6, 2026
4357f19
Merge branch 'Feature/AI-Suggestion' into development
SteveDala May 10, 2026
da7616f
Merge remote-tracking branch 'jtalev/allow-prediction-flag-on-units' …
SteveDala May 10, 2026
813a2b1
Merge remote-tracking branch 'jtalev/return-job-id-from-prediction-en…
SteveDala May 10, 2026
90d26ab
refactor(ai_eff): change job checking to make a little more sense
SteveDala May 11, 2026
f82d58d
feat(ai_eff): remove failing line rubocop
SteveDala May 16, 2026
d52a798
Return job id from prediction endpoint (#3)
jtalev May 16, 2026
c5a217f
Allow prediction flag on units (#2)
jtalev May 16, 2026
c846cfb
feat(ai_eff): suggested fix from #3
SteveDala May 16, 2026
1d9108d
Merge branch 'Feature/AI-suggestion' into development
SteveDala May 16, 2026
a8fa2ef
docs: change error message
SteveDala May 16, 2026
1d7d681
Merge pull request #4 from SteveDala/development
SteveDala May 16, 2026
260ad01
feat(ai_eff): add target date to ML payload
SteveDala May 17, 2026
ba0cb34
Merge branch 'development' of https://github.com/stevedala/doubtfire-…
SteveDala May 17, 2026
3637004
Merge pull request #5 from SteveDala/development
SteveDala May 17, 2026
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
3 changes: 2 additions & 1 deletion app/api/entities/task_definition_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ def staff?(my_role)
expose :abbreviation
expose :name
expose :description
expose :weighting
expose :estimated_hours
expose :predicted_effort
expose :target_grade

with_options(format_with: :date_only) do
Expand Down
5 changes: 3 additions & 2 deletions app/api/entities/unit_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def can_read_unit_config?(my_role)
expose :allow_student_change_tutorial, unless: :summary_only
expose :allow_flexible_dates, unless: :summary_only
expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only
expose :feedback_warning_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }
expose :feedback_overflow_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }
expose :allow_effort_predictions

expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only
expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only
Expand All @@ -65,7 +68,5 @@ def can_read_unit_config?(my_role)
# unit.group_memberships.where(active: true)
# end

expose :feedback_warning_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }
expose :feedback_overflow_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }
end
end
6 changes: 5 additions & 1 deletion app/api/sidekiq_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ class SidekiqApi < Grape::API
get '/sidekiq/:id' do
job_id = params[:id]
job_data = Sidekiq::Status.get_all(job_id)

initiator = Sidekiq::Status.get(job_id, :initiator)

if job_data.blank? || initiator.nil?
error!({ error: 'Job not found or has no owner' }, 404)
end

if current_user.id != initiator.to_i
error!({ error: 'You do not have permission to access this job' }, 403)
end
Expand Down
49 changes: 45 additions & 4 deletions app/api/task_definitions_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class TaskDefinitionsApi < Grape::API
optional :tutorial_stream_abbr, type: String, desc: 'The abbreviation of tutorial stream'
requires :name, type: String, desc: 'The name of this task def'
requires :description, type: String, desc: 'The description of this task def'
requires :weighting, type: Integer, desc: 'The weighting of this task'
requires :estimated_hours, type: Integer, desc: 'The estimated number of hours to complete this task'
optional :predicted_effort, type: Float, desc: 'The predicted effort of the task based on task features'
requires :target_grade, type: Integer, desc: 'Minimum grade for task'
optional :group_set_id, type: Integer, desc: 'Related group set'
requires :start_date, type: Date, desc: 'The date when the task should be started'
Expand Down Expand Up @@ -57,7 +58,8 @@ class TaskDefinitionsApi < Grape::API
.permit(
:name,
:description,
:weighting,
:estimated_hours,
:predicted_effort,
:target_grade,
:start_date,
:target_date,
Expand Down Expand Up @@ -115,7 +117,8 @@ class TaskDefinitionsApi < Grape::API
optional :tutorial_stream_abbr, type: String, desc: 'The abbreviation of the tutorial stream'
optional :name, type: String, desc: 'The name of this task def'
optional :description, type: String, desc: 'The description of this task def'
optional :weighting, type: Integer, desc: 'The weighting of this task'
optional :estimated_hours, type: Integer, desc: 'The estimated number of hours to complete this task'
optional :predicted_effort, type: Float, desc: 'The predicted effort of the task based on task features'
optional :target_grade, type: Integer, desc: 'Target grade for task'
optional :group_set_id, type: Integer, desc: 'Related group set'
optional :start_date, type: Date, desc: 'The date when the task should be started'
Expand Down Expand Up @@ -171,7 +174,8 @@ class TaskDefinitionsApi < Grape::API
.permit(
:name,
:description,
:weighting,
:estimated_hours,
:predicted_effort,
:target_grade,
:start_date,
:target_date,
Expand Down Expand Up @@ -926,6 +930,43 @@ class TaskDefinitionsApi < Grape::API
present job, with: Entities::SidekiqJobEntity
end

desc 'Predict the effort required for a task description'
params do
requires :unit_id, type: Integer, desc: 'The unit that has the task definition'
requires :task_def_id, type: Integer, desc: 'The task definition to predict effort for'
end
post '/units/:unit_id/task_definitions/:task_def_id/predict_effort' do
unit = Unit.find(params[:unit_id])
unless authorise? current_user, unit, :get_students
error!({ error: "Not authorised to run prediction." }, 403)
end

td = unit.task_definitions.find(params[:task_def_id])

begin
job_id = PredictEffortJob.perform_async(td.id, current_user.id)
error!({ error: 'Failed to enqueue prediction job' }, 500) if job_id.nil?

present(
{
job_id: job_id,
message: 'Prediction queued',
success: true
}
)
rescue StandardError => e
Rails.logger.error("Failed to enqueue prediction job: #{e.message}")

error!(
{
message: 'Failed to enqueue job',
error: e.message,
success: false
}, 500
)
end
end

# desc 'Retrieve the contents of the overseer execution script'
# params do
# requires :unit_id, type: Integer, desc: 'The unit that has the task definition'
Expand Down
8 changes: 6 additions & 2 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class UnitsApi < Grape::API
optional :assessment_enabled, type: Boolean
optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox'
optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking'
optional :allow_effort_predictions, type: Boolean, desc: 'Turn on/off ability for admins to run AI effort predictions for tasks belonging to this unit'

mutually_exclusive :teaching_period_id, :start_date
mutually_exclusive :teaching_period_id, :end_date
Expand Down Expand Up @@ -126,7 +127,8 @@ class UnitsApi < Grape::API
:overseer_image_id,
:assessment_enabled,
:feedback_warning_threshold_days,
:feedback_overflow_threshold_days
:feedback_overflow_threshold_days,
:allow_effort_predictions,
)

if unit.teaching_period_id.present? && (unit_parameters.key?(:start_date) || unit_parameters['teaching_period_id'] == -1)
Expand Down Expand Up @@ -174,6 +176,7 @@ class UnitsApi < Grape::API
optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true
optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox'
optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking'
optional :allow_effort_predictions, type: Boolean, desc: 'Turn on/off ability for admins to run AI effort predictions for tasks belonging to this unit', default: false

mutually_exclusive :teaching_period_id, :start_date
mutually_exclusive :teaching_period_id, :end_date
Expand Down Expand Up @@ -205,7 +208,8 @@ class UnitsApi < Grape::API
:portfolio_auto_generation_date,
:allow_student_change_tutorial,
:feedback_warning_threshold_days,
:feedback_overflow_threshold_days
:feedback_overflow_threshold_days,
:allow_effort_predictions,
)

# Ensure the user is authorised to convene units
Expand Down
4 changes: 2 additions & 2 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ def discuss_and_demonstrate_tasks
# get the weight of all tasks completed or marked as ready to assess
#
def completed_tasks_weight
ready_or_complete_tasks.empty? ? 0.0 : ready_or_complete_tasks.map { |task| task.task_definition.weighting }.inject(:+)
ready_or_complete_tasks.empty? ? 0.0 : ready_or_complete_tasks.map { |task| task.task_definition.estimated_hours }.inject(:+)
end

def convert_hash_to_pct(hash, total)
Expand Down Expand Up @@ -594,7 +594,7 @@ def assigned_task_defs
end

def total_task_weight
assigned_task_defs.map(&:weighting).inject(:+)
assigned_task_defs.map(&:estimated_hours).inject(:+)
end

def remaining_days
Expand Down
2 changes: 1 addition & 1 deletion app/models/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@ def assessed?
end

def weight
task_definition.weighting.to_f
task_definition.estimated_hours.to_f
end

def add_text_comment(user, text, reply_to_id = nil)
Expand Down
8 changes: 4 additions & 4 deletions app/models/task_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def self.permissions
validate :unit_must_be_same
validate :tutorial_stream_present?

validates :weighting, presence: true
validates :estimated_hours, presence: true

validate :check_existing_prerequisites

Expand Down Expand Up @@ -542,7 +542,7 @@ def to_csv_row
end

def self.csv_columns
[:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts,
[:name, :abbreviation, :description, :estimated_hours, :target_grade, :restrict_status_updates, :max_quality_pts,
:is_graded, :plagiarism_warn_pct, :scorm_enabled, :scorm_allow_review, :scorm_bypass_test, :scorm_time_delay_enabled,
:scorm_attempt_limit, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day,
:due_week, :due_day, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts]
Expand Down Expand Up @@ -572,7 +572,7 @@ def self.task_def_for_csv_row(unit, row)
result = TaskDefinition.find_or_create_by(unit_id: unit.id, tutorial_stream: tutorial_stream, name: name, abbreviation: abbreviation) do |td|
td.target_date = target_date
td.start_date = start_date
td.weighting = row[:weighting].to_i
td.estimated_hours = row[:estimated_hours].to_i
end
new_task = true
end
Expand All @@ -581,7 +581,7 @@ def self.task_def_for_csv_row(unit, row)
result.unit_id = unit.id
result.abbreviation = abbreviation
result.description = "#{row[:description]}".strip
result.weighting = row[:weighting].to_i
result.estimated_hours = row[:estimated_hours].to_i
result.target_grade = row[:target_grade].to_i
result.restrict_status_updates = %w(Yes y Y yes true TRUE 1).include? "#{row[:restrict_status_updates]}".strip
result.max_quality_pts = row[:max_quality_pts].to_i
Expand Down
56 changes: 56 additions & 0 deletions app/sidekiq/predict_effort_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
require "net/http"
require "json"

class PredictEffortJob
include Sidekiq::Worker
include Sidekiq::Status::Worker

def perform(task_def_id, user_id)
store initiator: user_id
td = TaskDefinition.find(task_def_id)
payload = build_payload(td)

ml_url = ENV.fetch('ML_SERVICE_URL')
if ml_url.blank?
raise StandardError, "ML_SERVICE_URL is not configured"
end
Rails.logger.info("ML payload: #{payload.to_json}")

response = Net::HTTP.post(
URI("#{ml_url}predict"),
payload.to_json,
"Content-Type" => "application/json"
)

unless response.is_a?(Net::HTTPSuccess)
raise StandardError, "ML service returned #{response.code}: #{response.body}"
end
result = JSON.parse(response.body)
Rails.logger.info("FastAPI response: #{response.body}")
td.update(predicted_effort: result["predicted_effort"])
store result: result
rescue StandardError => e
Rails.logger.error("PredictEffortJob failed: #{e.message}")

store(
status: 'failed',
message: e.message,
result: { error: e.message }
)

raise e
end

private

def build_payload(task_def)
{
estimated_hours: task_def.estimated_hours,
target_grade: task_def.target_grade,
start_date: task_def.start_date,
target_date: task_def.target_date,
due_date: task_def.due_date # ,
# TODO: task sheet for TF-IDF
}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ChangeWeightingToEstimatedHoursInTaskDefinitions < ActiveRecord::Migration[8.0]
def change
rename_column :task_definitions, :weighting, :estimated_hours
end
end
5 changes: 5 additions & 0 deletions db/migrate/20260425165056_add_predicted_effort_to_tasks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddPredictedEffortToTasks < ActiveRecord::Migration[8.0]
def change
add_column :task_definitions, :predicted_effort, :float
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class SetDefaultPredictedEffortOnTaskDefinitions < ActiveRecord::Migration[8.0]
def up
change_column_default :task_definitions, :predicted_effort, 1.0
TaskDefinition.where(predicted_effort: nil).update_all(predicted_effort: 1.0)
end

def down
change_column_default :task_definitions, :predicted_effort, nil
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddPredictionEnabledFlagToUnits < ActiveRecord::Migration[8.0]
def change
add_column :units, :allow_effort_predictions, :boolean, null: false, default: false
end
end
6 changes: 4 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.0].define(version: 2026_03_22_230239) do
ActiveRecord::Schema[8.0].define(version: 2026_05_04_104656) do
create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name", null: false
t.string "abbreviation", null: false
Expand Down Expand Up @@ -420,7 +420,7 @@
t.bigint "unit_id"
t.string "name"
t.string "description", limit: 4096
t.decimal "weighting", precision: 10
t.decimal "estimated_hours", precision: 10
t.datetime "target_date", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
Expand Down Expand Up @@ -450,6 +450,7 @@
t.boolean "use_resources_for_jplag_base_code", default: false, null: false
t.boolean "lock_assessments_to_tutorial_stream", default: false, null: false
t.boolean "requires_discussion", default: false, null: false
t.float "predicted_effort", default: 1.0
t.index ["abbreviation", "unit_id"], name: "index_task_definitions_on_abbreviation_and_unit_id", unique: true
t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id"
t.index ["name", "unit_id"], name: "index_task_definitions_on_name_and_unit_id", unique: true
Expand Down Expand Up @@ -743,6 +744,7 @@
t.boolean "mark_late_submissions_as_assess_in_portfolio", default: false, null: false
t.integer "feedback_warning_threshold_days", default: 5
t.integer "feedback_overflow_threshold_days", default: 7
t.boolean "allow_effort_predictions", default: false, null: false
t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id"
t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id"
t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id"
Expand Down
2 changes: 1 addition & 1 deletion lib/helpers/database_populator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ def generate_tasks_for_unit(unit, unit_details)
abbreviation: "A#{count + 1}",
unit_id: unit.id,
description: faker_random_sentence(5, 10),
weighting: BigDecimal("2"),
estimated_hours: BigDecimal("2"),
target_date: target_date,
upload_requirements: up_reqs,
start_date: start_date,
Expand Down
2 changes: 1 addition & 1 deletion lib/tasks/simulate_jplag_submissions.rake
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace :db do
unit_id: unit.id,
tutorial_stream: unit.tutorial_streams.first,
description: faker_random_sentence(5, 10),
weighting: BigDecimal("2"),
estimated_hours: BigDecimal("2"),
target_date: target_date,
upload_requirements: [
{
Expand Down
8 changes: 4 additions & 4 deletions test/api/comments/comment_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ def test_student_reply_to_other_student_in_same_group
tutorial_stream: unit.tutorial_streams.first,
name: 'Task to switch from ind to group after submission',
description: 'test def',
weighting: 4,
estimated_hours: 4,
target_grade: 0,
start_date: Time.zone.now - 1.week,
target_date: Time.zone.now - 1.day,
Expand Down Expand Up @@ -484,7 +484,7 @@ def test_read_receipts_for_task_status_comments
tutorial_stream: unit.tutorial_streams.first,
name: 'test_read_receipts_for_task_status_comments',
description: 'test_read_receipts_for_task_status_comments',
weighting: 4,
estimated_hours: 4,
target_grade: 0,
start_date: Time.zone.now - 2.weeks,
target_date: Time.zone.now + 1.week,
Expand Down Expand Up @@ -527,7 +527,7 @@ def test_project_plan_task_comments_dont_show_in_inbox
tutorial_stream: unit.tutorial_streams.first,
name: 'test_project_plan_task_comments_dont_show_in_inbox',
description: 'test_project_plan_task_comments_dont_show_in_inbox',
weighting: 4,
estimated_hours: 4,
target_grade: 0,
start_date: Time.zone.now - 2.weeks,
target_date: Time.zone.now + 1.week,
Expand Down Expand Up @@ -574,7 +574,7 @@ def test_discussed_in_class_task_comments_dont_show_in_inbox
tutorial_stream: unit.tutorial_streams.first,
name: 'test_discussed_in_class_task_comments_dont_show_in_inbox',
description: 'test_discussed_in_class_task_comments_dont_show_in_inbox',
weighting: 4,
estimated_hours: 4,
target_grade: 0,
start_date: Time.zone.now - 2.weeks,
target_date: Time.zone.now + 1.week,
Expand Down
Loading
Loading