Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_configuration.rs

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
32/// Long enough for a real Sisu round trip, short enough that a hung upstream cannot stall the
33/// whole tool call.
34const 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        // Fetched once and shared across facets instead of once per facet, since a single call
344        // commonly requests several facets that would otherwise repeat the same query.
345        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
893/// Staff contacts from role-based assignments, falling back to a best-effort Sisu lookup only
894/// when there are none and a Sisu code exists. Course instances also carry a static
895/// teacher-in-charge contact, but that field goes stale and is not surfaced here.
896async 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
962/// Looks the course code up in Sisu, degrading to a note rather than failing the tool call:
963/// an external HTTP hiccup must not take down a support answer that has other facets to give.
964async 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}