Skip to main content

headless_lms_chatbot/chatbot_tools/action_tools/
update_certificate.rs

1use chrono::{NaiveDate, NaiveTime};
2use headless_lms_authorization::Action;
3use indexmap::IndexMap;
4
5use headless_lms_models::chatbot_configurations::ToolCategory;
6use headless_lms_models::{
7    certificate_configuration_to_requirements, course_modules, generated_certificates,
8    generated_certificates::GeneratedCertificate,
9};
10use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
11
12use crate::{
13    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
14    chatbot_tools::{
15        ChatbotToolDeclaration,
16        action_tools::{
17            ActionAuditFields, ConfirmableActionTool, ExecutedAction, verify_display_field,
18        },
19        argument_parsing::parse_required_uuid,
20        certificate_validation_url,
21        tool_authorization::{ToolAuthorization, ToolRequirement},
22    },
23    prelude::*,
24    user_context::ChatbotTurnContext,
25};
26
27/// Corrects the name printed on an already-issued certificate, and optionally the date it was
28/// issued on, after the admin confirms. The certificate image is rendered from the row on every
29/// view, so both corrections take effect on the existing link.
30pub struct UpdateCertificateTool;
31
32/// `current_name_on_certificate` is the display/consistency field: re-checked against the row in
33/// [UpdateCertificateTool::execute] before anything is changed, so a stale or wrong value refuses
34/// rather than mutates.
35pub struct UpdateCertificateArguments {
36    certificate_id: Uuid,
37    course_id: Uuid,
38    current_name_on_certificate: String,
39    new_name_on_certificate: Option<String>,
40    new_date_issued: Option<DateTime<Utc>>,
41}
42
43/// What the confirmed call actually changed, which decides what the model is told to explain.
44pub struct UpdateCertificateFacts {
45    name_changed: bool,
46    date_changed: bool,
47    verification_id: String,
48}
49
50#[derive(Deserialize)]
51struct RawArguments {
52    certificate_id: String,
53    course_id: String,
54    current_name_on_certificate: String,
55    new_name_on_certificate: String,
56    new_date_issued: String,
57}
58
59impl ChatbotToolDeclaration for UpdateCertificateTool {
60    const NAME: &'static str = "update_certificate";
61
62    fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
63        vec![ToolRequirement::on_turn(Action::Teach, user_context)]
64    }
65
66    const CATEGORY: ToolCategory = ToolCategory::AdminSupportLearningProgress;
67
68    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
69        AzureLLMFunctionToolDefinition {
70            tool_type: LLMToolType::Function,
71            name: Self::NAME.to_string(),
72            description: "Correct the name printed on an issued certificate, and optionally the date it was issued on, after the admin confirms. Use to fix a misspelled or outdated name. Resolve the certificate with certificate_lookup first; this does not issue, revoke or regenerate certificates.".to_string(),
73            parameters: Schema::strict_object(
74                IndexMap::from([
75                    (
76                        "certificate_id".to_string(),
77                        SchemaPropertyType::Item(JsonItem {
78                            type_field: JSONType::String,
79                            description: Some("The certificate's id (UUID), as returned by certificate_lookup or user_course_state. Not the verification id.".to_string()),
80                        }),
81                    ),
82                    (
83                        "course_id".to_string(),
84                        SchemaPropertyType::Item(JsonItem {
85                            type_field: JSONType::String,
86                            description: Some("The id of the course the certificate was earned on, as returned alongside the certificate. Checked against the certificate before anything changes.".to_string()),
87                        }),
88                    ),
89                    (
90                        "current_name_on_certificate".to_string(),
91                        SchemaPropertyType::Item(JsonItem {
92                            type_field: JSONType::String,
93                            description: Some("The name currently printed on the certificate, exactly as it was returned. Shown to the admin and checked against the certificate before anything changes.".to_string()),
94                        }),
95                    ),
96                    (
97                        "new_name_on_certificate".to_string(),
98                        SchemaPropertyType::Item(JsonItem {
99                            type_field: JSONType::String,
100                            description: Some("The corrected name to print, or an empty string to leave the name unchanged.".to_string()),
101                        }),
102                    ),
103                    (
104                        "new_date_issued".to_string(),
105                        SchemaPropertyType::Item(JsonItem {
106                            type_field: JSONType::String,
107                            description: Some("The corrected issue date as YYYY-MM-DD (or a full RFC 3339 timestamp), or an empty string to leave the issue date unchanged.".to_string()),
108                        }),
109                    ),
110                ]),
111                None,
112            ),
113            strict: true,
114        }
115    }
116}
117
118impl ConfirmableActionTool for UpdateCertificateTool {
119    type Arguments = UpdateCertificateArguments;
120    type Facts = UpdateCertificateFacts;
121
122    fn call_requirements(
123        arguments: &Self::Arguments,
124        _user_context: &ChatbotTurnContext,
125    ) -> Vec<ToolRequirement> {
126        vec![ToolRequirement::on_course(
127            Action::Teach,
128            arguments.course_id,
129        )]
130    }
131
132    fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
133        let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
134            chatbot_err!(
135                InvalidToolArguments,
136                format!("Couldn't parse tool arguments. Arguments: {arguments}"),
137                e
138            )
139        })?;
140
141        let certificate_id = parse_required_uuid("certificate_id", &raw.certificate_id)?;
142        let course_id = parse_required_uuid("course_id", &raw.course_id)?;
143
144        let current_name_on_certificate = raw.current_name_on_certificate.trim().to_string();
145        if current_name_on_certificate.is_empty() {
146            return Err(chatbot_err!(
147                InvalidToolArguments,
148                "current_name_on_certificate must not be empty.".to_string()
149            ));
150        }
151
152        let new_name_trimmed = raw.new_name_on_certificate.trim();
153        let new_name_on_certificate = (!new_name_trimmed.is_empty())
154            .then(|| new_name_trimmed.to_string())
155            .filter(|new_name| new_name != &current_name_on_certificate);
156
157        let new_date_issued = parse_date_issued(raw.new_date_issued.trim())?;
158
159        if new_name_on_certificate.is_none() && new_date_issued.is_none() {
160            return Err(chatbot_err!(
161                InvalidToolArguments,
162                "Nothing to do: the name is unchanged and new_date_issued is empty.".to_string()
163            ));
164        }
165
166        Ok(UpdateCertificateArguments {
167            certificate_id,
168            course_id,
169            current_name_on_certificate,
170            new_name_on_certificate,
171            new_date_issued,
172        })
173    }
174
175    async fn execute(
176        conn: &mut PgConnection,
177        _app_config: &ApplicationConfiguration,
178        arguments: &Self::Arguments,
179        _authorization: &ToolAuthorization<Self>,
180    ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
181        let certificate = generated_certificates::get_by_id(conn, arguments.certificate_id)
182            .await
183            .optional()?
184            .ok_or_else(|| {
185                chatbot_err!(
186                    InvalidToolArguments,
187                    "The certificate no longer exists. Re-run certificate_lookup.".to_string()
188                )
189            })?;
190
191        verify_certificate_belongs_to_course(conn, &certificate, arguments.course_id).await?;
192
193        verify_display_field(
194            "name on the certificate",
195            &certificate.name_on_certificate,
196            &arguments.current_name_on_certificate,
197            "certificate_lookup",
198        )?;
199
200        // parse_arguments can only reject a no-op name, since the current date is not one of the
201        // model-supplied display fields; the date it asks for may already be the one on the row.
202        let date_issued = arguments.new_date_issued.unwrap_or(certificate.created_at);
203        let date_changed = date_issued != certificate.created_at;
204        if arguments.new_name_on_certificate.is_none() && !date_changed {
205            return Err(chatbot_err!(
206                InvalidToolArguments,
207                format!(
208                    "Nothing to do: the certificate is already dated {} and the name is unchanged.",
209                    date_issued.date_naive()
210                )
211            ));
212        }
213
214        let updated = generated_certificates::update_certificate(
215            conn,
216            arguments.certificate_id,
217            date_issued,
218            arguments.new_name_on_certificate.clone(),
219            Some(certificate.updated_at),
220        )
221        .await?
222        .ok_or_else(|| {
223            chatbot_err!(
224                ToolUseError,
225                "The certificate was changed by someone else while this was waiting for confirmation. Re-run certificate_lookup and ask again.".to_string()
226            )
227        })?;
228
229        let mut changes = Vec::new();
230        if let Some(new_name) = &arguments.new_name_on_certificate {
231            changes.push(format!(
232                "the name was changed from \"{}\" to \"{new_name}\"",
233                arguments.current_name_on_certificate
234            ));
235        }
236        if date_changed {
237            changes.push(format!(
238                "the issue date was changed to {}",
239                date_issued.date_naive()
240            ));
241        }
242
243        Ok((
244            ExecutedAction {
245                output: format!(
246                    "Certificate {} was updated: {}.",
247                    updated.verification_id,
248                    changes.join(" and ")
249                ),
250                client_payload: None,
251                audit: ActionAuditFields {
252                    target_user_id: Some(certificate.user_id),
253                    course_id: Some(arguments.course_id),
254                    summary: format!(
255                        "Certificate {} updated: {}",
256                        updated.verification_id,
257                        changes.join(" and ")
258                    ),
259                },
260            },
261            UpdateCertificateFacts {
262                name_changed: arguments.new_name_on_certificate.is_some(),
263                date_changed,
264                verification_id: updated.verification_id,
265            },
266        ))
267    }
268
269    fn output_description_instructions(
270        arguments: &Self::Arguments,
271        facts: Option<&Self::Facts>,
272        app_config: &ApplicationConfiguration,
273    ) -> Option<String> {
274        let base_url = app_config.base_url.trim_end_matches('/');
275
276        let Some(facts) = facts else {
277            return Some(format!(
278                "Nothing was changed, so the certificate still reads \"{}\". Its page at {}/manage/courses/{}/students/certificates is where the admin can check the current name before asking again.",
279                arguments.current_name_on_certificate, base_url, arguments.course_id
280            ));
281        };
282
283        let validation_url = certificate_validation_url(base_url, &facts.verification_id);
284        let mut notes = vec![format!(
285            "The certificate image is rendered from the record on every view, so the change is already visible: \
286            link the certificate as a markdown link to {validation_url} so the admin can see it. The link itself \
287            did not change, and any copy the student saved earlier still shows the old text."
288        )];
289
290        if facts.name_changed {
291            notes.push(
292                "Only the printed name changed. The student's account name is a different field and is untouched, \
293                so mention that if the admin expected both to change."
294                    .to_string(),
295            );
296        }
297
298        if facts.date_changed {
299            notes.push(format!(
300                "The issue date is the certificate's own creation timestamp, so moving it also moves where the \
301                certificate sorts in the student's profile and in {base_url}/manage/courses/{}/students/certificates.",
302                arguments.course_id
303            ));
304        }
305
306        Some(notes.join(" "))
307    }
308}
309
310/// Parses the optional `new_date_issued` argument: a bare `YYYY-MM-DD` is read as midnight UTC,
311/// and an empty string means the issue date is left alone.
312fn parse_date_issued(raw: &str) -> ChatbotResult<Option<DateTime<Utc>>> {
313    if raw.is_empty() {
314        return Ok(None);
315    }
316    if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
317        return Ok(Some(date.and_time(NaiveTime::MIN).and_utc()));
318    }
319    DateTime::parse_from_rfc3339(raw)
320        .map(|parsed| Some(parsed.with_timezone(&Utc)))
321        .map_err(|e| {
322            chatbot_err!(
323                InvalidToolArguments,
324                format!(
325                    "'{raw}' is not a date this tool understands. Use YYYY-MM-DD or a full RFC 3339 timestamp."
326                ),
327                e
328            )
329        })
330}
331
332/// Refuses unless every course the certificate's configuration requires a module from is
333/// `course_id`. Authorization is checked against `course_id` alone, so without this a call could
334/// pair a certificate with an unrelated course the admin happens to teach.
335async fn verify_certificate_belongs_to_course(
336    conn: &mut PgConnection,
337    certificate: &GeneratedCertificate,
338    course_id: Uuid,
339) -> ChatbotResult<()> {
340    let requirements = certificate_configuration_to_requirements::get_all_requirements_for_certificate_configuration(
341        conn,
342        certificate.certificate_configuration_id,
343    )
344    .await?;
345
346    let modules = course_modules::get_by_ids(conn, &requirements.course_module_ids).await?;
347    if modules.len() != requirements.course_module_ids.len() {
348        return Err(chatbot_err!(
349            ToolUseError,
350            "The certificate requires a course module that no longer exists, so the course it belongs to cannot be established.".to_string()
351        ));
352    }
353
354    if modules.is_empty() || modules.iter().any(|module| module.course_id != course_id) {
355        return Err(chatbot_err!(
356            InvalidToolArguments,
357            "This certificate was not earned on that course. Re-run certificate_lookup and use the course_id it returns for this certificate.".to_string()
358        ));
359    }
360
361    Ok(())
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    /// The model writes the date as free text, so a plain day is accepted alongside a full
369    /// timestamp and anything else has to fail while the LLM can still be told why.
370    #[test]
371    fn a_plain_day_and_a_full_timestamp_are_both_accepted() {
372        assert_eq!(parse_date_issued("").expect("empty is allowed"), None);
373        assert_eq!(
374            parse_date_issued("2026-02-03")
375                .expect("a plain day parses")
376                .map(|date| date.to_rfc3339()),
377            Some("2026-02-03T00:00:00+00:00".to_string())
378        );
379        assert_eq!(
380            parse_date_issued("2026-02-03T10:00:00Z")
381                .expect("an RFC 3339 timestamp parses")
382                .map(|date| date.to_rfc3339()),
383            Some("2026-02-03T10:00:00+00:00".to_string())
384        );
385        assert!(parse_date_issued("3rd of February").is_err());
386        assert!(parse_date_issued("03/02/2026").is_err());
387    }
388
389    fn raw_arguments(new_name: &str, new_date: &str) -> RawArguments {
390        RawArguments {
391            certificate_id: "00000000-0000-0000-0000-000000000001".to_string(),
392            course_id: "00000000-0000-0000-0000-000000000002".to_string(),
393            current_name_on_certificate: "Example Learner".to_string(),
394            new_name_on_certificate: new_name.to_string(),
395            new_date_issued: new_date.to_string(),
396        }
397    }
398
399    /// A call that would write the values already on the certificate is a wasted confirmation
400    /// prompt for the admin, so it is refused where the model can still correct itself.
401    #[test]
402    fn a_call_that_changes_nothing_is_refused() {
403        assert!(build_test_arguments(raw_arguments("", "")).is_err());
404        assert!(build_test_arguments(raw_arguments("Example Learner", "")).is_err());
405        // A case-only correction is a real change: verify_display_field still matches the row.
406        assert!(build_test_arguments(raw_arguments("Example learner ", "")).is_ok());
407        assert!(build_test_arguments(raw_arguments("", "2026-02-03")).is_ok());
408    }
409
410    fn build_test_arguments(raw: RawArguments) -> ChatbotResult<UpdateCertificateArguments> {
411        let json = serde_json::json!({
412            "certificate_id": raw.certificate_id,
413            "course_id": raw.course_id,
414            "current_name_on_certificate": raw.current_name_on_certificate,
415            "new_name_on_certificate": raw.new_name_on_certificate,
416            "new_date_issued": raw.new_date_issued,
417        });
418        UpdateCertificateTool::parse_arguments(&json.to_string())
419    }
420}