browsergym-workarena 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browsergym/workarena/__init__.py +13 -1
- browsergym/workarena/api/category.py +74 -0
- browsergym/workarena/api/change_request.py +87 -0
- browsergym/workarena/api/computer_asset.py +90 -0
- browsergym/workarena/api/cost_center.py +19 -0
- browsergym/workarena/api/expense_line.py +89 -0
- browsergym/workarena/api/incident.py +45 -0
- browsergym/workarena/api/knowledge.py +29 -0
- browsergym/workarena/api/problem.py +90 -0
- browsergym/workarena/api/report.py +183 -0
- browsergym/workarena/api/requested_items.py +63 -0
- browsergym/workarena/api/user.py +11 -8
- browsergym/workarena/api/utils.py +47 -3
- browsergym/workarena/config.py +21 -1
- browsergym/workarena/data_files/setup_files/forms/expected_incident_form_fields.json +1 -1
- browsergym/workarena/data_files/setup_files/forms/expected_request_item_form_fields.json +1 -0
- browsergym/workarena/data_files/setup_files/knowledge/protocols.json +46 -0
- browsergym/workarena/data_files/setup_files/knowledge/test.html +1 -0
- browsergym/workarena/data_files/setup_files/lists/expected_asset_list_columns.json +2 -24
- browsergym/workarena/data_files/setup_files/lists/expected_change_request_list_columns.json +4 -40
- browsergym/workarena/data_files/setup_files/lists/expected_expense_line_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_hardware_list_columns.json +1 -42
- browsergym/workarena/data_files/setup_files/lists/expected_incident_list_columns.json +2 -18
- browsergym/workarena/data_files/setup_files/lists/expected_problem_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_requested_items_list_columns.json +12 -0
- browsergym/workarena/data_files/setup_files/lists/expected_service_catalog_list_columns.json +2 -19
- browsergym/workarena/data_files/setup_files/lists/expected_user_list_columns.json +3 -50
- browsergym/workarena/data_files/task_configs/all_menu.json +95 -95
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -1
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -1
- browsergym/workarena/data_files/task_configs/filter_service_catalog_item_list_task.json +7986 -7982
- browsergym/workarena/data_files/task_configs/impersonation_users.json +3 -3
- browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -1
- browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -1
- browsergym/workarena/human_eval/console.js +176 -0
- browsergym/workarena/human_eval/tool.py +366 -0
- browsergym/workarena/install.py +81 -20
- browsergym/workarena/tasks/base.py +55 -20
- browsergym/workarena/tasks/comp_building_block.py +4 -0
- browsergym/workarena/tasks/compositional/__init__.py +76 -0
- browsergym/workarena/tasks/compositional/base.py +364 -0
- browsergym/workarena/tasks/compositional/dash_do_base.py +1366 -0
- browsergym/workarena/tasks/compositional/dash_do_catalog.py +1127 -0
- browsergym/workarena/tasks/compositional/dash_do_catalog_infeasible.py +2047 -0
- browsergym/workarena/tasks/compositional/dash_do_create_incident.py +403 -0
- browsergym/workarena/tasks/compositional/dash_do_create_incident_infeasible.py +278 -0
- browsergym/workarena/tasks/compositional/dash_do_create_problem.py +336 -0
- browsergym/workarena/tasks/compositional/dash_do_create_problem_infeasible.py +235 -0
- browsergym/workarena/tasks/compositional/dash_do_filter.py +1600 -0
- browsergym/workarena/tasks/compositional/dash_do_request_item.py +1315 -0
- browsergym/workarena/tasks/compositional/dash_do_request_item_infeasible.py +693 -0
- browsergym/workarena/tasks/compositional/delete_record.py +341 -0
- browsergym/workarena/tasks/compositional/edit_knowledge_base.py +457 -0
- browsergym/workarena/tasks/compositional/expense_management.py +598 -0
- browsergym/workarena/tasks/compositional/filter_and_do.py +139 -0
- browsergym/workarena/tasks/compositional/find_and_order_item.py +345 -0
- browsergym/workarena/tasks/compositional/manage_change_request_schedule.py +1417 -0
- browsergym/workarena/tasks/compositional/mark_duplicate_problems.py +499 -0
- browsergym/workarena/tasks/compositional/maximize_investment_return.py +1763 -0
- browsergym/workarena/tasks/compositional/navigate_and_do.py +1151 -0
- browsergym/workarena/tasks/compositional/navigate_and_do_infeasible.py +2100 -0
- browsergym/workarena/tasks/compositional/offboard_user.py +207 -0
- browsergym/workarena/tasks/compositional/onboard_user.py +226 -0
- browsergym/workarena/tasks/compositional/update_task.py +145 -0
- browsergym/workarena/tasks/compositional/utils/curriculum.py +215 -0
- browsergym/workarena/tasks/compositional/utils/infeasible_configs.py +151 -0
- browsergym/workarena/tasks/compositional/utils/knapsack.py +192 -0
- browsergym/workarena/tasks/compositional/warranty_check.py +227 -0
- browsergym/workarena/tasks/compositional/work_assignment.py +804 -0
- browsergym/workarena/tasks/compositional/workload_balancing.py +396 -0
- browsergym/workarena/tasks/dashboard.py +188 -8
- browsergym/workarena/tasks/form.py +1024 -232
- browsergym/workarena/tasks/knowledge.py +216 -25
- browsergym/workarena/tasks/list.py +519 -102
- browsergym/workarena/tasks/mark_duplicate_problem.py +171 -0
- browsergym/workarena/tasks/navigation.py +55 -13
- browsergym/workarena/tasks/scripts/extract_all_menu_items.py +9 -2
- browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +6 -5
- browsergym/workarena/tasks/scripts/service_catalog.py +2 -1
- browsergym/workarena/tasks/scripts/validate.py +8 -2
- browsergym/workarena/tasks/send_chat_message.py +90 -0
- browsergym/workarena/tasks/service_catalog.py +94 -26
- browsergym/workarena/tasks/utils/form.py +1 -4
- browsergym/workarena/tasks/utils/private_tasks.py +63 -0
- browsergym/workarena/tasks/utils/utils.py +13 -0
- {browsergym_workarena-0.2.0.dist-info → browsergym_workarena-0.3.0.dist-info}/METADATA +27 -20
- browsergym_workarena-0.3.0.dist-info/RECORD +138 -0
- {browsergym_workarena-0.2.0.dist-info → browsergym_workarena-0.3.0.dist-info}/entry_points.txt +1 -0
- browsergym_workarena-0.2.0.dist-info/RECORD +0 -85
- {browsergym_workarena-0.2.0.dist-info → browsergym_workarena-0.3.0.dist-info}/WHEEL +0 -0
- {browsergym_workarena-0.2.0.dist-info → browsergym_workarena-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
from typing import Tuple
|
|
2
|
+
from faker import Faker
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
fake = Faker()
|
|
6
|
+
|
|
7
|
+
from playwright.sync_api._generated import Page
|
|
8
|
+
|
|
9
|
+
from .base import CompositionalTask, HumanEvalTask
|
|
10
|
+
|
|
11
|
+
from ...api.incident import create_incident
|
|
12
|
+
from ...api.user import create_user
|
|
13
|
+
from ...api.utils import table_api_call, db_delete_from_table
|
|
14
|
+
from ..base import AbstractServiceNowTask
|
|
15
|
+
from ..list import FilterIncidentListTask
|
|
16
|
+
from ..form import EditIncidentTask
|
|
17
|
+
from ..knowledge import KnowledgeBaseSearchTask
|
|
18
|
+
from ..navigation import AllMenuTask
|
|
19
|
+
|
|
20
|
+
from ...instance import SNowInstance
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkAssignmentTask(CompositionalTask):
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
instance: SNowInstance = None,
|
|
27
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
28
|
+
level: int = 2,
|
|
29
|
+
max_experts_per_category: int = 2,
|
|
30
|
+
max_assignments: int = None,
|
|
31
|
+
min_assignments: int = None,
|
|
32
|
+
num_categories: int = None,
|
|
33
|
+
seed: int = None,
|
|
34
|
+
prefix: str = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Create a compositional task with specific subtasks
|
|
38
|
+
|
|
39
|
+
Parameters:
|
|
40
|
+
-----------
|
|
41
|
+
instance: SNowInstance
|
|
42
|
+
The ServiceNow instance to run the task on.
|
|
43
|
+
fixed_config: list[tuple[AbstractServiceNowTask, dict, bool]]
|
|
44
|
+
A list of tuples, each containing a subtask, its configuration and whether or not it should be validated.
|
|
45
|
+
level: int
|
|
46
|
+
The level of the task; choice between 2 and 3. L2 will have all the info in the the goal and start in the SNOW home page.
|
|
47
|
+
L3 will start in a private task page describing the information needed to complete the task and the related company protocol
|
|
48
|
+
to complete it.
|
|
49
|
+
max_experts_per_category: int
|
|
50
|
+
How many maximum new agents to create for each category.
|
|
51
|
+
max_assignments: int
|
|
52
|
+
Maximum number of incidents created to be assigned.
|
|
53
|
+
For a task, the number is randomly sampled between max_assignments and min_assignments.
|
|
54
|
+
max_assignments: int
|
|
55
|
+
Minimum number of incidents created to be assigned.
|
|
56
|
+
For a task, the number is randomly sampled between max_assignments and min_assignments.
|
|
57
|
+
prefix: str
|
|
58
|
+
Prefix to name the incidents created with a unique prefix
|
|
59
|
+
Attributes:
|
|
60
|
+
-----------
|
|
61
|
+
task_description: str
|
|
62
|
+
The start of the task description to be completed. e.g. "Referring to company protocol 'Work Assignment', assign incidents to different agents with the following information: \n"
|
|
63
|
+
short_description: str
|
|
64
|
+
A short description of the task to be completed. e.g. "Assign task to relevant expert agents"
|
|
65
|
+
"""
|
|
66
|
+
assert level in [2, 3], "Level must be either 2 or 3"
|
|
67
|
+
self.level = level
|
|
68
|
+
self.protocol_name = "Work Assignment: Assign Incidents to Relevant Agents"
|
|
69
|
+
super().__init__(
|
|
70
|
+
instance=instance,
|
|
71
|
+
fixed_config=fixed_config,
|
|
72
|
+
level=level,
|
|
73
|
+
protocol_name=self.protocol_name,
|
|
74
|
+
seed=seed,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.task_description = None
|
|
78
|
+
self.short_description = f"Assign work to relevant agents"
|
|
79
|
+
self.max_experts_per_category = max_experts_per_category
|
|
80
|
+
self.max_assignments = max_assignments
|
|
81
|
+
self.min_assignments = min_assignments
|
|
82
|
+
self.num_categories = num_categories
|
|
83
|
+
if self.num_categories > 4 or self.num_categories < 1:
|
|
84
|
+
raise Exception("Should have at least 1 and at most 4 categories.")
|
|
85
|
+
self.prefix = prefix
|
|
86
|
+
|
|
87
|
+
def setup_goal(self, page: Page) -> tuple[str, dict]:
|
|
88
|
+
self.incident_configs = []
|
|
89
|
+
number_assignments = self.random.randint(self.min_assignments, self.max_assignments)
|
|
90
|
+
|
|
91
|
+
all_existing_incidents = table_api_call(
|
|
92
|
+
instance=self.instance, table="incident", method="GET"
|
|
93
|
+
)["result"]
|
|
94
|
+
all_incident_numbers = [incident["number"] for incident in all_existing_incidents]
|
|
95
|
+
new_incident_numbers = []
|
|
96
|
+
for _ in range(number_assignments):
|
|
97
|
+
incident_number = (
|
|
98
|
+
self.prefix + str(id(self) % (10**8)).zfill(8)[:4] + str(random.randint(100, 999))
|
|
99
|
+
)
|
|
100
|
+
while (
|
|
101
|
+
incident_number in all_incident_numbers or incident_number in new_incident_numbers
|
|
102
|
+
):
|
|
103
|
+
incident_number = (
|
|
104
|
+
self.prefix
|
|
105
|
+
+ str(id(self) % (10**8)).zfill(8)[:4]
|
|
106
|
+
+ str(random.randint(100, 999))
|
|
107
|
+
)
|
|
108
|
+
new_incident_numbers.append(incident_number)
|
|
109
|
+
|
|
110
|
+
self.active_categories = self.random.choice(
|
|
111
|
+
["hardware", "software", "network", "database"], self.num_categories, replace=False
|
|
112
|
+
)
|
|
113
|
+
for incident_number in new_incident_numbers:
|
|
114
|
+
### We can reduce the categories here if the setup takes too long
|
|
115
|
+
category = self.random.choice(self.active_categories)
|
|
116
|
+
incident_response = create_incident(
|
|
117
|
+
instance=self.instance,
|
|
118
|
+
incident_number=incident_number,
|
|
119
|
+
caller_sys_id=self._base_user_sysid,
|
|
120
|
+
category=category,
|
|
121
|
+
priority=4,
|
|
122
|
+
impact=2, # priority is calculated as some combination of impact and urgency
|
|
123
|
+
urgency=3,
|
|
124
|
+
)
|
|
125
|
+
self.incident_configs.append(incident_response)
|
|
126
|
+
|
|
127
|
+
self.experts = dict({category: [] for category in self.active_categories})
|
|
128
|
+
for _ in range(self.max_experts_per_category):
|
|
129
|
+
for category in self.active_categories:
|
|
130
|
+
self.experts[category].append(
|
|
131
|
+
create_user(
|
|
132
|
+
instance=self.instance,
|
|
133
|
+
first_name=f"{fake.first_name()}-{fake.first_name()}",
|
|
134
|
+
last_name=f"{fake.last_name()}-{fake.last_name()}",
|
|
135
|
+
return_full_response=True,
|
|
136
|
+
user_roles=["itil"],
|
|
137
|
+
random=self.random,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
expert_string = ""
|
|
141
|
+
for category in self.active_categories:
|
|
142
|
+
category_experts = ", ".join(
|
|
143
|
+
expert["first_name"] + " " + expert["last_name"]
|
|
144
|
+
for expert in self.experts[category]
|
|
145
|
+
)
|
|
146
|
+
expert_string += f"{category.capitalize()} agents: {category_experts} \n"
|
|
147
|
+
incident_numbers = ", ".join(new_incident_numbers)
|
|
148
|
+
|
|
149
|
+
# Get the task description
|
|
150
|
+
self.task_description = (
|
|
151
|
+
f'Referring to company protocol "{self.protocol_name}" (located in the "Company Protocols" knowledge base) assign work to the agents with the following information: \n'
|
|
152
|
+
+ f"Incidents to assign: {incident_numbers} \n\n"
|
|
153
|
+
+ f"{expert_string}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Sample a configuration
|
|
157
|
+
config = self.fixed_config if self.fixed_config else self._get_config()
|
|
158
|
+
|
|
159
|
+
goal, info = super().setup_goal(page=page, config=config)
|
|
160
|
+
|
|
161
|
+
if self.level == 2:
|
|
162
|
+
goal = (
|
|
163
|
+
self.short_description
|
|
164
|
+
+ f"\n1. Navigate to the Service Desk > Incidents. \n"
|
|
165
|
+
+ f"\n2. You have to assign the following incidents to relevant agents: {incident_numbers}. You can filter the list using each incident number and use the 'Assigned to' field to assign an incident.\n"
|
|
166
|
+
+ f"\n3. You have to ensure that each incident is assigned to a relevant agent based on the category of the incident.\n"
|
|
167
|
+
+ f"\nThe category wise agents are as follows. You can assign an incident to ANY agent from the category:\n"
|
|
168
|
+
+ f"{expert_string}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return goal, info
|
|
172
|
+
|
|
173
|
+
def _get_config(self) -> list[tuple[AbstractServiceNowTask, dict, bool]]:
|
|
174
|
+
|
|
175
|
+
navigate_to_protocol_subtask = [
|
|
176
|
+
# Navigate to the KB
|
|
177
|
+
AllMenuTask(
|
|
178
|
+
instance=self.instance,
|
|
179
|
+
fixed_config={
|
|
180
|
+
"application": "Self-Service",
|
|
181
|
+
"module": "Knowledge",
|
|
182
|
+
"url": "/now/nav/ui/classic/params/target/%24knowledge.do",
|
|
183
|
+
},
|
|
184
|
+
is_validated=False,
|
|
185
|
+
used_in_level_2=False,
|
|
186
|
+
),
|
|
187
|
+
# Find the protocol for on-boarding a new user
|
|
188
|
+
KnowledgeBaseSearchTask(
|
|
189
|
+
instance=self.instance,
|
|
190
|
+
fixed_config={
|
|
191
|
+
"alternative_answers": [],
|
|
192
|
+
"item": f"{self.protocol_name}",
|
|
193
|
+
"question": "Can you find the Work Assignment Protocol in the Knowledge Base?",
|
|
194
|
+
"value": "",
|
|
195
|
+
},
|
|
196
|
+
is_validated=False,
|
|
197
|
+
used_in_level_2=False,
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
all_incident_assignments = []
|
|
202
|
+
|
|
203
|
+
for incident_config in self.incident_configs:
|
|
204
|
+
assigned_to = self.random.choice(self.experts[incident_config["category"]])
|
|
205
|
+
assigned_to = assigned_to["first_name"] + " " + assigned_to["last_name"]
|
|
206
|
+
assign_incidents_subtask = [
|
|
207
|
+
# Navigate to the incidents list
|
|
208
|
+
AllMenuTask(
|
|
209
|
+
instance=self.instance,
|
|
210
|
+
fixed_config={
|
|
211
|
+
"application": "Service Desk",
|
|
212
|
+
"module": "Incidents",
|
|
213
|
+
"url": "/now/nav/ui/classic/params/target/incident_list.do",
|
|
214
|
+
},
|
|
215
|
+
is_validated=False,
|
|
216
|
+
used_in_level_2=True,
|
|
217
|
+
),
|
|
218
|
+
# Filter incident
|
|
219
|
+
FilterIncidentListTask(
|
|
220
|
+
instance=self.instance,
|
|
221
|
+
fixed_config={
|
|
222
|
+
"filter_columns": [
|
|
223
|
+
"number",
|
|
224
|
+
],
|
|
225
|
+
"filter_kind": "AND",
|
|
226
|
+
"filter_values": [
|
|
227
|
+
incident_config["number"],
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
is_validated=False,
|
|
231
|
+
used_in_level_2=True,
|
|
232
|
+
),
|
|
233
|
+
# Edit incident
|
|
234
|
+
EditIncidentTask(
|
|
235
|
+
instance=self.instance,
|
|
236
|
+
# fixed_config=incident_config,
|
|
237
|
+
new_values={"assigned_to": assigned_to},
|
|
238
|
+
is_validated=False,
|
|
239
|
+
used_in_level_2=True,
|
|
240
|
+
record_sys_id=incident_config["sys_id"],
|
|
241
|
+
level=self.level,
|
|
242
|
+
),
|
|
243
|
+
]
|
|
244
|
+
all_incident_assignments.extend(assign_incidents_subtask)
|
|
245
|
+
|
|
246
|
+
config = navigate_to_protocol_subtask + all_incident_assignments
|
|
247
|
+
|
|
248
|
+
return config
|
|
249
|
+
|
|
250
|
+
def validate(self, page: Page, chat_messages: list[str]) -> Tuple[float, bool, str, dict]:
|
|
251
|
+
experts_sys_ids = {
|
|
252
|
+
category: [expert["sys_id"] for expert in self.experts[category]]
|
|
253
|
+
for category in self.experts
|
|
254
|
+
}
|
|
255
|
+
for incident_config in self.incident_configs:
|
|
256
|
+
incident_response = table_api_call(
|
|
257
|
+
instance=self.instance,
|
|
258
|
+
table="incident",
|
|
259
|
+
params={
|
|
260
|
+
"sysparm_query": f"sys_id={incident_config['sys_id']}",
|
|
261
|
+
"sysparm_fields": "category,assigned_to",
|
|
262
|
+
},
|
|
263
|
+
method="GET",
|
|
264
|
+
)["result"][0]
|
|
265
|
+
if incident_response["category"] != incident_config["category"]:
|
|
266
|
+
raise Exception("Corrupted incident data")
|
|
267
|
+
if not incident_response["assigned_to"]:
|
|
268
|
+
return (
|
|
269
|
+
0,
|
|
270
|
+
False,
|
|
271
|
+
"",
|
|
272
|
+
{
|
|
273
|
+
"message": f"The incident {incident_config['number']} has not been assigned to anyone."
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
if (
|
|
277
|
+
incident_response["assigned_to"]["value"]
|
|
278
|
+
not in experts_sys_ids[incident_response["category"]]
|
|
279
|
+
):
|
|
280
|
+
return (
|
|
281
|
+
0,
|
|
282
|
+
False,
|
|
283
|
+
"",
|
|
284
|
+
{
|
|
285
|
+
"message": f"The incident {incident_config['number']} was assigned to an incorrect expert."
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
# Validate final_l3 tasks
|
|
289
|
+
reward, done, message, info = super().validate(page, chat_messages)
|
|
290
|
+
return reward, done, message, info
|
|
291
|
+
|
|
292
|
+
def teardown(self) -> None:
|
|
293
|
+
for incident in self.incident_configs:
|
|
294
|
+
db_delete_from_table(
|
|
295
|
+
instance=self.instance, table="incident", sys_id=incident["sys_id"]
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
for experts in self.experts.values():
|
|
299
|
+
for expert in experts:
|
|
300
|
+
db_delete_from_table(
|
|
301
|
+
instance=self.instance, table="sys_user", sys_id=expert["sys_id"]
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return super().teardown()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class WorkAssignmentSmallTask(WorkAssignmentTask, HumanEvalTask):
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
instance: SNowInstance = None,
|
|
311
|
+
seed: int = None,
|
|
312
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
313
|
+
level: int = 2,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Small version of workassignment task.
|
|
317
|
+
"""
|
|
318
|
+
super().__init__(
|
|
319
|
+
instance=instance,
|
|
320
|
+
level=level,
|
|
321
|
+
max_experts_per_category=2,
|
|
322
|
+
max_assignments=4,
|
|
323
|
+
min_assignments=3,
|
|
324
|
+
num_categories=2,
|
|
325
|
+
fixed_config=fixed_config,
|
|
326
|
+
seed=seed,
|
|
327
|
+
prefix="WAS",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class WorkAssignmentMediumTask(WorkAssignmentTask):
|
|
332
|
+
def __init__(
|
|
333
|
+
self,
|
|
334
|
+
instance: SNowInstance = None,
|
|
335
|
+
seed: int = None,
|
|
336
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
337
|
+
level: int = 2,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""
|
|
340
|
+
Medium version of workassignment task.
|
|
341
|
+
"""
|
|
342
|
+
super().__init__(
|
|
343
|
+
instance=instance,
|
|
344
|
+
level=level,
|
|
345
|
+
max_experts_per_category=2,
|
|
346
|
+
max_assignments=6,
|
|
347
|
+
min_assignments=5,
|
|
348
|
+
num_categories=3,
|
|
349
|
+
fixed_config=fixed_config,
|
|
350
|
+
seed=seed,
|
|
351
|
+
prefix="WAM",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class WorkAssignmentLargeTask(WorkAssignmentTask):
|
|
356
|
+
def __init__(
|
|
357
|
+
self,
|
|
358
|
+
instance: SNowInstance = None,
|
|
359
|
+
seed: int = None,
|
|
360
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
361
|
+
level: int = 2,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Large version of workassignment task.
|
|
365
|
+
"""
|
|
366
|
+
super().__init__(
|
|
367
|
+
instance=instance,
|
|
368
|
+
level=level,
|
|
369
|
+
max_experts_per_category=2,
|
|
370
|
+
max_assignments=8,
|
|
371
|
+
min_assignments=7,
|
|
372
|
+
num_categories=4,
|
|
373
|
+
fixed_config=fixed_config,
|
|
374
|
+
seed=seed,
|
|
375
|
+
prefix="WAL",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class PriorityAssignmentTask(CompositionalTask):
|
|
380
|
+
def __init__(
|
|
381
|
+
self,
|
|
382
|
+
instance: SNowInstance = None,
|
|
383
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
384
|
+
level: int = 2,
|
|
385
|
+
max_tasks_per_priority: int = 2,
|
|
386
|
+
min_tasks_per_priority: int = 1,
|
|
387
|
+
num_categories: int = None,
|
|
388
|
+
seed: int = None,
|
|
389
|
+
prefix: str = None,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""
|
|
392
|
+
Create a compositional task with specific subtasks
|
|
393
|
+
|
|
394
|
+
Parameters:
|
|
395
|
+
-----------
|
|
396
|
+
instance: SNowInstance
|
|
397
|
+
The ServiceNow instance to run the task on.
|
|
398
|
+
fixed_config: list[tuple[AbstractServiceNowTask, dict, bool]]
|
|
399
|
+
A list of tuples, each containing a subtask, its configuration and whether or not it should be validated.
|
|
400
|
+
level: int
|
|
401
|
+
The level of the task; choice between 2 and 3. L2 will have all the info in the the goal and start in the SNOW home page.
|
|
402
|
+
L3 will start in a private task page describing the information needed to complete the task and the related company protocol
|
|
403
|
+
to complete it.
|
|
404
|
+
experts_per_category: int
|
|
405
|
+
How many new agents to create for each category.
|
|
406
|
+
max_assignments: int
|
|
407
|
+
Maximum number of incidents created to be assigned.
|
|
408
|
+
For a task, the number is randomly sampled between max_assignments and min_assignments.
|
|
409
|
+
max_assignments: int
|
|
410
|
+
Minimum number of incidents created to be assigned.
|
|
411
|
+
For a task, the number is randomly sampled between max_assignments and min_assignments.
|
|
412
|
+
prefix: str
|
|
413
|
+
Prefix to name the incidents created with a unique prefix
|
|
414
|
+
Attributes:
|
|
415
|
+
-----------
|
|
416
|
+
task_description: str
|
|
417
|
+
The start of the task description to be completed. e.g. "Referring to company protocol 'Priority Assignment', assign incidents to different agents in terms of priority with the following information: \n"
|
|
418
|
+
short_description: str
|
|
419
|
+
A short description of the task to be completed. e.g. "Assign task to relevant expert agents based on the incident priorities"
|
|
420
|
+
"""
|
|
421
|
+
assert level in [2, 3], "Level must be either 2 or 3"
|
|
422
|
+
self.level = level
|
|
423
|
+
self.protocol_name = "Work Assignment: Assign Incidents to Relevant Agents"
|
|
424
|
+
super().__init__(
|
|
425
|
+
instance=instance,
|
|
426
|
+
fixed_config=fixed_config,
|
|
427
|
+
level=level,
|
|
428
|
+
protocol_name=self.protocol_name,
|
|
429
|
+
seed=seed,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
self.task_description = None
|
|
433
|
+
self.short_description = None
|
|
434
|
+
self.experts_per_category = 3 # We divide agents into 'expert', 'supporter', and 'planner'
|
|
435
|
+
self.max_tasks_per_priority = max_tasks_per_priority
|
|
436
|
+
self.min_tasks_per_priority = min_tasks_per_priority
|
|
437
|
+
# Priority 1 is urgent, 3 is moderate, 5 is planning.
|
|
438
|
+
# Also priority depends on impact and urgency rather than being an independent attribute
|
|
439
|
+
self.priorities = {
|
|
440
|
+
1: {
|
|
441
|
+
"impact": 1,
|
|
442
|
+
"urgency": 1,
|
|
443
|
+
"num_incidents": self.random.randint(
|
|
444
|
+
self.min_tasks_per_priority, self.max_tasks_per_priority
|
|
445
|
+
),
|
|
446
|
+
"agent_type": "expert",
|
|
447
|
+
},
|
|
448
|
+
3: {
|
|
449
|
+
"impact": 2,
|
|
450
|
+
"urgency": 2,
|
|
451
|
+
"num_incidents": self.random.randint(
|
|
452
|
+
self.min_tasks_per_priority, self.max_tasks_per_priority
|
|
453
|
+
),
|
|
454
|
+
"agent_type": "supporter",
|
|
455
|
+
},
|
|
456
|
+
5: {
|
|
457
|
+
"impact": 3,
|
|
458
|
+
"urgency": 3,
|
|
459
|
+
"num_incidents": self.random.randint(
|
|
460
|
+
self.min_tasks_per_priority, self.max_tasks_per_priority
|
|
461
|
+
),
|
|
462
|
+
"agent_type": "planner",
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
self.num_categories = num_categories
|
|
466
|
+
if self.num_categories > 4 or self.num_categories < 1:
|
|
467
|
+
raise Exception("Should have at least 1 and at most 4 categories.")
|
|
468
|
+
self.prefix = prefix
|
|
469
|
+
|
|
470
|
+
def setup_goal(self, page: Page) -> tuple[str, dict]:
|
|
471
|
+
self.incident_configs = []
|
|
472
|
+
number_assignments = sum(
|
|
473
|
+
[attribute["num_incidents"] for attribute in self.priorities.values()]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
all_existing_incidents = table_api_call(
|
|
477
|
+
instance=self.instance, table="incident", method="GET"
|
|
478
|
+
)["result"]
|
|
479
|
+
all_incident_numbers = [incident["number"] for incident in all_existing_incidents]
|
|
480
|
+
|
|
481
|
+
new_incident_numbers = []
|
|
482
|
+
for _ in range(number_assignments):
|
|
483
|
+
incident_number = (
|
|
484
|
+
self.prefix + str(id(self) % (10**8)).zfill(8)[:4] + str(random.randint(100, 999))
|
|
485
|
+
)
|
|
486
|
+
while (
|
|
487
|
+
incident_number in all_incident_numbers or incident_number in new_incident_numbers
|
|
488
|
+
):
|
|
489
|
+
incident_number = (
|
|
490
|
+
self.prefix
|
|
491
|
+
+ str(id(self) % (10**8)).zfill(8)[:4]
|
|
492
|
+
+ str(random.randint(100, 999))
|
|
493
|
+
)
|
|
494
|
+
new_incident_numbers.append(incident_number)
|
|
495
|
+
incident_category = []
|
|
496
|
+
self.active_categories = self.random.choice(
|
|
497
|
+
["hardware", "software", "network", "database"], self.num_categories, replace=False
|
|
498
|
+
)
|
|
499
|
+
incident_number_idx = 0
|
|
500
|
+
for priority, attributes in self.priorities.items():
|
|
501
|
+
for _ in range(attributes["num_incidents"]):
|
|
502
|
+
category = self.random.choice(self.active_categories)
|
|
503
|
+
incident_response = create_incident(
|
|
504
|
+
instance=self.instance,
|
|
505
|
+
incident_number=new_incident_numbers[incident_number_idx],
|
|
506
|
+
caller_sys_id=self._base_user_sysid,
|
|
507
|
+
category=category,
|
|
508
|
+
priority=priority,
|
|
509
|
+
impact=attributes[
|
|
510
|
+
"impact"
|
|
511
|
+
], # priority is calculated as some combination of impact and urgency
|
|
512
|
+
urgency=attributes["urgency"],
|
|
513
|
+
)
|
|
514
|
+
self.incident_configs.append(incident_response)
|
|
515
|
+
incident_category.append(
|
|
516
|
+
[new_incident_numbers[incident_number_idx], category, priority]
|
|
517
|
+
)
|
|
518
|
+
incident_number_idx += 1
|
|
519
|
+
|
|
520
|
+
self.agents_per_category = dict({category: {} for category in self.active_categories})
|
|
521
|
+
for category in self.agents_per_category:
|
|
522
|
+
self.agents_per_category[category]["expert"] = create_user(
|
|
523
|
+
instance=self.instance,
|
|
524
|
+
first_name=f"{fake.first_name()}-{fake.first_name()}",
|
|
525
|
+
last_name=f"{fake.last_name()}-{fake.last_name()}",
|
|
526
|
+
return_full_response=True,
|
|
527
|
+
user_roles=["itil"],
|
|
528
|
+
random=self.random,
|
|
529
|
+
)
|
|
530
|
+
self.agents_per_category[category]["expert"]["full_name"] = (
|
|
531
|
+
self.agents_per_category[category]["expert"]["first_name"]
|
|
532
|
+
+ " "
|
|
533
|
+
+ self.agents_per_category[category]["expert"]["last_name"]
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
self.agents_per_category[category]["supporter"] = create_user(
|
|
537
|
+
instance=self.instance,
|
|
538
|
+
first_name=f"{fake.first_name()}-{fake.first_name()}",
|
|
539
|
+
last_name=f"{fake.last_name()}-{fake.last_name()}",
|
|
540
|
+
return_full_response=True,
|
|
541
|
+
user_roles=["itil"],
|
|
542
|
+
random=self.random,
|
|
543
|
+
)
|
|
544
|
+
self.agents_per_category[category]["supporter"]["full_name"] = (
|
|
545
|
+
self.agents_per_category[category]["supporter"]["first_name"]
|
|
546
|
+
+ " "
|
|
547
|
+
+ self.agents_per_category[category]["supporter"]["last_name"]
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
self.agents_per_category[category]["planner"] = create_user(
|
|
551
|
+
instance=self.instance,
|
|
552
|
+
first_name=f"{fake.first_name()}-{fake.first_name()}",
|
|
553
|
+
last_name=f"{fake.last_name()}-{fake.last_name()}",
|
|
554
|
+
return_full_response=True,
|
|
555
|
+
user_roles=["itil"],
|
|
556
|
+
random=self.random,
|
|
557
|
+
)
|
|
558
|
+
self.agents_per_category[category]["planner"]["full_name"] = (
|
|
559
|
+
self.agents_per_category[category]["planner"]["first_name"]
|
|
560
|
+
+ " "
|
|
561
|
+
+ self.agents_per_category[category]["planner"]["last_name"]
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
incident_numbers = ", ".join(new_incident_numbers)
|
|
565
|
+
|
|
566
|
+
expert_string = ""
|
|
567
|
+
for category in self.active_categories:
|
|
568
|
+
category_experts = f"Expert: {self.agents_per_category[category]['expert']['full_name']}, Supporter: {self.agents_per_category[category]['supporter']['full_name']}, Planner: {self.agents_per_category[category]['planner']['full_name']}"
|
|
569
|
+
expert_string += f"{category.capitalize()} agents - {category_experts} \n"
|
|
570
|
+
# Get the task description
|
|
571
|
+
self.short_description = f"Assign work using priority to relevant agents"
|
|
572
|
+
self.task_description = (
|
|
573
|
+
f'Referring to company protocol "{self.protocol_name}" (located in the "Company Protocols" knowledge base) assign work to the agents with the following information: \n'
|
|
574
|
+
+ f"Incidents to assign: {incident_numbers} \n\n"
|
|
575
|
+
+ f"{expert_string}"
|
|
576
|
+
)
|
|
577
|
+
# Sample a configuration
|
|
578
|
+
config = self.fixed_config if self.fixed_config else self._get_config()
|
|
579
|
+
|
|
580
|
+
goal, info = super().setup_goal(page=page, config=config)
|
|
581
|
+
|
|
582
|
+
if self.level == 2:
|
|
583
|
+
goal = (
|
|
584
|
+
self.short_description
|
|
585
|
+
+ f"\n1. Navigate to the Service Desk > Incidents. \n"
|
|
586
|
+
+ f"\n2. You have to assign the following incidents to relevant agents: {incident_numbers}. You can filter the list using each incident number and use the 'Assigned to' field to assign an incident.\n"
|
|
587
|
+
+ f"\n3. You have to ensure that each incident is assigned to a relevant agent based on the priority of the incident and its category. For an incident with priority 1 - Critical, assign it to an 'expert' agent of the category, for priority 3 - Moderate, assign it to a 'supporter' of the category, and for priority 5 - Planning assign it to a 'planner' of the category.\n"
|
|
588
|
+
+ f"\nThe category wise relevant agent are as follows:\n"
|
|
589
|
+
+ f"{expert_string}"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
return goal, info
|
|
593
|
+
|
|
594
|
+
def _get_config(self) -> list[tuple[AbstractServiceNowTask, dict, bool]]:
|
|
595
|
+
|
|
596
|
+
navigate_to_protocol_subtask = [
|
|
597
|
+
# Navigate to the KB
|
|
598
|
+
AllMenuTask(
|
|
599
|
+
instance=self.instance,
|
|
600
|
+
fixed_config={
|
|
601
|
+
"application": "Self-Service",
|
|
602
|
+
"module": "Knowledge",
|
|
603
|
+
"url": "/now/nav/ui/classic/params/target/%24knowledge.do",
|
|
604
|
+
},
|
|
605
|
+
is_validated=False,
|
|
606
|
+
used_in_level_2=False,
|
|
607
|
+
),
|
|
608
|
+
# Find the protocol for on-boarding a new user
|
|
609
|
+
KnowledgeBaseSearchTask(
|
|
610
|
+
instance=self.instance,
|
|
611
|
+
fixed_config={
|
|
612
|
+
"alternative_answers": [],
|
|
613
|
+
"item": f"{self.protocol_name}",
|
|
614
|
+
"question": "Can you find the Work Assignment Protocol in the Knowledge Base?",
|
|
615
|
+
"value": "",
|
|
616
|
+
},
|
|
617
|
+
is_validated=False,
|
|
618
|
+
used_in_level_2=False,
|
|
619
|
+
),
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
all_incident_assignments = []
|
|
623
|
+
|
|
624
|
+
for incident_config in self.incident_configs:
|
|
625
|
+
assigned_to = self.agents_per_category[incident_config["category"]][
|
|
626
|
+
self.priorities[int(incident_config["priority"])]["agent_type"]
|
|
627
|
+
]["full_name"]
|
|
628
|
+
assign_incidents_subtask = [
|
|
629
|
+
# Navigate to the incidents list
|
|
630
|
+
AllMenuTask(
|
|
631
|
+
instance=self.instance,
|
|
632
|
+
fixed_config={
|
|
633
|
+
"application": "Service Desk",
|
|
634
|
+
"module": "Incidents",
|
|
635
|
+
"url": "/now/nav/ui/classic/params/target/incident_list.do",
|
|
636
|
+
},
|
|
637
|
+
is_validated=False,
|
|
638
|
+
used_in_level_2=True,
|
|
639
|
+
),
|
|
640
|
+
# Filter incident
|
|
641
|
+
FilterIncidentListTask(
|
|
642
|
+
instance=self.instance,
|
|
643
|
+
fixed_config={
|
|
644
|
+
"filter_columns": [
|
|
645
|
+
"number",
|
|
646
|
+
],
|
|
647
|
+
"filter_kind": "AND",
|
|
648
|
+
"filter_values": [
|
|
649
|
+
incident_config["number"],
|
|
650
|
+
],
|
|
651
|
+
},
|
|
652
|
+
is_validated=False,
|
|
653
|
+
used_in_level_2=True,
|
|
654
|
+
),
|
|
655
|
+
# Edit incident
|
|
656
|
+
EditIncidentTask(
|
|
657
|
+
instance=self.instance,
|
|
658
|
+
# fixed_config=incident_config,
|
|
659
|
+
new_values={"assigned_to": assigned_to},
|
|
660
|
+
is_validated=False,
|
|
661
|
+
used_in_level_2=True,
|
|
662
|
+
record_sys_id=incident_config["sys_id"],
|
|
663
|
+
level=self.level,
|
|
664
|
+
),
|
|
665
|
+
]
|
|
666
|
+
all_incident_assignments.extend(assign_incidents_subtask)
|
|
667
|
+
|
|
668
|
+
config = navigate_to_protocol_subtask + all_incident_assignments
|
|
669
|
+
|
|
670
|
+
return config
|
|
671
|
+
|
|
672
|
+
def validate(self, page: Page, chat_messages: list[str]) -> Tuple[float, bool, str, dict]:
|
|
673
|
+
agents_per_category_sys_ids = {
|
|
674
|
+
category: {
|
|
675
|
+
agent_type: agent["sys_id"]
|
|
676
|
+
for agent_type, agent in self.agents_per_category[category].items()
|
|
677
|
+
}
|
|
678
|
+
for category in self.agents_per_category
|
|
679
|
+
}
|
|
680
|
+
for incident_config in self.incident_configs:
|
|
681
|
+
incident_response = table_api_call(
|
|
682
|
+
instance=self.instance,
|
|
683
|
+
table="incident",
|
|
684
|
+
params={
|
|
685
|
+
"sysparm_query": f"sys_id={incident_config['sys_id']}",
|
|
686
|
+
"sysparm_fields": "category,assigned_to,priority",
|
|
687
|
+
},
|
|
688
|
+
method="GET",
|
|
689
|
+
)["result"][0]
|
|
690
|
+
if incident_response["category"] != incident_config["category"]:
|
|
691
|
+
raise Exception("Corrupted incident data")
|
|
692
|
+
if not incident_response["assigned_to"]:
|
|
693
|
+
return (
|
|
694
|
+
0,
|
|
695
|
+
False,
|
|
696
|
+
"",
|
|
697
|
+
{
|
|
698
|
+
"message": f"The incident {incident_config['number']} has not been assigned to anyone."
|
|
699
|
+
},
|
|
700
|
+
)
|
|
701
|
+
if (
|
|
702
|
+
incident_response["assigned_to"]["value"]
|
|
703
|
+
!= agents_per_category_sys_ids[incident_response["category"]][
|
|
704
|
+
self.priorities[int(incident_response["priority"])]["agent_type"]
|
|
705
|
+
]
|
|
706
|
+
):
|
|
707
|
+
return (
|
|
708
|
+
0,
|
|
709
|
+
False,
|
|
710
|
+
"",
|
|
711
|
+
{
|
|
712
|
+
"message": f"The incident {incident_config['number']} was assigned to an incorrect agent."
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
# Validate final_l3 tasks
|
|
716
|
+
reward, done, message, info = super().validate(page, chat_messages)
|
|
717
|
+
return reward, done, message, info
|
|
718
|
+
|
|
719
|
+
def teardown(self) -> None:
|
|
720
|
+
for incident in self.incident_configs:
|
|
721
|
+
db_delete_from_table(
|
|
722
|
+
instance=self.instance, table="incident", sys_id=incident["sys_id"]
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
for category in self.agents_per_category.values():
|
|
726
|
+
for agent in category.values():
|
|
727
|
+
db_delete_from_table(
|
|
728
|
+
instance=self.instance, table="sys_user", sys_id=agent["sys_id"]
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
return super().teardown()
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class PriorityAssignmentSmallTask(PriorityAssignmentTask, HumanEvalTask):
|
|
735
|
+
def __init__(
|
|
736
|
+
self,
|
|
737
|
+
instance: SNowInstance = None,
|
|
738
|
+
seed: int = None,
|
|
739
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
740
|
+
level: int = 3,
|
|
741
|
+
) -> None:
|
|
742
|
+
"""
|
|
743
|
+
Small version of priority assignment task.
|
|
744
|
+
"""
|
|
745
|
+
super().__init__(
|
|
746
|
+
instance=instance,
|
|
747
|
+
level=level,
|
|
748
|
+
num_categories=2,
|
|
749
|
+
fixed_config=fixed_config,
|
|
750
|
+
seed=0,
|
|
751
|
+
prefix="PAS",
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
class PriorityAssignmentMediumTask(PriorityAssignmentTask):
|
|
756
|
+
def __init__(
|
|
757
|
+
self,
|
|
758
|
+
instance: SNowInstance = None,
|
|
759
|
+
seed: int = None,
|
|
760
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
761
|
+
level: int = 3,
|
|
762
|
+
) -> None:
|
|
763
|
+
"""
|
|
764
|
+
Medium version of priority assignment task.
|
|
765
|
+
"""
|
|
766
|
+
super().__init__(
|
|
767
|
+
instance=instance,
|
|
768
|
+
level=level,
|
|
769
|
+
num_categories=3,
|
|
770
|
+
fixed_config=fixed_config,
|
|
771
|
+
seed=seed,
|
|
772
|
+
prefix="PAM",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
class PriorityAssignmentLargeTask(PriorityAssignmentTask):
|
|
777
|
+
def __init__(
|
|
778
|
+
self,
|
|
779
|
+
instance: SNowInstance = None,
|
|
780
|
+
seed: int = None,
|
|
781
|
+
fixed_config: list[AbstractServiceNowTask] = None,
|
|
782
|
+
level: int = 3,
|
|
783
|
+
) -> None:
|
|
784
|
+
"""
|
|
785
|
+
Large version of priority assignment task.
|
|
786
|
+
"""
|
|
787
|
+
super().__init__(
|
|
788
|
+
instance=instance,
|
|
789
|
+
level=level,
|
|
790
|
+
num_categories=4,
|
|
791
|
+
fixed_config=fixed_config,
|
|
792
|
+
seed=seed,
|
|
793
|
+
prefix="PAL",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
__TASKS__ = [
|
|
798
|
+
WorkAssignmentSmallTask,
|
|
799
|
+
WorkAssignmentMediumTask,
|
|
800
|
+
WorkAssignmentLargeTask,
|
|
801
|
+
PriorityAssignmentSmallTask,
|
|
802
|
+
PriorityAssignmentMediumTask,
|
|
803
|
+
PriorityAssignmentLargeTask,
|
|
804
|
+
]
|