Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
certificate_lookup.rs

1use headless_lms_authorization::Action;
2use headless_lms_utils::cache::Cache;
3
4use indexmap::IndexMap;
5
6use headless_lms_models::chatbot_configurations::ToolCategory;
7use headless_lms_models::{
8    generated_certificates, generated_certificates::UserCertificate, user_details,
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        ChatbotTool, ChatbotToolDeclaration, ToolProperties, argument_parsing::parse_required_uuid,
16        certificate_validation_url, search_url, tool_authorization::ToolRequirement,
17    },
18    prelude::*,
19    user_context::ChatbotTurnContext,
20};
21
22/// Resolves issued certificates for support, either from the verification id on a certificate link
23/// or from the holder's user_id. Unlike `user_course_state`'s certificates facet this needs no
24/// course, and it says nothing about eligibility: it only reports certificates that exist.
25pub type CertificateLookupTool = ToolProperties<CertificateLookupState>;
26
27pub struct CertificateLookupState {
28    output: CertificateLookupOutput,
29    base_url: String,
30    lookup: CertificateLookup,
31    /// The holder's email, for the admin-page links. Every certificate in one result belongs to
32    /// the same user, and it is `None` when the lookup found none or the account has no details.
33    holder_email: Option<String>,
34}
35
36/// What the call looked the certificates up by, which decides what an empty result means.
37enum CertificateLookup {
38    VerificationId(String),
39    UserId(Uuid),
40}
41
42pub struct CertificateLookupArguments {
43    lookup: CertificateLookup,
44}
45
46#[derive(Deserialize)]
47struct RawArguments {
48    verification_id: String,
49    user_id: String,
50}
51
52#[derive(Serialize)]
53struct CertificateLookupOutput {
54    certificates: Vec<CertificateRow>,
55}
56
57#[derive(Serialize)]
58struct CertificateRow {
59    certificate_id: Uuid,
60    user_id: Uuid,
61    verification_id: String,
62    name_on_certificate: String,
63    issued_at: DateTime<Utc>,
64    course_id: Uuid,
65    course_name: String,
66    course_module_name: Option<String>,
67    validation_url: String,
68}
69
70impl CertificateRow {
71    fn from_certificate(certificate: UserCertificate, base_url: &str) -> Self {
72        Self {
73            certificate_id: certificate.id,
74            user_id: certificate.user_id,
75            validation_url: certificate_validation_url(base_url, &certificate.verification_id),
76            verification_id: certificate.verification_id,
77            name_on_certificate: certificate.name_on_certificate,
78            issued_at: certificate.created_at,
79            course_id: certificate.course_id,
80            course_name: certificate.course_name,
81            course_module_name: certificate.course_module_name,
82        }
83    }
84}
85
86/// Manual, not derived: exactly one of the two arguments has to be given, which
87/// `#[derive(Deserialize)]` can't express, and this is what [ChatbotTool::Arguments]'s
88/// `DeserializeOwned` bound is satisfied by (`parse_arguments` below is overridden and never
89/// calls it, but the bound still has to hold).
90impl<'de> serde::Deserialize<'de> for CertificateLookupArguments {
91    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92    where
93        D: serde::Deserializer<'de>,
94    {
95        let raw = RawArguments::deserialize(deserializer)?;
96        build_arguments(raw).map_err(serde::de::Error::custom)
97    }
98}
99
100fn build_arguments(raw: RawArguments) -> ChatbotResult<CertificateLookupArguments> {
101    let verification_id = raw.verification_id.trim();
102    let user_id = raw.user_id.trim();
103
104    let lookup = match (verification_id.is_empty(), user_id.is_empty()) {
105        (true, true) => {
106            return Err(chatbot_err!(
107                InvalidToolArguments,
108                "Give either verification_id or user_id; both are empty.".to_string()
109            ));
110        }
111        (false, false) => {
112            return Err(chatbot_err!(
113                InvalidToolArguments,
114                "Give either verification_id or user_id, not both: they would answer different questions.".to_string()
115            ));
116        }
117        (false, true) => CertificateLookup::VerificationId(verification_id.to_string()),
118        (true, false) => CertificateLookup::UserId(parse_required_uuid("user_id", user_id)?),
119    };
120
121    Ok(CertificateLookupArguments { lookup })
122}
123
124impl ChatbotToolDeclaration for CertificateLookupTool {
125    const NAME: &'static str = "certificate_lookup";
126
127    fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
128        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
129    }
130
131    const CATEGORY: ToolCategory = ToolCategory::AdminSupportLearningProgress;
132
133    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
134        AzureLLMFunctionToolDefinition {
135            tool_type: LLMToolType::Function,
136            name: Self::NAME.to_string(),
137            description: "Look up certificates that have been issued, by the verification id from a certificate link or by the holder's user_id. Returns the ids and the validation URL needed to talk about or correct a certificate. Requires global admin.".to_string(),
138            parameters: Schema::strict_object(
139                IndexMap::from([
140                    (
141                        "verification_id".to_string(),
142                        SchemaPropertyType::Item(JsonItem {
143                            type_field: JSONType::String,
144                            description: Some("The verification id of a single certificate, as it appears at the end of a /certificates/validate/... link, or an empty string to look up by user_id instead.".to_string()),
145                        }),
146                    ),
147                    (
148                        "user_id".to_string(),
149                        SchemaPropertyType::Item(JsonItem {
150                            type_field: JSONType::String,
151                            description: Some("The holder's user_id, as returned by find_user, to list every certificate they hold across all courses. Empty string when looking up by verification_id.".to_string()),
152                        }),
153                    ),
154                ]),
155                None,
156            ),
157            strict: true,
158        }
159    }
160}
161
162impl ChatbotTool for CertificateLookupTool {
163    type Arguments = CertificateLookupArguments;
164
165    fn call_requirements(
166        _arguments: &Self::Arguments,
167        _user_context: &ChatbotTurnContext,
168    ) -> Vec<ToolRequirement> {
169        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
170    }
171
172    fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
173        let raw: RawArguments = serde_json::from_str(&args_string).map_err(|e| {
174            chatbot_err!(
175                InvalidToolArguments,
176                format!("Couldn't parse tool arguments. Arguments: {args_string}"),
177                e
178            )
179        })?;
180        build_arguments(raw)
181    }
182
183    async fn from_db_and_arguments(
184        conn: &mut PgConnection,
185        app_config: &ApplicationConfiguration,
186        _cache: &Cache,
187        arguments: Self::Arguments,
188        _user_context: &ChatbotTurnContext,
189    ) -> ChatbotResult<Self> {
190        let base_url = app_config.base_url.trim_end_matches('/').to_string();
191
192        let certificates = match &arguments.lookup {
193            CertificateLookup::VerificationId(verification_id) => {
194                generated_certificates::get_by_verification_id(conn, verification_id)
195                    .await?
196                    .into_iter()
197                    .collect()
198            }
199            CertificateLookup::UserId(user_id) => {
200                generated_certificates::get_all_by_user_id(conn, *user_id).await?
201            }
202        };
203
204        let holder_email = match certificates.first() {
205            Some(certificate) => {
206                user_details::get_user_details_by_user_id(conn, certificate.user_id)
207                    .await
208                    .optional()?
209                    .map(|detail| detail.email)
210            }
211            None => None,
212        };
213
214        let certificates = certificates
215            .into_iter()
216            .map(|certificate| CertificateRow::from_certificate(certificate, &base_url))
217            .collect();
218
219        Ok(CertificateLookupTool {
220            state: CertificateLookupState {
221                output: CertificateLookupOutput { certificates },
222                base_url,
223                lookup: arguments.lookup,
224                holder_email,
225            },
226        })
227    }
228
229    fn output(&self) -> String {
230        serde_json::to_string_pretty(&self.state.output).unwrap_or_else(|_| "{}".to_string())
231    }
232
233    fn output_description_instructions(&self) -> Option<String> {
234        let mut notes = vec![
235            "Whenever you mention a certificate, link it: render its validation_url as a markdown link on the \
236            certificate itself instead of pasting the URL as text. That page both proves the certificate is \
237            genuine and shows its image, so the verification id in it grants access to the image - share it only \
238            with the certificate's owner or an admin acting for them."
239                .to_string(),
240            "issued_at is the date printed on the certificate, and certificate_id plus course_id are what \
241            update_certificate needs to correct it."
242                .to_string(),
243        ];
244
245        if self.state.output.certificates.is_empty() {
246            notes.push(match &self.state.lookup {
247                CertificateLookup::VerificationId(_) => {
248                    "No certificate has that verification id. It is a short string that gets copied by hand, so check it \
249                    character by character before concluding the certificate was revoked or never existed."
250                        .to_string()
251                }
252                CertificateLookup::UserId(_) => {
253                    "This user holds no certificates. That is not a failure: a certificate only exists once the \
254                    student clicks generate, so an eligible student who never did has none. Use \
255                    user_course_state's certificates facet to see whether they are eligible on a given course."
256                        .to_string()
257                }
258            });
259        } else if let Some(email) = &self.state.holder_email {
260            let mut course_ids: Vec<Uuid> = self
261                .state
262                .output
263                .certificates
264                .iter()
265                .map(|certificate| certificate.course_id)
266                .collect();
267            course_ids.sort_unstable();
268            course_ids.dedup();
269            let certificates_pages: Vec<String> = course_ids
270                .iter()
271                .map(|course_id| {
272                    search_url(
273                        &self.state.base_url,
274                        &format!("/manage/courses/{course_id}/students/certificates"),
275                        email,
276                    )
277                })
278                .collect();
279            notes.push(format!(
280                "{} lists the same certificates from the course side (issued date, verification URL, image) for \
281                cross-checking.",
282                certificates_pages.join(" and ")
283            ));
284        }
285
286        Some(notes.join(" "))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn raw(verification_id: &str, user_id: &str) -> RawArguments {
295        RawArguments {
296            verification_id: verification_id.to_string(),
297            user_id: user_id.to_string(),
298        }
299    }
300
301    /// The schema forces both properties to be present, so "absent" is an empty string and
302    /// exactly one of the two has to carry a value for the call to mean anything.
303    #[test]
304    fn exactly_one_of_the_two_arguments_is_required() {
305        assert!(build_arguments(raw("", "  ")).is_err());
306        assert!(build_arguments(raw("abc123", "00000000-0000-0000-0000-000000000001")).is_err());
307        assert!(matches!(
308            build_arguments(raw(" abc123 ", "")),
309            Ok(CertificateLookupArguments {
310                lookup: CertificateLookup::VerificationId(verification_id)
311            }) if verification_id == "abc123"
312        ));
313        assert!(matches!(
314            build_arguments(raw("", "00000000-0000-0000-0000-000000000001")),
315            Ok(CertificateLookupArguments {
316                lookup: CertificateLookup::UserId(_)
317            })
318        ));
319    }
320}