1use headless_lms_authorization::Action;
2use std::str::FromStr;
3use std::time::Duration;
4
5use indexmap::IndexMap;
6
7use headless_lms_models::chatbot_configurations::ToolCategory;
8use headless_lms_models::{
9 certificate_configurations, chapters, course_instances,
10 course_modules::CompletionPolicy,
11 courses::{self, CourseAiPolicy},
12 exams, peer_or_self_review_configs,
13 peer_or_self_review_configs::PeerReviewProcessingStrategy,
14 roles::{Role, UserRole, get_course_related_roles},
15 user_details::get_users_details_by_user_id_map,
16 users,
17};
18use headless_lms_utils::{
19 json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType, string_array_property},
20 services::sisu::{SisuClient, SisuCourseContact},
21};
22
23use crate::{
24 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
25 chatbot_tools::{
26 ChatbotTool, ChatbotToolDeclaration, ToolProperties, tool_authorization::ToolRequirement,
27 },
28 prelude::*,
29 user_context::ChatbotTurnContext,
30};
31
32const SISU_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
35
36pub type CourseConfigurationTool = ToolProperties<CourseConfigurationState>;
37
38pub struct CourseConfigurationState {
39 facets: IndexMap<String, CourseConfigurationFacetValue>,
40 base_url: String,
41 course_id: Uuid,
42}
43
44#[derive(Serialize)]
45#[serde(untagged)]
46enum CourseConfigurationFacetValue {
47 Modules(Vec<ModuleInfo>),
48 Certificates(Vec<CertificateConfigurationInfo>),
49 Exams(Vec<ExamInfo>),
50 Schedule(ScheduleInfo),
51 ReviewPolicy(ReviewPolicyInfo),
52 Policies(PoliciesInfo),
53 Staff(StaffInfo),
54}
55
56#[derive(Serialize)]
57struct ModuleInfo {
58 course_module_id: Uuid,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 name: Option<String>,
61 order_number: i32,
62 completion_policy: &'static str,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 automatic_completion_number_of_exercises_attempted_treshold: Option<i32>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 automatic_completion_number_of_points_treshold: Option<i32>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 automatic_completion_requires_exam: Option<bool>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 ects_credits: Option<f32>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 uh_course_code: Option<String>,
73 certification_enabled: bool,
74 enable_registering_completion_to_uh_open_university: bool,
75 enable_credit_registration_via_suotar: bool,
76}
77
78#[derive(Serialize)]
79struct CertificateConfigurationInfo {
80 certificate_configuration_id: Uuid,
81 is_default_certificate_configuration: bool,
82 required_course_module_ids: Vec<Uuid>,
83 required_course_module_names: Vec<String>,
84}
85
86#[derive(Serialize)]
87struct ExamInfo {
88 exam_id: Uuid,
89 name: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 starts_at: Option<DateTime<Utc>>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 ends_at: Option<DateTime<Utc>>,
94 time_minutes: i32,
95 minimum_points_treshold: i32,
96 grade_manually: bool,
97 modules_that_require_this_exam_for_automatic_completion: Vec<String>,
98}
99
100#[derive(Serialize)]
101struct ScheduleInfo {
102 chapter_locking_enabled: bool,
103 chapters: Vec<ChapterScheduleInfo>,
104 course_instances: Vec<CourseInstanceScheduleInfo>,
105}
106
107#[derive(Serialize)]
108struct ChapterScheduleInfo {
109 chapter_number: i32,
110 name: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 opens_at: Option<DateTime<Utc>>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 deadline: Option<DateTime<Utc>>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 per_exercise_deadline_overrides: Option<ChapterDeadlineOverrideSummary>,
117}
118
119#[derive(Serialize)]
120struct ChapterDeadlineOverrideSummary {
121 #[serde(skip_serializing_if = "Option::is_none")]
122 earliest_exercise_deadline_override: Option<DateTime<Utc>>,
123 exercise_deadline_override_count: i64,
124 exercise_deadline_override_distinct_count: i64,
125}
126
127#[derive(Serialize)]
128struct CourseInstanceScheduleInfo {
129 #[serde(skip_serializing_if = "Option::is_none")]
130 name: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 starts_at: Option<DateTime<Utc>>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 ends_at: Option<DateTime<Utc>>,
135}
136
137#[derive(Serialize)]
138struct ReviewPolicyInfo {
139 peer_reviews_to_give: i32,
140 peer_reviews_to_receive: i32,
141 accepting_threshold: f32,
142 processing_strategy: PeerReviewProcessingStrategy,
143 manual_review_cutoff_in_days: i32,
144 points_are_all_or_nothing: bool,
145 reset_answer_if_zero_points_from_review: bool,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 flagged_answers_threshold: Option<i32>,
148 flagged_answers_skip_manual_review_and_allow_retry: bool,
149 note: &'static str,
150}
151
152#[derive(Serialize)]
153struct PoliciesInfo {
154 #[serde(skip_serializing_if = "Option::is_none")]
155 closed_at: Option<DateTime<Utc>>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 closed_additional_message: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 closed_course_successor_id: Option<Uuid>,
160 cheater_detection_enabled: bool,
161 ai_policy: CourseAiPolicy,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 course_material_ai_instructions: Option<bool>,
164 is_draft: bool,
165 is_test_mode: bool,
166 is_unlisted: bool,
167 is_joinable_by_code_only: bool,
168 ask_marketing_consent: bool,
169}
170
171#[derive(Serialize)]
172struct StaffInfo {
173 role_based_staff: Vec<RoleBasedStaffContactInfo>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 sisu_fallback: Option<SisuFallbackResult>,
176}
177
178#[derive(Serialize)]
179struct RoleBasedStaffContactInfo {
180 #[serde(skip_serializing_if = "Option::is_none")]
181 name: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 email: Option<String>,
184 role: UserRole,
185 scope: &'static str,
186}
187
188#[derive(Serialize)]
189#[serde(untagged)]
190enum SisuFallbackResult {
191 Contacts {
192 course_code: String,
193 contacts: Vec<SisuCourseContact>,
194 },
195 Error {
196 error: String,
197 },
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201enum CourseConfigurationFacet {
202 Modules,
203 Certificates,
204 Exams,
205 Schedule,
206 ReviewPolicy,
207 Policies,
208 Staff,
209}
210
211impl CourseConfigurationFacet {
212 fn wire_name(self) -> &'static str {
213 match self {
214 Self::Modules => "modules",
215 Self::Certificates => "certificates",
216 Self::Exams => "exams",
217 Self::Schedule => "schedule",
218 Self::ReviewPolicy => "review_policy",
219 Self::Policies => "policies",
220 Self::Staff => "staff",
221 }
222 }
223
224 fn from_wire_name(s: &str) -> Option<Self> {
225 match s {
226 "modules" => Some(Self::Modules),
227 "certificates" => Some(Self::Certificates),
228 "exams" => Some(Self::Exams),
229 "schedule" => Some(Self::Schedule),
230 "review_policy" => Some(Self::ReviewPolicy),
231 "policies" => Some(Self::Policies),
232 "staff" => Some(Self::Staff),
233 _ => None,
234 }
235 }
236}
237
238pub struct CourseConfigurationArguments {
239 course_id: Uuid,
240 facets: Vec<CourseConfigurationFacet>,
241}
242
243impl<'de> serde::Deserialize<'de> for CourseConfigurationArguments {
244 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
245 where
246 D: serde::Deserializer<'de>,
247 {
248 #[derive(Deserialize)]
249 struct Raw {
250 course_id: String,
251 facets: Vec<String>,
252 }
253 let raw = Raw::deserialize(deserializer)?;
254 let course_id = Uuid::from_str(&raw.course_id).map_err(serde::de::Error::custom)?;
255
256 let mut facets = Vec::new();
257 for wire_name in &raw.facets {
258 let facet = CourseConfigurationFacet::from_wire_name(wire_name).ok_or_else(|| {
259 serde::de::Error::custom(format!(
260 "Unknown facet '{wire_name}'. Valid facets: modules, certificates, exams, schedule, review_policy, policies, staff."
261 ))
262 })?;
263 if !facets.contains(&facet) {
264 facets.push(facet);
265 }
266 }
267 if facets.is_empty() {
268 return Err(serde::de::Error::custom(
269 "At least one facet must be requested.",
270 ));
271 }
272
273 Ok(CourseConfigurationArguments { course_id, facets })
274 }
275}
276
277impl ChatbotToolDeclaration for CourseConfigurationTool {
278 const NAME: &'static str = "course_configuration";
279
280 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
281 vec![ToolRequirement::on_turn(Action::Teach, user_context)]
282 }
283
284 const CATEGORY: ToolCategory = ToolCategory::AdminSupportCourses;
285
286 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
287 AzureLLMFunctionToolDefinition {
288 tool_type: LLMToolType::Function,
289 name: Self::NAME.to_string(),
290 description: "Get how a course is configured for support purposes: modules and their completion policy, certificates, exams, chapter/instance schedule, peer-or-self review policy, course-level policies, and staff contacts. Requires global admin.".to_string(),
291 parameters: Schema::strict_object(
292 IndexMap::from([
293 (
294 "course_id".to_string(),
295 SchemaPropertyType::Item(JsonItem {
296 type_field: JSONType::String,
297 description: Some("The id of the course to inspect.".to_string()),
298 }),
299 ),
300 (
301 "facets".to_string(),
302 string_array_property(Some(
303 "Which parts of the course configuration to fetch. Valid values: 'modules', 'certificates', 'exams', 'schedule', 'review_policy', 'policies', 'staff'. At least one is required.",
304 )),
305 ),
306 ]),
307 None,
308 ),
309 strict: true,
310 }
311 }
312}
313
314impl ChatbotTool for CourseConfigurationTool {
315 type Arguments = CourseConfigurationArguments;
316
317 fn call_requirements(
318 arguments: &Self::Arguments,
319 _user_context: &ChatbotTurnContext,
320 ) -> Vec<ToolRequirement> {
321 vec![ToolRequirement::on_course(
322 Action::Teach,
323 arguments.course_id,
324 )]
325 }
326
327 async fn from_db_and_arguments(
328 conn: &mut PgConnection,
329 app_config: &ApplicationConfiguration,
330 arguments: Self::Arguments,
331 _user_context: &ChatbotTurnContext,
332 ) -> ChatbotResult<Self> {
333 let course_id = arguments.course_id;
334 let base_url = app_config.base_url.trim_end_matches('/').to_string();
335 let course = courses::get_course(conn, course_id).await.map_err(|e| {
336 chatbot_err!(
337 ToolUseError,
338 format!("No course found with id {course_id}."),
339 e
340 )
341 })?;
342
343 let modules = if arguments.facets.iter().any(|f| {
346 matches!(
347 f,
348 CourseConfigurationFacet::Modules
349 | CourseConfigurationFacet::Certificates
350 | CourseConfigurationFacet::Exams
351 | CourseConfigurationFacet::Staff
352 )
353 }) {
354 Some(course_modules_for(conn, course_id).await?)
355 } else {
356 None
357 };
358
359 let mut facets = IndexMap::new();
360 for facet in &arguments.facets {
361 let value = match facet {
362 CourseConfigurationFacet::Modules => {
363 let modules = modules.as_ref().ok_or_else(|| {
364 chatbot_err!(
365 ToolUseError,
366 "expected modules to have been prefetched".to_string()
367 )
368 })?;
369 CourseConfigurationFacetValue::Modules(
370 modules.iter().map(module_to_info).collect(),
371 )
372 }
373 CourseConfigurationFacet::Certificates => {
374 let configurations =
375 certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
376 conn, course_id,
377 )
378 .await?;
379 let modules = modules.as_ref().ok_or_else(|| {
380 chatbot_err!(
381 ToolUseError,
382 "expected modules to have been prefetched".to_string()
383 )
384 })?;
385 let infos = configurations
386 .iter()
387 .map(|c| {
388 let module_names = c
389 .requirements
390 .course_module_ids
391 .iter()
392 .map(|module_id| {
393 modules
394 .iter()
395 .find(|m| &m.id == module_id)
396 .and_then(|m| m.name.clone())
397 .unwrap_or_else(|| "Default module".to_string())
398 })
399 .collect::<Vec<_>>();
400 CertificateConfigurationInfo {
401 certificate_configuration_id: c.certificate_configuration.id,
402 is_default_certificate_configuration: c
403 .requirements
404 .is_default_certificate_configuration(),
405 required_course_module_ids: c
406 .requirements
407 .course_module_ids
408 .clone(),
409 required_course_module_names: module_names,
410 }
411 })
412 .collect::<Vec<_>>();
413 CourseConfigurationFacetValue::Certificates(infos)
414 }
415 CourseConfigurationFacet::Exams => {
416 let course_exams = exams::get_exams_for_course(conn, course_id).await?;
417 let modules = modules.as_ref().ok_or_else(|| {
418 chatbot_err!(
419 ToolUseError,
420 "expected modules to have been prefetched".to_string()
421 )
422 })?;
423 let exam_ids: Vec<Uuid> = course_exams.iter().map(|e| e.id).collect();
424 let exams_by_id: std::collections::HashMap<Uuid, exams::ExamSummary> =
425 exams::get_summaries_by_ids(conn, &exam_ids)
426 .await?
427 .into_iter()
428 .map(|exam| (exam.id, exam))
429 .collect();
430 let mut rows = Vec::with_capacity(course_exams.len());
431 for course_exam in &course_exams {
432 let Some(exam) = exams_by_id.get(&course_exam.id) else {
433 continue;
434 };
435 let required_by_modules = modules
436 .iter()
437 .filter(|m| {
438 m.completion_policy
439 .automatic()
440 .map(|r| r.requires_exam)
441 .unwrap_or(false)
442 })
443 .map(|m| {
444 m.name
445 .clone()
446 .unwrap_or_else(|| "Default module".to_string())
447 })
448 .collect::<Vec<_>>();
449 rows.push(ExamInfo {
450 exam_id: exam.id,
451 name: exam.name.clone(),
452 starts_at: exam.starts_at,
453 ends_at: exam.ends_at,
454 time_minutes: exam.time_minutes,
455 minimum_points_treshold: exam.minimum_points_treshold,
456 grade_manually: exam.grade_manually,
457 modules_that_require_this_exam_for_automatic_completion:
458 required_by_modules,
459 });
460 }
461 CourseConfigurationFacetValue::Exams(rows)
462 }
463 CourseConfigurationFacet::Schedule => {
464 let db_chapters = chapters::get_course_chapters(conn, course_id).await?;
465 let instances =
466 course_instances::get_course_instances_for_course(conn, course_id).await?;
467 let overrides = chapters::exercise_deadline_overrides_by_chapter_for_course(
468 conn, course_id,
469 )
470 .await?;
471
472 let chapters_info = db_chapters
473 .iter()
474 .map(|c| {
475 let override_summary =
476 overrides
477 .get(&c.id)
478 .map(|o| ChapterDeadlineOverrideSummary {
479 earliest_exercise_deadline_override: o
480 .earliest_exercise_deadline_override,
481 exercise_deadline_override_count: o
482 .exercise_deadline_override_count,
483 exercise_deadline_override_distinct_count: o
484 .exercise_deadline_override_distinct_count,
485 });
486 ChapterScheduleInfo {
487 chapter_number: c.chapter_number,
488 name: c.name.clone(),
489 opens_at: c.opens_at,
490 deadline: c.deadline,
491 per_exercise_deadline_overrides: override_summary,
492 }
493 })
494 .collect::<Vec<_>>();
495
496 let instances_info = instances
497 .iter()
498 .map(|i| CourseInstanceScheduleInfo {
499 name: i.name.clone(),
500 starts_at: i.starts_at,
501 ends_at: i.ends_at,
502 })
503 .collect::<Vec<_>>();
504
505 CourseConfigurationFacetValue::Schedule(ScheduleInfo {
506 chapter_locking_enabled: course.chapter_locking_enabled,
507 chapters: chapters_info,
508 course_instances: instances_info,
509 })
510 }
511 CourseConfigurationFacet::ReviewPolicy => {
512 let config = peer_or_self_review_configs::get_default_for_course_by_course_id(
513 conn, course_id,
514 )
515 .await?;
516 CourseConfigurationFacetValue::ReviewPolicy(ReviewPolicyInfo {
517 peer_reviews_to_give: config.peer_reviews_to_give,
518 peer_reviews_to_receive: config.peer_reviews_to_receive,
519 accepting_threshold: config.accepting_threshold,
520 processing_strategy: config.processing_strategy,
521 manual_review_cutoff_in_days: config.manual_review_cutoff_in_days,
522 points_are_all_or_nothing: config.points_are_all_or_nothing,
523 reset_answer_if_zero_points_from_review: config
524 .reset_answer_if_zero_points_from_review,
525 flagged_answers_threshold: course.flagged_answers_threshold,
526 flagged_answers_skip_manual_review_and_allow_retry: course
527 .flagged_answers_skip_manual_review_and_allow_retry,
528 note: "This is the course's default review config. Individual exercises can override it with their own.",
529 })
530 }
531 CourseConfigurationFacet::Policies => {
532 CourseConfigurationFacetValue::Policies(PoliciesInfo {
533 closed_at: course.closed_at,
534 closed_additional_message: course.closed_additional_message.clone(),
535 closed_course_successor_id: course.closed_course_successor_id,
536 cheater_detection_enabled: course.cheater_detection_enabled,
537 ai_policy: course.ai_policy,
538 course_material_ai_instructions: course.course_material_ai_instructions,
539 is_draft: course.is_draft,
540 is_test_mode: course.is_test_mode,
541 is_unlisted: course.is_unlisted,
542 is_joinable_by_code_only: course.is_joinable_by_code_only,
543 ask_marketing_consent: course.ask_marketing_consent,
544 })
545 }
546 CourseConfigurationFacet::Staff => {
547 let modules = modules.as_ref().ok_or_else(|| {
548 chatbot_err!(
549 ToolUseError,
550 "expected modules to have been prefetched".to_string()
551 )
552 })?;
553 CourseConfigurationFacetValue::Staff(
554 staff_facet(conn, app_config, course_id, modules).await?,
555 )
556 }
557 };
558 facets.insert(facet.wire_name().to_string(), value);
559 }
560
561 Ok(CourseConfigurationTool {
562 state: CourseConfigurationState {
563 facets,
564 base_url,
565 course_id,
566 },
567 })
568 }
569
570 fn output(&self) -> String {
571 serde_json::to_string_pretty(&self.state.facets)
572 .unwrap_or_else(|_| "Failed to serialize course configuration.".to_string())
573 }
574
575 fn output_description_instructions(&self) -> Option<String> {
576 let facets = &self.state.facets;
577 let mut notes: Vec<String> = Vec::new();
578
579 if let Some(CourseConfigurationFacetValue::Modules(modules)) = facets.get("modules") {
580 if modules.iter().any(|m| m.completion_policy == "manual") {
581 notes.push(
582 "A module with completion_policy \"manual\" is completed by staff action, \
583 not automatically; the absent automatic_completion_* fields there mean \
584 \"not applicable\", not \"no threshold configured\"."
585 .to_string(),
586 );
587 }
588 if modules.iter().any(|m| m.completion_policy == "automatic") {
589 notes.push(
590 "For \"automatic\" modules, an absent points or exercises-attempted \
591 threshold means that particular requirement isn't imposed (the other one \
592 still gates completion), and switching a module to \"manual\" wipes any \
593 stored thresholds. Meeting the listed thresholds is not sufficient by \
594 itself: an answer sitting in WaitingForManualGrading still blocks \
595 completion, and automatic_completion_requires_exam: true requires a passed \
596 exam (by the exam's minimum_points_treshold), not merely an attempted one. \
597 \"Attempted\" means an exercise's activity_progress is submitted or \
598 completed."
599 .to_string(),
600 );
601 }
602 if modules.iter().any(|m| m.name.is_none()) {
603 notes.push(
604 "A module with no name is the course's default/base module; elsewhere in \
605 the platform it is shown under the course's own name (e.g. as \"Default \
606 module\" in the certificates facet)."
607 .to_string(),
608 );
609 }
610 notes.push(
611 "enable_registering_completion_to_uh_open_university and \
612 enable_credit_registration_via_suotar are mutually exclusive \
613 credit-registration routes (student-initiated link vs. system push); both \
614 false means the student cannot register credits at all. \
615 certification_enabled alone is not sufficient for a certificate to exist — a \
616 certificate_configuration must also reference the module."
617 .to_string(),
618 );
619 notes.push(format!(
620 "Modules can be reviewed at {base_url}/manage/courses/{course_id}/modules, \
621 though that page renders completion_policy as an automatic-completion checkbox \
622 rather than a named policy and shows no certificate settings.",
623 base_url = self.state.base_url,
624 course_id = self.state.course_id
625 ));
626 }
627
628 if let Some(CourseConfigurationFacetValue::Certificates(certs)) = facets.get("certificates")
629 {
630 if certs.is_empty() {
631 notes.push(
632 "This facet only returns certificate configurations that require exactly \
633 one module (\"default\" is inferred from that, not a stored flag); a \
634 genuine certificate spanning multiple modules is invisible here, so an \
635 empty list does not mean the course has no certificate."
636 .to_string(),
637 );
638 } else {
639 notes.push(
640 "is_default_certificate_configuration is always true in this output and \
641 carries no information."
642 .to_string(),
643 );
644 }
645 }
646
647 if let Some(CourseConfigurationFacetValue::Exams(exams)) = facets.get("exams") {
648 if !exams.is_empty() {
649 notes.push(
650 "time_minutes is the per-student budget counted from that student's own \
651 exam enrollment start, not from starts_at; both it and the exam window \
652 must still be open. minimum_points_treshold is the pass threshold in \
653 points. Exams belong to an organization, so the same exam can be attached \
654 to several courses, and modules_that_require_this_exam_for_automatic_completion \
655 is computed across all of the course's modules and attached to every exam \
656 row — it does not identify which exam a given module actually requires, \
657 and over-reports on a multi-exam course."
658 .to_string(),
659 );
660 notes.push(format!(
661 "Each exam can be reviewed at {}/manage/exams/<exam_id>; that page does not \
662 show modules_that_require_this_exam_for_automatic_completion.",
663 self.state.base_url
664 ));
665 }
666 if exams.iter().any(|e| e.ends_at.is_none()) {
667 notes.push(
668 "An exam with ends_at absent blocks all submissions — it does not mean the \
669 deadline is unset or unlimited."
670 .to_string(),
671 );
672 }
673 }
674
675 if let Some(CourseConfigurationFacetValue::Schedule(schedule)) = facets.get("schedule") {
676 notes.push(
677 "chapter_locking_enabled is only the course-level switch; per-user chapter \
678 locking (Unlocked / CompletedAndLocked / NotUnlockedYet) is separate and not \
679 shown here, so a \"locked chapter\" complaint can come from either mechanism."
680 .to_string(),
681 );
682 notes.push(format!(
683 "chapter_locking_enabled can be checked in the Edit dialog at \
684 {base_url}/manage/courses/{course_id}/overview; chapter opens_at and deadline \
685 can be checked at {base_url}/manage/courses/{course_id}/pages, inside each \
686 chapter's own edit dialog rather than the chapter list itself.",
687 base_url = self.state.base_url,
688 course_id = self.state.course_id
689 ));
690 if schedule.chapters.iter().any(|c| c.opens_at.is_none()) {
691 notes.push(
692 "A chapter with opens_at absent is always open, not \"opening date \
693 unknown\"; deadline absent means no deadline."
694 .to_string(),
695 );
696 }
697 if schedule
698 .chapters
699 .iter()
700 .any(|c| c.per_exercise_deadline_overrides.is_some())
701 {
702 notes.push(
703 "earliest_exercise_deadline_override is the earliest effective exercise \
704 deadline (falling back to the chapter's own), so it is populated even with \
705 zero real overrides; only a non-zero exercise_deadline_override_count means \
706 exercises actually differ from the chapter deadline."
707 .to_string(),
708 );
709 }
710 if schedule
711 .course_instances
712 .iter()
713 .any(|i| i.starts_at.is_none() || i.ends_at.is_none())
714 {
715 notes.push(
716 "A course instance with starts_at or ends_at absent is open-ended on that \
717 side."
718 .to_string(),
719 );
720 }
721 }
722
723 if let Some(CourseConfigurationFacetValue::ReviewPolicy(review)) =
724 facets.get("review_policy")
725 {
726 notes.push(
727 "accepting_threshold is compared against the average of received Likert 1–5 \
728 answers, not points or a percentage. peer_reviews_to_give gates entry to the \
729 review queue at all — a student who never gives reviews is never queued to \
730 receive any, which is the most common cause of \"I never got my peer \
731 reviews\". manual_review_cutoff_in_days is a timeout on the student's own wait, \
732 not a teacher deadline."
733 .to_string(),
734 );
735 notes.push(match review.processing_strategy {
736 PeerReviewProcessingStrategy::AutomaticallyGradeByAverage => {
737 "processing_strategy AutomaticallyGradeByAverage: below \
738 accepting_threshold the answer is rejected, and \
739 reset_answer_if_zero_points_from_review takes effect under this strategy."
740 .to_string()
741 }
742 PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage => {
743 "processing_strategy AutomaticallyGradeOrManualReviewByAverage: below \
744 accepting_threshold the answer goes to a teacher instead of being \
745 auto-rejected."
746 .to_string()
747 }
748 PeerReviewProcessingStrategy::ManualReviewEverything => {
749 "processing_strategy ManualReviewEverything: a teacher reviews every \
750 answer, but only once the give-and-receive counts are met."
751 .to_string()
752 }
753 });
754 if review.flagged_answers_threshold.is_none() {
755 notes.push(
756 "flagged_answers_threshold absent means peer flagging never \
757 auto-escalates an answer."
758 .to_string(),
759 );
760 }
761 notes.push(format!(
762 "Peer-review settings can be checked at \
763 {base_url}/cms/courses/{course_id}/default-peer-review, and \
764 flagged_answers_threshold / flagged_answers_skip_manual_review_and_allow_retry \
765 in the Edit dialog at {base_url}/manage/courses/{course_id}/overview.",
766 base_url = self.state.base_url,
767 course_id = self.state.course_id
768 ));
769 }
770
771 if let Some(CourseConfigurationFacetValue::Policies(policies)) = facets.get("policies") {
772 notes.push(
773 "closed_at is a scheduled closing timestamp: absent means the course is never \
774 scheduled to close, and a future value means it is still open today — compare \
775 it to now rather than treating its presence as \"closed\". \
776 closed_course_successor_id absent means there is no successor course to point \
777 the student at."
778 .to_string(),
779 );
780 if policies.closed_additional_message.is_some() {
781 notes.push(
782 "closed_additional_message is the teacher's own text; quote it rather than \
783 paraphrasing."
784 .to_string(),
785 );
786 }
787 if policies.ai_policy == CourseAiPolicy::NotSet {
788 notes.push(
789 "ai_policy: NotSet is meaningfully different from NoAi — it means no policy \
790 was chosen, not that AI is disallowed."
791 .to_string(),
792 );
793 }
794 if policies.course_material_ai_instructions.is_some() {
795 notes.push(
796 "course_material_ai_instructions is serialized as a bool even though the \
797 underlying column is text; its presence only tells you instructions exist, \
798 not what they say."
799 .to_string(),
800 );
801 }
802 notes.push(format!(
803 "closed_at, closed_additional_message, closed_course_successor_id, is_draft, \
804 is_test_mode, is_unlisted, is_joinable_by_code_only and ai_policy can be \
805 checked in the Edit dialog at {base_url}/manage/courses/{course_id}/overview; \
806 cheater_detection_enabled instead shows up as per-module thresholds at \
807 {base_url}/manage/courses/{course_id}/other/cheaters.",
808 base_url = self.state.base_url,
809 course_id = self.state.course_id
810 ));
811 }
812
813 if let Some(CourseConfigurationFacetValue::Staff(staff)) = facets.get("staff") {
814 notes.push(
815 "role_based_staff.scope (\"course\" / \"course_instance\" / \"organization\") is \
816 the only way to tell someone who teaches this course from someone who just \
817 runs its organization — the role list intentionally includes org-scoped roles."
818 .to_string(),
819 );
820 if staff.sisu_fallback.is_some() {
821 notes.push(
822 "sisu_fallback is present only when role_based_staff is empty; its Error \
823 variant is a note to look the code up by hand, not a failed tool call."
824 .to_string(),
825 );
826 }
827 if staff
828 .role_based_staff
829 .iter()
830 .any(|r| r.scope == "course" || r.scope == "course_instance")
831 {
832 notes.push(format!(
833 "role_based_staff rows scoped to \"course\" or \"course_instance\" can be \
834 checked at {base_url}/manage/courses/{course_id}/permissions; the \
835 organization-scoped rows here aren't on that page, and this facet carries \
836 no organization id to link to those.",
837 base_url = self.state.base_url,
838 course_id = self.state.course_id
839 ));
840 }
841 }
842
843 if facets.contains_key("modules")
844 || facets.contains_key("schedule")
845 || facets.contains_key("policies")
846 {
847 notes.push(
848 "Quote deadline and completion-policy values exactly as configured rather than \
849 paraphrasing them."
850 .to_string(),
851 );
852 }
853
854 (!notes.is_empty()).then(|| notes.join(" "))
855 }
856}
857
858async fn course_modules_for(
859 conn: &mut PgConnection,
860 course_id: Uuid,
861) -> ChatbotResult<Vec<headless_lms_models::course_modules::CourseModule>> {
862 Ok(headless_lms_models::course_modules::get_by_course_id(conn, course_id).await?)
863}
864
865fn module_to_info(module: &headless_lms_models::course_modules::CourseModule) -> ModuleInfo {
866 let (completion_policy, exercises_attempted_treshold, points_treshold, requires_exam) =
867 match &module.completion_policy {
868 CompletionPolicy::Automatic(requirements) => (
869 "automatic",
870 requirements.number_of_exercises_attempted_treshold,
871 requirements.number_of_points_treshold,
872 Some(requirements.requires_exam),
873 ),
874 CompletionPolicy::Manual => ("manual", None, None, None),
875 };
876 ModuleInfo {
877 course_module_id: module.id,
878 name: module.name.clone(),
879 order_number: module.order_number,
880 completion_policy,
881 automatic_completion_number_of_exercises_attempted_treshold: exercises_attempted_treshold,
882 automatic_completion_number_of_points_treshold: points_treshold,
883 automatic_completion_requires_exam: requires_exam,
884 ects_credits: module.ects_credits,
885 uh_course_code: module.uh_course_code.clone(),
886 certification_enabled: module.certification_enabled,
887 enable_registering_completion_to_uh_open_university: module
888 .enable_registering_completion_to_uh_open_university,
889 enable_credit_registration_via_suotar: module.enable_credit_registration_via_suotar,
890 }
891}
892
893async fn staff_facet(
897 conn: &mut PgConnection,
898 app_config: &ApplicationConfiguration,
899 course_id: Uuid,
900 modules: &[headless_lms_models::course_modules::CourseModule],
901) -> ChatbotResult<StaffInfo> {
902 let related_roles = get_course_related_roles(conn, course_id).await?;
903 let role_based_roles: Vec<Role> = related_roles
904 .into_iter()
905 .filter(|role| {
906 !role.is_global
907 && matches!(
908 role.role,
909 UserRole::Teacher | UserRole::Assistant | UserRole::CourseOrExamCreator
910 )
911 })
912 .collect();
913
914 let mut role_based = Vec::with_capacity(role_based_roles.len());
915 if !role_based_roles.is_empty() {
916 let role_user_ids: Vec<Uuid> = role_based_roles.iter().map(|role| role.user_id).collect();
917 let role_users = users::get_by_ids(conn, &role_user_ids).await?;
918 let details = get_users_details_by_user_id_map(conn, &role_users).await?;
919 for role in &role_based_roles {
920 let scope = if role.course_instance_id.is_some() {
921 "course_instance"
922 } else if role.course_id.is_some() {
923 "course"
924 } else {
925 "organization"
926 };
927 let detail = details.get(&role.user_id);
928 role_based.push(RoleBasedStaffContactInfo {
929 name: detail.and_then(combined_name),
930 email: detail.map(|d| d.email.clone()),
931 role: role.role,
932 scope,
933 });
934 }
935 }
936
937 let sisu_course_code = modules.iter().find_map(|m| m.uh_course_code.clone());
938
939 let sisu_fallback = if role_based.is_empty()
940 && let Some(code) = sisu_course_code
941 {
942 Some(sisu_lookup(app_config, &code).await)
943 } else {
944 None
945 };
946
947 Ok(StaffInfo {
948 role_based_staff: role_based,
949 sisu_fallback,
950 })
951}
952
953fn combined_name(detail: &headless_lms_models::user_details::UserDetail) -> Option<String> {
954 let name = [detail.first_name.as_deref(), detail.last_name.as_deref()]
955 .into_iter()
956 .flatten()
957 .collect::<Vec<_>>()
958 .join(" ");
959 (!name.is_empty()).then_some(name)
960}
961
962async fn sisu_lookup(
965 app_config: &ApplicationConfiguration,
966 uh_course_code: &str,
967) -> SisuFallbackResult {
968 let client = match SisuClient::new(app_config.base_url.clone()) {
969 Ok(client) => client,
970 Err(e) => {
971 return SisuFallbackResult::Error {
972 error: format!("Sisu lookup failed, look up code {uh_course_code} manually: {e}"),
973 };
974 }
975 };
976
977 match tokio::time::timeout(
978 SISU_LOOKUP_TIMEOUT,
979 client.get_course_contacts(uh_course_code),
980 )
981 .await
982 {
983 Ok(Ok(contacts)) if !contacts.is_empty() => SisuFallbackResult::Contacts {
984 course_code: uh_course_code.to_string(),
985 contacts,
986 },
987 Ok(Ok(_)) => SisuFallbackResult::Error {
988 error: format!(
989 "Sisu has no responsible-teacher contact for code {uh_course_code}, look it up manually."
990 ),
991 },
992 Ok(Err(e)) => SisuFallbackResult::Error {
993 error: format!("Sisu lookup failed, look up code {uh_course_code} manually: {e}"),
994 },
995 Err(_) => SisuFallbackResult::Error {
996 error: format!("Sisu lookup timed out, look up code {uh_course_code} manually."),
997 },
998 }
999}