browsergym-workarena 0.1.0rc6__py3-none-any.whl → 0.2.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 +7 -2
- browsergym/workarena/api/ui_themes.py +35 -0
- browsergym/workarena/api/user.py +153 -0
- browsergym/workarena/api/utils.py +1 -1
- browsergym/workarena/config.py +43 -1
- browsergym/workarena/data_files/setup_files/lists/expected_asset_list_columns.json +34 -1
- browsergym/workarena/data_files/setup_files/lists/expected_change_request_list_columns.json +48 -1
- browsergym/workarena/data_files/setup_files/lists/expected_hardware_list_columns.json +53 -1
- browsergym/workarena/data_files/setup_files/lists/expected_incident_list_columns.json +28 -1
- browsergym/workarena/data_files/setup_files/lists/expected_service_catalog_list_columns.json +29 -1
- browsergym/workarena/data_files/setup_files/ui_themes/workarena_themes.xml +2313 -0
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_minmax_task.json +1 -0
- browsergym/workarena/data_files/task_configs/dashboard_retrieval_value_task.json +1 -0
- browsergym/workarena/data_files/task_configs/report_retrieval_minmax_task.json +1 -0
- browsergym/workarena/data_files/task_configs/report_retrieval_value_task.json +1 -0
- browsergym/workarena/data_files/task_configs/sort_asset_list_task.json +547 -11391
- browsergym/workarena/data_files/task_configs/sort_change_request_list_task.json +558 -11090
- browsergym/workarena/data_files/task_configs/sort_hardware_list_task.json +576 -11162
- browsergym/workarena/data_files/task_configs/sort_incident_list_task.json +528 -11172
- browsergym/workarena/data_files/task_configs/sort_service_catalog_item_list_task.json +533 -11491
- browsergym/workarena/data_files/task_configs/sort_user_list_task.json +568 -10582
- browsergym/workarena/install.py +625 -153
- browsergym/workarena/tasks/base.py +85 -26
- browsergym/workarena/tasks/dashboard.py +620 -0
- browsergym/workarena/tasks/form.py +127 -90
- browsergym/workarena/tasks/knowledge.py +30 -14
- browsergym/workarena/tasks/list.py +157 -65
- browsergym/workarena/tasks/navigation.py +18 -16
- browsergym/workarena/tasks/scripts/generate_dashboard_configs.py +272 -0
- browsergym/workarena/tasks/scripts/generate_forms.py +2 -2
- browsergym/workarena/tasks/scripts/list.py +33 -9
- browsergym/workarena/tasks/scripts/validate.py +2 -2
- browsergym/workarena/tasks/service_catalog.py +106 -74
- browsergym/workarena/tasks/utils/form.py +5 -3
- browsergym/workarena/tasks/utils/js_utils.js +123 -2
- browsergym/workarena/tasks/utils/string.py +15 -0
- browsergym/workarena/tasks/utils/utils.py +20 -0
- browsergym/workarena/utils.py +31 -2
- {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/METADATA +7 -3
- {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/RECORD +43 -32
- {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/WHEEL +1 -1
- {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/entry_points.txt +0 -0
- {browsergym_workarena-0.1.0rc6.dist-info → browsergym_workarena-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,6 +7,7 @@ from english_words import get_english_words_set
|
|
|
7
7
|
from playwright.sync_api._generated import Page
|
|
8
8
|
from time import sleep
|
|
9
9
|
from typing import List, Tuple
|
|
10
|
+
from urllib import parse
|
|
10
11
|
|
|
11
12
|
from ..api.utils import (
|
|
12
13
|
db_delete_from_table,
|
|
@@ -32,6 +33,7 @@ from ..config import (
|
|
|
32
33
|
)
|
|
33
34
|
from ..instance import SNowInstance
|
|
34
35
|
from .utils.form import fill_text
|
|
36
|
+
from .utils.utils import check_url_suffix_match
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
ENGLISH_WORDS = list(get_english_words_set(["web2"]))
|
|
@@ -40,6 +42,7 @@ ENGLISH_WORDS = list(get_english_words_set(["web2"]))
|
|
|
40
42
|
class ServiceNowFormTask(AbstractServiceNowTask):
|
|
41
43
|
def __init__(
|
|
42
44
|
self,
|
|
45
|
+
seed: int,
|
|
43
46
|
start_rel_url,
|
|
44
47
|
instance: SNowInstance = None,
|
|
45
48
|
extra_mandatory_fields: List = [],
|
|
@@ -68,14 +71,13 @@ class ServiceNowFormTask(AbstractServiceNowTask):
|
|
|
68
71
|
# Prohibited fields: fields that we shouldn't interact with
|
|
69
72
|
self.prohibited_fields = prohibited_fields
|
|
70
73
|
|
|
71
|
-
super().__init__(instance=instance, start_rel_url=start_rel_url)
|
|
74
|
+
super().__init__(seed=seed, instance=instance, start_rel_url=start_rel_url)
|
|
72
75
|
|
|
73
76
|
def _get_form(self, page):
|
|
74
77
|
"""
|
|
75
78
|
Loads a bunch of info about the form on a page into object variables
|
|
76
79
|
|
|
77
80
|
"""
|
|
78
|
-
self._wait_for_ready(page)
|
|
79
81
|
|
|
80
82
|
# Extract Glide table information
|
|
81
83
|
logging.debug("Extracting Glide table metadata")
|
|
@@ -106,10 +108,19 @@ class ServiceNowFormTask(AbstractServiceNowTask):
|
|
|
106
108
|
)["result"][0]["label"].lower()
|
|
107
109
|
|
|
108
110
|
# Get the form fields
|
|
111
|
+
def is_field_visible(field):
|
|
112
|
+
return page.evaluate(
|
|
113
|
+
f"""
|
|
114
|
+
{self.form_js_selector}.isVisible(
|
|
115
|
+
{self.form_js_selector}.getGlideUIElement('{field}'),
|
|
116
|
+
{self.form_js_selector}.getControl('{field}')
|
|
117
|
+
);"""
|
|
118
|
+
)
|
|
119
|
+
|
|
109
120
|
logging.debug("Extracting valid form fields")
|
|
110
121
|
editable_fields = page.evaluate(f"{self.form_js_selector}.getEditableFields()")
|
|
111
122
|
field_elements = page.evaluate(f"{self.form_js_selector}.elements")
|
|
112
|
-
|
|
123
|
+
all_fields = [f["fieldName"] for f in field_elements]
|
|
113
124
|
self.fields = {
|
|
114
125
|
f["fieldName"]: f
|
|
115
126
|
for f in field_elements
|
|
@@ -130,16 +141,25 @@ class ServiceNowFormTask(AbstractServiceNowTask):
|
|
|
130
141
|
self.optional_fields = [f for f in set(self.fields.keys()) - set(self.mandatory_fields)]
|
|
131
142
|
|
|
132
143
|
# Sanity check
|
|
133
|
-
assert len(self.fields) > 0, "No
|
|
144
|
+
assert len(self.fields) > 0, "No fields found on page."
|
|
145
|
+
assert len(editable_fields) > 0, "No editable fields found on page."
|
|
146
|
+
# ... check that the script that marks some fields as mandatory worked
|
|
134
147
|
assert set(self.extra_mandatory_fields) <= set(
|
|
135
148
|
self.mandatory_fields
|
|
136
149
|
), "Some extra mandatory fields are not mandatory in the form."
|
|
150
|
+
# ... check that the script that makes some fields read-only worked
|
|
137
151
|
assert all(
|
|
138
152
|
f not in self.fields for f in self.prohibited_fields
|
|
139
153
|
), "Some prohibited fields are editable in the form."
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
# ... check that all the fields that the config expects are present and that extra fields are not visible
|
|
155
|
+
all_visible_fields = set([f for f in all_fields if is_field_visible(f)])
|
|
156
|
+
expected_visible_fields = set([f for f in self.expected_fields if is_field_visible(f)])
|
|
157
|
+
set_diff = all_visible_fields.union(
|
|
158
|
+
expected_visible_fields
|
|
159
|
+
) - all_visible_fields.intersection(expected_visible_fields)
|
|
160
|
+
assert (
|
|
161
|
+
len(set_diff) == 0
|
|
162
|
+
), f"The fields {set_diff} are either missing or unexpectedly visible on the form. Re-run 'workarena-install' to correct this."
|
|
143
163
|
|
|
144
164
|
def _preprocess_fields(self, field, value):
|
|
145
165
|
"""
|
|
@@ -174,12 +194,12 @@ class ServiceNowFormTask(AbstractServiceNowTask):
|
|
|
174
194
|
|
|
175
195
|
def _wait_for_ready(self, page: Page) -> None:
|
|
176
196
|
"""
|
|
177
|
-
Waits for the main iframe to be fully loaded
|
|
197
|
+
Waits for the main iframe to be fully loaded.
|
|
178
198
|
|
|
179
199
|
"""
|
|
180
200
|
logging.debug(f"Waiting for {self.js_prefix} to be fully loaded")
|
|
181
201
|
page.wait_for_function(
|
|
182
|
-
f"typeof window.{self.js_prefix} !== 'undefined' && window.{self.js_prefix}.WORKARENA_LOAD_COMPLETE"
|
|
202
|
+
f"typeof window.{self.js_prefix} !== 'undefined' && window.{self.js_prefix}.WORKARENA_LOAD_COMPLETE",
|
|
183
203
|
)
|
|
184
204
|
logging.debug(f"Detected {self.js_prefix} ready")
|
|
185
205
|
|
|
@@ -191,33 +211,28 @@ class ServiceNowFormTask(AbstractServiceNowTask):
|
|
|
191
211
|
page.wait_for_function(f"typeof window.{self.js_prefix}.g_tabs2Sections !== 'undefined'")
|
|
192
212
|
logging.debug("Detected Glide tabs API ready")
|
|
193
213
|
|
|
194
|
-
def
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
waLog('Mandatory fields set successfully.');
|
|
213
|
-
}}
|
|
214
|
-
)
|
|
215
|
-
);
|
|
216
|
-
}}
|
|
217
|
-
""",
|
|
218
|
-
],
|
|
219
|
-
)
|
|
214
|
+
def get_init_scripts(self) -> List[str]:
|
|
215
|
+
# Extract expected URL suffix
|
|
216
|
+
url_suffix = parse.urlparse(self.start_url).path.split("/")[-1]
|
|
217
|
+
|
|
218
|
+
# Add a few initialization scripts
|
|
219
|
+
return super().get_init_scripts() + [
|
|
220
|
+
"registerGsftMainLoaded();",
|
|
221
|
+
# ... Mark the extra mandatory fields as such
|
|
222
|
+
f"""
|
|
223
|
+
function addFormMandatoryFields() {{
|
|
224
|
+
waLog('Setting mandatory fields', 'addFormMandatoryFields');
|
|
225
|
+
{";".join([f"{self.js_api_forms}.setMandatory('{f}', true)" for f in self.extra_mandatory_fields])}
|
|
226
|
+
waLog('Mandatory fields set successfully.', 'addFormMandatoryFields');
|
|
227
|
+
}}
|
|
228
|
+
|
|
229
|
+
runInGsftMainOnlyAndProtectByURL(addFormMandatoryFields, '{url_suffix}');
|
|
230
|
+
""",
|
|
231
|
+
]
|
|
220
232
|
|
|
233
|
+
def start(self, page: Page) -> None:
|
|
234
|
+
super().start(page)
|
|
235
|
+
self._wait_for_ready(page)
|
|
221
236
|
self._get_form(page)
|
|
222
237
|
|
|
223
238
|
|
|
@@ -252,7 +267,9 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
252
267
|
|
|
253
268
|
def __init__(
|
|
254
269
|
self,
|
|
270
|
+
seed: int,
|
|
255
271
|
form_url: str,
|
|
272
|
+
table_label: str,
|
|
256
273
|
instance: SNowInstance = None,
|
|
257
274
|
extra_mandatory_fields: List = [],
|
|
258
275
|
prohibited_fields: List = [],
|
|
@@ -264,6 +281,7 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
264
281
|
expected_fields_path: str = None,
|
|
265
282
|
) -> None:
|
|
266
283
|
super().__init__(
|
|
284
|
+
seed=seed,
|
|
267
285
|
instance=instance,
|
|
268
286
|
start_rel_url=form_url,
|
|
269
287
|
extra_mandatory_fields=extra_mandatory_fields,
|
|
@@ -271,6 +289,9 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
271
289
|
)
|
|
272
290
|
self.form_url = form_url
|
|
273
291
|
|
|
292
|
+
# Table pretty printed name
|
|
293
|
+
self.table_label = table_label
|
|
294
|
+
|
|
274
295
|
# Key in which the sys_id of the created record will be stored in the local storage
|
|
275
296
|
self.session_sys_id_field = f"{id(self)}.record_sys_id"
|
|
276
297
|
|
|
@@ -294,67 +315,65 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
294
315
|
with open(expected_fields_path, "r") as f:
|
|
295
316
|
self.expected_fields = json.load(f)
|
|
296
317
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
self.
|
|
318
|
+
def get_init_scripts(self) -> List[str]:
|
|
319
|
+
# Extract expected URL suffix
|
|
320
|
+
url_suffix = parse.urlparse(self.start_url).path.split("/")[-1]
|
|
321
|
+
|
|
322
|
+
# Add a few initialization scripts
|
|
323
|
+
return super().get_init_scripts() + [
|
|
324
|
+
f"""
|
|
325
|
+
function patchSubmitButton() {{
|
|
326
|
+
waLog('Attempting to override form submit function', 'patchSubmitButton');
|
|
327
|
+
// Save the original function if it hasn't been saved yet
|
|
328
|
+
if(typeof old_gsftSubmit == 'undefined'){{
|
|
329
|
+
old_gsftSubmit = new Function('return ' + gsftSubmit.toString())();
|
|
330
|
+
waLog('Saved original submit function', 'patchSubmitButton');
|
|
331
|
+
}}
|
|
332
|
+
|
|
333
|
+
// Override the function to save the sys_id in the local storage
|
|
334
|
+
gsftSubmit = function(control, form, action_name) {{
|
|
335
|
+
localStorage['{self.session_sys_id_field}'] = {self.js_api_forms}.getUniqueValue();
|
|
336
|
+
old_gsftSubmit(control, form, action_name);
|
|
337
|
+
}};
|
|
338
|
+
waLog('Patched submit function. All done.', 'patchSubmitButton');
|
|
339
|
+
}}
|
|
340
|
+
|
|
341
|
+
runInGsftMainOnlyAndProtectByURL(patchSubmitButton, '{url_suffix}');
|
|
342
|
+
"""
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
def setup_goal(self, page: Page) -> tuple[str, dict]:
|
|
346
|
+
super().setup_goal(page=page)
|
|
347
|
+
|
|
348
|
+
# Get the task configuration
|
|
300
349
|
assert self.all_configs is not None, "No configuration available for the task."
|
|
301
350
|
config = self.fixed_config if self.fixed_config else self.random.choice(self.all_configs)
|
|
302
|
-
|
|
303
351
|
self.template_record = config["template_record"]
|
|
352
|
+
for f, func in self.unique_valued_fields.items():
|
|
353
|
+
self.template_record[f] = func(self.template_record[f])
|
|
304
354
|
self.task_fields = config["task_fields"]
|
|
305
|
-
|
|
306
355
|
self.created_sysids = []
|
|
307
356
|
|
|
308
|
-
#
|
|
357
|
+
# Generate the goal
|
|
309
358
|
goal = (
|
|
310
359
|
f"Create a new {self.table_label} with "
|
|
311
360
|
+ " and ".join(
|
|
312
361
|
[
|
|
313
|
-
f'a value of "{self.template_record[f]}"'
|
|
362
|
+
f'a value of "{self.template_record[f]}"'
|
|
363
|
+
+ f' for field "{config["fields"][f]}"'
|
|
314
364
|
for f in self.task_fields
|
|
315
365
|
]
|
|
316
366
|
)
|
|
317
367
|
+ "."
|
|
318
368
|
)
|
|
319
369
|
info = {}
|
|
320
|
-
return goal, info
|
|
321
370
|
|
|
322
|
-
|
|
323
|
-
self._add_init_scripts_to_context_and_reload(
|
|
324
|
-
page,
|
|
325
|
-
[
|
|
326
|
-
f"""
|
|
327
|
-
// Check that the script is running in the main iframe
|
|
328
|
-
if (window.frameElement?.id === '{self.js_prefix}') {{
|
|
329
|
-
waLog('Attempting to override form submit function');
|
|
330
|
-
waitForCondition(() => typeof {self.js_api_forms} !== 'undefined', 100)
|
|
331
|
-
.then(waitForCondition(() => typeof gsftSubmit !== 'undefined', 100)
|
|
332
|
-
.then(
|
|
333
|
-
function overrideSubmit(){{
|
|
334
|
-
// Save the original function if it hasn't been saved yet
|
|
335
|
-
if(typeof old_gsftSubmit == 'undefined'){{
|
|
336
|
-
old_gsftSubmit = new Function('return ' + gsftSubmit.toString())();
|
|
337
|
-
waLog('Saved original submit function');
|
|
338
|
-
}}
|
|
339
|
-
|
|
340
|
-
// Override the function to save the sys_id in the local storage
|
|
341
|
-
gsftSubmit = function(control, form, action_name) {{
|
|
342
|
-
localStorage['{self.session_sys_id_field}'] = {self.js_api_forms}.getUniqueValue();
|
|
343
|
-
old_gsftSubmit(control, form, action_name);
|
|
344
|
-
}};
|
|
345
|
-
waLog('Patched submit function. All done.');
|
|
346
|
-
}}
|
|
347
|
-
)
|
|
348
|
-
);
|
|
349
|
-
}}
|
|
350
|
-
"""
|
|
351
|
-
],
|
|
352
|
-
)
|
|
371
|
+
return goal, info
|
|
353
372
|
|
|
354
|
-
def _generate_random_config(self,
|
|
373
|
+
def _generate_random_config(self, page: Page) -> None:
|
|
355
374
|
"""Generate a random configuration for the task."""
|
|
356
|
-
self.
|
|
357
|
-
|
|
375
|
+
self.setup(page=page)
|
|
376
|
+
|
|
358
377
|
# Determine task fields
|
|
359
378
|
logging.debug("Determining task fields")
|
|
360
379
|
# ... check that we have enough fields
|
|
@@ -479,7 +498,7 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
479
498
|
return goal, info
|
|
480
499
|
|
|
481
500
|
def cheat(self, page: Page, chat_messages: list[str]) -> None:
|
|
482
|
-
super().cheat(page, chat_messages)
|
|
501
|
+
super().cheat(page=page, chat_messages=chat_messages)
|
|
483
502
|
self._wait_for_ready(page)
|
|
484
503
|
iframe = page.frame_locator(f'iframe[name="{self.js_prefix}"]')
|
|
485
504
|
|
|
@@ -519,11 +538,11 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
519
538
|
if section_id not in tab_sections:
|
|
520
539
|
return
|
|
521
540
|
|
|
522
|
-
page.
|
|
541
|
+
page.evaluate_handle(
|
|
523
542
|
f"""{self.js_prefix}.g_tabs2Sections.tabsTabs[
|
|
524
543
|
{tab_sections[section_id]}
|
|
525
|
-
].element
|
|
526
|
-
)
|
|
544
|
+
].element"""
|
|
545
|
+
).click(force=True)
|
|
527
546
|
|
|
528
547
|
for field in self.task_fields:
|
|
529
548
|
# Get the field's input control
|
|
@@ -568,8 +587,17 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
568
587
|
that are not part of the task.
|
|
569
588
|
|
|
570
589
|
"""
|
|
571
|
-
|
|
572
|
-
|
|
590
|
+
# check that the page is at the right url
|
|
591
|
+
right_url = check_url_suffix_match(page, expected_url=self.start_url, task=self)
|
|
592
|
+
if not right_url:
|
|
593
|
+
return (
|
|
594
|
+
0,
|
|
595
|
+
False,
|
|
596
|
+
"",
|
|
597
|
+
{
|
|
598
|
+
"message": f"The page is not in the right URL to validate task {self.__class__.__name__}."
|
|
599
|
+
},
|
|
600
|
+
)
|
|
573
601
|
# Retrieve the created record's sys_id from the session storage
|
|
574
602
|
sys_id = page.evaluate("localStorage").get(self.session_sys_id_field, None)
|
|
575
603
|
|
|
@@ -627,7 +655,7 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
627
655
|
logging.info(
|
|
628
656
|
f'The field "{self.fields[f]["label"]}" has the wrong value. Expected: "{self.template_record[f]}", got: "{record[f]}".'
|
|
629
657
|
)
|
|
630
|
-
error_msg =
|
|
658
|
+
error_msg = f'The field "{self.fields[f]["label"]}" has the wrong value.'
|
|
631
659
|
return (
|
|
632
660
|
0,
|
|
633
661
|
True, # End episode (incorrect information pushed to the DB)
|
|
@@ -638,7 +666,6 @@ class GenericNewRecordTask(ServiceNowFormTask):
|
|
|
638
666
|
return 1, True, "Nice work, thank you!", {"message": "The record was successfully created."}
|
|
639
667
|
|
|
640
668
|
def teardown(self) -> None:
|
|
641
|
-
self._wait_for_ready(self.page)
|
|
642
669
|
|
|
643
670
|
# Retrieve the current record's sys_id from the session storage
|
|
644
671
|
sys_id = self.page.evaluate("localStorage").get(self.session_sys_id_field, None)
|
|
@@ -664,10 +691,12 @@ class CreateChangeRequestTask(GenericNewRecordTask):
|
|
|
664
691
|
|
|
665
692
|
"""
|
|
666
693
|
|
|
667
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
694
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
668
695
|
super().__init__(
|
|
696
|
+
seed=seed,
|
|
669
697
|
instance=instance,
|
|
670
698
|
form_url="/now/nav/ui/classic/params/target/change_request.do",
|
|
699
|
+
table_label="change request",
|
|
671
700
|
prohibited_fields=["chg_model", "state"],
|
|
672
701
|
fixed_config=fixed_config,
|
|
673
702
|
config_path=CREATE_CHANGE_REQUEST_CONFIG_PATH,
|
|
@@ -681,10 +710,12 @@ class CreateIncidentTask(GenericNewRecordTask):
|
|
|
681
710
|
|
|
682
711
|
"""
|
|
683
712
|
|
|
684
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
713
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
685
714
|
super().__init__(
|
|
715
|
+
seed=seed,
|
|
686
716
|
instance=instance,
|
|
687
717
|
form_url="/now/nav/ui/classic/params/target/incident.do",
|
|
718
|
+
table_label="incident",
|
|
688
719
|
prohibited_fields=["state"],
|
|
689
720
|
fixed_config=fixed_config,
|
|
690
721
|
config_path=CREATE_INCIDENT_CONFIG_PATH,
|
|
@@ -698,10 +729,12 @@ class CreateHardwareAssetTask(GenericNewRecordTask):
|
|
|
698
729
|
|
|
699
730
|
"""
|
|
700
731
|
|
|
701
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
732
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
702
733
|
super().__init__(
|
|
734
|
+
seed=seed,
|
|
703
735
|
instance=instance,
|
|
704
736
|
form_url="/now/nav/ui/classic/params/target/alm_hardware.do",
|
|
737
|
+
table_label="hardware asset",
|
|
705
738
|
prohibited_fields=["install_status"],
|
|
706
739
|
extra_mandatory_fields=[
|
|
707
740
|
"model",
|
|
@@ -722,10 +755,12 @@ class CreateProblemTask(GenericNewRecordTask):
|
|
|
722
755
|
|
|
723
756
|
"""
|
|
724
757
|
|
|
725
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
758
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
726
759
|
super().__init__(
|
|
760
|
+
seed=seed,
|
|
727
761
|
instance=instance,
|
|
728
762
|
form_url="/now/nav/ui/classic/params/target/problem.do",
|
|
763
|
+
table_label="problem",
|
|
729
764
|
prohibited_fields=["state", "first_reported_by_task"],
|
|
730
765
|
fixed_config=fixed_config,
|
|
731
766
|
config_path=CREATE_PROBLEM_CONFIG_PATH,
|
|
@@ -743,10 +778,12 @@ class CreateUserTask(GenericNewRecordTask):
|
|
|
743
778
|
|
|
744
779
|
"""
|
|
745
780
|
|
|
746
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
781
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
747
782
|
super().__init__(
|
|
783
|
+
seed=seed,
|
|
748
784
|
instance=instance,
|
|
749
785
|
form_url="/now/nav/ui/classic/params/target/sys_user.do",
|
|
786
|
+
table_label="user",
|
|
750
787
|
extra_mandatory_fields=["user_name", "first_name", "last_name", "email"],
|
|
751
788
|
unique_valued_fields={"user_name": lambda x: str(hash(x + self.unique_id))},
|
|
752
789
|
fixed_config=fixed_config,
|
|
@@ -7,11 +7,13 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
|
|
9
9
|
from playwright.sync_api import Page
|
|
10
|
+
from urllib import parse
|
|
10
11
|
|
|
11
12
|
from .base import AbstractServiceNowTask
|
|
12
|
-
from ..config import KB_FILEPATH, KB_CONFIG_PATH, SNOW_BROWSER_TIMEOUT
|
|
13
|
+
from ..config import KB_NAME, KB_FILEPATH, KB_CONFIG_PATH, SNOW_BROWSER_TIMEOUT
|
|
13
14
|
from ..install import check_knowledge_base
|
|
14
15
|
from ..instance import SNowInstance
|
|
16
|
+
from .utils.utils import check_url_suffix_match
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class KnowledgeBaseSearchTask(AbstractServiceNowTask):
|
|
@@ -30,17 +32,18 @@ class KnowledgeBaseSearchTask(AbstractServiceNowTask):
|
|
|
30
32
|
|
|
31
33
|
"""
|
|
32
34
|
|
|
33
|
-
def __init__(self, instance=None, fixed_config: dict = None) -> None:
|
|
35
|
+
def __init__(self, seed: int, instance=None, fixed_config: dict = None) -> None:
|
|
34
36
|
super().__init__(
|
|
37
|
+
seed=seed,
|
|
35
38
|
instance=instance,
|
|
36
|
-
start_rel_url="/now/nav/ui/classic/params/target
|
|
39
|
+
start_rel_url="/now/nav/ui/classic/params/target/kb?id=kb_home",
|
|
37
40
|
)
|
|
38
41
|
|
|
39
42
|
# Load the knowledge base and check its integrity
|
|
40
43
|
with open(KB_FILEPATH, "r") as f:
|
|
41
44
|
self.kb_entries = json.load(f)
|
|
42
45
|
_, requires_install, requires_delete = check_knowledge_base(
|
|
43
|
-
self.instance, kb_data=self.kb_entries
|
|
46
|
+
self.instance, kb_data=self.kb_entries, kb_name=KB_NAME
|
|
44
47
|
)
|
|
45
48
|
with open(KB_CONFIG_PATH, "r") as f:
|
|
46
49
|
self.all_configs = json.load(f)
|
|
@@ -53,7 +56,7 @@ class KnowledgeBaseSearchTask(AbstractServiceNowTask):
|
|
|
53
56
|
|
|
54
57
|
def _wait_for_ready(self, page: Page) -> None:
|
|
55
58
|
"""
|
|
56
|
-
|
|
59
|
+
Checks that the main iframe is fully loaded
|
|
57
60
|
|
|
58
61
|
"""
|
|
59
62
|
# TODO: We don't use the flag-based method used in other tasks
|
|
@@ -80,32 +83,36 @@ class KnowledgeBaseSearchTask(AbstractServiceNowTask):
|
|
|
80
83
|
f"Timed out waiting for iframe to be ready in {self.instance.snow_url}"
|
|
81
84
|
)
|
|
82
85
|
|
|
83
|
-
def
|
|
84
|
-
|
|
86
|
+
def setup_goal(self, page: Page) -> tuple[str, dict]:
|
|
87
|
+
super().setup_goal(page=page)
|
|
88
|
+
|
|
89
|
+
# Get task configuration
|
|
85
90
|
config = self.fixed_config if self.fixed_config else self.random.choice(self.all_configs)
|
|
86
91
|
self.item = config["item"]
|
|
87
92
|
self.answer = config["value"]
|
|
88
93
|
self.alternative_answers = config["alternative_answers"]
|
|
89
94
|
self.question = config["question"]
|
|
90
95
|
|
|
91
|
-
#
|
|
96
|
+
# Generate goal
|
|
92
97
|
goal = f'Answer the following question using the knowledge base: "{self.question}"'
|
|
93
98
|
info = {}
|
|
94
99
|
|
|
95
100
|
return goal, info
|
|
96
101
|
|
|
97
102
|
def cheat(self, page: Page, chat_messages: list[str]) -> None:
|
|
98
|
-
super().cheat(page, chat_messages)
|
|
103
|
+
super().cheat(page=page, chat_messages=chat_messages)
|
|
99
104
|
self._wait_for_ready(page)
|
|
100
105
|
|
|
101
106
|
iframe = page.frame(name="gsft_main")
|
|
102
|
-
search = iframe.locator(
|
|
107
|
+
search = iframe.locator('input[aria-label="Search"][role="textbox"]')
|
|
103
108
|
search.fill(f'"{self.item}"')
|
|
104
|
-
|
|
109
|
+
|
|
110
|
+
with page.expect_navigation():
|
|
111
|
+
self.page.keyboard.press("Enter")
|
|
105
112
|
|
|
106
113
|
# Click on the article
|
|
107
|
-
with
|
|
108
|
-
iframe.locator(".
|
|
114
|
+
with page.expect_navigation():
|
|
115
|
+
iframe.locator("a.kb-title").first.click()
|
|
109
116
|
|
|
110
117
|
# Color the query and answer (this is just for visualization, it changes nothing to the validation)
|
|
111
118
|
paragraphs = iframe.locator("p")
|
|
@@ -130,7 +137,16 @@ class KnowledgeBaseSearchTask(AbstractServiceNowTask):
|
|
|
130
137
|
chat_messages.append({"role": "assistant", "message": str(self.answer)})
|
|
131
138
|
|
|
132
139
|
def validate(self, page: Page, chat_messages: list[str]) -> tuple[float, bool, str, dict]:
|
|
133
|
-
|
|
140
|
+
right_url = check_url_suffix_match(page, expected_url="target/kb", task=self)
|
|
141
|
+
if not right_url:
|
|
142
|
+
return (
|
|
143
|
+
0,
|
|
144
|
+
False,
|
|
145
|
+
"",
|
|
146
|
+
{
|
|
147
|
+
"message": f"The page is not in the right URL to validate task {self.__class__.__name__}."
|
|
148
|
+
},
|
|
149
|
+
)
|
|
134
150
|
if chat_messages and chat_messages[-1]["role"] == "assistant":
|
|
135
151
|
answer = chat_messages[-1]["message"]
|
|
136
152
|
else:
|