Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
certificate_lookup.rs

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