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