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 completions_can_be_registered_to_sisu: bool,
75 certification_enabled: 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 = "Vec::is_empty")]
175 sisu_contacts: Vec<SisuContactsResult>,
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 SisuContactsResult {
191 Contacts(SisuCourseContacts),
192 Error { error: String },
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
196enum CourseConfigurationFacet {
197 Modules,
198 Certificates,
199 Exams,
200 Schedule,
201 ReviewPolicy,
202 Policies,
203 Staff,
204}
205
206impl CourseConfigurationFacet {
207 fn wire_name(self) -> &'static str {
208 match self {
209 Self::Modules => "modules",
210 Self::Certificates => "certificates",
211 Self::Exams => "exams",
212 Self::Schedule => "schedule",
213 Self::ReviewPolicy => "review_policy",
214 Self::Policies => "policies",
215 Self::Staff => "staff",
216 }
217 }
218
219 fn from_wire_name(s: &str) -> Option<Self> {
220 match s {
221 "modules" => Some(Self::Modules),
222 "certificates" => Some(Self::Certificates),
223 "exams" => Some(Self::Exams),
224 "schedule" => Some(Self::Schedule),
225 "review_policy" => Some(Self::ReviewPolicy),
226 "policies" => Some(Self::Policies),
227 "staff" => Some(Self::Staff),
228 _ => None,
229 }
230 }
231}
232
233pub struct CourseConfigurationArguments {
234 course_id: Uuid,
235 facets: Vec<CourseConfigurationFacet>,
236}
237
238impl<'de> serde::Deserialize<'de> for CourseConfigurationArguments {
239 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
240 where
241 D: serde::Deserializer<'de>,
242 {
243 #[derive(Deserialize)]
244 struct Raw {
245 course_id: String,
246 facets: Vec<String>,
247 }
248 let raw = Raw::deserialize(deserializer)?;
249 let course_id = Uuid::from_str(&raw.course_id).map_err(serde::de::Error::custom)?;
250
251 let mut facets = Vec::new();
252 for wire_name in &raw.facets {
253 let facet = CourseConfigurationFacet::from_wire_name(wire_name).ok_or_else(|| {
254 serde::de::Error::custom(format!(
255 "Unknown facet '{wire_name}'. Valid facets: modules, certificates, exams, schedule, review_policy, policies, staff."
256 ))
257 })?;
258 if !facets.contains(&facet) {
259 facets.push(facet);
260 }
261 }
262 if facets.is_empty() {
263 return Err(serde::de::Error::custom(
264 "At least one facet must be requested.",
265 ));
266 }
267
268 Ok(CourseConfigurationArguments { course_id, facets })
269 }
270}
271
272impl ChatbotToolDeclaration for CourseConfigurationTool {
273 const NAME: &'static str = "course_configuration";
274
275 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
276 vec![ToolRequirement::on_turn(Action::Teach, user_context)]
277 }
278
279 const CATEGORY: ToolCategory = ToolCategory::AdminSupportCourses;
280
281 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
282 AzureLLMFunctionToolDefinition {
283 tool_type: LLMToolType::Function,
284 name: Self::NAME.to_string(),
285 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(),
286 parameters: Schema::strict_object(
287 IndexMap::from([
288 (
289 "course_id".to_string(),
290 SchemaPropertyType::Item(JsonItem {
291 type_field: JSONType::String,
292 description: Some("The id of the course to inspect.".to_string()),
293 }),
294 ),
295 (
296 "facets".to_string(),
297 string_array_property(Some(
298 "Which parts of the course configuration to fetch. Valid values: 'modules', 'certificates', 'exams', 'schedule', 'review_policy', 'policies', 'staff'. At least one is required.",
299 )),
300 ),
301 ]),
302 None,
303 ),
304 strict: true,
305 }
306 }
307}
308
309impl ChatbotTool for CourseConfigurationTool {
310 type Arguments = CourseConfigurationArguments;
311
312 fn call_requirements(
313 arguments: &Self::Arguments,
314 _user_context: &ChatbotTurnContext,
315 ) -> Vec<ToolRequirement> {
316 vec![ToolRequirement::on_course(
317 Action::Teach,
318 arguments.course_id,
319 )]
320 }
321
322 async fn from_db_and_arguments(
323 conn: &mut PgConnection,
324 app_config: &ApplicationConfiguration,
325 cache: &Cache,
326 arguments: Self::Arguments,
327 _user_context: &ChatbotTurnContext,
328 ) -> ChatbotResult<Self> {
329 let course_id = arguments.course_id;
330 let base_url = app_config.base_url.trim_end_matches('/').to_string();
331 let course = courses::get_course(conn, course_id).await.map_err(|e| {
332 chatbot_err!(
333 ToolUseError,
334 format!("No course found with id {course_id}."),
335 e
336 )
337 })?;
338
339 let modules = if arguments.facets.iter().any(|f| {
342 matches!(
343 f,
344 CourseConfigurationFacet::Modules
345 | CourseConfigurationFacet::Certificates
346 | CourseConfigurationFacet::Exams
347 | CourseConfigurationFacet::Staff
348 )
349 }) {
350 Some(course_modules_for(conn, course_id).await?)
351 } else {
352 None
353 };
354
355 let mut facets = IndexMap::new();
356 for facet in &arguments.facets {
357 let value = match facet {
358 CourseConfigurationFacet::Modules => {
359 let modules = modules.as_ref().ok_or_else(|| {
360 chatbot_err!(
361 ToolUseError,
362 "expected modules to have been prefetched".to_string()
363 )
364 })?;
365 CourseConfigurationFacetValue::Modules(
366 modules.iter().map(module_to_info).collect(),
367 )
368 }
369 CourseConfigurationFacet::Certificates => {
370 let configurations =
371 certificate_configurations::get_default_certificate_configurations_and_requirements_by_course(
372 conn, course_id,
373 )
374 .await?;
375 let modules = modules.as_ref().ok_or_else(|| {
376 chatbot_err!(
377 ToolUseError,
378 "expected modules to have been prefetched".to_string()
379 )
380 })?;
381 let infos = configurations
382 .iter()
383 .map(|c| {
384 let module_names = c
385 .requirements
386 .course_module_ids
387 .iter()
388 .map(|module_id| {
389 modules
390 .iter()
391 .find(|m| &m.id == module_id)
392 .and_then(|m| m.name.clone())
393 .unwrap_or_else(|| "Default module".to_string())
394 })
395 .collect::<Vec<_>>();
396 CertificateConfigurationInfo {
397 certificate_configuration_id: c.certificate_configuration.id,
398 is_default_certificate_configuration: c
399 .requirements
400 .is_default_certificate_configuration(),
401 required_course_module_ids: c
402 .requirements
403 .course_module_ids
404 .clone(),
405 required_course_module_names: module_names,
406 }
407 })
408 .collect::<Vec<_>>();
409 CourseConfigurationFacetValue::Certificates(infos)
410 }
411 CourseConfigurationFacet::Exams => {
412 let course_exams = exams::get_exams_for_course(conn, course_id).await?;
413 let modules = modules.as_ref().ok_or_else(|| {
414 chatbot_err!(
415 ToolUseError,
416 "expected modules to have been prefetched".to_string()
417 )
418 })?;
419 let exam_ids: Vec<Uuid> = course_exams.iter().map(|e| e.id).collect();
420 let exams_by_id: std::collections::HashMap<Uuid, exams::ExamSummary> =
421 exams::get_summaries_by_ids(conn, &exam_ids)
422 .await?
423 .into_iter()
424 .map(|exam| (exam.id, exam))
425 .collect();
426 let mut rows = Vec::with_capacity(course_exams.len());
427 for course_exam in &course_exams {
428 let Some(exam) = exams_by_id.get(&course_exam.id) else {
429 continue;
430 };
431 let required_by_modules = modules
432 .iter()
433 .filter(|m| {
434 m.completion_policy
435 .automatic()
436 .map(|r| r.requires_exam)
437 .unwrap_or(false)
438 })
439 .map(|m| {
440 m.name
441 .clone()
442 .unwrap_or_else(|| "Default module".to_string())
443 })
444 .collect::<Vec<_>>();
445 rows.push(ExamInfo {
446 exam_id: exam.id,
447 name: exam.name.clone(),
448 starts_at: exam.starts_at,
449 ends_at: exam.ends_at,
450 time_minutes: exam.time_minutes,
451 minimum_points_treshold: exam.minimum_points_treshold,
452 grade_manually: exam.grade_manually,
453 modules_that_require_this_exam_for_automatic_completion:
454 required_by_modules,
455 });
456 }
457 CourseConfigurationFacetValue::Exams(rows)
458 }
459 CourseConfigurationFacet::Schedule => {
460 let db_chapters = chapters::get_course_chapters(conn, course_id).await?;
461 let instances =
462 course_instances::get_course_instances_for_course(conn, course_id).await?;
463 let overrides = chapters::exercise_deadline_overrides_by_chapter_for_course(
464 conn, course_id,
465 )
466 .await?;
467
468 let chapters_info = db_chapters
469 .iter()
470 .map(|c| {
471 let override_summary =
472 overrides
473 .get(&c.id)
474 .map(|o| ChapterDeadlineOverrideSummary {
475 earliest_exercise_deadline_override: o
476 .earliest_exercise_deadline_override,
477 exercise_deadline_override_count: o
478 .exercise_deadline_override_count,
479 exercise_deadline_override_distinct_count: o
480 .exercise_deadline_override_distinct_count,
481 });
482 ChapterScheduleInfo {
483 chapter_number: c.chapter_number,
484 name: c.name.clone(),
485 opens_at: c.opens_at,
486 deadline: c.deadline,
487 per_exercise_deadline_overrides: override_summary,
488 }
489 })
490 .collect::<Vec<_>>();
491
492 let instances_info = instances
493 .iter()
494 .map(|i| CourseInstanceScheduleInfo {
495 name: i.name.clone(),
496 starts_at: i.starts_at,
497 ends_at: i.ends_at,
498 })
499 .collect::<Vec<_>>();
500
501 CourseConfigurationFacetValue::Schedule(ScheduleInfo {
502 chapter_locking_enabled: course.chapter_locking_enabled,
503 chapters: chapters_info,
504 course_instances: instances_info,
505 })
506 }
507 CourseConfigurationFacet::ReviewPolicy => {
508 let config = peer_or_self_review_configs::get_default_for_course_by_course_id(
509 conn, course_id,
510 )
511 .await?;
512 CourseConfigurationFacetValue::ReviewPolicy(ReviewPolicyInfo {
513 peer_reviews_to_give: config.peer_reviews_to_give,
514 peer_reviews_to_receive: config.peer_reviews_to_receive,
515 accepting_threshold: config.accepting_threshold,
516 processing_strategy: config.processing_strategy,
517 manual_review_cutoff_in_days: config.manual_review_cutoff_in_days,
518 points_are_all_or_nothing: config.points_are_all_or_nothing,
519 reset_answer_if_zero_points_from_review: config
520 .reset_answer_if_zero_points_from_review,
521 flagged_answers_threshold: course.flagged_answers_threshold,
522 flagged_answers_skip_manual_review_and_allow_retry: course
523 .flagged_answers_skip_manual_review_and_allow_retry,
524 note: "This is the course's default review config. Individual exercises can override it with their own.",
525 })
526 }
527 CourseConfigurationFacet::Policies => {
528 CourseConfigurationFacetValue::Policies(PoliciesInfo {
529 closed_at: course.closed_at,
530 closed_additional_message: course.closed_additional_message.clone(),
531 closed_course_successor_id: course.closed_course_successor_id,
532 cheater_detection_enabled: course.cheater_detection_enabled,
533 ai_policy: course.ai_policy,
534 course_material_ai_instructions: course.course_material_ai_instructions,
535 is_draft: course.is_draft,
536 is_test_mode: course.is_test_mode,
537 is_unlisted: course.is_unlisted,
538 is_joinable_by_code_only: course.is_joinable_by_code_only,
539 ask_marketing_consent: course.ask_marketing_consent,
540 })
541 }
542 CourseConfigurationFacet::Staff => {
543 let modules = modules.as_ref().ok_or_else(|| {
544 chatbot_err!(
545 ToolUseError,
546 "expected modules to have been prefetched".to_string()
547 )
548 })?;
549 CourseConfigurationFacetValue::Staff(
550 staff_facet(conn, app_config, cache, course_id, modules).await?,
551 )
552 }
553 };
554 facets.insert(facet.wire_name().to_string(), value);
555 }
556
557 Ok(CourseConfigurationTool {
558 state: CourseConfigurationState {
559 facets,
560 base_url,
561 course_id,
562 },
563 })
564 }
565
566 fn output(&self) -> String {
567 serde_json::to_string_pretty(&self.state.facets)
568 .unwrap_or_else(|_| "Failed to serialize course configuration.".to_string())
569 }
570
571 fn output_description_instructions(&self) -> Option<String> {
572 let facets = &self.state.facets;
573 let mut notes: Vec<String> = Vec::new();
574
575 if let Some(CourseConfigurationFacetValue::Modules(modules)) = facets.get("modules") {
576 if modules.iter().any(|m| m.completion_policy == "manual") {
577 notes.push(
578 "A module with completion_policy \"manual\" is completed by staff action, \
579 not automatically; the absent automatic_completion_* fields there mean \
580 \"not applicable\", not \"no threshold configured\"."
581 .to_string(),
582 );
583 }
584 if modules.iter().any(|m| m.completion_policy == "automatic") {
585 notes.push(
586 "For \"automatic\" modules, an absent points or exercises-attempted \
587 threshold means that particular requirement isn't imposed (the other one \
588 still gates completion), and switching a module to \"manual\" wipes any \
589 stored thresholds. Meeting the listed thresholds is not sufficient by \
590 itself: an answer sitting in WaitingForManualGrading still blocks \
591 completion, and automatic_completion_requires_exam: true requires a passed \
592 exam (by the exam's minimum_points_treshold), not merely an attempted one. \
593 \"Attempted\" means an exercise's activity_progress is submitted or \
594 completed."
595 .to_string(),
596 );
597 }
598 if modules.iter().any(|m| m.name.is_none()) {
599 notes.push(
600 "A module with no name is the course's default/base module; elsewhere in \
601 the platform it is shown under the course's own name (e.g. as \"Default \
602 module\" in the certificates facet)."
603 .to_string(),
604 );
605 }
606 notes.push(
607 "Credits are registered only for modules with a uh_course_code and \
608 completions_can_be_registered_to_sisu on; otherwise the student cannot register \
609 credits at all. certification_enabled alone is not sufficient for a \
610 certificate to exist — a certificate_configuration must also reference the \
611 module."
612 .to_string(),
613 );
614 notes.push(format!(
615 "Modules can be reviewed at {base_url}/manage/courses/{course_id}/modules, \
616 though that page renders completion_policy as an automatic-completion checkbox \
617 rather than a named policy and shows no certificate settings.",
618 base_url = self.state.base_url,
619 course_id = self.state.course_id
620 ));
621 }
622
623 if let Some(CourseConfigurationFacetValue::Certificates(certs)) = facets.get("certificates")
624 {
625 if certs.is_empty() {
626 notes.push(
627 "This facet only returns certificate configurations that require exactly \
628 one module (\"default\" is inferred from that, not a stored flag); a \
629 genuine certificate spanning multiple modules is invisible here, so an \
630 empty list does not mean the course has no certificate."
631 .to_string(),
632 );
633 } else {
634 notes.push(
635 "is_default_certificate_configuration is always true in this output and \
636 carries no information."
637 .to_string(),
638 );
639 }
640 }
641
642 if let Some(CourseConfigurationFacetValue::Exams(exams)) = facets.get("exams") {
643 if !exams.is_empty() {
644 notes.push(
645 "time_minutes is the per-student budget counted from that student's own \
646 exam enrollment start, not from starts_at; both it and the exam window \
647 must still be open. minimum_points_treshold is the pass threshold in \
648 points. Exams belong to an organization, so the same exam can be attached \
649 to several courses, and modules_that_require_this_exam_for_automatic_completion \
650 is computed across all of the course's modules and attached to every exam \
651 row — it does not identify which exam a given module actually requires, \
652 and over-reports on a multi-exam course."
653 .to_string(),
654 );
655 notes.push(format!(
656 "Each exam can be reviewed at {}/manage/exams/<exam_id>; that page does not \
657 show modules_that_require_this_exam_for_automatic_completion.",
658 self.state.base_url
659 ));
660 }
661 if exams.iter().any(|e| e.ends_at.is_none()) {
662 notes.push(
663 "An exam with ends_at absent blocks all submissions — it does not mean the \
664 deadline is unset or unlimited."
665 .to_string(),
666 );
667 }
668 }
669
670 if let Some(CourseConfigurationFacetValue::Schedule(schedule)) = facets.get("schedule") {
671 notes.push(
672 "chapter_locking_enabled is only the course-level switch; per-user chapter \
673 locking (Unlocked / CompletedAndLocked / NotUnlockedYet) is separate and not \
674 shown here, so a \"locked chapter\" complaint can come from either mechanism."
675 .to_string(),
676 );
677 notes.push(format!(
678 "chapter_locking_enabled can be checked in the Edit dialog at \
679 {base_url}/manage/courses/{course_id}/overview; chapter opens_at and deadline \
680 can be checked at {base_url}/manage/courses/{course_id}/pages, inside each \
681 chapter's own edit dialog rather than the chapter list itself.",
682 base_url = self.state.base_url,
683 course_id = self.state.course_id
684 ));
685 if schedule.chapters.iter().any(|c| c.opens_at.is_none()) {
686 notes.push(
687 "A chapter with opens_at absent is always open, not \"opening date \
688 unknown\"; deadline absent means no deadline."
689 .to_string(),
690 );
691 }
692 if schedule
693 .chapters
694 .iter()
695 .any(|c| c.per_exercise_deadline_overrides.is_some())
696 {
697 notes.push(
698 "earliest_exercise_deadline_override is the earliest effective exercise \
699 deadline (falling back to the chapter's own), so it is populated even with \
700 zero real overrides; only a non-zero exercise_deadline_override_count means \
701 exercises actually differ from the chapter deadline."
702 .to_string(),
703 );
704 }
705 if schedule
706 .course_instances
707 .iter()
708 .any(|i| i.starts_at.is_none() || i.ends_at.is_none())
709 {
710 notes.push(
711 "A course instance with starts_at or ends_at absent is open-ended on that \
712 side."
713 .to_string(),
714 );
715 }
716 }
717
718 if let Some(CourseConfigurationFacetValue::ReviewPolicy(review)) =
719 facets.get("review_policy")
720 {
721 notes.push(
722 "accepting_threshold is compared against the average of received Likert 1–5 \
723 answers, not points or a percentage. peer_reviews_to_give gates entry to the \
724 review queue at all — a student who never gives reviews is never queued to \
725 receive any, which is the most common cause of \"I never got my peer \
726 reviews\". manual_review_cutoff_in_days is a timeout on the student's own wait, \
727 not a teacher deadline."
728 .to_string(),
729 );
730 notes.push(match review.processing_strategy {
731 PeerReviewProcessingStrategy::AutomaticallyGradeByAverage => {
732 "processing_strategy AutomaticallyGradeByAverage: below \
733 accepting_threshold the answer is rejected, and \
734 reset_answer_if_zero_points_from_review takes effect under this strategy."
735 .to_string()
736 }
737 PeerReviewProcessingStrategy::AutomaticallyGradeOrManualReviewByAverage => {
738 "processing_strategy AutomaticallyGradeOrManualReviewByAverage: below \
739 accepting_threshold the answer goes to a teacher instead of being \
740 auto-rejected."
741 .to_string()
742 }
743 PeerReviewProcessingStrategy::ManualReviewEverything => {
744 "processing_strategy ManualReviewEverything: a teacher reviews every \
745 answer, but only once the give-and-receive counts are met."
746 .to_string()
747 }
748 });
749 if review.flagged_answers_threshold.is_none() {
750 notes.push(
751 "flagged_answers_threshold absent means peer flagging never \
752 auto-escalates an answer."
753 .to_string(),
754 );
755 }
756 notes.push(format!(
757 "Peer-review settings can be checked at \
758 {base_url}/cms/courses/{course_id}/default-peer-review, and \
759 flagged_answers_threshold / flagged_answers_skip_manual_review_and_allow_retry \
760 in the Edit dialog at {base_url}/manage/courses/{course_id}/overview.",
761 base_url = self.state.base_url,
762 course_id = self.state.course_id
763 ));
764 }
765
766 if let Some(CourseConfigurationFacetValue::Policies(policies)) = facets.get("policies") {
767 notes.push(
768 "closed_at is a scheduled closing timestamp: absent means the course is never \
769 scheduled to close, and a future value means it is still open today — compare \
770 it to now rather than treating its presence as \"closed\". \
771 closed_course_successor_id absent means there is no successor course to point \
772 the student at."
773 .to_string(),
774 );
775 if policies.closed_additional_message.is_some() {
776 notes.push(
777 "closed_additional_message is the teacher's own text; quote it rather than \
778 paraphrasing."
779 .to_string(),
780 );
781 }
782 if policies.ai_policy == CourseAiPolicy::NotSet {
783 notes.push(
784 "ai_policy: NotSet is meaningfully different from NoAi — it means no policy \
785 was chosen, not that AI is disallowed."
786 .to_string(),
787 );
788 }
789 if policies.course_material_ai_instructions.is_some() {
790 notes.push(
791 "course_material_ai_instructions is serialized as a bool even though the \
792 underlying column is text; its presence only tells you instructions exist, \
793 not what they say."
794 .to_string(),
795 );
796 }
797 notes.push(format!(
798 "closed_at, closed_additional_message, closed_course_successor_id, is_draft, \
799 is_test_mode, is_unlisted, is_joinable_by_code_only and ai_policy can be \
800 checked in the Edit dialog at {base_url}/manage/courses/{course_id}/overview; \
801 cheater_detection_enabled instead shows up as per-module thresholds at \
802 {base_url}/manage/courses/{course_id}/other/cheaters.",
803 base_url = self.state.base_url,
804 course_id = self.state.course_id
805 ));
806 }
807
808 if let Some(CourseConfigurationFacetValue::Staff(staff)) = facets.get("staff") {
809 notes.push(
810 "role_based_staff.scope (\"course\" / \"course_instance\" / \"organization\") is \
811 the only way to tell someone who teaches this course from someone who just \
812 runs its organization — the role list intentionally includes org-scoped roles."
813 .to_string(),
814 );
815 if !staff.sisu_contacts.is_empty() {
816 notes.push(
817 "sisu_contacts is who the University of Helsinki's study system Sisu lists \
818 for each UH course code today, and is the authoritative answer to who \
819 teaches the university course: contacts from the course unit come first, \
820 then teachers of its current implementation (source.kind \"realisation\"). \
821 role \"contact-info\" entries may be a free-text note instead of a person. \
822 With no contacts, responsible_organisations is the unit to ask. An Error \
823 entry 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 completions_can_be_registered_to_sisu: module.completions_can_be_registered_to_sisu,
887 certification_enabled: module.certification_enabled,
888 }
889}
890
891async fn staff_facet(
896 conn: &mut PgConnection,
897 app_config: &ApplicationConfiguration,
898 cache: &Cache,
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 mut uh_course_codes: Vec<String> = modules
938 .iter()
939 .filter_map(|m| m.uh_course_code.clone())
940 .collect();
941 uh_course_codes.sort();
942 uh_course_codes.dedup();
943 let sisu_contacts = futures::future::join_all(
945 uh_course_codes
946 .iter()
947 .map(|code| sisu_lookup(app_config, cache, code)),
948 )
949 .await;
950
951 Ok(StaffInfo {
952 role_based_staff: role_based,
953 sisu_contacts,
954 })
955}
956
957fn combined_name(detail: &headless_lms_models::user_details::UserDetail) -> Option<String> {
958 let name = [detail.first_name.as_deref(), detail.last_name.as_deref()]
959 .into_iter()
960 .flatten()
961 .collect::<Vec<_>>()
962 .join(" ");
963 (!name.is_empty()).then_some(name)
964}
965
966async fn sisu_lookup(
969 app_config: &ApplicationConfiguration,
970 cache: &Cache,
971 uh_course_code: &str,
972) -> SisuContactsResult {
973 let client = match SisuClient::new(app_config.base_url.clone()) {
974 Ok(client) => client,
975 Err(e) => {
976 return SisuContactsResult::Error {
977 error: format!("Sisu lookup failed, look up code {uh_course_code} manually: {e}"),
978 };
979 }
980 };
981
982 match tokio::time::timeout(
983 SISU_LOOKUP_TIMEOUT,
984 client.get_course_contacts(cache, uh_course_code),
985 )
986 .await
987 {
988 Ok(Ok(contacts)) => SisuContactsResult::Contacts(contacts),
989 Ok(Err(e)) => SisuContactsResult::Error {
990 error: format!("Sisu lookup failed, look up code {uh_course_code} manually: {e}"),
991 },
992 Err(_) => SisuContactsResult::Error {
993 error: format!("Sisu lookup timed out, look up code {uh_course_code} manually."),
994 },
995 }
996}