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