From a0935ee7cce66930d3ec7cd600e400851f4997e5 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Fri, 7 Nov 2025 19:43:05 +0100 Subject: [PATCH] Introduce cloze-task --- .gitignore | 1 + Agents.md | 2 + challenges/src/endpoints/cloze.rs | 992 ++++++++++++++++++ challenges/src/endpoints/mod.rs | 13 +- challenges/src/endpoints/subtasks/feedback.rs | 1 + challenges/src/services/subtasks.rs | 1 + cloze-create.json | 14 + config.toml | 7 +- entity/src/challenges_cloze_attempts.rs | 36 + entity/src/challenges_cloze_blanks.rs | 50 + entity/src/challenges_cloze_options.rs | 42 + entity/src/challenges_clozes.rs | 57 + entity/src/lib.rs | 4 + entity/src/prelude.rs | 4 + entity/src/sea_orm_active_enums.rs | 2 + lib/src/config/challenges.rs | 8 + migration/src/lib.rs | 2 + migration/src/m20251107_120000_cloze_tasks.rs | 264 +++++ schemas/src/challenges/cloze.rs | 228 ++++ schemas/src/challenges/mod.rs | 1 + 20 files changed, 1725 insertions(+), 4 deletions(-) create mode 100644 Agents.md create mode 100644 challenges/src/endpoints/cloze.rs create mode 100644 cloze-create.json create mode 100644 entity/src/challenges_cloze_attempts.rs create mode 100644 entity/src/challenges_cloze_blanks.rs create mode 100644 entity/src/challenges_cloze_options.rs create mode 100644 entity/src/challenges_clozes.rs create mode 100644 migration/src/m20251107_120000_cloze_tasks.rs create mode 100644 schemas/src/challenges/cloze.rs diff --git a/.gitignore b/.gitignore index 548c6e4..5e96198 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.env /.devshell result +/.idea/ diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..573d428 --- /dev/null +++ b/Agents.md @@ -0,0 +1,2 @@ +Your instructions are located at ../Agents.md. +Read the ENTIRE file. They are INCREDIBLY important. diff --git a/challenges/src/endpoints/cloze.rs b/challenges/src/endpoints/cloze.rs new file mode 100644 index 0000000..5dc3178 --- /dev/null +++ b/challenges/src/endpoints/cloze.rs @@ -0,0 +1,992 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt, + sync::Arc, +}; + +use chrono::{DateTime, Utc}; +use entity::{ + challenges_cloze_attempts, challenges_cloze_blanks, challenges_cloze_options, + challenges_clozes, challenges_user_subtasks, sea_orm_active_enums::ChallengesSubtaskType, +}; +use lib::{ + auth::{AdminAuth, VerifiedUserAuth}, + config::Config, + SharedState, +}; +use poem::web::Data; +use poem_ext::{db::DbTxn, response}; +use poem_openapi::{ + param::{Path, Query}, + payload::Json, + OpenApi, +}; +use schemas::challenges::{ + cloze::{ + Cloze, ClozeSummary, ClozeVariant, ClozeWithSolution, CreateClozeBlank, CreateClozeRequest, + SolveClozeFeedback, SolveClozeRequest, UpdateClozeRequest, + }, + subtasks::Subtask, +}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseTransaction, DbErr, EntityTrait, QueryFilter, + QueryOrder, Set, Unchanged, +}; +use tracing::warn; +use uuid::Uuid; + +use super::Tags; +use crate::services::subtasks::{ + create_subtask, deduct_hearts, get_subtask, get_user_subtask, query_subtask, + query_subtask_admin, query_subtasks, send_task_rewards, update_subtask, update_user_subtask, + CreateSubtaskError, QuerySubtaskAdminError, QuerySubtasksFilter, UpdateSubtaskError, + UserSubtaskExt, +}; + +pub struct Clozes { + pub state: Arc, + pub config: Arc, +} + +#[OpenApi(tag = "Tags::Cloze")] +impl Clozes { + #[oai(path = "/tasks/:task_id/clozes", method = "get")] + #[allow(clippy::too_many_arguments)] + async fn list_clozes( + &self, + task_id: Path, + attempted: Query>, + solved: Query>, + rated: Query>, + enabled: Query>, + retired: Query>, + creator: Query>, + db: Data<&DbTxn>, + auth: VerifiedUserAuth, + ) -> ListClozes::Response { + let entries = query_subtasks::( + &db, + &auth.0, + task_id.0, + QuerySubtasksFilter { + attempted: attempted.0, + solved: solved.0, + rated: rated.0, + enabled: enabled.0, + retired: retired.0, + creator: creator.0, + ty: Some(ChallengesSubtaskType::Cloze), + }, + |specific, subtask| (specific, subtask), + ) + .await?; + + let hydrated = hydrate_many(&***db, entries).await?; + ListClozes::ok( + hydrated + .into_iter() + .map(|item| { + ClozeSummary::from(&item.cloze, item.subtask, &item.blanks, &item.options) + }) + .collect(), + ) + } + + #[oai(path = "/tasks/:task_id/clozes/:subtask_id", method = "get")] + async fn get_cloze( + &self, + task_id: Path, + subtask_id: Path, + db: Data<&DbTxn>, + auth: VerifiedUserAuth, + ) -> GetCloze::Response { + match query_subtask::( + &db, + &auth.0, + task_id.0, + subtask_id.0, + |specific, subtask| (specific, subtask), + ) + .await? + { + Some(item) => { + let hydrated = hydrate_single(&***db, item).await?; + GetCloze::ok(Cloze::from( + &hydrated.cloze, + hydrated.subtask, + &hydrated.blanks, + &hydrated.options, + )) + } + None => GetCloze::subtask_not_found(), + } + } + + #[oai(path = "/tasks/:task_id/clozes/:subtask_id/solution", method = "get")] + async fn get_cloze_with_solution( + &self, + task_id: Path, + subtask_id: Path, + db: Data<&DbTxn>, + auth: VerifiedUserAuth, + ) -> GetClozeWithSolution::Response { + match query_subtask_admin::( + &db, + &auth.0, + task_id.0, + subtask_id.0, + |specific, subtask| (specific, subtask), + ) + .await? + { + Ok(item) => { + let hydrated = hydrate_single(&***db, item).await?; + GetClozeWithSolution::ok(ClozeWithSolution::from( + &hydrated.cloze, + hydrated.subtask, + &hydrated.blanks, + &hydrated.options, + )) + } + Err(QuerySubtaskAdminError::NotFound) => GetClozeWithSolution::subtask_not_found(), + Err(QuerySubtaskAdminError::NoAccess) => GetClozeWithSolution::forbidden(), + } + } + + #[oai(path = "/tasks/:task_id/clozes", method = "post")] + async fn create_cloze( + &self, + task_id: Path, + data: Json, + db: Data<&DbTxn>, + auth: VerifiedUserAuth, + ) -> CreateCloze::Response { + let Json(payload) = data; + let CreateClozeRequest { + subtask, + content, + blanks, + options, + case_sensitive, + } = payload; + + let prepared = match PreparedCloze::new(content, case_sensitive, blanks, options) { + Ok(value) => value, + Err(err) => return CreateCloze::invalid_payload(err.to_string()), + }; + + let subtask = match create_subtask( + &db, + &self.state.services, + &self.config, + &auth.0, + task_id.0, + subtask, + ChallengesSubtaskType::Cloze, + ) + .await? + { + Ok(subtask) => subtask, + Err(CreateSubtaskError::TaskNotFound) => return CreateCloze::task_not_found(), + Err(CreateSubtaskError::Forbidden) => return CreateCloze::forbidden(), + Err(CreateSubtaskError::Banned(until)) => return CreateCloze::banned(until), + Err(CreateSubtaskError::XpLimitExceeded(limit)) => { + return CreateCloze::xp_limit_exceeded(limit) + } + Err(CreateSubtaskError::CoinLimitExceeded(limit)) => { + return CreateCloze::coin_limit_exceeded(limit) + } + }; + + let cloze = challenges_clozes::ActiveModel { + subtask_id: Set(subtask.id), + content: Set(prepared.content.clone()), + case_sensitive: Set(prepared.case_sensitive), + } + .insert(&***db) + .await?; + + persist_prepared(&***db, cloze.subtask_id, &prepared).await?; + + let hydrated = hydrate_single(&***db, (cloze, subtask)).await?; + CreateCloze::ok(ClozeWithSolution::from( + &hydrated.cloze, + hydrated.subtask, + &hydrated.blanks, + &hydrated.options, + )) + } + + #[oai(path = "/tasks/:task_id/clozes/:subtask_id", method = "patch")] + async fn update_cloze( + &self, + task_id: Path, + subtask_id: Path, + data: Json, + db: Data<&DbTxn>, + auth: AdminAuth, + ) -> UpdateCloze::Response { + let Json(payload) = data; + let (cloze, subtask) = match update_subtask::( + &db, + &auth.0, + task_id.0, + subtask_id.0, + payload.subtask, + ) + .await? + { + Ok(value) => value, + Err(UpdateSubtaskError::SubtaskNotFound) => return UpdateCloze::subtask_not_found(), + Err(UpdateSubtaskError::TaskNotFound) => return UpdateCloze::task_not_found(), + }; + + let hydrated = hydrate_single(&***db, (cloze.clone(), subtask.clone())).await?; + let option_positions: HashMap = hydrated + .options + .iter() + .enumerate() + .map(|(idx, option)| (option.id, idx)) + .collect(); + + let existing_blanks: Vec = hydrated + .blanks + .iter() + .map(|blank| CreateClozeBlank { + placeholder: blank.placeholder as u32, + answer: blank.answer.clone(), + synonyms: blank.synonyms.clone(), + option_index: blank + .correct_option_id + .and_then(|id| option_positions.get(&id).map(|idx| *idx as u32)), + }) + .collect(); + let existing_options: Vec = hydrated + .options + .iter() + .map(|option| option.label.clone()) + .collect(); + + let content = payload.content.get_new(&cloze.content).clone(); + let case_sensitive = *payload.case_sensitive.get_new(&cloze.case_sensitive); + let blanks = payload.blanks.get_new(&existing_blanks).clone(); + let options = payload.options.get_new(&existing_options).clone(); + + let payload_changed = content != cloze.content + || case_sensitive != cloze.case_sensitive + || blanks != existing_blanks + || options != existing_options; + + if !payload_changed { + return UpdateCloze::ok(ClozeWithSolution::from( + &hydrated.cloze, + hydrated.subtask, + &hydrated.blanks, + &hydrated.options, + )); + } + + let prepared = match PreparedCloze::new(content, case_sensitive, blanks, options) { + Ok(value) => value, + Err(err) => return UpdateCloze::invalid_payload(err.to_string()), + }; + + let updated = challenges_clozes::ActiveModel { + subtask_id: Unchanged(cloze.subtask_id), + content: Set(prepared.content.clone()), + case_sensitive: Set(prepared.case_sensitive), + } + .update(&***db) + .await?; + + reset_payload(&***db, cloze.subtask_id).await?; + persist_prepared(&***db, cloze.subtask_id, &prepared).await?; + + let hydrated = hydrate_single(&***db, (updated, subtask)).await?; + UpdateCloze::ok(ClozeWithSolution::from( + &hydrated.cloze, + hydrated.subtask, + &hydrated.blanks, + &hydrated.options, + )) + } + + #[oai(path = "/tasks/:task_id/clozes/:subtask_id/attempts", method = "post")] + async fn solve_cloze( + &self, + task_id: Path, + subtask_id: Path, + data: Json, + db: Data<&DbTxn>, + auth: VerifiedUserAuth, + ) -> SolveCloze::Response { + let Some((cloze, subtask)) = + get_subtask::(&db, task_id.0, subtask_id.0).await? + else { + return SolveCloze::subtask_not_found(); + }; + + if !auth.0.admin && auth.0.id != subtask.creator && !subtask.enabled { + return SolveCloze::subtask_not_found(); + } + + let (blanks, options) = load_payload(&***db, cloze.subtask_id).await?; + if data.0.answers.len() != blanks.len() { + return SolveCloze::invalid_payload(format!( + "Expected {} answers, got {}.", + blanks.len(), + data.0.answers.len() + )); + } + + let user_subtask = get_user_subtask(&db, auth.0.id, subtask.id).await?; + let solved_previously = user_subtask.is_solved(); + + if let Some(last_attempt) = user_subtask.last_attempt() { + let wait = self.config.challenges.clozes.timeout as i64 + - (Utc::now() - last_attempt).num_seconds(); + if wait > 0 { + return SolveCloze::too_many_requests(wait as u64); + } + } + + let variant = if options.is_empty() { + ClozeVariant::TypeIn + } else { + ClozeVariant::Options + }; + + let blanks_by_id: HashMap = + blanks.iter().map(|blank| (blank.id, blank)).collect(); + let options_by_id: HashMap = + options.iter().map(|option| (option.id, option)).collect(); + let mut remaining: HashSet = blanks_by_id.keys().copied().collect(); + let mut used_options = HashSet::new(); + let mut correct = 0u32; + + for answer in &data.0.answers { + let Some(blank) = blanks_by_id.get(&answer.blank_id) else { + return SolveCloze::invalid_payload(format!( + "Unknown blank id {}.", + answer.blank_id + )); + }; + + if !remaining.remove(&answer.blank_id) { + return SolveCloze::invalid_payload(format!( + "Blank {} provided multiple times.", + answer.blank_id + )); + } + + let success = match variant { + ClozeVariant::TypeIn => { + if answer.option_id.is_some() { + return SolveCloze::invalid_payload( + "option_id is not allowed for type-in clozes.".into(), + ); + } + let text = answer.text.as_deref().unwrap_or("").trim(); + if text.is_empty() { + false + } else { + let normalized = normalize_answer(text, cloze.case_sensitive); + let mut matches = + normalize_answer(&blank.answer, cloze.case_sensitive) == normalized; + if !matches { + matches = blank.synonyms.iter().any(|syn| { + normalize_answer(syn, cloze.case_sensitive) == normalized + }); + } + matches + } + } + ClozeVariant::Options => { + let Some(option_id) = answer.option_id else { + return SolveCloze::invalid_payload( + "option_id must be provided for option-based clozes.".into(), + ); + }; + if answer.text.is_some() { + return SolveCloze::invalid_payload( + "text answers are not allowed for option-based clozes.".into(), + ); + } + if !options_by_id.contains_key(&option_id) { + return SolveCloze::invalid_payload(format!( + "Option {} does not belong to this cloze.", + option_id + )); + } + if !used_options.insert(option_id) { + return SolveCloze::invalid_payload(format!( + "Option {} was used more than once.", + option_id + )); + } + blank.correct_option_id == Some(option_id) + } + }; + + if success { + correct += 1; + } + } + + if !remaining.is_empty() { + return SolveCloze::invalid_payload("One or more blanks were not answered.".into()); + } + + let solved = correct as usize == blanks.len(); + + if !deduct_hearts(&self.state.services, &self.config, &auth.0, &subtask).await? { + return SolveCloze::not_enough_hearts(); + } + + if !solved_previously { + let now = Utc::now().naive_utc(); + if solved { + update_user_subtask( + &db, + user_subtask.as_ref(), + challenges_user_subtasks::ActiveModel { + user_id: Set(auth.0.id), + subtask_id: Set(subtask.id), + solved_timestamp: Set(Some(now)), + last_attempt_timestamp: Set(Some(now)), + attempts: Set(user_subtask.attempts() as i32 + 1), + ..Default::default() + }, + ) + .await?; + + if auth.0.id != subtask.creator { + send_task_rewards(&self.state.services, &db, auth.0.id, &subtask).await?; + } + } else { + update_user_subtask( + &db, + user_subtask.as_ref(), + challenges_user_subtasks::ActiveModel { + user_id: Set(auth.0.id), + subtask_id: Set(subtask.id), + last_attempt_timestamp: Set(Some(now)), + attempts: Set(user_subtask.attempts() as i32 + 1), + ..Default::default() + }, + ) + .await?; + } + + challenges_cloze_attempts::ActiveModel { + id: Set(Uuid::new_v4()), + cloze_id: Set(cloze.subtask_id), + user_id: Set(auth.0.id), + timestamp: Set(now), + correct: Set(correct as i32), + total: Set(blanks.len() as i32), + solved: Set(solved), + } + .insert(&***db) + .await?; + } + + SolveCloze::ok(SolveClozeFeedback { + solved, + correct, + total: blanks.len() as u32, + }) + } +} + +response!(ListClozes = { + Ok(200) => Vec, +}); + +response!(GetCloze = { + Ok(200) => Cloze, + SubtaskNotFound(404, error), +}); + +response!(GetClozeWithSolution = { + Ok(200) => ClozeWithSolution, + SubtaskNotFound(404, error), + Forbidden(403, error), +}); + +response!(CreateCloze = { + Ok(201) => ClozeWithSolution, + TaskNotFound(404, error), + Forbidden(403, error), + Banned(403, error) => Option>, + XpLimitExceeded(403, error) => u64, + CoinLimitExceeded(403, error) => u64, + InvalidPayload(400, error) => String, +}); + +response!(UpdateCloze = { + Ok(200) => ClozeWithSolution, + SubtaskNotFound(404, error), + TaskNotFound(404, error), + InvalidPayload(400, error) => String, +}); + +response!(SolveCloze = { + Ok(201) => SolveClozeFeedback, + TooManyRequests(429, error) => u64, + SubtaskNotFound(404, error), + NotEnoughHearts(403, error), + InvalidPayload(400, error) => String, +}); + +struct HydratedCloze { + cloze: challenges_clozes::Model, + subtask: Subtask, + blanks: Vec, + options: Vec, +} + +async fn hydrate_many( + db: &DatabaseTransaction, + entries: Vec<(challenges_clozes::Model, Subtask)>, +) -> Result, DbErr> { + if entries.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec = entries.iter().map(|(cloze, _)| cloze.subtask_id).collect(); + + let mut blanks = challenges_cloze_blanks::Entity::find() + .filter(challenges_cloze_blanks::Column::ClozeId.is_in(ids.clone())) + .order_by_asc(challenges_cloze_blanks::Column::Placeholder) + .all(db) + .await?; + + let mut options = challenges_cloze_options::Entity::find() + .filter(challenges_cloze_options::Column::ClozeId.is_in(ids.clone())) + .order_by_asc(challenges_cloze_options::Column::Position) + .all(db) + .await?; + + blanks.sort_by_key(|blank| (blank.cloze_id, blank.placeholder, blank.id)); + options.sort_by_key(|option| (option.cloze_id, option.position, option.id)); + + let mut blanks_by_cloze: HashMap> = HashMap::new(); + for blank in blanks { + blanks_by_cloze + .entry(blank.cloze_id) + .or_default() + .push(blank); + } + + let mut options_by_cloze: HashMap> = HashMap::new(); + for option in options { + options_by_cloze + .entry(option.cloze_id) + .or_default() + .push(option); + } + + Ok(entries + .into_iter() + .map(|(cloze, subtask)| HydratedCloze { + blanks: blanks_by_cloze + .remove(&cloze.subtask_id) + .unwrap_or_default(), + options: options_by_cloze + .remove(&cloze.subtask_id) + .unwrap_or_default(), + cloze, + subtask, + }) + .collect()) +} + +async fn hydrate_single( + db: &DatabaseTransaction, + item: (challenges_clozes::Model, Subtask), +) -> Result { + let mut hydrated = hydrate_many(db, vec![item]).await?; + Ok(hydrated.remove(0)) +} + +async fn load_payload( + db: &DatabaseTransaction, + cloze_id: Uuid, +) -> Result< + ( + Vec, + Vec, + ), + DbErr, +> { + let blanks = challenges_cloze_blanks::Entity::find() + .filter(challenges_cloze_blanks::Column::ClozeId.eq(cloze_id)) + .order_by_asc(challenges_cloze_blanks::Column::Placeholder) + .order_by_asc(challenges_cloze_blanks::Column::Id) + .all(db) + .await?; + + let options = challenges_cloze_options::Entity::find() + .filter(challenges_cloze_options::Column::ClozeId.eq(cloze_id)) + .order_by_asc(challenges_cloze_options::Column::Position) + .order_by_asc(challenges_cloze_options::Column::Id) + .all(db) + .await?; + + Ok((blanks, options)) +} + +#[derive(Debug)] +struct PreparedCloze { + content: String, + case_sensitive: bool, + blanks: Vec, + options: Vec, +} + +#[derive(Debug)] +struct PreparedBlank { + placeholder: u32, + answer: String, + synonyms: Vec, + option_index: Option, +} + +impl PreparedCloze { + fn new( + content: String, + case_sensitive: bool, + blanks: Vec, + options: Vec, + ) -> Result { + let placeholders = extract_placeholders(&content)?; + if placeholders.is_empty() { + return Err(ValidationError::NoPlaceholders); + } + + let mut defs: HashMap = HashMap::new(); + for blank in blanks { + let placeholder = blank.placeholder; + if defs.insert(placeholder, blank).is_some() { + return Err(ValidationError::DuplicatePlaceholder(placeholder)); + } + } + + let mut sanitized_options = Vec::with_capacity(options.len()); + let mut option_labels = HashSet::with_capacity(options.len()); + for option in options { + let label = option.trim().to_string(); + if label.is_empty() { + return Err(ValidationError::EmptyOption); + } + if !option_labels.insert(label.clone()) { + return Err(ValidationError::DuplicateOption(label)); + } + sanitized_options.push(label); + } + + let variant_b = !sanitized_options.is_empty(); + if variant_b && sanitized_options.len() < placeholders.len() { + return Err(ValidationError::TooFewOptions { + blanks: placeholders.len(), + options: sanitized_options.len(), + }); + } + + let mut used_option_indices = HashSet::new(); + let mut prepared_blanks = Vec::with_capacity(placeholders.len()); + + for placeholder in &placeholders { + let Some(def) = defs.remove(placeholder) else { + return Err(ValidationError::MissingDefinition(*placeholder)); + }; + + if def.answer.trim().is_empty() { + return Err(ValidationError::EmptyAnswer(*placeholder)); + } + let answer = def.answer; + let synonyms = sanitize_synonyms(&def.synonyms, case_sensitive); + + let option_index = match (variant_b, def.option_index) { + (true, Some(idx)) => { + let idx = idx as usize; + if idx >= sanitized_options.len() { + return Err(ValidationError::OptionOutOfRange { + placeholder: *placeholder, + index: idx, + }); + } + if !used_option_indices.insert(idx) { + return Err(ValidationError::OptionAlreadyUsed(idx)); + } + Some(idx) + } + (true, None) => return Err(ValidationError::MissingOptionAssignment(*placeholder)), + (false, Some(_)) => { + return Err(ValidationError::UnexpectedOptionAssignment(*placeholder)) + } + (false, None) => None, + }; + + prepared_blanks.push(PreparedBlank { + placeholder: *placeholder, + answer, + synonyms, + option_index, + }); + } + + if !defs.is_empty() { + let trimmed: Vec = defs.keys().copied().collect(); + warn!( + placeholders = ?trimmed, + "Ignoring blank definitions without matching placeholders" + ); + } + + prepared_blanks.sort_by_key(|blank| blank.placeholder); + + Ok(Self { + content, + case_sensitive, + blanks: prepared_blanks, + options: sanitized_options, + }) + } +} + +fn sanitize_synonyms(values: &[String], case_sensitive: bool) -> Vec { + let mut unique = HashSet::new(); + let mut sanitized = Vec::new(); + for value in values { + if value.trim().is_empty() { + continue; + } + let normalized = normalize_answer(value, case_sensitive); + if normalized.is_empty() { + continue; + } + if unique.insert(normalized) { + sanitized.push(value.clone()); + } + } + sanitized +} + +fn normalize_answer(value: &str, case_sensitive: bool) -> String { + let mut out = String::with_capacity(value.len()); + let mut whitespace = false; + for ch in value.trim().chars() { + if ch.is_whitespace() { + if !whitespace { + out.push(' '); + } + whitespace = true; + } else { + whitespace = false; + if case_sensitive { + out.push(ch); + } else { + out.push(ch.to_ascii_lowercase()); + } + } + } + out +} + +fn extract_placeholders(content: &str) -> Result, ValidationError> { + let mut placeholders = Vec::new(); + let mut seen = HashSet::new(); + let mut remaining = content; + while let Some(idx) = remaining.find("{{blank_") { + let start = idx + 8; + let after = &remaining[start..]; + let mut digits = String::new(); + for ch in after.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + } else { + break; + } + } + if digits.is_empty() { + return Err(ValidationError::InvalidPlaceholder); + } + let suffix = &after[digits.len()..]; + if !suffix.starts_with("}}") { + return Err(ValidationError::InvalidPlaceholder); + } + let placeholder: u32 = digits + .parse() + .map_err(|_| ValidationError::InvalidPlaceholder)?; + if placeholder == 0 { + return Err(ValidationError::InvalidPlaceholder); + } + if !seen.insert(placeholder) { + return Err(ValidationError::DuplicatePlaceholder(placeholder)); + } + placeholders.push(placeholder); + remaining = &suffix[2..]; + } + Ok(placeholders) +} + +async fn persist_prepared( + db: &DatabaseTransaction, + cloze_id: Uuid, + prepared: &PreparedCloze, +) -> Result<(), DbErr> { + let mut option_ids = Vec::with_capacity(prepared.options.len()); + for (idx, label) in prepared.options.iter().enumerate() { + let id = Uuid::new_v4(); + challenges_cloze_options::ActiveModel { + id: Set(id), + cloze_id: Set(cloze_id), + position: Set(idx as i32), + label: Set(label.clone()), + } + .insert(db) + .await?; + option_ids.push(id); + } + + for blank in &prepared.blanks { + challenges_cloze_blanks::ActiveModel { + id: Set(Uuid::new_v4()), + cloze_id: Set(cloze_id), + placeholder: Set(blank.placeholder as i32), + answer: Set(blank.answer.clone()), + synonyms: Set(blank.synonyms.clone()), + correct_option_id: Set(blank.option_index.map(|idx| option_ids[idx])), + } + .insert(db) + .await?; + } + + Ok(()) +} + +async fn reset_payload(db: &DatabaseTransaction, cloze_id: Uuid) -> Result<(), DbErr> { + challenges_cloze_blanks::Entity::delete_many() + .filter(challenges_cloze_blanks::Column::ClozeId.eq(cloze_id)) + .exec(db) + .await?; + challenges_cloze_options::Entity::delete_many() + .filter(challenges_cloze_options::Column::ClozeId.eq(cloze_id)) + .exec(db) + .await?; + challenges_cloze_attempts::Entity::delete_many() + .filter(challenges_cloze_attempts::Column::ClozeId.eq(cloze_id)) + .exec(db) + .await?; + Ok(()) +} + +#[derive(Debug)] +enum ValidationError { + NoPlaceholders, + InvalidPlaceholder, + DuplicatePlaceholder(u32), + MissingDefinition(u32), + EmptyAnswer(u32), + EmptyOption, + DuplicateOption(String), + TooFewOptions { blanks: usize, options: usize }, + MissingOptionAssignment(u32), + UnexpectedOptionAssignment(u32), + OptionOutOfRange { placeholder: u32, index: usize }, + OptionAlreadyUsed(usize), +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoPlaceholders => write!(f, "Content must contain at least one placeholder."), + Self::InvalidPlaceholder => write!( + f, + "Placeholders must follow the form {{blank_}} with positive numbers." + ), + Self::DuplicatePlaceholder(idx) => write!( + f, + "Placeholder {{blank_{idx}}} appears more than once (in the content or definitions)." + ), + Self::MissingDefinition(idx) => write!( + f, + "Placeholder {{blank_{idx}}} is missing a corresponding definition." + ), + Self::EmptyAnswer(idx) => write!( + f, + "Placeholder {{blank_{idx}}} must have a non-empty answer." + ), + Self::EmptyOption => write!(f, "Option labels cannot be empty."), + Self::DuplicateOption(label) => write!(f, "Option \"{label}\" is duplicated."), + Self::TooFewOptions { blanks, options } => write!( + f, + "Received {options} options for {blanks} blanks. Variant B requires at least as many options as blanks." + ), + Self::MissingOptionAssignment(idx) => write!( + f, + "Placeholder {{blank_{idx}}} requires an option assignment when options exist." + ), + Self::UnexpectedOptionAssignment(idx) => write!( + f, + "Placeholder {{blank_{idx}}} references an option even though no options were provided." + ), + Self::OptionOutOfRange { placeholder, index } => write!( + f, + "Placeholder {{blank_{placeholder}}} references option index {index}, which is out of range." + ), + Self::OptionAlreadyUsed(index) => { + write!(f, "Option index {index} is assigned to multiple blanks.") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_placeholders() { + let content = "Fill {{blank_1}} and {{blank_2}}."; + assert_eq!(extract_placeholders(content).unwrap(), vec![1, 2]); + } + + #[test] + fn normalizes_answers() { + assert_eq!(normalize_answer(" Foo bar ", false), "foo bar"); + assert_eq!(normalize_answer(" Foo bar ", true), "Foo bar"); + } + + #[test] + fn prepared_rejects_duplicate_placeholder_definitions() { + let blank = CreateClozeBlank { + placeholder: 1, + answer: "A".into(), + synonyms: vec![], + option_index: None, + }; + let err = PreparedCloze::new( + "{{blank_1}}".into(), + false, + vec![blank.clone(), blank], + vec![], + ) + .unwrap_err(); + assert!(matches!(err, ValidationError::DuplicatePlaceholder(1))); + } + + #[test] + fn prepared_requires_option_assignments_when_options_exist() { + let blank = CreateClozeBlank { + placeholder: 1, + answer: "A".into(), + synonyms: vec![], + option_index: None, + }; + let err = PreparedCloze::new("{{blank_1}}".into(), false, vec![blank], vec!["A".into()]) + .unwrap_err(); + assert!(matches!(err, ValidationError::MissingOptionAssignment(1))); + } +} diff --git a/challenges/src/endpoints/mod.rs b/challenges/src/endpoints/mod.rs index b670a24..7378552 100644 --- a/challenges/src/endpoints/mod.rs +++ b/challenges/src/endpoints/mod.rs @@ -7,12 +7,13 @@ use sandkasten_client::SandkastenClient; use tokio::sync::Semaphore; use self::{ - challenges::Challenges, coding_challenges::CodingChallenges, course_tasks::CourseTasks, - leaderboard::LeaderboardEndpoints, matchings::Matchings, multiple_choice::MultipleChoice, - question::Questions, subtasks::Subtasks, + challenges::Challenges, cloze::Clozes, coding_challenges::CodingChallenges, + course_tasks::CourseTasks, leaderboard::LeaderboardEndpoints, matchings::Matchings, + multiple_choice::MultipleChoice, question::Questions, subtasks::Subtasks, }; mod challenges; +mod cloze; pub mod coding_challenges; mod course_tasks; mod leaderboard; @@ -33,6 +34,8 @@ pub enum Tags { MultipleChoice, /// Simple questions with typed answers (subtasks) Questions, + /// Cloze (fill-in-the-blank) subtasks + Cloze, /// One to one matchings (subtasks) Matchings, /// Coding challenges (subtasks) @@ -67,6 +70,10 @@ pub async fn setup_api( state: Arc::clone(&state), config: Arc::clone(&config), }, + Clozes { + state: Arc::clone(&state), + config: Arc::clone(&config), + }, Matchings { state: Arc::clone(&state), config: Arc::clone(&config), diff --git a/challenges/src/endpoints/subtasks/feedback.rs b/challenges/src/endpoints/subtasks/feedback.rs index 3b7a6e7..4fb132b 100644 --- a/challenges/src/endpoints/subtasks/feedback.rs +++ b/challenges/src/endpoints/subtasks/feedback.rs @@ -73,6 +73,7 @@ impl Api { config.multiple_choice_questions.creator_coins } ChallengesSubtaskType::Question => config.questions.creator_coins, + ChallengesSubtaskType::Cloze => config.clozes.creator_coins, }; self.state .services diff --git a/challenges/src/services/subtasks.rs b/challenges/src/services/subtasks.rs index b539f16..97f878c 100644 --- a/challenges/src/services/subtasks.rs +++ b/challenges/src/services/subtasks.rs @@ -80,6 +80,7 @@ fn subtask_hearts(config: &Config, ty: ChallengesSubtaskType) -> u32 { ChallengesSubtaskType::Matching => config.matchings.hearts, ChallengesSubtaskType::MultipleChoiceQuestion => config.multiple_choice_questions.hearts, ChallengesSubtaskType::Question => config.questions.hearts, + ChallengesSubtaskType::Cloze => config.clozes.hearts, } } diff --git a/cloze-create.json b/cloze-create.json new file mode 100644 index 0000000..ae57b2f --- /dev/null +++ b/cloze-create.json @@ -0,0 +1,14 @@ +{ + "subtask": {"xp": 5, "coins": 0}, + "content": "Paris is the capital of {{blank_1}}.", + "blanks": [ + { + "placeholder": 1, + "answer": "France", + "synonyms": ["French Republic"], + "option_index": null + } + ], + "options": [], + "case_sensitive": false +} diff --git a/config.toml b/config.toml index 12f07b4..bdd600f 100644 --- a/config.toml +++ b/config.toml @@ -1,4 +1,4 @@ -jwt_secret = "dev-secret" +jwt_secret = "changeme" internal_jwt_ttl = 10 # seconds cache_ttl = 600 # seconds @@ -51,6 +51,11 @@ timeout = 2 # seconds hearts = 1 creator_coins = 1 +[challenges.clozes] +timeout = 2 # seconds +hearts = 1 +creator_coins = 1 + [challenges.coding_challenges] sandkasten_url = "https://sandkasten.bootstrap.academy" max_concurrency = 2 diff --git a/entity/src/challenges_cloze_attempts.rs b/entity/src/challenges_cloze_attempts.rs new file mode 100644 index 0000000..1df9880 --- /dev/null +++ b/entity/src/challenges_cloze_attempts.rs @@ -0,0 +1,36 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "challenges_cloze_attempts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub cloze_id: Uuid, + pub user_id: Uuid, + pub timestamp: DateTime, + pub correct: i32, + pub total: i32, + pub solved: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::challenges_clozes::Entity", + from = "Column::ClozeId", + to = "super::challenges_clozes::Column::SubtaskId", + on_update = "NoAction", + on_delete = "Cascade" + )] + ChallengesClozes, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozes.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/challenges_cloze_blanks.rs b/entity/src/challenges_cloze_blanks.rs new file mode 100644 index 0000000..615e326 --- /dev/null +++ b/entity/src/challenges_cloze_blanks.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "challenges_cloze_blanks")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub cloze_id: Uuid, + pub placeholder: i32, + #[sea_orm(column_type = "Text")] + pub answer: String, + pub synonyms: Vec, + pub correct_option_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::challenges_clozes::Entity", + from = "Column::ClozeId", + to = "super::challenges_clozes::Column::SubtaskId", + on_update = "NoAction", + on_delete = "Cascade" + )] + ChallengesClozes, + #[sea_orm( + belongs_to = "super::challenges_cloze_options::Entity", + from = "Column::CorrectOptionId", + to = "super::challenges_cloze_options::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + ChallengesClozeOptions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozes.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozeOptions.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/challenges_cloze_options.rs b/entity/src/challenges_cloze_options.rs new file mode 100644 index 0000000..7bde62f --- /dev/null +++ b/entity/src/challenges_cloze_options.rs @@ -0,0 +1,42 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "challenges_cloze_options")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub cloze_id: Uuid, + pub position: i32, + #[sea_orm(column_type = "Text")] + pub label: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::challenges_clozes::Entity", + from = "Column::ClozeId", + to = "super::challenges_clozes::Column::SubtaskId", + on_update = "NoAction", + on_delete = "Cascade" + )] + ChallengesClozes, + #[sea_orm(has_one = "super::challenges_cloze_blanks::Entity")] + ChallengesClozeBlanks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozes.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozeBlanks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/challenges_clozes.rs b/entity/src/challenges_clozes.rs new file mode 100644 index 0000000..3985c88 --- /dev/null +++ b/entity/src/challenges_clozes.rs @@ -0,0 +1,57 @@ +//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.2 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "challenges_clozes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub subtask_id: Uuid, + #[sea_orm(column_type = "Text")] + pub content: String, + pub case_sensitive: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::challenges_cloze_options::Entity")] + ChallengesClozeOptions, + #[sea_orm(has_many = "super::challenges_cloze_blanks::Entity")] + ChallengesClozeBlanks, + #[sea_orm(has_many = "super::challenges_cloze_attempts::Entity")] + ChallengesClozeAttempts, + #[sea_orm( + belongs_to = "super::challenges_subtasks::Entity", + from = "Column::SubtaskId", + to = "super::challenges_subtasks::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + ChallengesSubtasks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozeOptions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozeBlanks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesClozeAttempts.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChallengesSubtasks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/lib.rs b/entity/src/lib.rs index a91da2d..f166b86 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -5,6 +5,10 @@ pub mod prelude; pub mod challenges_ban; pub mod challenges_challenge_categories; pub mod challenges_challenges; +pub mod challenges_cloze_attempts; +pub mod challenges_cloze_blanks; +pub mod challenges_cloze_options; +pub mod challenges_clozes; pub mod challenges_coding_challenge_result; pub mod challenges_coding_challenge_submissions; pub mod challenges_coding_challenges; diff --git a/entity/src/prelude.rs b/entity/src/prelude.rs index c4cfb72..493573c 100644 --- a/entity/src/prelude.rs +++ b/entity/src/prelude.rs @@ -4,6 +4,10 @@ pub use super::{ challenges_ban::Entity as ChallengesBan, challenges_challenge_categories::Entity as ChallengesChallengeCategories, challenges_challenges::Entity as ChallengesChallenges, + challenges_cloze_attempts::Entity as ChallengesClozeAttempts, + challenges_cloze_blanks::Entity as ChallengesClozeBlanks, + challenges_cloze_options::Entity as ChallengesClozeOptions, + challenges_clozes::Entity as ChallengesClozes, challenges_coding_challenge_result::Entity as ChallengesCodingChallengeResult, challenges_coding_challenge_submissions::Entity as ChallengesCodingChallengeSubmissions, challenges_coding_challenges::Entity as ChallengesCodingChallenges, diff --git a/entity/src/sea_orm_active_enums.rs b/entity/src/sea_orm_active_enums.rs index e90b703..df2ec47 100644 --- a/entity/src/sea_orm_active_enums.rs +++ b/entity/src/sea_orm_active_enums.rs @@ -109,6 +109,8 @@ pub enum ChallengesSubtaskType { MultipleChoiceQuestion, #[sea_orm(string_value = "question")] Question, + #[sea_orm(string_value = "cloze")] + Cloze, } #[derive( Debug, diff --git a/lib/src/config/challenges.rs b/lib/src/config/challenges.rs index 508bb8a..38f11bf 100644 --- a/lib/src/config/challenges.rs +++ b/lib/src/config/challenges.rs @@ -13,6 +13,7 @@ pub struct ChallengesConfig { pub multiple_choice_questions: MultipleChoiceQuestions, pub questions: Questions, pub matchings: Matchings, + pub clozes: Clozes, pub coding_challenges: CodingChallenges, } @@ -45,6 +46,13 @@ pub struct Matchings { pub creator_coins: u32, } +#[derive(Debug, Deserialize)] +pub struct Clozes { + pub timeout: u64, + pub hearts: u32, + pub creator_coins: u32, +} + #[derive(Debug, Deserialize)] pub struct CodingChallenges { pub sandkasten_url: Url, diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d1a6dad..07defa0 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -35,6 +35,7 @@ mod m20230815_162457_remove_subtask_fee; mod m20230815_203544_remove_subtask_unlocked; mod m20230816_173651_retire_subtasks; mod m20231014_142202_category_creation_timestamp; +mod m20251107_120000_cloze_tasks; #[async_trait::async_trait] impl MigratorTrait for Migrator { @@ -70,6 +71,7 @@ impl MigratorTrait for Migrator { Box::new(m20230815_203544_remove_subtask_unlocked::Migration), Box::new(m20230816_173651_retire_subtasks::Migration), Box::new(m20231014_142202_category_creation_timestamp::Migration), + Box::new(m20251107_120000_cloze_tasks::Migration), ] } } diff --git a/migration/src/m20251107_120000_cloze_tasks.rs b/migration/src/m20251107_120000_cloze_tasks.rs new file mode 100644 index 0000000..855e7c8 --- /dev/null +++ b/migration/src/m20251107_120000_cloze_tasks.rs @@ -0,0 +1,264 @@ +use sea_orm_migration::{prelude::*, sea_orm::Statement}; + +use crate::m20230322_163425_challenges_init::Subtask; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(Statement::from_string( + manager.get_database_backend(), + "ALTER TYPE challenges_subtask_type ADD VALUE IF NOT EXISTS 'cloze'", + )) + .await?; + + manager + .create_table( + Table::create() + .table(Cloze::Table) + .col( + ColumnDef::new(Cloze::SubtaskId) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Cloze::Content).text().not_null()) + .col( + ColumnDef::new(Cloze::CaseSensitive) + .boolean() + .not_null() + .default(false), + ) + .foreign_key( + ForeignKey::create() + .from(Cloze::Table, Cloze::SubtaskId) + .to(Subtask::Table, Subtask::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ClozeOption::Table) + .col( + ColumnDef::new(ClozeOption::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ClozeOption::ClozeId).uuid().not_null()) + .col(ColumnDef::new(ClozeOption::Position).integer().not_null()) + .col(ColumnDef::new(ClozeOption::Label).text().not_null()) + .foreign_key( + ForeignKey::create() + .from(ClozeOption::Table, ClozeOption::ClozeId) + .to(Cloze::Table, Cloze::SubtaskId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ClozeBlank::Table) + .col( + ColumnDef::new(ClozeBlank::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ClozeBlank::ClozeId).uuid().not_null()) + .col(ColumnDef::new(ClozeBlank::Placeholder).integer().not_null()) + .col(ColumnDef::new(ClozeBlank::Answer).text().not_null()) + .col( + ColumnDef::new(ClozeBlank::Synonyms) + .array(ColumnType::Text) + .not_null(), + ) + .col(ColumnDef::new(ClozeBlank::CorrectOptionId).uuid().null()) + .foreign_key( + ForeignKey::create() + .from(ClozeBlank::Table, ClozeBlank::ClozeId) + .to(Cloze::Table, Cloze::SubtaskId) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(ClozeBlank::Table, ClozeBlank::CorrectOptionId) + .to(ClozeOption::Table, ClozeOption::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ClozeAttempt::Table) + .col( + ColumnDef::new(ClozeAttempt::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ClozeAttempt::ClozeId).uuid().not_null()) + .col(ColumnDef::new(ClozeAttempt::UserId).uuid().not_null()) + .col( + ColumnDef::new(ClozeAttempt::Timestamp) + .timestamp() + .not_null(), + ) + .col(ColumnDef::new(ClozeAttempt::Correct).integer().not_null()) + .col(ColumnDef::new(ClozeAttempt::Total).integer().not_null()) + .col(ColumnDef::new(ClozeAttempt::Solved).boolean().not_null()) + .foreign_key( + ForeignKey::create() + .from(ClozeAttempt::Table, ClozeAttempt::ClozeId) + .to(Cloze::Table, Cloze::SubtaskId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_cloze_blanks_unique_placeholder") + .table(ClozeBlank::Table) + .col(ClozeBlank::ClozeId) + .col(ClozeBlank::Placeholder) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_cloze_blanks_unique_option") + .table(ClozeBlank::Table) + .col(ClozeBlank::CorrectOptionId) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_cloze_options_position") + .table(ClozeOption::Table) + .col(ClozeOption::ClozeId) + .col(ClozeOption::Position) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_cloze_options_position") + .table(ClozeOption::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_cloze_blanks_unique_option") + .table(ClozeBlank::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_cloze_blanks_unique_placeholder") + .table(ClozeBlank::Table) + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(ClozeAttempt::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(ClozeBlank::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(ClozeOption::Table).to_owned()) + .await?; + + manager + .drop_table(Table::drop().table(Cloze::Table).to_owned()) + .await?; + + // The enum value cannot easily be removed once added, so the down migration + // intentionally leaves it in place. + Ok(()) + } +} + +#[derive(Iden)] +pub enum Cloze { + #[iden = "challenges_clozes"] + Table, + SubtaskId, + Content, + CaseSensitive, +} + +#[derive(Iden)] +pub enum ClozeOption { + #[iden = "challenges_cloze_options"] + Table, + Id, + ClozeId, + Position, + Label, +} + +#[derive(Iden)] +pub enum ClozeBlank { + #[iden = "challenges_cloze_blanks"] + Table, + Id, + ClozeId, + Placeholder, + Answer, + Synonyms, + CorrectOptionId, +} + +#[derive(Iden)] +pub enum ClozeAttempt { + #[iden = "challenges_cloze_attempts"] + Table, + Id, + ClozeId, + UserId, + Timestamp, + Correct, + Total, + Solved, +} diff --git a/schemas/src/challenges/cloze.rs b/schemas/src/challenges/cloze.rs new file mode 100644 index 0000000..c5a1e21 --- /dev/null +++ b/schemas/src/challenges/cloze.rs @@ -0,0 +1,228 @@ +use entity::{challenges_cloze_blanks, challenges_cloze_options, challenges_clozes}; +use poem_ext::patch_value::PatchValue; +use poem_openapi::{Enum, Object}; +use uuid::Uuid; + +use super::subtasks::{CreateSubtaskRequest, Subtask, UpdateSubtaskRequest}; + +#[derive(Debug, Clone, Enum)] +#[oai(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ClozeVariant { + /// No options are given; learners must type all answers. + TypeIn, + /// Each blank must be matched against a provided option. + Options, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeSummary { + #[oai(flatten)] + pub subtask: Subtask, + /// The markdown text that contains placeholders like `{{blank_1}}`. + pub content: String, + /// Whether comparisons are case sensitive for all blanks in this cloze. + pub case_sensitive: bool, + /// The detected variant inferred from the stored options. + pub variant: ClozeVariant, + /// Metadata for each blank without revealing the solution. + pub blanks: Vec, + /// Optional pool of options (variant B) that can be assigned to blanks. + pub options: Vec, +} + +#[derive(Debug, Clone, Object)] +pub struct Cloze { + #[oai(flatten)] + pub summary: ClozeSummary, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeWithSolution { + #[oai(flatten)] + pub summary: ClozeSummary, + /// The solution metadata for each blank. + pub blank_solutions: Vec, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeBlank { + /// Unique identifier of the blank. + pub id: Uuid, + /// The 1-based placeholder index extracted from the markdown (`{{blank_}}`). + pub placeholder: u32, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeBlankWithSolution { + #[oai(flatten)] + pub blank: ClozeBlank, + /// The canonical answer for this blank. + pub answer: String, + /// A small list of accepted synonyms (case sensitivity depends on the cloze settings). + pub synonyms: Vec, + /// The option id that solves this blank for variant B; `null` for variant A. + pub option_id: Option, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeOption { + /// Unique identifier of the option. + pub id: Uuid, + /// Visible label of the option. + pub label: String, +} + +#[derive(Debug, Clone, Object)] +pub struct CreateClozeRequest { + #[oai(flatten)] + pub subtask: CreateSubtaskRequest, + /// Markdown text that contains numbered placeholders in the form `{{blank_}}`. + /// The placeholders define where answers will be rendered. + #[oai(validator(max_length = 16384))] + pub content: String, + /// Ordered list of blank definitions. Each placeholder referenced in `content` must appear here + /// exactly once. Extra definitions that refer to non-existent placeholders are ignored. + #[oai(validator(min_items = 1, max_items = 32))] + pub blanks: Vec, + /// Option pool for variant B. If this list is empty, the cloze behaves like variant A + /// (learner types the answer). When it is not empty, `len(options)` must be >= number of blanks. + #[oai(validator(max_items = 64, max_length = 256))] + pub options: Vec, + /// Whether comparisons should be case sensitive. Defaults to `false`. + #[oai(default)] + pub case_sensitive: bool, +} + +#[derive(Debug, Clone, Object, PartialEq, Eq)] +pub struct CreateClozeBlank { + /// The placeholder index that this definition belongs to (the `` in `{{blank_}}`). + #[oai(validator(minimum(value = "1"), maximum(value = "2147483647")))] + pub placeholder: u32, + /// The canonical answer for this blank. + #[oai(validator(min_length = 1, max_length = 512))] + pub answer: String, + /// Optional synonyms that should be accepted for variant A. + #[oai(validator(max_items = 8, max_length = 256), default)] + pub synonyms: Vec, + /// Index inside the `options` array that solves this blank for variant B. Must be `None` + /// when `options` is empty. + pub option_index: Option, +} + +#[derive(Debug, Clone, Object)] +pub struct UpdateClozeRequest { + #[oai(flatten)] + pub subtask: UpdateSubtaskRequest, + /// Updated markdown body. + #[oai(validator(max_length = 16384))] + pub content: PatchValue, + /// Full replacement for all blank definitions. Omit this field to keep the current configuration. + #[oai(validator(min_items = 1, max_items = 32))] + pub blanks: PatchValue>, + /// Full replacement for the options pool. Omit to keep the existing options. + #[oai(validator(max_items = 64, max_length = 256))] + pub options: PatchValue>, + /// Case sensitivity flag. + pub case_sensitive: PatchValue, +} + +#[derive(Debug, Clone, Object)] +pub struct SolveClozeRequest { + /// Exactly one entry per blank id returned by the API. + #[oai(validator(min_items = 1, max_items = 32))] + pub answers: Vec, +} + +#[derive(Debug, Clone, Object)] +pub struct ClozeAnswerSubmission { + /// The blank identifier to fill. + pub blank_id: Uuid, + /// Free-form answer (variant A). + #[oai(validator(max_length = 512))] + pub text: Option, + /// Selected option id (variant B). + pub option_id: Option, +} + +#[derive(Debug, Clone, Object)] +pub struct SolveClozeFeedback { + /// Whether every blank was correct. + pub solved: bool, + /// Number of correctly solved blanks. + pub correct: u32, + /// Total number of blanks in this cloze. + pub total: u32, +} + +impl ClozeSummary { + pub fn from( + cloze: &challenges_clozes::Model, + subtask: Subtask, + blanks: &[challenges_cloze_blanks::Model], + options: &[challenges_cloze_options::Model], + ) -> Self { + Self { + subtask, + content: cloze.content.clone(), + case_sensitive: cloze.case_sensitive, + variant: if options.is_empty() { + ClozeVariant::TypeIn + } else { + ClozeVariant::Options + }, + blanks: blanks + .iter() + .map(|blank| ClozeBlank { + id: blank.id, + placeholder: u32::try_from(blank.placeholder).unwrap_or_default(), + }) + .collect(), + options: options + .iter() + .map(|option| ClozeOption { + id: option.id, + label: option.label.clone(), + }) + .collect(), + } + } +} + +impl Cloze { + pub fn from( + cloze: &challenges_clozes::Model, + subtask: Subtask, + blanks: &[challenges_cloze_blanks::Model], + options: &[challenges_cloze_options::Model], + ) -> Self { + Self { + summary: ClozeSummary::from(cloze, subtask, blanks, options), + } + } +} + +impl ClozeWithSolution { + pub fn from( + cloze: &challenges_clozes::Model, + subtask: Subtask, + blanks: &[challenges_cloze_blanks::Model], + options: &[challenges_cloze_options::Model], + ) -> Self { + let summary = ClozeSummary::from(cloze, subtask, blanks, options); + Self { + blank_solutions: blanks + .iter() + .map(|blank| ClozeBlankWithSolution { + blank: ClozeBlank { + id: blank.id, + placeholder: u32::try_from(blank.placeholder).unwrap_or_default(), + }, + answer: blank.answer.clone(), + synonyms: blank.synonyms.clone(), + option_id: blank.correct_option_id, + }) + .collect(), + summary, + } + } +} diff --git a/schemas/src/challenges/mod.rs b/schemas/src/challenges/mod.rs index bf74a60..5968642 100644 --- a/schemas/src/challenges/mod.rs +++ b/schemas/src/challenges/mod.rs @@ -1,5 +1,6 @@ #[allow(clippy::module_inception)] pub mod challenges; +pub mod cloze; pub mod coding_challenges; pub mod course_tasks; pub mod leaderboard;