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