Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_configuration.rs

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
33/// Long enough for the handful of Sisu requests one uncached lookup makes, short enough that a
34/// hung upstream cannot stall the whole tool call.
35const 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        // Fetched once and shared across facets instead of once per facet, since a single call
341        // commonly requests several facets that would otherwise repeat the same query.
342        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
896/// Staff contacts from role-based assignments plus a best-effort Sisu lookup per UH course code,
897/// since Sisu is the only up-to-date source of who teaches a university course. Course instances
898/// also carry a static teacher-in-charge contact, but that field goes stale and is not surfaced
899/// here.
900async 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    // Concurrent so that an unreachable Sisu costs one timeout, not one per course code.
949    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
971/// Looks the course code up in Sisu, degrading to a note rather than failing the tool call:
972/// an external HTTP hiccup must not take down a support answer that has other facets to give.
973async 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}