headless_lms_chatbot/chatbot_tools/custom_tools/
certificate_lookup.rs1use 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
22pub type CertificateLookupTool = ToolProperties<CertificateLookupState>;
26
27pub struct CertificateLookupState {
28 output: CertificateLookupOutput,
29 base_url: String,
30 lookup: CertificateLookup,
31 holder_email: Option<String>,
34}
35
36enum 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
86impl<'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 #[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}