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,11 @@
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
+ # ---------------------------------------------------------------------------------
@@ -0,0 +1,248 @@
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 atexit
14
+ import datetime
15
+ import logging
16
+ import os
17
+ import traceback
18
+ from http import HTTPStatus
19
+ from io import StringIO
20
+
21
+ import aiohttp
22
+ import backoff
23
+ import nest_asyncio
24
+ import pandas as pd
25
+
26
+ from datarobot_dome.constants import DATAROBOT_ACTUAL_ON_PREM_ST_SAAS_URL
27
+ from datarobot_dome.constants import DATAROBOT_CONFIGURED_ON_PREM_ST_SAAS_URL
28
+ from datarobot_dome.constants import DATAROBOT_SERVERLESS_PLATFORM
29
+ from datarobot_dome.constants import DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC
30
+ from datarobot_dome.constants import LOGGER_NAME_PREFIX
31
+ from datarobot_dome.constants import RETRY_COUNT
32
+ from datarobot_dome.constants import ModerationEventTypes
33
+
34
+ RETRY_STATUS_CODES = [
35
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
36
+ HTTPStatus.BAD_GATEWAY,
37
+ HTTPStatus.GATEWAY_TIMEOUT,
38
+ ]
39
+ RETRY_AFTER_STATUS_CODES = [HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.SERVICE_UNAVAILABLE]
40
+
41
+
42
+ # We want this logger to be available for backoff too, hence defining outside the class
43
+ logger = logging.getLogger(LOGGER_NAME_PREFIX + ".AsyncHTTPClient")
44
+
45
+
46
+ # Event handlers for backoff
47
+ def _timeout_backoff_handler(details):
48
+ logger.warning(
49
+ f"HTTP Timeout: Backing off {details['wait']} seconds after {details['tries']} tries"
50
+ )
51
+
52
+
53
+ def _timeout_giveup_handler(details):
54
+ url = details["args"][1]
55
+ logger.error(f"Giving up predicting on {url}, Retried {details['tries']} after HTTP Timeout")
56
+
57
+
58
+ def _retry_backoff_handler(details):
59
+ status_code = details["value"].status
60
+ message = details["value"].reason
61
+ retry_after_value = details["value"].headers.get("Retry-After")
62
+ logger.warning(
63
+ f"Received status code {status_code}, message {message},"
64
+ f" Retry-After val: {retry_after_value} "
65
+ f"Backing off {details['wait']} seconds after {details['tries']} tries"
66
+ )
67
+
68
+
69
+ def _retry_giveup_handler(details):
70
+ message = (
71
+ f"Giving up predicting on {details['args'][1]}, Retried {details['tries']} retries, "
72
+ f"elapsed time {details['elapsed']} sec, but couldn't get predictions"
73
+ )
74
+ raise Exception(message)
75
+
76
+
77
+ class AsyncHTTPClient:
78
+ def __init__(self, timeout=DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC):
79
+ self._logger = logging.getLogger(LOGGER_NAME_PREFIX + "." + self.__class__.__name__)
80
+ self.csv_headers = {
81
+ "Content-Type": "text/csv",
82
+ "Accept": "text/csv",
83
+ "Authorization": f"Bearer {os.environ['DATAROBOT_API_TOKEN']}",
84
+ }
85
+ self.json_headers = {
86
+ "Content-Type": "application/json",
87
+ "Accept": "application/json",
88
+ "Authorization": f"Bearer {os.environ['DATAROBOT_API_TOKEN']}",
89
+ }
90
+ self.session = None
91
+ self.events_url = f"{os.environ['DATAROBOT_ENDPOINT']}/remoteEvents/"
92
+
93
+ try:
94
+ self.loop = asyncio.get_event_loop()
95
+ except RuntimeError as e:
96
+ if str(e).startswith("There is no current event loop in thread") or str(e).startswith(
97
+ "Event loop is closed"
98
+ ):
99
+ self.loop = asyncio.new_event_loop()
100
+ asyncio.set_event_loop(self.loop)
101
+ else:
102
+ raise
103
+ self.loop.run_until_complete(self.__create_client_session(timeout))
104
+ self.loop.set_debug(True)
105
+ nest_asyncio.apply(loop=self.loop)
106
+
107
+ atexit.register(self.shutdown)
108
+
109
+ async def __create_client_session(self, timeout):
110
+ client_timeout = aiohttp.ClientTimeout(
111
+ connect=timeout, sock_connect=timeout, sock_read=timeout
112
+ )
113
+ # Creation of client session needs to happen within in async function
114
+ self.session = aiohttp.ClientSession(timeout=client_timeout)
115
+
116
+ def shutdown(self):
117
+ asyncio.run(self.session.close())
118
+
119
+ @staticmethod
120
+ def is_serverless_deployment(deployment):
121
+ if not deployment.prediction_environment:
122
+ return False
123
+
124
+ if deployment.prediction_environment.get("platform") == DATAROBOT_SERVERLESS_PLATFORM:
125
+ return True
126
+
127
+ return False
128
+
129
+ @staticmethod
130
+ def is_on_prem_st_saas_endpoint():
131
+ return os.environ.get("DATAROBOT_ENDPOINT").startswith(
132
+ DATAROBOT_CONFIGURED_ON_PREM_ST_SAAS_URL
133
+ )
134
+
135
+ @backoff.on_predicate(
136
+ backoff.runtime,
137
+ predicate=lambda r: r.status in RETRY_AFTER_STATUS_CODES,
138
+ value=lambda r: int(r.headers.get("Retry-After", DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC)),
139
+ max_time=RETRY_COUNT * DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC,
140
+ max_tries=RETRY_COUNT,
141
+ jitter=None,
142
+ logger=logger,
143
+ on_backoff=_retry_backoff_handler,
144
+ on_giveup=_retry_giveup_handler,
145
+ )
146
+ @backoff.on_predicate(
147
+ backoff.fibo,
148
+ predicate=lambda r: r.status in RETRY_STATUS_CODES,
149
+ jitter=None,
150
+ max_tries=RETRY_COUNT,
151
+ max_time=RETRY_COUNT * DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC,
152
+ logger=logger,
153
+ on_backoff=_retry_backoff_handler,
154
+ on_giveup=_retry_giveup_handler,
155
+ )
156
+ @backoff.on_exception(
157
+ backoff.fibo,
158
+ asyncio.TimeoutError,
159
+ max_tries=RETRY_COUNT,
160
+ max_time=RETRY_COUNT * DEFAULT_GUARD_PREDICTION_TIMEOUT_IN_SEC,
161
+ logger=logger,
162
+ on_backoff=_timeout_backoff_handler,
163
+ on_giveup=_timeout_giveup_handler,
164
+ raise_on_giveup=True,
165
+ )
166
+ async def post_predict_request(self, url_path, input_df):
167
+ return await self.session.post(
168
+ url_path, data=input_df.to_csv(index=False), headers=self.csv_headers
169
+ )
170
+
171
+ async def predict(self, deployment, input_df):
172
+ deployment_id = str(deployment.id)
173
+ if self.is_on_prem_st_saas_endpoint():
174
+ url_path = DATAROBOT_ACTUAL_ON_PREM_ST_SAAS_URL
175
+ elif self.is_serverless_deployment(deployment):
176
+ url_path = f"{os.environ['DATAROBOT_ENDPOINT']}"
177
+ else:
178
+ prediction_server = deployment.default_prediction_server
179
+ if not prediction_server:
180
+ raise ValueError(
181
+ "Can't make prediction request because Deployment object doesn't contain "
182
+ "default prediction server"
183
+ )
184
+ datarobot_key = prediction_server.get("datarobot-key")
185
+ if datarobot_key:
186
+ self.csv_headers["datarobot-key"] = datarobot_key
187
+
188
+ url_path = f"{prediction_server['url']}/predApi/v1.0"
189
+
190
+ url_path += f"/deployments/{deployment_id}/predictions"
191
+ response = await self.post_predict_request(url_path, input_df)
192
+ if not response.ok:
193
+ raise Exception(
194
+ f"Failed to get guard predictions: {response.reason} Status: {response.status}"
195
+ )
196
+ csv_data = await response.text()
197
+ return pd.read_csv(StringIO(csv_data))
198
+
199
+ async def async_report_event(
200
+ self, title, message, event_type, deployment_id, guard_name=None, metric_name=None
201
+ ):
202
+ payload = {
203
+ "title": title,
204
+ "message": message,
205
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
206
+ "deploymentId": str(deployment_id),
207
+ "eventType": event_type,
208
+ "moderationData": {"guardName": "", "metricName": ""},
209
+ }
210
+ error_text = ""
211
+ if metric_name:
212
+ payload["moderationData"]["metricName"] = metric_name
213
+ error_text = f"for metric {metric_name}"
214
+ if guard_name:
215
+ payload["moderationData"]["guardName"] = guard_name
216
+ error_text = f"for guard {guard_name}"
217
+
218
+ response = await self.session.post(self.events_url, json=payload, headers=self.json_headers)
219
+ if response.status != HTTPStatus.CREATED:
220
+ # Lets not raise - we just failed to report an event, let the moderation
221
+ # continue
222
+ logger.error(
223
+ f"Failed to post event {event_type} {error_text} "
224
+ f" Status: {response.status} Message: {response.reason}"
225
+ )
226
+
227
+ async def bulk_upload_custom_metrics(self, url, payload, deployment_id):
228
+ self._logger.debug("Uploading custom metrics")
229
+ try:
230
+ response = await self.session.post(url, json=payload, headers=self.json_headers)
231
+ if response.status != HTTPStatus.ACCEPTED:
232
+ raise Exception(
233
+ f"Error uploading custom metrics: Status Code: {response.status_code}"
234
+ f"Message: {response.text}"
235
+ )
236
+ self._logger.info("Successfully uploaded custom metrics")
237
+ except Exception as e:
238
+ title = "Failed to upload custom metrics"
239
+ message = f"Exception: {e} Payload: {payload}"
240
+ self._logger.error(title + " " + message)
241
+ self._logger.error(traceback.format_exc())
242
+ await self.async_report_event(
243
+ title,
244
+ message,
245
+ ModerationEventTypes.MODERATION_METRIC_REPORTING_ERROR,
246
+ deployment_id,
247
+ )
248
+ # Lets not raise the exception, just walk off
@@ -0,0 +1,227 @@
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
+ import time
14
+ import traceback
15
+ from re import match
16
+
17
+ import tiktoken
18
+
19
+ from datarobot_dome.constants import AGENTIC_PIPELINE_INTERACTIONS_ATTR
20
+ from datarobot_dome.constants import NONE_CUSTOM_PY_RESPONSE
21
+ from datarobot_dome.constants import PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE
22
+ from datarobot_dome.constants import RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE
23
+ from datarobot_dome.constants import GuardStage
24
+ from datarobot_dome.guard_executor import AsyncGuardExecutor
25
+ from datarobot_dome.guard_helpers import calculate_token_counts_for_cost_calculations
26
+ from datarobot_dome.guard_helpers import get_citation_columns
27
+ from datarobot_dome.guard_helpers import get_rouge_1_score
28
+
29
+ _logger = logging.getLogger("chat_helper")
30
+
31
+
32
+ def get_all_citation_columns(df):
33
+ citation_columns = []
34
+ for pattern in [
35
+ "CITATION_CONTENT_",
36
+ "CITATION_SOURCE_",
37
+ "CITATION_PAGE_",
38
+ "CITATION_CHUNK_ID_",
39
+ "CITATION_START_INDEX_",
40
+ "CITATION_SIMILARITY_SCORE_",
41
+ ]:
42
+ citation_columns.extend(list(filter(lambda column: match(pattern, column), df.columns)))
43
+ return citation_columns
44
+
45
+
46
+ def build_moderations_attribute_for_completion(pipeline, df):
47
+ """
48
+ Given the dataframe build a moderation attribute to be returned with
49
+ chat completion or chat completion chunk
50
+ """
51
+ if df is None or df.empty:
52
+ return None
53
+
54
+ prompt_column_name = pipeline.get_input_column(GuardStage.PROMPT)
55
+ blocked_message_prompt_column_name = f"blocked_message_{prompt_column_name}"
56
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
57
+ replaced_message_prompt_column_name = f"replaced_message_{prompt_column_name}"
58
+ blocked_message_completion_column_name = f"blocked_message_{response_column_name}"
59
+ replaced_message_response_column_name = f"replaced_message_{response_column_name}"
60
+
61
+ moderations = df.to_dict(orient="records")[0]
62
+ columns_to_drop = [
63
+ pipeline.get_input_column(GuardStage.PROMPT),
64
+ # Its already copied as part of the completion.choices[0].message.content
65
+ pipeline.get_input_column(GuardStage.RESPONSE),
66
+ blocked_message_prompt_column_name,
67
+ blocked_message_completion_column_name,
68
+ replaced_message_prompt_column_name,
69
+ replaced_message_response_column_name,
70
+ f"Noneed_{prompt_column_name}",
71
+ f"Noneed_{response_column_name}",
72
+ ]
73
+ citation_columns = get_all_citation_columns(df)
74
+ if len(citation_columns) > 0:
75
+ columns_to_drop += citation_columns
76
+ for column in columns_to_drop:
77
+ if column in moderations:
78
+ moderations.pop(column)
79
+
80
+ return moderations
81
+
82
+
83
+ def run_postscore_guards(pipeline, predictions_df, postscore_guards=None):
84
+ """Run postscore guards on the input data."""
85
+ if not postscore_guards:
86
+ postscore_guards = pipeline.get_postscore_guards()
87
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
88
+ blocked_completion_column_name = f"blocked_{response_column_name}"
89
+ input_df = predictions_df.copy(deep=True)
90
+ if len(postscore_guards) == 0:
91
+ input_df[blocked_completion_column_name] = False
92
+ return input_df, 0
93
+
94
+ start_time = time.time()
95
+ try:
96
+ postscore_df, postscore_latency = AsyncGuardExecutor(pipeline).run_guards(
97
+ input_df, postscore_guards, GuardStage.RESPONSE
98
+ )
99
+ except Exception as ex:
100
+ end_time = time.time()
101
+ _logger.error(f"Failed to run postscore guards: {ex}")
102
+ _logger.error(traceback.format_exc())
103
+ postscore_df = input_df
104
+ postscore_df[blocked_completion_column_name] = False
105
+ postscore_latency = end_time - start_time
106
+
107
+ # Again ensure the indexing matches the input dataframe indexing
108
+ postscore_df.index = predictions_df.index
109
+ _logger.debug("After passing completions through post score guards")
110
+ _logger.debug(postscore_df)
111
+ _logger.debug(f"Post Score Guard Latency: {postscore_latency} sec")
112
+
113
+ return postscore_df, postscore_latency
114
+
115
+
116
+ def get_response_message_and_finish_reason(pipeline, postscore_df, streaming=False):
117
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
118
+ blocked_completion_column_name = f"blocked_{response_column_name}"
119
+ replaced_response_column_name = f"replaced_{response_column_name}"
120
+ if postscore_df.empty:
121
+ response_message = NONE_CUSTOM_PY_RESPONSE
122
+ finish_reason = "stop"
123
+ elif postscore_df.loc[0, blocked_completion_column_name]:
124
+ blocked_message_completion_column_name = f"blocked_message_{response_column_name}"
125
+ response_message = postscore_df.loc[0, blocked_message_completion_column_name]
126
+ finish_reason = "content_filter"
127
+ elif (
128
+ replaced_response_column_name in postscore_df.columns
129
+ and postscore_df.loc[0, replaced_response_column_name]
130
+ ):
131
+ replaced_message_response_column_name = f"replaced_message_{response_column_name}"
132
+ response_message = postscore_df.loc[0, replaced_message_response_column_name]
133
+ # In case of streaming - if the guard replaces the text, we don't want to
134
+ # stop streaming - so don't put finish_reason in case of streaming
135
+ finish_reason = None if streaming else "content_filter"
136
+ else:
137
+ response_message = postscore_df.loc[0, response_column_name]
138
+ finish_reason = None if streaming else "stop"
139
+
140
+ return response_message, finish_reason
141
+
142
+
143
+ def calculate_token_counts_and_confidence_score(pipeline, result_df):
144
+ prompt_column_name = pipeline.get_input_column(GuardStage.PROMPT)
145
+ blocked_prompt_column_name = f"blocked_{prompt_column_name}"
146
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
147
+ blocked_completion_column_name = f"blocked_{response_column_name}"
148
+
149
+ encoding = tiktoken.get_encoding("cl100k_base")
150
+
151
+ citation_columns = get_citation_columns(result_df.columns)
152
+
153
+ def _get_llm_contexts(index):
154
+ contexts = []
155
+ if len(citation_columns) >= 0:
156
+ for column in citation_columns:
157
+ contexts.append(result_df.loc[index][column])
158
+ return contexts
159
+
160
+ for index, row in result_df.iterrows():
161
+ if not (
162
+ row.get(blocked_prompt_column_name, False)
163
+ or row.get(blocked_completion_column_name, False)
164
+ ):
165
+ completion = result_df.loc[index][response_column_name]
166
+ if completion != NONE_CUSTOM_PY_RESPONSE:
167
+ result_df.loc[index, "datarobot_token_count"] = len(
168
+ encoding.encode(str(completion), disallowed_special=())
169
+ )
170
+ result_df.loc[index, "datarobot_confidence_score"] = get_rouge_1_score(
171
+ pipeline.rouge_scorer, _get_llm_contexts(index), [completion]
172
+ )
173
+ else:
174
+ result_df.loc[index, "datarobot_confidence_score"] = 0.0
175
+ else:
176
+ # If the row is blocked, set default value
177
+ result_df.loc[index, "datarobot_confidence_score"] = 0.0
178
+
179
+
180
+ def add_citations_to_df(citations, df):
181
+ if not citations:
182
+ return df
183
+
184
+ for index, citation in enumerate(citations):
185
+ df[f"CITATION_CONTENT_{index}"] = citation["content"]
186
+ return df
187
+
188
+
189
+ def add_token_count_columns_to_df(pipeline, df, usage=None):
190
+ if not usage:
191
+ prompt_column_name = pipeline.get_input_column(GuardStage.PROMPT)
192
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
193
+ df = calculate_token_counts_for_cost_calculations(
194
+ prompt_column_name, response_column_name, df
195
+ )
196
+ else:
197
+ df[PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE] = [usage.prompt_tokens]
198
+ df[RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE] = [usage.completion_tokens]
199
+ return df
200
+
201
+
202
+ def remove_unnecessary_columns(pipeline, result_df):
203
+ prompt_column_name = pipeline.get_input_column(GuardStage.PROMPT)
204
+ blocked_message_prompt_column_name = f"blocked_message_{prompt_column_name}"
205
+ response_column_name = pipeline.get_input_column(GuardStage.RESPONSE)
206
+ replaced_message_prompt_column_name = f"replaced_message_{prompt_column_name}"
207
+ blocked_message_completion_column_name = f"blocked_message_{response_column_name}"
208
+ replaced_message_response_column_name = f"replaced_message_{response_column_name}"
209
+ # We don't need these columns, because they have already been copied into
210
+ # 'completion' column
211
+ columns_to_remove = [
212
+ blocked_message_prompt_column_name,
213
+ blocked_message_completion_column_name,
214
+ replaced_message_prompt_column_name,
215
+ replaced_message_response_column_name,
216
+ f"Noneed_{prompt_column_name}",
217
+ f"Noneed_{response_column_name}",
218
+ PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE,
219
+ RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE,
220
+ AGENTIC_PIPELINE_INTERACTIONS_ATTR,
221
+ ]
222
+ columns_to_remove.extend(get_all_citation_columns(result_df))
223
+ for column in columns_to_remove:
224
+ if column in result_df.columns:
225
+ result_df = result_df.drop(column, axis=1)
226
+
227
+ return result_df