aslan-ng commited on
Commit
4ac9e5f
·
verified ·
1 Parent(s): 6bb1cbb

Create agent.py

Browse files
Files changed (1) hide show
  1. agent.py +738 -0
agent.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Imports
2
+
3
+ import os
4
+ import json
5
+ import smolagents
6
+ import pandas as pd
7
+ import numpy as np
8
+ import networkx as nx
9
+ from huggingface_hub import login, HfApi
10
+ from datasets import Dataset, DatasetDict, load_dataset
11
+ import difflib
12
+ import openai
13
+ from langchain_community.utilities.wikipedia import WikipediaAPIWrapper
14
+
15
+ # Settings
16
+
17
+ REPO_ID_TECHSPARK_STAFF = "aslan-ng/CMU_TechSpark_Staff"
18
+ REPO_ID_TECHSPARK_COURSES = "aslan-ng/CMU_TechSpark_Courses"
19
+ REPO_ID_TECHSPARK_TOOLS = "aslan-ng/CMU_TechSpark_Tools"
20
+ REPO_ID_TECHSPARK_MAP_NODES = "aslan-ng/CMU_TechSpark_Map_Nodes"
21
+ REPO_ID_TECHSPARK_MAP_EDGES = "aslan-ng/CMU_TechSpark_Map_Edges"
22
+ OPENAI_API = os.getenv("OPENAI_API")
23
+ hf_token = os.getenv("HF_TOKEN_TECHSPARK_AI")
24
+ login(hf_token)
25
+
26
+ NUMERIC_PROFILE = ["Laser Cutting", "Wood Working", "Wood CNC", "Metal Machining", "Metal CNC", "3D Printer", "Welding", "Electronics"]
27
+
28
+ # Load Data
29
+
30
+ def load_data_from_huggingface():
31
+ """
32
+ Loads data from HuggingFace.
33
+ """
34
+ # Staff (People)
35
+ ds_staff = load_dataset(REPO_ID_TECHSPARK_STAFF)
36
+ staff_df = ds_staff["train"].to_pandas()
37
+
38
+ # Courses
39
+ ds_courses = load_dataset(REPO_ID_TECHSPARK_COURSES)
40
+ courses_df = ds_courses["train"].to_pandas()
41
+
42
+ # Tools
43
+ ds_tools = load_dataset(REPO_ID_TECHSPARK_TOOLS)
44
+ tools_df = ds_tools["train"].to_pandas()
45
+
46
+ # Map Nodes
47
+ ds_nodes = load_dataset(REPO_ID_TECHSPARK_MAP_NODES)
48
+ nodes_df = ds_nodes["train"].to_pandas()
49
+
50
+ # Map Edges
51
+ ds_edges = load_dataset(REPO_ID_TECHSPARK_MAP_EDGES)
52
+ edges_df = ds_edges["train"].to_pandas()
53
+
54
+ return staff_df, courses_df, tools_df, nodes_df, edges_df
55
+
56
+ staff_df, courses_df, tools_df, nodes_df, edges_df = load_data_from_huggingface()
57
+
58
+ # General Functions
59
+
60
+ def vector_1st_distance(x: list, y: list):
61
+ """
62
+ Calculate the average 1st distance between two vectors.
63
+ """
64
+ if len(x) != len(y):
65
+ raise ValueError
66
+ return sum(np.array(x) - np.array(y)) / len(x)
67
+
68
+ def skill_score(
69
+ skill_profile: dict, # The skill profile that we want to analyze
70
+ laser_cutting: float = None,
71
+ wood_working: float = None,
72
+ wood_cnc: float = None,
73
+ metal_machining: float = None,
74
+ metal_cnc: float = None,
75
+ three_d_printer: float = None,
76
+ welding: float = None,
77
+ electronics: float = None,
78
+ ):
79
+ """
80
+ Calculate the skill score for a given skill profile. Useful for both staff and courses skill profiles.
81
+ """
82
+ x = []
83
+ y = []
84
+ if laser_cutting is not None:
85
+ x.append(skill_profile['Laser Cutting'])
86
+ y.append(laser_cutting)
87
+ if wood_working is not None:
88
+ x.append(skill_profile['Wood Working'])
89
+ y.append(wood_working)
90
+ if wood_cnc is not None:
91
+ x.append(skill_profile['Wood CNC'])
92
+ y.append(wood_cnc)
93
+ if metal_machining is not None:
94
+ x.append(skill_profile['Metal Machining'])
95
+ y.append(metal_machining)
96
+ if metal_cnc is not None:
97
+ x.append(skill_profile['Metal CNC'])
98
+ y.append(metal_cnc)
99
+ if three_d_printer is not None:
100
+ x.append(skill_profile['3D Printer'])
101
+ y.append(three_d_printer)
102
+ if welding is not None:
103
+ x.append(skill_profile['Welding'])
104
+ y.append(welding)
105
+ if electronics is not None:
106
+ x.append(skill_profile['Electronics'])
107
+ y.append(electronics)
108
+ return vector_1st_distance(x, y)
109
+
110
+ # Staff Functions
111
+
112
+ def all_staff():
113
+ """
114
+ Return a list of all staff.
115
+ """
116
+ return staff_df["Name"].dropna().tolist()
117
+
118
+ def match_staff_name(name: str):
119
+ """
120
+ Match the staff name to the closest match in the staff list.
121
+ """
122
+ matches = difflib.get_close_matches(name, all_staff(), n=1, cutoff=0.2)
123
+ return matches[0] if matches else None
124
+
125
+ def all_available_staff(exclude: list):
126
+ """
127
+ Return a list of all staff with exclusion.
128
+ """
129
+ try:
130
+ exclude = list(exclude)
131
+ except:
132
+ pass
133
+ if exclude is None or len(exclude) == 0:
134
+ return all_staff()
135
+ excluded_names = []
136
+ for raw_name in exclude:
137
+ excluded_name = match_staff_name(raw_name)
138
+ if excluded_name:
139
+ excluded_names.append(excluded_name)
140
+ return [name for name in all_staff() if name not in excluded_names]
141
+
142
+ def get_staff_full_profile(name: str):
143
+ """
144
+ Get the staff full profile given its name (including description and skill).
145
+ """
146
+ name = match_staff_name(name)
147
+ if name:
148
+ full_profile = staff_df[staff_df["Name"] == name].iloc[0].to_dict()
149
+ return full_profile
150
+ return None
151
+
152
+ def get_staff_skills_profile(name: str):
153
+ """
154
+ Get the staff skills profile given its name.
155
+ """
156
+ full_profile = get_staff_full_profile(name)
157
+ return {k: full_profile[k] for k in NUMERIC_PROFILE}
158
+
159
+ def get_staff_profile(name: str):
160
+ """
161
+ Get the staff profile without skill part.
162
+ """
163
+ full_profile = get_staff_full_profile(name)
164
+ return {k: v for k, v in full_profile.items() if k not in NUMERIC_PROFILE}
165
+
166
+ def search_staff_by_skills(
167
+ laser_cutting: float = None,
168
+ wood_working: float = None,
169
+ wood_cnc: float = None,
170
+ metal_machining: float = None,
171
+ metal_cnc: float = None,
172
+ three_d_printer: float = None,
173
+ welding: float = None,
174
+ electronics: float = None,
175
+ exclude: list = None,
176
+ n_results: int = 1,
177
+ ):
178
+ names = all_available_staff(exclude)
179
+ scored = []
180
+ for name in names:
181
+ skills_profile = get_staff_skills_profile(name)
182
+ score = skill_score(
183
+ skill_profile=skills_profile,
184
+ laser_cutting=laser_cutting,
185
+ wood_working=wood_working,
186
+ wood_cnc=wood_cnc,
187
+ metal_machining=metal_machining,
188
+ metal_cnc=metal_cnc,
189
+ three_d_printer=three_d_printer,
190
+ welding=welding,
191
+ electronics=electronics,
192
+ )
193
+ # keep only positive scores
194
+ if score is not None and score > 0:
195
+ scored.append((name, score))
196
+ scored.sort(key=lambda x: x[1]) # sort by score ascending (lower = better)
197
+ return [name for name, score in scored[:n_results]]
198
+
199
+ class SearchStaffInformation(smolagents.tools.Tool):
200
+ name = "search_staff_information"
201
+ description = (
202
+ "Search the staff information by its name."
203
+ )
204
+ inputs = {
205
+ "name": {"type": "string", "description": "Name of the staff member."},
206
+ }
207
+ output_type = "object"
208
+
209
+ def forward(self, name: str) -> str:
210
+ return json.dumps(get_staff_profile(name))
211
+
212
+ class FindSuitableStaff(smolagents.tools.Tool):
213
+ name = "find_suitable_staff"
214
+ description = (
215
+ "Find the most suitable staff member for the task based on required skills."
216
+ )
217
+ inputs = {
218
+ "laser_cutting": {"type": "number", "nullable": True, "description": "Laser cutting skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
219
+ "wood_working": {"type": "number", "nullable": True, "description": "Wood working skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
220
+ "wood_cnc": {"type": "number", "nullable": True, "description": "Wood CNC skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
221
+ "metal_machining": {"type": "number", "nullable": True, "description": "Metal machining skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
222
+ "metal_cnc": {"type": "number", "nullable": True, "description": "Metal CNC skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
223
+ "three_d_printer": {"type": "number", "nullable": True, "description": "3D printer skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
224
+ "welding": {"type": "number", "nullable": True, "description": "Welding skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
225
+ "electronics": {"type": "number", "nullable": True, "description": "Electronics skill required for the task. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
226
+ "exclude": {"type": "number", "nullable": True, "description": "A list of names that we want to exclude from searching. Default is None or an empty list."}
227
+ }
228
+ output_type = "object"
229
+
230
+ def forward(self,
231
+ laser_cutting: float = None,
232
+ wood_working: float = None,
233
+ wood_cnc: float = None,
234
+ metal_machining: float = None,
235
+ metal_cnc: float = None,
236
+ three_d_printer: float = None,
237
+ welding: float = None,
238
+ electronics: float = None,
239
+ exclude: list = None,
240
+ ) -> str:
241
+ names = search_staff_by_skills(
242
+ laser_cutting = laser_cutting,
243
+ wood_working = wood_working,
244
+ wood_cnc = wood_cnc,
245
+ metal_machining = metal_machining,
246
+ metal_cnc = metal_cnc,
247
+ three_d_printer = three_d_printer,
248
+ welding = welding,
249
+ electronics = electronics,
250
+ exclude = exclude,
251
+ n_results = 2
252
+ )
253
+ staff_profiles = [get_staff_profile(name) for name in names]
254
+ return json.dumps(staff_profiles)
255
+
256
+ # Course Functions
257
+
258
+ def all_courses_code():
259
+ """
260
+ Return a list of all course codes.
261
+ """
262
+ return courses_df["Code"].dropna().astype(str).tolist()
263
+
264
+ def all_courses_name():
265
+ """
266
+ Return a list of all course names.
267
+ """
268
+ return courses_df["Name"].dropna().tolist()
269
+
270
+ def course_name_to_code(course_name):
271
+ """
272
+ Convert the course name to course code.
273
+ """
274
+ return str(courses_df[courses_df["Name"] == course_name]["Code"].iloc[0])
275
+
276
+ def course_code_to_name(course_code):
277
+ """
278
+ Convert the course code to course name.
279
+ """
280
+ return str(courses_df[courses_df["Code"].astype(str) == str(course_code)]["Name"].iloc[0])
281
+
282
+ def match_course_name_code(input):
283
+ """
284
+ Match the course to the closest match in the course list and return their codes.
285
+ """
286
+ input = str(input)
287
+ matches = None
288
+ code_matches = difflib.get_close_matches(input, all_courses_code(), n=3, cutoff=0.2)
289
+ name_matches_code = difflib.get_close_matches(input, all_courses_name(), n=2, cutoff=0.3)
290
+ if name_matches_code:
291
+ name_matches = [course_name_to_code(name) for name in name_matches_code]
292
+ else:
293
+ name_matches = None
294
+ if code_matches and name_matches:
295
+ matches = code_matches + name_matches
296
+ elif code_matches and not name_matches:
297
+ matches = code_matches
298
+ elif name_matches and not code_matches:
299
+ matches = name_matches
300
+ return matches
301
+
302
+ def get_course_full_profile(course):
303
+ """
304
+ Get the course full profile given its code (including description and skill).
305
+ """
306
+ # Ensure the input code is a string for comparison
307
+ matches = match_course_name_code(course)
308
+ code = matches[0] if matches else None
309
+ if code:
310
+ full_profile = courses_df[courses_df["Code"].astype(str) == code].iloc[0].to_dict()
311
+ return full_profile
312
+ return None
313
+
314
+ def get_course_skills_profile(course_code):
315
+ """
316
+ Get the course skills profile given its code.
317
+ """
318
+ full_profile = get_course_full_profile(course_code)
319
+ return {k: full_profile[k] for k in NUMERIC_PROFILE}
320
+
321
+ def get_course_profile(course_code):
322
+ """
323
+ Get the course profile without skill part.
324
+ """
325
+ full_profile = get_course_full_profile(course_code)
326
+ return {k: v for k, v in full_profile.items() if k not in NUMERIC_PROFILE}
327
+
328
+ def search_course_by_skills(
329
+ laser_cutting: float = None,
330
+ wood_working: float = None,
331
+ wood_cnc: float = None,
332
+ metal_machining: float = None,
333
+ metal_cnc: float = None,
334
+ three_d_printer: float = None,
335
+ welding: float = None,
336
+ electronics: float = None,
337
+ n_results: int = 1,
338
+ ):
339
+ names = all_courses_code()
340
+ scored_courses = []
341
+
342
+ for name in names:
343
+ skills_profile = get_course_skills_profile(name)
344
+
345
+ score = skill_score(
346
+ skill_profile=skills_profile,
347
+ laser_cutting=laser_cutting,
348
+ wood_working=wood_working,
349
+ wood_cnc=wood_cnc,
350
+ metal_machining=metal_machining,
351
+ metal_cnc=metal_cnc,
352
+ three_d_printer=three_d_printer,
353
+ welding=welding,
354
+ electronics=electronics,
355
+ )
356
+
357
+ if score is not None:
358
+ scored_courses.append((abs(score), name))
359
+ # store (absolute_score, course_name)
360
+
361
+ scored_courses.sort(key=lambda x: x[0])
362
+ return [name for _, name in scored_courses[:n_results]]
363
+
364
+ class SearchCourseInformation(smolagents.tools.Tool):
365
+ name = "search_course_information"
366
+ description = (
367
+ "Search the course information by the course name or course number (code)."
368
+ )
369
+ inputs = {
370
+ "name": {"type": "string", "description": "Course name or course number (code)."},
371
+ }
372
+ output_type = "object"
373
+
374
+ def forward(self, name: str) -> str:
375
+ return json.dumps(get_course_profile(name))
376
+
377
+ class FindSuitableCourses(smolagents.tools.Tool):
378
+ name = "find_suitable_courses"
379
+ description = (
380
+ "Find the top 3 most suitable courses for the task based on required skills. The first element is the best match."
381
+ )
382
+ inputs = {
383
+ "laser_cutting": {"type": "number", "nullable": True, "description": "Laser cutting skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
384
+ "wood_working": {"type": "number", "nullable": True, "description": "Wood working skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
385
+ "wood_cnc": {"type": "number", "nullable": True, "description": "Wood CNC skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
386
+ "metal_machining": {"type": "number", "nullable": True, "description": "Metal machining skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
387
+ "metal_cnc": {"type": "number", "nullable": True, "description": "Metal CNC skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
388
+ "three_d_printer": {"type": "number", "nullable": True, "description": "3D printer skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
389
+ "welding": {"type": "number", "nullable": True, "description": "Welding skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
390
+ "electronics": {"type": "number", "nullable": True, "description": "Electronics skill being taught during the course. It is a number between 0 (no expertise required) to 3 (high expertise expertise). Default is None. If left None, it will be ignored. (Optional)"},
391
+ }
392
+ output_type = "object"
393
+
394
+ def forward(self,
395
+ laser_cutting: float = None,
396
+ wood_working: float = None,
397
+ wood_cnc: float = None,
398
+ metal_machining: float = None,
399
+ metal_cnc: float = None,
400
+ three_d_printer: float = None,
401
+ welding: float = None,
402
+ electronics: float = None,
403
+ ) -> str:
404
+ matches = search_course_by_skills(
405
+ laser_cutting = laser_cutting,
406
+ wood_working = wood_working,
407
+ wood_cnc = wood_cnc,
408
+ metal_machining = metal_machining,
409
+ metal_cnc = metal_cnc,
410
+ three_d_printer = three_d_printer,
411
+ welding = welding,
412
+ electronics = electronics,
413
+ n_results = 3,
414
+ )
415
+ options = [get_course_profile(course) for course in matches]
416
+ return json.dumps(options)
417
+
418
+ # Machines and Tools Functions
419
+
420
+ def all_tools():
421
+ """
422
+ Return a list of all tools and machines.
423
+ """
424
+ return tools_df["Name"].dropna().astype(str).tolist()
425
+
426
+ def match_tool_name(input):
427
+ """
428
+ Match the course to the closest match in the course list and return their codes.
429
+ """
430
+ input = str(input)
431
+ matches = difflib.get_close_matches(input, all_tools(), n=1, cutoff=0.4)
432
+ return matches[0] if matches else None
433
+
434
+ def get_tool_location(name: str):
435
+ """
436
+ Get the tool location given its name.
437
+ """
438
+ tool_name = match_tool_name(name)
439
+ if tool_name is not None:
440
+ return tools_df[tools_df["Name"] == tool_name].iloc[0]["Location"]
441
+ else:
442
+ raise ValueError("Not found.")
443
+
444
+ def is_tool_accessible(name):
445
+ """
446
+ Check if the machine is accessible to students, and if they require taking mandatory courses.
447
+ """
448
+ result = None
449
+ tool_name = match_tool_name(name)
450
+ if tool_name is not None:
451
+ accessible = tools_df[tools_df["Name"] == tool_name].iloc[0]["Accessible by Students"]
452
+ accessible = bool(accessible)
453
+ course_code = tools_df[tools_df["Name"] == tool_name].iloc[0]["Required Course"]
454
+ else:
455
+ raise ValueError("Not found.")
456
+
457
+ if accessible is True:
458
+ if course_code:
459
+ # Accessible
460
+ result_short = "Yes"
461
+ result_description = f"Student can access it, but they may benefit from taking the course {course_code}: {course_code_to_name(course_code)}"
462
+ else:
463
+ # Accessible
464
+ result_short = "Yes"
465
+ result_description = "Student can access it."
466
+ else:
467
+ if course_code:
468
+ # Accessible but conditional (only by passing the course)
469
+ result_short = "Conditional"
470
+ result_description = f"Student can access it only if they take the course {course_code}: {course_code_to_name(course_code)}."
471
+ else:
472
+ # Not accessible by students. Need staff members!
473
+ result_short = "No"
474
+ result_description = "Student cannot access it. Only available to staff memebers. Ask them to do your task for you."
475
+
476
+ result = {
477
+ "name": tool_name,
478
+ "short answer": result_short,
479
+ "description": result_description
480
+ }
481
+ return json.dumps(result)
482
+
483
+ class SearchMachineLocation(smolagents.tools.Tool):
484
+ name = "search_machine_location"
485
+ description = (
486
+ "Search the machine or tool location in the TechSpark."
487
+ )
488
+ inputs = {
489
+ "name": {"type": "string", "description": "Tool or machine name."},
490
+ }
491
+ output_type = "object"
492
+
493
+ def forward(self, name: str) -> str:
494
+ return json.dumps(get_tool_location(name))
495
+
496
+ class CheckMachineAccessibility(smolagents.tools.Tool):
497
+ name = "check_machine_accessibility"
498
+ description = (
499
+ "Check whether machine or tool is accessible to students. Some are accessible, some need to take a course to become accessible, and some are only available to staff members."
500
+ )
501
+ inputs = {
502
+ "name": {"type": "string", "description": "Tool or machine name."},
503
+ }
504
+ output_type = "object"
505
+
506
+ def forward(self, name: str) -> str:
507
+ return json.dumps(is_tool_accessible(name))
508
+
509
+ # Wikipedia Functions
510
+
511
+ class WikipediaSearch(smolagents.Tool):
512
+ """
513
+ Create tool for searching Wikipedia
514
+ """
515
+ name = "wikipedia_search"
516
+ description = "Search Wikipedia, the free encyclopedia."
517
+ inputs = {
518
+ "query": {"type": "string", "nullable": False, "description": "The search terms",},
519
+ }
520
+ output_type = "string"
521
+
522
+ def forward(self, query: str | None = None) -> str:
523
+ if not query:
524
+ return "Error: 'query' is required."
525
+ wikipedia_api = WikipediaAPIWrapper(top_k_results=1)
526
+ answer = wikipedia_api.run(query)
527
+ return answer
528
+
529
+ # Map Functions
530
+
531
+ def all_nodes():
532
+ """
533
+ Return a list of all nodes name.
534
+ """
535
+ return nodes_df["Name"].dropna().astype(str).tolist()
536
+
537
+ def match_node_name(input):
538
+ """
539
+ Match the input to the closest match in the nodes list and return their id.
540
+ """
541
+ input = str(input)
542
+ matches = difflib.get_close_matches(input, all_nodes(), n=1, cutoff=0.2)
543
+ return matches[0] if matches else None
544
+
545
+ def node_pos(id: int):
546
+ row = nodes_df.loc[nodes_df["ID"] == id, ["X", "Y"]]
547
+ if row.empty:
548
+ return None
549
+ return row.iloc[0].tolist()
550
+
551
+ def node_name(id: int):
552
+ row = nodes_df.loc[nodes_df["ID"] == id, ["Name"]]
553
+ if row.empty:
554
+ return None
555
+ return row.iloc[0]["Name"]
556
+
557
+ def node_id(name: str):
558
+ row = nodes_df.loc[nodes_df["Name"] == name, ["ID"]]
559
+ if row.empty:
560
+ return None
561
+ return row.iloc[0]["ID"]
562
+
563
+ def load_graph(nodes_df, edges_df):
564
+ G = nx.Graph()
565
+
566
+ # Add nodes with attributes
567
+ for _, row in nodes_df.iterrows():
568
+ G.add_node(row["ID"])
569
+
570
+ # Add edges
571
+ for _, row in edges_df.iterrows():
572
+ G.add_edge(row["ID 1"], row["ID 2"])
573
+
574
+ return G
575
+
576
+ G = load_graph(nodes_df, edges_df)
577
+
578
+ def path_finder(destination: int, source: int):
579
+ try:
580
+ path = nx.shortest_path(G, source=source, target=destination)
581
+ path = [[int(x), int(y)] for x, y in zip(path[:-1], path[1:])]
582
+ except nx.NetworkXNoPath:
583
+ return None
584
+ return path
585
+
586
+ def shortest_path(destination: int, source: int = None):
587
+ if source is None:
588
+ entrances = [0, 7]
589
+ paths = []
590
+ for entrance in entrances:
591
+ path = path_finder(destination, entrance)
592
+ paths.append(path)
593
+ path = min(paths, key=len)
594
+ else:
595
+ path = path_finder(destination, source)
596
+ return path
597
+
598
+ def path_to_vector(path):
599
+ path_vector = []
600
+ for piece in path:
601
+ start = piece[0]
602
+ end = piece[1]
603
+ start_pos = node_pos(start)
604
+ end_pos = node_pos(end)
605
+ path_vector.append(
606
+ [
607
+ end_pos[0] - start_pos[0],
608
+ end_pos[1] - start_pos[1],
609
+ ]
610
+ )
611
+ return path_vector
612
+
613
+ def path_to_names(path):
614
+ names = []
615
+ for i in range(len(path)):
616
+ if i == 0:
617
+ names.append(node_name(path[i][0]))
618
+ names.append(node_name(path[i][1]))
619
+ else:
620
+ names.append(node_name(path[i][1]))
621
+ return names
622
+
623
+ def vector_angle_signed(v1, v2):
624
+ v1 = np.array(v1, dtype=float)
625
+ v2 = np.array(v2, dtype=float)
626
+
627
+ # Normalize
628
+ n1 = v1 / np.linalg.norm(v1)
629
+ n2 = v2 / np.linalg.norm(v2)
630
+
631
+ # Dot and cross
632
+ dot = np.dot(n1, n2)
633
+ cross = n1[0] * n2[1] - n1[1] * n2[0] # z-component of cross product in 2D
634
+
635
+ # Angle (radians → degrees)
636
+ angle = np.degrees(np.arctan2(cross, dot))
637
+
638
+ return angle
639
+
640
+ def turn_side(v1, v2):
641
+ angle = vector_angle_signed(v1, v2)
642
+ threshold = 10
643
+ if abs(angle) < threshold:
644
+ return "go straight"
645
+ elif angle > 0:
646
+ return "turn left"
647
+ else:
648
+ return "turn right"
649
+
650
+ def path_human(destination, source=None):
651
+ destination_name = match_node_name(destination)
652
+ if source is not None:
653
+ source_name = match_node_name(source)
654
+ source_id = node_id(source_name)
655
+ else:
656
+ source_name = None
657
+ source_id = None
658
+ destination_id = node_id(destination_name)
659
+ path = shortest_path(destination=destination_id, source=source_id)
660
+ names = path_to_names(path)
661
+ vectors = path_to_vector(path)
662
+ turns = []
663
+ for i in range(len(vectors) - 1):
664
+ v1 = vectors[i]
665
+ v2 = vectors[i+1]
666
+ turns.append(turn_side(v1, v2))
667
+
668
+ txt = f"Enter from {names[0]}, "
669
+ for i in range(len(turns)):
670
+ txt += f"you'll reach {names[i+1]}, "
671
+ txt += f"and then {turns[i]}, "
672
+ txt += f"and finally reach {names[-1]}."
673
+
674
+ return txt
675
+
676
+ class PathFinding(smolagents.tools.Tool):
677
+ name = "find_path"
678
+ description = (
679
+ "Find the easiest path to reach areas and locations inside the TechSpark. Also useful to help the user to reach machines in those locations."
680
+ )
681
+ inputs = {
682
+ "destination": {"type": "string", "description": "Name of the location inside the TechSpark."},
683
+ }
684
+ output_type = "object"
685
+
686
+ def forward(self, destination: str) -> str:
687
+ return path_human(destination, source=None)
688
+
689
+ # Agent
690
+
691
+ techspark_definition = """
692
+ TechSpark is the largest makerspace at CMU (Carnegie Mellon University), located in the College of Engineering. 
693
+ Its mission is to promote a vibrant, student-centric making culture to enhance educational, extracurricular, and research activities across the entire campus community.
694
+ """
695
+
696
+ instruction = """
697
+ You are a helpful assistant for the CMU TechSpark facility. Your purpose is to assist users with inquiries related to staff, courses, and tools.
698
+ Use the available tools to find information about staff members, suggest suitable staff based on skills, or provide training information for machines.
699
+ Respond concisely and directly with the information requested by the user, utilizing the output from the tools.
700
+ Which machines to use for a task, and where to find them.
701
+ When you were in doubt, try searching wikipedia to gain more knowledge.
702
+ Only answer questions related to TechSpark and manufacturing. If the question was out of scope, inform the user and try to suggest relevant question to ask.
703
+
704
+ Safety is important. So:
705
+ - When talking about any machines, check whether it is accessbile to students or not.
706
+ - Try to match them to correct staff member specially when you are not sure about your answer or the student work might be dangerous. It is always a good idea to suggest some staff members if they can help and validate users request.
707
+
708
+ Always return smooth, human-readable results.
709
+ """
710
+
711
+ system_prompt = f"""
712
+ {techspark_definition}
713
+ {instruction}
714
+ """
715
+
716
+ model = smolagents.OpenAIServerModel(
717
+ model_id="gpt-4.1-mini",
718
+ api_key=OPENAI_API,
719
+ )
720
+
721
+ agent = smolagents.CodeAgent(
722
+ tools=[
723
+ smolagents.FinalAnswerTool(),
724
+ SearchStaffInformation(),
725
+ FindSuitableStaff(),
726
+ SearchCourseInformation(),
727
+ FindSuitableCourses(),
728
+ SearchMachineLocation(),
729
+ CheckMachineAccessibility(),
730
+ WikipediaSearch(),
731
+ PathFinding(),
732
+ ],
733
+ instructions=system_prompt,
734
+ model=model,
735
+ add_base_tools=False,
736
+ max_steps=10,
737
+ verbosity_level=2, # show steps in logs for class demo
738
+ )