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
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import numpy as np
|
|
4
|
+
import playwright.sync_api
|
|
5
|
+
import re
|
|
6
|
+
import tenacity
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
from urllib import parse
|
|
11
|
+
|
|
12
|
+
from .base import AbstractServiceNowTask
|
|
13
|
+
from ..api.utils import table_api_call, table_column_info
|
|
14
|
+
from ..config import (
|
|
15
|
+
DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
16
|
+
DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
17
|
+
REPORT_RETRIEVAL_MINMAX_CONFIG_PATH,
|
|
18
|
+
REPORT_RETRIEVAL_VALUE_CONFIG_PATH,
|
|
19
|
+
REPORT_DATE_FILTER,
|
|
20
|
+
REPORT_PATCH_FLAG,
|
|
21
|
+
)
|
|
22
|
+
from ..instance import SNowInstance
|
|
23
|
+
from .utils.string import share_tri_gram
|
|
24
|
+
|
|
25
|
+
# XXX: Some notes on plot types
|
|
26
|
+
# - We currently don't support maps because they are clickable and would require a more evolved cheat function
|
|
27
|
+
SUPPORTED_PLOT_TYPES = ["area", "bar", "column", "line", "pie", "spline"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DashboardRetrievalTask(AbstractServiceNowTask, ABC):
|
|
31
|
+
"""
|
|
32
|
+
A task to retrieve information from a ServiceNow dashboard
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, seed: int, instance: SNowInstance = None, fixed_config: dict = None) -> None:
|
|
37
|
+
super().__init__(seed=seed, instance=instance, start_rel_url="")
|
|
38
|
+
self.iframe_id = "gsft_main"
|
|
39
|
+
self.fixed_config = fixed_config
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def all_configs(self) -> List[dict]:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def _get_charts(self, page: playwright.sync_api.Page) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Extract all charts on the page
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
-----------
|
|
51
|
+
page: playwright.sync_api.Page
|
|
52
|
+
The playright page on which the charts are to be extracted
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
--------
|
|
56
|
+
charts: list
|
|
57
|
+
A list of charts where the chart is represented as a tuple of the chart title and the id of the
|
|
58
|
+
element that contains the chart.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
iframe = page.frame(name=self.iframe_id)
|
|
62
|
+
|
|
63
|
+
charts = page.evaluate(
|
|
64
|
+
f"{self.iframe_id}.Highcharts.charts.map((x) => {{if(x){{return [x.renderTo.ariaLabel, x.renderTo.id];}}}})"
|
|
65
|
+
)
|
|
66
|
+
charts = [
|
|
67
|
+
(title.replace("Highcharts interactive chart.", "").replace(".", "").strip(), id)
|
|
68
|
+
for title, id in charts
|
|
69
|
+
if title
|
|
70
|
+
and iframe.locator(f"#{id}").count()
|
|
71
|
+
> 0 # Check if the element is actually on page (sometime rendering breaks)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
return charts
|
|
75
|
+
|
|
76
|
+
def _read_chart(self, page: playwright.sync_api.Page, element_id: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Read the chart at the specified index
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
-----------
|
|
82
|
+
page: playwright.sync_api.Page
|
|
83
|
+
The playright page on which the charts are to be extracted
|
|
84
|
+
element_id: str
|
|
85
|
+
The ID of the element that contains the chart
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
--------
|
|
89
|
+
chart_type: str
|
|
90
|
+
The type of the chart
|
|
91
|
+
chart_data: dict
|
|
92
|
+
The data of the chart
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
self._wait_for_ready(page)
|
|
96
|
+
|
|
97
|
+
# Validate plot type
|
|
98
|
+
types = page.evaluate(
|
|
99
|
+
f"{self.iframe_id}.Highcharts.charts.find(chart => chart && chart.renderTo.id === '{element_id}').types"
|
|
100
|
+
)
|
|
101
|
+
if len(set(types)) > 1:
|
|
102
|
+
raise NotImplementedError("Multiple chart types in the same chart not supported")
|
|
103
|
+
type = types[0]
|
|
104
|
+
if type not in SUPPORTED_PLOT_TYPES:
|
|
105
|
+
raise NotImplementedError(f"Chart type {type} not supported")
|
|
106
|
+
|
|
107
|
+
# Get data
|
|
108
|
+
data = page.evaluate(
|
|
109
|
+
f"""
|
|
110
|
+
{self.iframe_id}.Highcharts.charts.find(chart => chart && chart.renderTo.id === "{element_id}")
|
|
111
|
+
.series.map(series => ({{
|
|
112
|
+
name: series.name,
|
|
113
|
+
data: series.data.map(
|
|
114
|
+
point => ({{
|
|
115
|
+
label_cat: point.category,
|
|
116
|
+
label_name: point.name,
|
|
117
|
+
label_origx: point.origXValue,
|
|
118
|
+
count: point.y,
|
|
119
|
+
percent: point.percent
|
|
120
|
+
}}))
|
|
121
|
+
}}));
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Post-process each series
|
|
126
|
+
for i in range(len(data)):
|
|
127
|
+
# For each data point in the series
|
|
128
|
+
for j in range(len(data[i]["data"])):
|
|
129
|
+
data_point = data[i]["data"][j]
|
|
130
|
+
|
|
131
|
+
# Remove None percent values when count is 0
|
|
132
|
+
if data_point["count"] == 0:
|
|
133
|
+
data_point["percent"] = 0
|
|
134
|
+
|
|
135
|
+
# Strip trailing spaces from labels
|
|
136
|
+
data_point["label_cat"] = (
|
|
137
|
+
data_point["label_cat"].strip() if data_point["label_cat"] else ""
|
|
138
|
+
)
|
|
139
|
+
data_point["label_name"] = (
|
|
140
|
+
data_point["label_name"].strip() if data_point["label_name"] else ""
|
|
141
|
+
)
|
|
142
|
+
data_point["label_origx"] = (
|
|
143
|
+
data_point["label_origx"].strip() if data_point["label_origx"] else ""
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Determine which label to use (this is a heuristic)
|
|
147
|
+
# Usually, when the origx value is present, it is more detailed than the other labels.
|
|
148
|
+
# However, in some rare cases, it corresponds to some strange value that doesn't get rendered.
|
|
149
|
+
# As a heuristic for the last point, let's just make sure that origx has at least a one trigram
|
|
150
|
+
# overlap with any of the other labels.
|
|
151
|
+
if data_point["label_origx"] != "" and any(
|
|
152
|
+
share_tri_gram(data_point["label_origx"], data_point[x])
|
|
153
|
+
for x in ["label_cat", "label_name"]
|
|
154
|
+
):
|
|
155
|
+
data_point["label"] = data_point["label_origx"]
|
|
156
|
+
else:
|
|
157
|
+
if type in ["bar", "column", "spline"]:
|
|
158
|
+
data_point["label"] = data_point["label_cat"]
|
|
159
|
+
else:
|
|
160
|
+
data_point["label"] = data_point["label_name"]
|
|
161
|
+
del data_point["label_cat"]
|
|
162
|
+
del data_point["label_name"]
|
|
163
|
+
del data_point["label_origx"]
|
|
164
|
+
|
|
165
|
+
assert len(set([dp["label"] for dp in data[i]["data"]])) == len(
|
|
166
|
+
data[i]["data"]
|
|
167
|
+
), "Detected duplicate labels in the same series"
|
|
168
|
+
|
|
169
|
+
return type, data
|
|
170
|
+
|
|
171
|
+
def _get_chart_by_title(
|
|
172
|
+
self, page: playwright.sync_api.Page, title: str = None
|
|
173
|
+
) -> Tuple[str, dict]:
|
|
174
|
+
"""
|
|
175
|
+
Get the chart data by title
|
|
176
|
+
|
|
177
|
+
Parameters:
|
|
178
|
+
-----------
|
|
179
|
+
page: playwright.sync_api.Page
|
|
180
|
+
The playright page on which the charts are to be extracted
|
|
181
|
+
title: str
|
|
182
|
+
The title of the chart to be read. If None, returns the first chart.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
--------
|
|
186
|
+
chart_type: str
|
|
187
|
+
The type of the chart
|
|
188
|
+
chart_data: dict
|
|
189
|
+
The data of the chart
|
|
190
|
+
element_id: str
|
|
191
|
+
The ID of the element that contains the chart
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
# Get chart titles and element IDs
|
|
195
|
+
charts = self._get_charts(page)
|
|
196
|
+
|
|
197
|
+
if not title:
|
|
198
|
+
title = charts[0][0]
|
|
199
|
+
|
|
200
|
+
# Find chart index by title
|
|
201
|
+
chart_idx = [title.lower() for title, _ in charts].index(title.lower())
|
|
202
|
+
|
|
203
|
+
# Load chart data
|
|
204
|
+
return *self._read_chart(page, element_id=charts[chart_idx][1]), charts[chart_idx][1]
|
|
205
|
+
|
|
206
|
+
def _wait_for_ready(self, page: playwright.sync_api.Page) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Wait for the page to be ready for task execution
|
|
209
|
+
|
|
210
|
+
Parameters:
|
|
211
|
+
-----------
|
|
212
|
+
page: playwright.sync_api.Page
|
|
213
|
+
The page to wait on
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
logging.debug(f"Waiting for {self.iframe_id} to be fully loaded")
|
|
217
|
+
page.wait_for_function(
|
|
218
|
+
f"typeof window.{self.iframe_id} !== 'undefined' && window.{self.iframe_id}.WORKARENA_LOAD_COMPLETE",
|
|
219
|
+
)
|
|
220
|
+
logging.debug(f"Detected {self.iframe_id} ready")
|
|
221
|
+
|
|
222
|
+
logging.debug("Waiting for Highcharts API to be available")
|
|
223
|
+
page.wait_for_function(f"window.{self.iframe_id}.Highcharts")
|
|
224
|
+
logging.debug("Detected Highcharts API ready")
|
|
225
|
+
|
|
226
|
+
logging.debug("Waiting for all plots to be loaded available")
|
|
227
|
+
page.wait_for_function(f"window.{self.iframe_id}.WORKARENA_HIGHCHARTS_ALL_LOADED")
|
|
228
|
+
logging.debug("All plots loaded")
|
|
229
|
+
|
|
230
|
+
def get_init_scripts(self) -> List[str]:
|
|
231
|
+
# Configure to page type
|
|
232
|
+
# ... extract URL suffix
|
|
233
|
+
url_suffix = parse.unquote(
|
|
234
|
+
parse.urlparse(self.config["url"].replace("%3F", "?")).path.split("/")[-1]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return super().get_init_scripts() + [
|
|
238
|
+
"registerGsftMainLoaded();",
|
|
239
|
+
f"""
|
|
240
|
+
async function renderAllCharts() {{
|
|
241
|
+
waLog('Forcing load of all charts', 'loadAllCharts');
|
|
242
|
+
|
|
243
|
+
await waitForCondition(() => window.WORKARENA_LOAD_COMPLETE, 100);
|
|
244
|
+
|
|
245
|
+
const canvas = window.SNC.canvas;
|
|
246
|
+
if (canvas) {{
|
|
247
|
+
waLog('This is a dashboard page.', 'loadAllCharts');
|
|
248
|
+
// Trigger the rendering of each widget
|
|
249
|
+
canvas.layoutJson.panes.forEach((p) => canvas.canvasUtils.renderSlowWidget(canvas.canvasUtils.getWidgetContainer(p.uuid)));
|
|
250
|
+
// Wait for all widgets to be rendered
|
|
251
|
+
await waitForCondition(() => window.SNC.canvas.layoutJson.panes.map((p) => p.isRendered).every(value => value == true), 100);
|
|
252
|
+
}}
|
|
253
|
+
else {{
|
|
254
|
+
waLog('This is a report page.', 'loadAllCharts');
|
|
255
|
+
// Wait for axes to be visible (we need to use this approach since there is no canvas to help us)
|
|
256
|
+
await waitForCondition(() => document.body.innerText.toLowerCase().includes("no data to display") || document.querySelectorAll(".highcharts-point").length > 0, 100);
|
|
257
|
+
}}
|
|
258
|
+
|
|
259
|
+
// Wait for Highcharts to say that the charts are rendered
|
|
260
|
+
waitForCondition(() => Highcharts.charts.all((c) => c.hasLoaded), 100)
|
|
261
|
+
.then(() => {{
|
|
262
|
+
window.WORKARENA_HIGHCHARTS_ALL_LOADED = true;
|
|
263
|
+
waLog('All charts loaded', 'loadAllCharts');
|
|
264
|
+
}});
|
|
265
|
+
}}
|
|
266
|
+
runInGsftMainOnlyAndProtectByURL(renderAllCharts, '{url_suffix}');
|
|
267
|
+
""",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def setup_goal(self, page: playwright.sync_api.Page) -> Tuple[str | dict]:
|
|
271
|
+
super().setup_goal(page=page)
|
|
272
|
+
|
|
273
|
+
# Configure task
|
|
274
|
+
# ... sample a configuration
|
|
275
|
+
self.config = (
|
|
276
|
+
self.fixed_config if self.fixed_config else self.random.choice(self.all_configs())
|
|
277
|
+
)
|
|
278
|
+
# ... set start URL based on config
|
|
279
|
+
self.start_url = self.instance.snow_url + self.config["url"]
|
|
280
|
+
|
|
281
|
+
# Produce goal string based on question type
|
|
282
|
+
chart_locator = (
|
|
283
|
+
f"the \"{self.config['chart_series']}\" series of "
|
|
284
|
+
if self.config["chart_series"]
|
|
285
|
+
else ""
|
|
286
|
+
) + (
|
|
287
|
+
f"the \"{self.config['chart_title']}\" chart"
|
|
288
|
+
if self.config["chart_title"]
|
|
289
|
+
else "the chart"
|
|
290
|
+
)
|
|
291
|
+
if self.config["question"].startswith("value"):
|
|
292
|
+
q_info = [x.strip() for x in self.config["question"].split(";")]
|
|
293
|
+
goal = f'What is the value of "{q_info[2]}" in {chart_locator} (in {q_info[1]})?'
|
|
294
|
+
elif self.config["question"] == "max":
|
|
295
|
+
goal = f"What is the maximum value in {chart_locator}? Give me both the label and the count. If there are many, pick one."
|
|
296
|
+
elif self.config["question"] == "min":
|
|
297
|
+
goal = f"What is the minimum value in {chart_locator}? Give me both the label and the count. If there are many, pick one."
|
|
298
|
+
else:
|
|
299
|
+
raise NotImplementedError(f"Question type {self.config['question']} not supported")
|
|
300
|
+
|
|
301
|
+
return goal, {}
|
|
302
|
+
|
|
303
|
+
def cheat(self, page: playwright.sync_api.Page, chat_messages: list[str]) -> None:
|
|
304
|
+
super().cheat(page, chat_messages)
|
|
305
|
+
self._wait_for_ready(page)
|
|
306
|
+
|
|
307
|
+
# Get the chart data
|
|
308
|
+
chart_type, chart_data, chart_element_id = self._get_chart_by_title(
|
|
309
|
+
page, self.config["chart_title"]
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Extract the series
|
|
313
|
+
if len(chart_data) == 1:
|
|
314
|
+
chart_data = chart_data[0]["data"]
|
|
315
|
+
else:
|
|
316
|
+
chart_data = [
|
|
317
|
+
series["data"]
|
|
318
|
+
for series in chart_data
|
|
319
|
+
if series["name"] == self.config["chart_series"]
|
|
320
|
+
][0]
|
|
321
|
+
|
|
322
|
+
# Scroll to the chart
|
|
323
|
+
iframe = page.frame(name=self.iframe_id)
|
|
324
|
+
iframe.evaluate_handle(
|
|
325
|
+
f"findElementInShadowDOM('#{chart_element_id}')"
|
|
326
|
+
).scroll_into_view_if_needed()
|
|
327
|
+
|
|
328
|
+
# Extract the value and add it to the chat
|
|
329
|
+
if self.config["question"].startswith("value"):
|
|
330
|
+
format = self.config["question"].split(";")[1].strip()
|
|
331
|
+
label = self.config["question"].split(";")[2].strip()
|
|
332
|
+
value = [
|
|
333
|
+
point["count" if format == "count" else "percent"]
|
|
334
|
+
for point in chart_data
|
|
335
|
+
if point["label"] == label
|
|
336
|
+
][0]
|
|
337
|
+
chat_messages.append({"message": str(value), "role": "assistant"})
|
|
338
|
+
elif self.config["question"] == "max":
|
|
339
|
+
max_point = max(chart_data, key=lambda x: x["count"])
|
|
340
|
+
chat_messages.append(
|
|
341
|
+
{"message": f"{max_point['label']}, {max_point['count']}", "role": "assistant"}
|
|
342
|
+
)
|
|
343
|
+
elif self.config["question"] == "min":
|
|
344
|
+
min_point = min(chart_data, key=lambda x: x["count"])
|
|
345
|
+
chat_messages.append(
|
|
346
|
+
{"message": f"{min_point['label']}, {min_point['count']}", "role": "assistant"}
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
raise NotImplementedError(f"Question type \"{self.config['question']}\" not supported")
|
|
350
|
+
|
|
351
|
+
def validate(
|
|
352
|
+
self, page: playwright.sync_api.Page, chat_messages: list[str]
|
|
353
|
+
) -> Tuple[float, bool, str, dict]:
|
|
354
|
+
super().validate(page, chat_messages)
|
|
355
|
+
self._wait_for_ready(page)
|
|
356
|
+
|
|
357
|
+
# Get the chart data
|
|
358
|
+
_, chart_data, _ = self._get_chart_by_title(page, self.config["chart_title"])
|
|
359
|
+
|
|
360
|
+
# Extract the series
|
|
361
|
+
if len(chart_data) == 1:
|
|
362
|
+
chart_data = chart_data[0]["data"]
|
|
363
|
+
else:
|
|
364
|
+
chart_data = [
|
|
365
|
+
series["data"]
|
|
366
|
+
for series in chart_data
|
|
367
|
+
if series["name"] == self.config["chart_series"]
|
|
368
|
+
][0]
|
|
369
|
+
|
|
370
|
+
# Extract the agent's response
|
|
371
|
+
if chat_messages and chat_messages[-1]["role"] == "assistant":
|
|
372
|
+
response = chat_messages[-1]["message"]
|
|
373
|
+
else:
|
|
374
|
+
return (
|
|
375
|
+
0,
|
|
376
|
+
False,
|
|
377
|
+
"",
|
|
378
|
+
{"message": "The assistant did not provide an answer."},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Extract all numbers mentioned by the agent
|
|
382
|
+
# ... some value labels may contain numbers so we need to remove the labels from the response first
|
|
383
|
+
labels = set([point["label"] for point in chart_data])
|
|
384
|
+
response_ = str(response)
|
|
385
|
+
for label in labels:
|
|
386
|
+
response_ = response_.replace(label, "")
|
|
387
|
+
# ... then we extract numbers
|
|
388
|
+
response_floats = np.unique(
|
|
389
|
+
[float(x) for x in re.findall(r"[\d]+(?:[.,]\d+)?", response_.replace(",", ""))]
|
|
390
|
+
)
|
|
391
|
+
del response_
|
|
392
|
+
|
|
393
|
+
# Validate the response
|
|
394
|
+
if self.config["question"].startswith("value"):
|
|
395
|
+
# if more than one number is in the prompt, there is necessarily a false positive
|
|
396
|
+
if len(response_floats) > 1:
|
|
397
|
+
error_msg = "Incorrect answer. More than one number detected in the response."
|
|
398
|
+
return 0.0, True, error_msg, {"message": error_msg}
|
|
399
|
+
|
|
400
|
+
format = self.config["question"].split(";")[1].strip()
|
|
401
|
+
label = self.config["question"].split(";")[2].strip()
|
|
402
|
+
|
|
403
|
+
expected_value = float(
|
|
404
|
+
[
|
|
405
|
+
point["count" if format == "count" else "percent"]
|
|
406
|
+
for point in chart_data
|
|
407
|
+
if point["label"] == label
|
|
408
|
+
][0]
|
|
409
|
+
)
|
|
410
|
+
if np.isclose(expected_value, response_floats[0]):
|
|
411
|
+
return 1.0, True, "Nice work, thank you!", {"message": "Correct answer."}
|
|
412
|
+
else:
|
|
413
|
+
return 0.0, True, f"Incorrect answer.", {"message": "Incorrect answer."}
|
|
414
|
+
|
|
415
|
+
# ... validate max/min responses
|
|
416
|
+
elif "max" in self.config["question"] or "min" in self.config["question"]:
|
|
417
|
+
# Determine whether to find max or min based on configuration
|
|
418
|
+
target_func = max if self.config["question"] == "max" else min
|
|
419
|
+
|
|
420
|
+
# Get the target count value (max or min)
|
|
421
|
+
target_count = float(target_func(chart_data, key=lambda x: x["count"])["count"])
|
|
422
|
+
|
|
423
|
+
# Find all points with the target count value
|
|
424
|
+
target_points = [point for point in chart_data if point["count"] == target_count]
|
|
425
|
+
|
|
426
|
+
# if more than one number is in the prompt, there is necessarily a false positive
|
|
427
|
+
if len(response_floats) > 1:
|
|
428
|
+
error_msg = "Incorrect answer. More than one number detected in the response."
|
|
429
|
+
return 0.0, True, error_msg, {"message": error_msg}
|
|
430
|
+
|
|
431
|
+
# Check if any of these points are mentioned in the response
|
|
432
|
+
for point in target_points:
|
|
433
|
+
if point["label"].lower() in response.lower() and np.isclose(
|
|
434
|
+
target_count, response_floats[0]
|
|
435
|
+
):
|
|
436
|
+
return 1.0, True, "Nice work, thank you!", {"message": "Correct answer."}
|
|
437
|
+
|
|
438
|
+
# If no correct point is mentioned in the response
|
|
439
|
+
return 0.0, True, "Incorrect answer.", {"message": "Incorrect answer."}
|
|
440
|
+
|
|
441
|
+
else:
|
|
442
|
+
raise NotImplementedError(f"Question type \"{self.config['question']}\" not supported")
|
|
443
|
+
|
|
444
|
+
def teardown(self) -> None:
|
|
445
|
+
return super().teardown()
|
|
446
|
+
|
|
447
|
+
def _generate_random_config(
|
|
448
|
+
self, page: playwright.sync_api.Page, is_report=True, question_types=["value"]
|
|
449
|
+
) -> dict:
|
|
450
|
+
"""
|
|
451
|
+
Generate a random configuration for the task
|
|
452
|
+
|
|
453
|
+
Parameters:
|
|
454
|
+
-----------
|
|
455
|
+
page: playwright.sync_api.Page
|
|
456
|
+
The page on which the task is to be executed
|
|
457
|
+
is_report: bool
|
|
458
|
+
Whether to sample a report or a dashboard task configuration
|
|
459
|
+
question_types: list
|
|
460
|
+
The types of questions to sample from (uniformely)
|
|
461
|
+
|
|
462
|
+
"""
|
|
463
|
+
# Generate a bunch of reports based on valid table fields
|
|
464
|
+
ON_THE_FLY_REPORTS = []
|
|
465
|
+
for table in [
|
|
466
|
+
"alm_asset",
|
|
467
|
+
"alm_hardware",
|
|
468
|
+
"asmt_assessment_instance_question",
|
|
469
|
+
"asmt_m2m_stakeholder",
|
|
470
|
+
"ast_contract",
|
|
471
|
+
"change_request",
|
|
472
|
+
"cmdb_ci_computer",
|
|
473
|
+
"incident",
|
|
474
|
+
"sc_cat_item",
|
|
475
|
+
"sys_user",
|
|
476
|
+
]:
|
|
477
|
+
cols = [
|
|
478
|
+
x
|
|
479
|
+
for x, y in table_column_info(instance=self.instance, table=table).items()
|
|
480
|
+
if y.get("cangroup", False)
|
|
481
|
+
and y.get("type", None) == "choice"
|
|
482
|
+
and "upon" not in x.lower()
|
|
483
|
+
]
|
|
484
|
+
for col in cols:
|
|
485
|
+
ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "pie"})
|
|
486
|
+
ON_THE_FLY_REPORTS.append({"table": table, "field": col, "type": "bar"})
|
|
487
|
+
|
|
488
|
+
# Reports that are already in the instance
|
|
489
|
+
system_report_tables = "alm_asset,alm_hardware,asmt_assessment_instance_question,asmt_m2m_stakeholder,ast_contract,change_request,cmdb_ci_computer"
|
|
490
|
+
SYSTEM_REPORTS = table_api_call(
|
|
491
|
+
instance=self.instance,
|
|
492
|
+
table="sys_report",
|
|
493
|
+
params={
|
|
494
|
+
"sysparm_query": f"sys_class_name=sys_report^active=true^typeINtrend,donut,vertical_bar,line,horizontal_bar,pie,bar,spline,area^descriptionLIKE{REPORT_PATCH_FLAG}^tableIN{system_report_tables}",
|
|
495
|
+
"sysparm_fields": "sys_id",
|
|
496
|
+
},
|
|
497
|
+
)["result"]
|
|
498
|
+
|
|
499
|
+
REPORTS = ON_THE_FLY_REPORTS + SYSTEM_REPORTS
|
|
500
|
+
|
|
501
|
+
# XXX: It's not ideal to use sys_ids but I couldn't find a better way
|
|
502
|
+
DASHBOARDS = [
|
|
503
|
+
"812fa4400f1130101527008c07767e1a", # Assessment overview
|
|
504
|
+
"fa5fe3e1773130107384c087cc5a99d5", # Asset overview
|
|
505
|
+
"68ee1f30770230107384c087cc5a992e", # Asset contract overview
|
|
506
|
+
"05b0a8b7c3123010a282a539e540dd69", # Change overview
|
|
507
|
+
"18b1f472533130104c90ddeeff7b12a6", # Incident overview
|
|
508
|
+
"287d07d1ff3130106c1ef9a7cddcbd5d", # Request overview
|
|
509
|
+
"7ab78953eb32011008f2951ff15228e6", # Service catalog overview
|
|
510
|
+
"2d297c880f1130101527008c07767e27", # Survey overview
|
|
511
|
+
"6b706f448f231110953ddffc9071a4f3", # Telemetry - Table growth
|
|
512
|
+
"15c5d2d377213010a435478c4f5a993c", # Usage overview
|
|
513
|
+
"85a57f9677100110ba155631dc5a9905", # Web api usage overview
|
|
514
|
+
"c38ca3a273031010ae8dd21efaf6a747", # Data classification
|
|
515
|
+
"3d48f669538223008329ddeeff7b1253", # Problem overview
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
# Select between a full dashboard and a report
|
|
519
|
+
if is_report:
|
|
520
|
+
report = REPORTS[self.random.randint(0, len(REPORTS))]
|
|
521
|
+
|
|
522
|
+
# On the fly generated report
|
|
523
|
+
if not report.get("sys_id", None):
|
|
524
|
+
url = f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fsysparm_field%3D{report['field']}%26sysparm_type%3D{report['type']}%26sysparm_table%3D{report['table']}%26sysparm_from_list%3Dtrue%26sysparm_chart_size%3Dlarge%26sysparm_manual_labor%3Dtrue%26sysparm_query=sys_created_on<javascript:gs.dateGenerate('{REPORT_DATE_FILTER}','00:00:00')^EQ"
|
|
525
|
+
# Report from the database
|
|
526
|
+
else:
|
|
527
|
+
url = f"/now/nav/ui/classic/params/target/sys_report_template.do%3Fjvar_report_id={report['sys_id']}"
|
|
528
|
+
else:
|
|
529
|
+
# Dashboard from the database
|
|
530
|
+
dashboard = self.random.choice(DASHBOARDS)
|
|
531
|
+
url = f"/now/nav/ui/classic/params/target/%24pa_dashboard.do%3Fsysparm_dashboard%3D{dashboard}"
|
|
532
|
+
|
|
533
|
+
# We need to do this to bypass the init script protection by URL
|
|
534
|
+
self.fixed_config = {
|
|
535
|
+
"url": url,
|
|
536
|
+
"chart_title": "",
|
|
537
|
+
"chart_series": "",
|
|
538
|
+
"question": "max",
|
|
539
|
+
} # Dummy config
|
|
540
|
+
self.setup(page=page)
|
|
541
|
+
|
|
542
|
+
# Handle the case where a dashboard is not found
|
|
543
|
+
page.wait_for_load_state("networkidle")
|
|
544
|
+
iframe = page.frame(name=self.iframe_id)
|
|
545
|
+
assert iframe.get_by_text("not found").count() == 0, "Report or dashboard not found"
|
|
546
|
+
|
|
547
|
+
# Find all the charts
|
|
548
|
+
self._wait_for_ready(page)
|
|
549
|
+
charts = self._get_charts(page)
|
|
550
|
+
|
|
551
|
+
# Randomly select a chart
|
|
552
|
+
assert len(charts) > 0, f"No charts found on the page {self.instance.snow_url}{url}"
|
|
553
|
+
chart_idx = self.random.randint(0, len(charts))
|
|
554
|
+
chart_title = charts[chart_idx][0] if not is_report else "" # No title for reports
|
|
555
|
+
_, chart_data, _ = self._get_chart_by_title(page, chart_title)
|
|
556
|
+
|
|
557
|
+
# Select a series randomly
|
|
558
|
+
series_idx = self.random.randint(len(chart_data))
|
|
559
|
+
chart_series = chart_data[series_idx]["name"] if len(chart_data) > 1 else ""
|
|
560
|
+
chart_data = chart_data[series_idx]["data"]
|
|
561
|
+
|
|
562
|
+
# Check if the data is interesting
|
|
563
|
+
labels = [point["label"] for point in chart_data]
|
|
564
|
+
assert len(labels) > 1, f"Not enough data in the chart (only {len(labels)} label)"
|
|
565
|
+
assert not any(
|
|
566
|
+
l.isdigit() for l in labels
|
|
567
|
+
), "Some chart labels are digits, which would cause errors in validation. Skipping."
|
|
568
|
+
|
|
569
|
+
# Sample a type of question
|
|
570
|
+
question = self.random.choice(question_types)
|
|
571
|
+
|
|
572
|
+
if question == "value":
|
|
573
|
+
# Sample a random type of value to ask for
|
|
574
|
+
format = self.random.choice(["count", "percent"])
|
|
575
|
+
|
|
576
|
+
# Select a random label from the chart data
|
|
577
|
+
label = self.random.choice(labels)
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
"url": url,
|
|
581
|
+
"chart_title": chart_title,
|
|
582
|
+
"chart_series": chart_series,
|
|
583
|
+
"question": f"{question}; {format}; {label}",
|
|
584
|
+
}
|
|
585
|
+
else:
|
|
586
|
+
return {
|
|
587
|
+
"url": url,
|
|
588
|
+
"chart_title": chart_title,
|
|
589
|
+
"chart_series": chart_series,
|
|
590
|
+
"question": question,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class DashboardValueRetrievalTask(DashboardRetrievalTask):
|
|
595
|
+
def all_configs(self):
|
|
596
|
+
return json.load(open(DASHBOARD_RETRIEVAL_VALUE_CONFIG_PATH, "r"))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class DashboardMinMaxRetrievalTask(DashboardRetrievalTask):
|
|
600
|
+
def all_configs(self):
|
|
601
|
+
return json.load(open(DASHBOARD_RETRIEVAL_MINMAX_CONFIG_PATH, "r"))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class ReportValueRetrievalTask(DashboardRetrievalTask):
|
|
605
|
+
def all_configs(self):
|
|
606
|
+
return json.load(open(REPORT_RETRIEVAL_VALUE_CONFIG_PATH, "r"))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class ReportMinMaxRetrievalTask(DashboardRetrievalTask):
|
|
610
|
+
def all_configs(self):
|
|
611
|
+
return json.load(open(REPORT_RETRIEVAL_MINMAX_CONFIG_PATH, "r"))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
__TASKS__ = [
|
|
615
|
+
var
|
|
616
|
+
for var in locals().values()
|
|
617
|
+
if isinstance(var, type)
|
|
618
|
+
and issubclass(var, DashboardRetrievalTask)
|
|
619
|
+
and var is not DashboardRetrievalTask
|
|
620
|
+
]
|