datarobot-moderations 11.1.12__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.
@@ -0,0 +1,376 @@
1
+ # ---------------------------------------------------------------------------------
2
+ # Copyright (c) 2025 DataRobot, Inc. and its affiliates. All rights reserved.
3
+ # Last updated 2025.
4
+ #
5
+ # DataRobot, Inc. Confidential.
6
+ # This is proprietary source code of DataRobot, Inc. and its affiliates.
7
+ #
8
+ # This file and its contents are subject to DataRobot Tool and Utility Agreement.
9
+ # For details, see
10
+ # https://www.datarobot.com/wp-content/uploads/2021/07/DataRobot-Tool-and-Utility-Agreement.pdf.
11
+ # ---------------------------------------------------------------------------------
12
+ import asyncio
13
+ import logging
14
+ import math
15
+ import os
16
+ import traceback
17
+ from datetime import datetime
18
+ from datetime import timezone
19
+
20
+ import datarobot as dr
21
+ import numpy as np
22
+ from datarobot.errors import ClientError
23
+ from datarobot.mlops.events import MLOpsEvent
24
+ from datarobot.models.deployment import CustomMetric
25
+
26
+ from datarobot_dome.async_http_client import AsyncHTTPClient
27
+ from datarobot_dome.constants import DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC
28
+ from datarobot_dome.constants import LOGGER_NAME_PREFIX
29
+ from datarobot_dome.constants import ModerationEventTypes
30
+
31
+ CUSTOM_METRICS_BULK_UPLOAD_API_PREFIX = "deployments"
32
+ CUSTOM_METRICS_BULK_UPLOAD_API_SUFFIX = "customMetrics/bulkUpload/"
33
+
34
+
35
+ class Pipeline:
36
+ common_message = "Custom Metrics and deployment settings will not be available"
37
+
38
+ def __init__(self, async_http_timeout_sec=DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC):
39
+ self._logger = logging.getLogger(LOGGER_NAME_PREFIX + "." + self.__class__.__name__)
40
+ self.custom_metric = {}
41
+ self._deployment = None
42
+ self._association_id_column_name = None
43
+ self._datarobot_url = None
44
+ self._datarobot_api_token = None
45
+ self.dr_client = None
46
+ self._headers = None
47
+ self._deployment_id = None
48
+ self._model_id = None
49
+ self.async_http_client = None
50
+ self._custom_metrics_bulk_upload_url = None
51
+ self._assoc_id_specific_custom_metric_ids = list()
52
+ self.aggregate_custom_metric = None
53
+ self.custom_metric_map = dict()
54
+ # List of custom metrics names which do not need the association id while reporting
55
+ self.custom_metrics_no_association_ids = list()
56
+ self.delayed_custom_metric_creation = False
57
+ self.upload_custom_metrics_tasks = set()
58
+
59
+ self.create_dr_client()
60
+
61
+ if self._datarobot_url and self._datarobot_api_token:
62
+ self.async_http_client = AsyncHTTPClient(async_http_timeout_sec)
63
+
64
+ def create_dr_client(self):
65
+ if self.dr_client:
66
+ return
67
+
68
+ # This URL and Token is where the custom LLM model is running.
69
+ self._datarobot_url = os.environ.get("DATAROBOT_ENDPOINT", None)
70
+ if self._datarobot_url is None:
71
+ self._logger.warning(f"Missing DataRobot endpoint, {self.common_message}")
72
+ return
73
+
74
+ self._datarobot_api_token = os.environ.get("DATAROBOT_API_TOKEN", None)
75
+ if self._datarobot_api_token is None:
76
+ self._logger.warning(f"Missing DataRobot API Token, {self.common_message}")
77
+ return
78
+
79
+ # This is regular / default DataRobot Client
80
+ self.dr_client = dr.Client(endpoint=self._datarobot_url, token=self._datarobot_api_token)
81
+ self._headers = {
82
+ "Content-Type": "application/json",
83
+ "Authorization": f"Bearer {self._datarobot_api_token}",
84
+ }
85
+
86
+ def _query_self_deployment(self):
87
+ """
88
+ Query the details of the deployment (LLM) that this pipeline is running
89
+ moderations for
90
+ :return:
91
+ """
92
+ self._deployment_id = os.environ.get("MLOPS_DEPLOYMENT_ID", None)
93
+ if self._deployment_id is None:
94
+ self._logger.warning(f'Custom Model workshop "test" mode?, {self.common_message}')
95
+ return
96
+
97
+ # Get the model id from environ variable, because moderation lib cannot
98
+ # query deployment each time there is scoring data.
99
+ self._model_id = os.environ.get("MLOPS_MODEL_ID", None)
100
+ self._logger.info(f"Model ID from env variable {self._model_id}")
101
+
102
+ try:
103
+ self._deployment = dr.Deployment.get(deployment_id=self._deployment_id)
104
+ self._logger.info(f"Model ID set on the deployment {self._deployment.model['id']}")
105
+ except Exception as e:
106
+ self._logger.warning(
107
+ f"Couldn't query the deployment Exception: {e}, {self.common_message}"
108
+ )
109
+
110
+ def _query_association_id_column_name(self):
111
+ self._logger.info(f"Deployment ID: {self._deployment_id}")
112
+ if self._deployment is None:
113
+ return
114
+
115
+ self._logger.info(f"Check Association ID Column name: {self._association_id_column_name}")
116
+ # Apparently, the pipeline.init() is called only once when deployment is created.
117
+ # If the association id column name is not set (we cannot set it during creation
118
+ # of the deployment), moderation library never gets it. So, moderation library
119
+ # is going to query it one time during prediction request
120
+ if self._association_id_column_name:
121
+ return
122
+
123
+ try:
124
+ association_id_settings = self._deployment.get_association_id_settings()
125
+ self._logger.debug(f"Association id settings: {association_id_settings}")
126
+ column_names = association_id_settings.get("column_names")
127
+ if column_names and len(column_names) > 0:
128
+ self._association_id_column_name = column_names[0]
129
+ self.auto_generate_association_ids = association_id_settings.get(
130
+ "auto_generate_id", False
131
+ )
132
+ except Exception as e:
133
+ self._logger.warning(
134
+ f"Couldn't query the association id settings, "
135
+ f"custom metrics will not be available {e}"
136
+ )
137
+ self._logger.error(traceback.format_exc())
138
+
139
+ if self._association_id_column_name is None:
140
+ self._logger.warning(
141
+ "Association ID column is not set on the deployment, "
142
+ "data quality analysis will not be available"
143
+ )
144
+ else:
145
+ self._logger.info(f"Association ID column name: {self._association_id_column_name}")
146
+
147
+ def create_custom_metrics_if_any(self):
148
+ """
149
+ Over-arching function to create the custom-metrics in the DR app for the deployment.
150
+ Provides some protection for inactive deployments.
151
+ """
152
+ self._query_self_deployment()
153
+ if self._deployment is None:
154
+ return
155
+
156
+ self._custom_metrics_bulk_upload_url = (
157
+ CUSTOM_METRICS_BULK_UPLOAD_API_PREFIX
158
+ + "/"
159
+ + self._deployment_id
160
+ + "/"
161
+ + CUSTOM_METRICS_BULK_UPLOAD_API_SUFFIX
162
+ )
163
+ self._logger.info(f"URL: {self._custom_metrics_bulk_upload_url}")
164
+
165
+ if self._deployment.status == "inactive":
166
+ self.delayed_custom_metric_creation = True
167
+ self._logger.warning("Deployment is not active, delaying custom metric creation")
168
+ else:
169
+ self._logger.info("Deployment is active, creating custom metrics")
170
+ self.create_custom_metrics()
171
+ self.delayed_custom_metric_creation = False
172
+
173
+ def create_custom_metrics(self):
174
+ """
175
+ Creates all the custom-metrics in the DR app for an active deployment.
176
+
177
+ The `custom_metric_map` and `_requires_association_id` attributes are consulted to
178
+ insure the appropriate data is put in place for reporting.
179
+ """
180
+ cleanup_metrics_list = list()
181
+ for index, (metric_name, custom_metric) in enumerate(self.custom_metric_map.items()):
182
+ metric_definition = custom_metric["metric_definition"]
183
+ try:
184
+ # We create metrics one by one, instead of using a library call. This gives
185
+ # us control over which are duplicates, if max limit reached etc and we can
186
+ # take appropriate actions accordingly. Performance wise it is same, because
187
+ # library also runs a loop to create custom metrics one by one
188
+ _metric_obj = CustomMetric.create(
189
+ deployment_id=self._deployment_id,
190
+ name=metric_name,
191
+ directionality=metric_definition["directionality"],
192
+ aggregation_type=metric_definition["type"],
193
+ time_step=metric_definition["timeStep"],
194
+ units=metric_definition["units"],
195
+ baseline_value=metric_definition["baselineValue"],
196
+ is_model_specific=metric_definition["isModelSpecific"],
197
+ )
198
+ custom_metric["id"] = _metric_obj.id
199
+ custom_metric["requires_association_id"] = self._requires_association_id(
200
+ metric_name
201
+ )
202
+ except ClientError as e:
203
+ if e.status_code == 409:
204
+ if "not unique for deployment" in e.json["message"]:
205
+ # Duplicate entry nothing to worry - just continue
206
+ self._logger.error(f"Metric '{metric_name}' already exists, skipping")
207
+ continue
208
+ elif e.json["message"].startswith("Maximum number of custom metrics reached"):
209
+ # Reached the limit - we can't create more
210
+ cleanup_metrics_list = list(self.custom_metric_map.keys())[index:]
211
+ title = "Failed to create custom metric"
212
+ message = (
213
+ f"Metric Name '{metric_name}', "
214
+ "Maximum number of custom metrics reached, "
215
+ f"Cannot create rest of the metrics: {cleanup_metrics_list}"
216
+ )
217
+ self._logger.error(message)
218
+ self.send_event_sync(
219
+ title, message, ModerationEventTypes.MODERATION_METRIC_CREATION_ERROR
220
+ )
221
+ # Lets not raise the exception, for now - break the loop and
222
+ # consolidate valid custom metrics
223
+ break
224
+ # Else raise it and catch in next block
225
+ raise
226
+ except Exception as e:
227
+ title = "Failed to create custom metric"
228
+ message = f"Exception: {e} Custom metric definition: {custom_metric}"
229
+ self._logger.error(title + " " + message)
230
+ self._logger.error(traceback.format_exc())
231
+ cleanup_metrics_list.append(metric_name)
232
+ self.send_event_sync(
233
+ title,
234
+ message,
235
+ ModerationEventTypes.MODERATION_METRIC_CREATION_ERROR,
236
+ metric_name=metric_name,
237
+ )
238
+ # Lets again not raise exception
239
+ continue
240
+
241
+ # Now query all the metrics and get their custom metric ids. Specifically,
242
+ # required in case a metric is duplicated, in which case, we don't have its
243
+ # id in the loop above
244
+ #
245
+ # We have to go through pagination - dmm list_custom_metrics does not implement
246
+ # pagination
247
+ custom_metrics_list = []
248
+ offset, limit = 0, 50
249
+ while True:
250
+ response_list = self.dr_client.get(
251
+ f"deployments/{self._deployment_id}/customMetrics/?offset={offset}&limit={limit}"
252
+ ).json()
253
+ custom_metrics_list.extend(response_list["data"])
254
+ offset += response_list["count"]
255
+ if response_list["next"] is None:
256
+ break
257
+
258
+ for metric in custom_metrics_list:
259
+ metric_name = metric["name"]
260
+ if metric_name not in self.custom_metric_map:
261
+ self._logger.error(f"Metric '{metric_name}' exists at DR but not in moderation")
262
+ continue
263
+ self.custom_metric_map[metric_name]["id"] = metric["id"]
264
+ self.custom_metric_map[metric_name]["requires_association_id"] = (
265
+ self._requires_association_id(metric_name)
266
+ )
267
+
268
+ # These are the metrics we couldn't create - so, don't track them
269
+ for metric_name in cleanup_metrics_list:
270
+ if not self.custom_metric_map[metric_name].get("id"):
271
+ self._logger.error(f"Skipping metric creation: {metric_name}")
272
+ del self.custom_metric_map[metric_name]
273
+
274
+ def _requires_association_id(self, metric_name):
275
+ return metric_name not in self.custom_metrics_no_association_ids
276
+
277
+ @property
278
+ def prediction_url(self):
279
+ return self._datarobot_url
280
+
281
+ @property
282
+ def api_token(self):
283
+ return self._datarobot_api_token
284
+
285
+ def get_association_id_column_name(self):
286
+ return self._association_id_column_name
287
+
288
+ def get_new_metrics_payload(self):
289
+ """
290
+ Resets the data for aggregate metrics reporting based on the `custom_metric_map`.
291
+
292
+ It will create the custom-metrics in DR app, if they have been delayed (e.g. originally
293
+ inactive).
294
+ """
295
+ if self._deployment_id is None:
296
+ return
297
+ if self.delayed_custom_metric_creation:
298
+ # Try creating custom metrics now if possible
299
+ self.create_custom_metrics_if_any()
300
+ if self.delayed_custom_metric_creation:
301
+ return
302
+
303
+ self._query_association_id_column_name()
304
+
305
+ if self._deployment is None:
306
+ return
307
+
308
+ self.aggregate_custom_metric = dict()
309
+ for metric_name, metric_info in self.custom_metric_map.items():
310
+ if not metric_info["requires_association_id"]:
311
+ self.aggregate_custom_metric[metric_name] = {
312
+ "customMetricId": str(metric_info["id"])
313
+ }
314
+
315
+ def set_custom_metrics_aggregate_entry(self, entry, value):
316
+ if isinstance(value, np.generic):
317
+ entry["value"] = value.item()
318
+ else:
319
+ entry["value"] = value
320
+ entry["timestamp"] = str(datetime.now(timezone.utc).isoformat())
321
+ entry["sampleSize"] = 1
322
+
323
+ def upload_custom_metrics(self, payload):
324
+ if len(payload["buckets"]) == 0:
325
+ self._logger.warning("No custom metrics to report, empty payload")
326
+ return
327
+ url = self._datarobot_url + "/" + self._custom_metrics_bulk_upload_url
328
+ asyncio.run(self.async_upload_custom_metrics(url, payload))
329
+
330
+ async def async_upload_custom_metrics(self, url, payload):
331
+ upload_task = self.async_http_client.loop.create_task(
332
+ self.async_http_client.bulk_upload_custom_metrics(url, payload, self._deployment_id)
333
+ )
334
+ self.upload_custom_metrics_tasks.add(upload_task)
335
+ upload_task.add_done_callback(self.upload_custom_metrics_tasks.discard)
336
+ await asyncio.sleep(0)
337
+
338
+ def add_aggregate_metrics_to_payload(self, payload):
339
+ """
340
+ Takes the provided payload and add aggregate metric values to it.
341
+ Then, uploads the updated payload to the DR app using the bulk upload url.
342
+ """
343
+ if self._model_id:
344
+ payload["modelId"] = self._model_id
345
+
346
+ for metric_name, metric_value in self.aggregate_custom_metric.items():
347
+ if "value" not in metric_value:
348
+ # Different exception paths - especially with asyncio can
349
+ # end up not adding values for some aggregated custom metrics
350
+ # Capturing them for future fixes
351
+ self._logger.warning(f"No value for custom metric {metric_name}")
352
+ continue
353
+ if not math.isnan(metric_value["value"]):
354
+ payload["buckets"].append(metric_value)
355
+
356
+ self._logger.debug(f"Payload: {payload}")
357
+ return payload
358
+
359
+ @property
360
+ def custom_metrics(self):
361
+ return {
362
+ metric_name: metric_info for metric_name, metric_info in self.custom_metric_map.items()
363
+ }
364
+
365
+ def send_event_sync(self, title, message, event_type, guard_name=None, metric_name=None):
366
+ if self._deployment_id is None:
367
+ return
368
+
369
+ MLOpsEvent.report_moderation_event(
370
+ event_type=event_type,
371
+ title=title,
372
+ message=message,
373
+ deployment_id=self._deployment_id,
374
+ metric_name=metric_name,
375
+ guard_name=guard_name,
376
+ )
@@ -0,0 +1,127 @@
1
+ # ---------------------------------------------------------------------------------
2
+ # Copyright (c) 2025 DataRobot, Inc. and its affiliates. All rights reserved.
3
+ # Last updated 2025.
4
+ #
5
+ # DataRobot, Inc. Confidential.
6
+ # This is proprietary source code of DataRobot, Inc. and its affiliates.
7
+ #
8
+ # This file and its contents are subject to DataRobot Tool and Utility Agreement.
9
+ # For details, see
10
+ # https://www.datarobot.com/wp-content/uploads/2021/07/DataRobot-Tool-and-Utility-Agreement.pdf.
11
+ # ---------------------------------------------------------------------------------
12
+ import logging
13
+ from typing import Any
14
+
15
+ from datarobot.enums import CustomMetricAggregationType
16
+ from datarobot.enums import CustomMetricDirectionality
17
+
18
+ from datarobot_dome.constants import CUSTOM_METRIC_DESCRIPTION_SUFFIX
19
+ from datarobot_dome.constants import LOGGER_NAME_PREFIX
20
+ from datarobot_dome.metrics.factory import MetricScorerFactory
21
+ from datarobot_dome.metrics.metric_scorer import MetricScorer
22
+ from datarobot_dome.metrics.metric_scorer import ScorerType
23
+ from datarobot_dome.pipeline.pipeline import Pipeline
24
+
25
+ LATENCY_NAME = "VDB Score Latency"
26
+
27
+ score_latency = {
28
+ "name": LATENCY_NAME,
29
+ "directionality": CustomMetricDirectionality.LOWER_IS_BETTER,
30
+ "units": "seconds",
31
+ "type": CustomMetricAggregationType.AVERAGE,
32
+ "baselineValue": 0,
33
+ "isModelSpecific": True,
34
+ "timeStep": "hour",
35
+ "description": f"Latency of actual VDB Score. {CUSTOM_METRIC_DESCRIPTION_SUFFIX}",
36
+ }
37
+
38
+
39
+ class VDBPipeline(Pipeline):
40
+ def __init__(self):
41
+ super().__init__()
42
+ self._score_configs: dict[ScorerType, dict[str, Any]] = {
43
+ ScorerType.CITATION_TOKEN_AVERAGE: {},
44
+ ScorerType.CITATION_TOKEN_COUNT: {},
45
+ ScorerType.DOCUMENT_AVERAGE: {},
46
+ ScorerType.DOCUMENT_COUNT: {},
47
+ }
48
+ self._scorers: list[MetricScorer] = list()
49
+ self._logger = logging.getLogger(LOGGER_NAME_PREFIX + "." + self.__class__.__name__)
50
+ self._add_default_custom_metrics()
51
+ self.create_custom_metrics_if_any()
52
+ self.create_scorers()
53
+
54
+ def _add_default_custom_metrics(self):
55
+ """Adds the default custom metrics based on the `_score_configs` map."""
56
+ # create a list of tuples, so we can track the scorer type
57
+ metric_list = [(score_latency, None)]
58
+ for score_type, score_config in self._score_configs.items():
59
+ metric_config = MetricScorerFactory.custom_metric_config(score_type, score_config)
60
+ metric_list.append((metric_config, score_type))
61
+
62
+ # Metric list so far does not need association id for reporting
63
+ for metric_config, score_type in metric_list:
64
+ name = metric_config["name"]
65
+ self.custom_metrics_no_association_ids.append(name)
66
+ self.custom_metric_map[name] = {
67
+ "metric_definition": metric_config,
68
+ "scorer_type": score_type,
69
+ }
70
+
71
+ def create_scorers(self):
72
+ """
73
+ Creates a scorer for each metric in the custom_metric_map list.
74
+
75
+ NOTE: all metrics that failed to be created in DR app have been removed
76
+ """
77
+ if not self._deployment:
78
+ self._logger.debug("Skipping creation of scorers due to no deployment")
79
+ return
80
+
81
+ input_column = self._deployment.model["target_name"]
82
+ for metric_name, metric_data in self.custom_metric_map.items():
83
+ score_type = metric_data.get("scorer_type")
84
+ if not score_type:
85
+ continue
86
+
87
+ score_config = self._score_configs.get(score_type)
88
+ if score_config.get("input_column") is None:
89
+ score_config["input_column"] = input_column
90
+ scorer = MetricScorerFactory.create(score_type, score_config)
91
+ self._scorers.append(scorer)
92
+
93
+ def scorers(self) -> list[MetricScorer]:
94
+ """Get all scorers for this pipeline."""
95
+ return self._scorers
96
+
97
+ def record_aggregate_value(self, metric_name: str, value: Any) -> None:
98
+ """
99
+ Locally records the metric_name/value in the pipeline's area for aggregate metrics where the
100
+ bulk upload with pick it up.
101
+ """
102
+ if self.aggregate_custom_metric is None:
103
+ return
104
+
105
+ entry = self.aggregate_custom_metric[metric_name]
106
+ self.set_custom_metrics_aggregate_entry(entry, value)
107
+
108
+ def record_score_latency(self, latency_in_sec: float):
109
+ """Records aggregate latency metric value locally"""
110
+ self.record_aggregate_value(LATENCY_NAME, latency_in_sec)
111
+
112
+ def report_custom_metrics(self):
113
+ """
114
+ Reports all the custom-metrics to DR app.
115
+
116
+ The bulk upload includes grabbing all the aggregated metrics.
117
+ """
118
+ if self.delayed_custom_metric_creation:
119
+ # Flag is not set yet, so no point reporting custom metrics
120
+ return
121
+
122
+ if not self._deployment:
123
+ # in "test" mode, there is not a deployment and therefore no custom_metrics
124
+ return
125
+
126
+ payload = self.add_aggregate_metrics_to_payload({"buckets": []})
127
+ self.upload_custom_metrics(payload)