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.
- datarobot_dome/__init__.py +11 -0
- datarobot_dome/async_http_client.py +248 -0
- datarobot_dome/chat_helper.py +227 -0
- datarobot_dome/constants.py +318 -0
- datarobot_dome/drum_integration.py +977 -0
- datarobot_dome/guard.py +736 -0
- datarobot_dome/guard_executor.py +755 -0
- datarobot_dome/guard_helpers.py +457 -0
- datarobot_dome/guards/__init__.py +11 -0
- datarobot_dome/guards/guard_llm_mixin.py +232 -0
- datarobot_dome/llm.py +148 -0
- datarobot_dome/metrics/__init__.py +11 -0
- datarobot_dome/metrics/citation_metrics.py +98 -0
- datarobot_dome/metrics/factory.py +52 -0
- datarobot_dome/metrics/metric_scorer.py +78 -0
- datarobot_dome/pipeline/__init__.py +11 -0
- datarobot_dome/pipeline/llm_pipeline.py +474 -0
- datarobot_dome/pipeline/pipeline.py +376 -0
- datarobot_dome/pipeline/vdb_pipeline.py +127 -0
- datarobot_dome/streaming.py +395 -0
- datarobot_moderations-11.1.12.dist-info/METADATA +113 -0
- datarobot_moderations-11.1.12.dist-info/RECORD +23 -0
- datarobot_moderations-11.1.12.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,755 @@
|
|
|
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 json
|
|
14
|
+
import logging
|
|
15
|
+
import operator
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
|
|
19
|
+
import nest_asyncio
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import requests.exceptions
|
|
22
|
+
|
|
23
|
+
from datarobot_dome.constants import AGENTIC_PIPELINE_INTERACTIONS_ATTR
|
|
24
|
+
from datarobot_dome.constants import LOGGER_NAME_PREFIX
|
|
25
|
+
from datarobot_dome.constants import PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE
|
|
26
|
+
from datarobot_dome.constants import RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE
|
|
27
|
+
from datarobot_dome.constants import GuardAction
|
|
28
|
+
from datarobot_dome.constants import GuardOperatorType
|
|
29
|
+
from datarobot_dome.constants import GuardStage
|
|
30
|
+
from datarobot_dome.constants import GuardTimeoutAction
|
|
31
|
+
from datarobot_dome.constants import GuardType
|
|
32
|
+
from datarobot_dome.constants import ModerationEventTypes
|
|
33
|
+
from datarobot_dome.constants import OOTBType
|
|
34
|
+
from datarobot_dome.guard import AgentGoalAccuracyGuard
|
|
35
|
+
from datarobot_dome.guard import FaithfulnessGuard
|
|
36
|
+
from datarobot_dome.guard import Guard
|
|
37
|
+
from datarobot_dome.guard import ModelGuard
|
|
38
|
+
from datarobot_dome.guard import NeMoGuard
|
|
39
|
+
from datarobot_dome.guard import OOTBCostMetric
|
|
40
|
+
from datarobot_dome.guard import OOTBGuard
|
|
41
|
+
from datarobot_dome.guard import TaskAdherenceGuard
|
|
42
|
+
from datarobot_dome.guard_helpers import calculate_agent_goal_accuracy
|
|
43
|
+
from datarobot_dome.guard_helpers import calculate_faithfulness
|
|
44
|
+
from datarobot_dome.guard_helpers import calculate_task_adherence
|
|
45
|
+
from datarobot_dome.guard_helpers import calculate_token_counts_for_cost_calculations
|
|
46
|
+
from datarobot_dome.guard_helpers import get_citation_columns
|
|
47
|
+
from datarobot_dome.guard_helpers import get_rouge_1_score
|
|
48
|
+
from datarobot_dome.guard_helpers import get_token_count
|
|
49
|
+
from datarobot_dome.guard_helpers import nemo_response_stage_input_formatter
|
|
50
|
+
from datarobot_dome.guard_helpers import nemo_response_stage_output_formatter
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_operator_comparator(comparator): # noqa: PLR0911
|
|
54
|
+
if comparator == GuardOperatorType.GREATER_THAN:
|
|
55
|
+
return operator.gt
|
|
56
|
+
elif comparator == GuardOperatorType.LESS_THAN:
|
|
57
|
+
return operator.lt
|
|
58
|
+
elif comparator == GuardOperatorType.EQUALS:
|
|
59
|
+
return operator.eq
|
|
60
|
+
elif comparator == GuardOperatorType.NOT_EQUALS:
|
|
61
|
+
return operator.ne
|
|
62
|
+
# IS and IS_NOT are identical to EQUALS and NOT_EQUALS
|
|
63
|
+
# this is because the "==" operator can be used for comparands of any type
|
|
64
|
+
elif comparator == GuardOperatorType.IS:
|
|
65
|
+
return operator.eq
|
|
66
|
+
elif comparator == GuardOperatorType.IS_NOT:
|
|
67
|
+
return operator.ne
|
|
68
|
+
# MATCHES and DOES_NOT_MATCH are used for string comparands
|
|
69
|
+
elif comparator == GuardOperatorType.MATCHES:
|
|
70
|
+
return lambda x, y: [str(__x) in y for __x in x]
|
|
71
|
+
elif comparator == GuardOperatorType.DOES_NOT_MATCH:
|
|
72
|
+
return lambda x, y: [str(__x) not in y for __x in x]
|
|
73
|
+
# CONTAINS and DOES_NOT_CONTAIN are used for list of strings comparands
|
|
74
|
+
elif comparator == GuardOperatorType.CONTAINS:
|
|
75
|
+
return lambda x, y: [all(item in str(__x) for item in y) for __x in x]
|
|
76
|
+
elif comparator == GuardOperatorType.DOES_NOT_CONTAIN:
|
|
77
|
+
return lambda x, y: [not all(item in str(__x) for item in y) for __x in x]
|
|
78
|
+
else:
|
|
79
|
+
raise NotImplementedError(f"Comparator {comparator} not implemented")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AsyncGuardExecutor:
|
|
83
|
+
guard_executor_map = {
|
|
84
|
+
GuardType.MODEL: "run_model_guard",
|
|
85
|
+
GuardType.OOTB: "run_ootb_guard",
|
|
86
|
+
GuardType.NEMO_GUARDRAILS: "run_nemo_guard",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def __init__(self, pipeline):
|
|
90
|
+
self._logger = logging.getLogger(LOGGER_NAME_PREFIX + "." + self.__class__.__name__)
|
|
91
|
+
self.pipeline = pipeline
|
|
92
|
+
self.async_http_client = self.pipeline.get_async_http_client()
|
|
93
|
+
self.loop = asyncio.get_event_loop()
|
|
94
|
+
self.loop.set_debug(True)
|
|
95
|
+
nest_asyncio.apply(loop=self.loop)
|
|
96
|
+
|
|
97
|
+
async def run_guard(self, guard, copy_df, stage):
|
|
98
|
+
start_time = time.time()
|
|
99
|
+
executor = getattr(self, self.guard_executor_map[guard.type])
|
|
100
|
+
df = await executor(guard, copy_df, stage)
|
|
101
|
+
end_time = time.time()
|
|
102
|
+
|
|
103
|
+
latency = end_time - start_time
|
|
104
|
+
if guard.has_latency_custom_metric():
|
|
105
|
+
self.pipeline.report_guard_latency(guard, latency)
|
|
106
|
+
|
|
107
|
+
return df, latency
|
|
108
|
+
|
|
109
|
+
def _should_intervene(self, guard):
|
|
110
|
+
if (
|
|
111
|
+
guard.intervention
|
|
112
|
+
and guard.intervention.threshold is not None
|
|
113
|
+
and guard.intervention.comparator is not None
|
|
114
|
+
):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def _get_enforced_and_action_column_names(cls, intervention_action, input_column):
|
|
120
|
+
action_column_name = "action_" + input_column
|
|
121
|
+
if intervention_action == GuardAction.REPLACE:
|
|
122
|
+
enforced_column_name = f"replaced_{input_column}"
|
|
123
|
+
else:
|
|
124
|
+
enforced_column_name = f"{intervention_action}ed_{input_column}"
|
|
125
|
+
enforced_message_column_name = None
|
|
126
|
+
if intervention_action == GuardAction.BLOCK:
|
|
127
|
+
enforced_message_column_name = f"blocked_message_{input_column}"
|
|
128
|
+
elif intervention_action == GuardAction.REPLACE:
|
|
129
|
+
enforced_message_column_name = f"replaced_message_{input_column}"
|
|
130
|
+
|
|
131
|
+
return enforced_column_name, enforced_message_column_name, action_column_name
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def _initialize_enforced_and_action_columns(
|
|
135
|
+
cls, df, enforced_column_name, enforced_message_column_name, action_column_name
|
|
136
|
+
):
|
|
137
|
+
df[enforced_column_name] = False
|
|
138
|
+
df[action_column_name] = ""
|
|
139
|
+
if enforced_message_column_name:
|
|
140
|
+
df[enforced_message_column_name] = ""
|
|
141
|
+
|
|
142
|
+
async def run_model_guard(self, guard, copy_df, stage):
|
|
143
|
+
if not isinstance(guard, ModelGuard):
|
|
144
|
+
raise ValueError(f"Guard object should be of type ModelGuard, got: {type(guard)}")
|
|
145
|
+
metric_column = guard.model_info.target_name
|
|
146
|
+
|
|
147
|
+
llm_input_column = self.pipeline.get_input_column(stage)
|
|
148
|
+
guard_input_column = guard.get_input_column(stage)
|
|
149
|
+
|
|
150
|
+
intervene = self._should_intervene(guard)
|
|
151
|
+
try:
|
|
152
|
+
if llm_input_column not in copy_df.columns:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Expecting column {llm_input_column} in DF, but is missing. Stage: {stage}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Copy and rename only the column guard needs, we don't need
|
|
158
|
+
# to send rest of data to the guard model
|
|
159
|
+
input_df_to_guard = copy_df[[llm_input_column]].copy()
|
|
160
|
+
input_df_to_guard.rename(columns={llm_input_column: guard_input_column}, inplace=True)
|
|
161
|
+
|
|
162
|
+
result_df = await self.async_http_client.predict(guard.deployment, input_df_to_guard)
|
|
163
|
+
if metric_column not in result_df.columns:
|
|
164
|
+
# This is caught anyways in the exception handling code and masked
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Missing output column {metric_column} in the model guard response"
|
|
167
|
+
f"Columns obtained: {result_df.columns}"
|
|
168
|
+
)
|
|
169
|
+
if (
|
|
170
|
+
intervene
|
|
171
|
+
and guard.intervention.action == GuardAction.REPLACE
|
|
172
|
+
and guard.model_info.replacement_text_column_name not in result_df.columns
|
|
173
|
+
):
|
|
174
|
+
# In case of "replace" intervention we expect the guard to send the
|
|
175
|
+
# replacement as well
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Missing replacement column {guard.model_info.replacement_text_column_name} "
|
|
178
|
+
f"in the model guard response Columns obtained: {result_df.columns}"
|
|
179
|
+
)
|
|
180
|
+
# Ensure that index of result and copy dfs are same, so that concat will work
|
|
181
|
+
# correctly
|
|
182
|
+
result_df.index = copy_df.index
|
|
183
|
+
columns_to_concat = [metric_column]
|
|
184
|
+
if intervene and guard.intervention.action == GuardAction.REPLACE:
|
|
185
|
+
columns_to_concat.append(guard.model_info.replacement_text_column_name)
|
|
186
|
+
|
|
187
|
+
copy_df = pd.concat([copy_df, result_df[columns_to_concat]], axis="columns")
|
|
188
|
+
|
|
189
|
+
if intervene:
|
|
190
|
+
copy_df, _ = self._intervene(guard, copy_df, stage, metric_column)
|
|
191
|
+
else:
|
|
192
|
+
copy_df = self._dont_intervene(guard, copy_df, stage)
|
|
193
|
+
# eg. toxicity_toxic_PREDICTION should be renamed to "Prompts_toxicity_toxic_PREDICTION"
|
|
194
|
+
# and "Response_toxicity_toxic_PREDICTION", if toxicity is configured for both
|
|
195
|
+
# prompts and responses
|
|
196
|
+
copy_df.rename(
|
|
197
|
+
columns={metric_column: Guard.get_stage_str(stage) + "_" + metric_column},
|
|
198
|
+
inplace=True,
|
|
199
|
+
)
|
|
200
|
+
except Exception as ex:
|
|
201
|
+
if isinstance(ex, asyncio.TimeoutError):
|
|
202
|
+
title = "Model Guard timeout"
|
|
203
|
+
message = f'Timed out waiting for guard "{guard.name}" to predict'
|
|
204
|
+
if self.pipeline.guard_timeout_action == GuardTimeoutAction.BLOCK:
|
|
205
|
+
copy_df = self._timeout_intervention(guard, copy_df, stage)
|
|
206
|
+
else:
|
|
207
|
+
# No intervention - continue scoring / returning response
|
|
208
|
+
copy_df = self._dont_intervene(guard, copy_df, stage)
|
|
209
|
+
else:
|
|
210
|
+
title = "Model guard predictions failed"
|
|
211
|
+
message = f'Guard "{guard.name}": Exception {ex}'
|
|
212
|
+
self._logger.error(traceback.format_exc())
|
|
213
|
+
# No intervention
|
|
214
|
+
copy_df = self._dont_intervene(guard, copy_df, stage)
|
|
215
|
+
self._logger.error(title + " " + message)
|
|
216
|
+
await self.pipeline.send_event_async(
|
|
217
|
+
title,
|
|
218
|
+
message,
|
|
219
|
+
ModerationEventTypes.MODERATION_MODEL_RUNTIME_ERROR,
|
|
220
|
+
guard_name=guard.name,
|
|
221
|
+
)
|
|
222
|
+
return copy_df
|
|
223
|
+
|
|
224
|
+
def _timeout_intervention(self, guard, copy_df, stage):
|
|
225
|
+
timeout_action = self.pipeline.guard_timeout_action
|
|
226
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
227
|
+
(
|
|
228
|
+
enforced_column_name,
|
|
229
|
+
enforced_message_column_name,
|
|
230
|
+
action_column_name,
|
|
231
|
+
) = self._get_enforced_and_action_column_names(timeout_action, input_column)
|
|
232
|
+
copy_df[enforced_column_name] = True
|
|
233
|
+
copy_df[action_column_name] = self.pipeline.guard_timeout_action
|
|
234
|
+
if enforced_message_column_name:
|
|
235
|
+
copy_df[enforced_message_column_name] = (
|
|
236
|
+
f"DataRobot Moderation system {self.pipeline.guard_timeout_action}ing "
|
|
237
|
+
f"it due to timeout on {guard.name} guard"
|
|
238
|
+
)
|
|
239
|
+
return copy_df
|
|
240
|
+
|
|
241
|
+
def _dont_intervene(self, guard, copy_df, stage):
|
|
242
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
243
|
+
(
|
|
244
|
+
enforced_column_name,
|
|
245
|
+
enforced_message_column_name,
|
|
246
|
+
action_column_name,
|
|
247
|
+
) = self._get_enforced_and_action_column_names(
|
|
248
|
+
guard.get_intervention_action(), input_column
|
|
249
|
+
)
|
|
250
|
+
self._initialize_enforced_and_action_columns(
|
|
251
|
+
copy_df,
|
|
252
|
+
enforced_column_name,
|
|
253
|
+
enforced_message_column_name,
|
|
254
|
+
action_column_name,
|
|
255
|
+
)
|
|
256
|
+
return copy_df
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def intervene(cls, guard, copy_df, input_column, metric_column):
|
|
260
|
+
intervention_action = guard.get_intervention_action()
|
|
261
|
+
(
|
|
262
|
+
enforced_column_name,
|
|
263
|
+
enforced_message_column_name,
|
|
264
|
+
action_column_name,
|
|
265
|
+
) = cls._get_enforced_and_action_column_names(intervention_action, input_column)
|
|
266
|
+
cls._initialize_enforced_and_action_columns(
|
|
267
|
+
copy_df,
|
|
268
|
+
enforced_column_name,
|
|
269
|
+
enforced_message_column_name,
|
|
270
|
+
action_column_name,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Do intervention
|
|
274
|
+
threshold = guard.get_comparand()
|
|
275
|
+
comparator = get_operator_comparator(guard.intervention.comparator)
|
|
276
|
+
# update new tracking columns for this guard
|
|
277
|
+
copy_df[action_column_name] = copy_df[action_column_name].mask(
|
|
278
|
+
comparator(copy_df[metric_column], threshold),
|
|
279
|
+
intervention_action,
|
|
280
|
+
)
|
|
281
|
+
copy_df[enforced_column_name] = copy_df[enforced_column_name].mask(
|
|
282
|
+
comparator(copy_df[metric_column], threshold),
|
|
283
|
+
True,
|
|
284
|
+
)
|
|
285
|
+
if guard.intervention.action == GuardAction.REPLACE:
|
|
286
|
+
copy_df[enforced_message_column_name] = copy_df[enforced_message_column_name].mask(
|
|
287
|
+
comparator(copy_df[metric_column], threshold),
|
|
288
|
+
copy_df[guard.model_info.replacement_text_column_name],
|
|
289
|
+
)
|
|
290
|
+
elif guard.intervention.action == GuardAction.BLOCK:
|
|
291
|
+
if guard.intervention.message:
|
|
292
|
+
copy_df[enforced_message_column_name] = copy_df[enforced_message_column_name].mask(
|
|
293
|
+
comparator(copy_df[metric_column], threshold),
|
|
294
|
+
guard.intervention.message,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
num_intervened = int(
|
|
298
|
+
copy_df[copy_df[action_column_name] == guard.intervention.action][
|
|
299
|
+
action_column_name
|
|
300
|
+
].count()
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return copy_df, num_intervened
|
|
304
|
+
|
|
305
|
+
def _intervene(self, guard, copy_df, stage, metric_column):
|
|
306
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
307
|
+
return self.intervene(guard, copy_df, input_column, metric_column)
|
|
308
|
+
|
|
309
|
+
async def _handle_faithfulness(self, guard, copy_df, stage, intervene):
|
|
310
|
+
if not isinstance(guard, FaithfulnessGuard):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"Guard object should be of type FaithfulnessGuard, got: {type(guard)}"
|
|
313
|
+
)
|
|
314
|
+
if stage == GuardStage.PROMPT:
|
|
315
|
+
raise ValueError("Faithfulness only supports evaluating the response")
|
|
316
|
+
|
|
317
|
+
citation_columns = get_citation_columns(copy_df.columns)
|
|
318
|
+
if len(citation_columns) == 0:
|
|
319
|
+
# For now, let us simply log the error. In future, we can add new error
|
|
320
|
+
# custom metrics to track it
|
|
321
|
+
title = "Faithfulness guard configured without citation columns"
|
|
322
|
+
message = f"Input Column Names: {copy_df.columns}"
|
|
323
|
+
self._logger.error(title + " " + message)
|
|
324
|
+
await self.pipeline.send_event_async(
|
|
325
|
+
title,
|
|
326
|
+
message,
|
|
327
|
+
ModerationEventTypes.MODERATION_MODEL_CONFIG_ERROR,
|
|
328
|
+
guard_name=guard.name,
|
|
329
|
+
)
|
|
330
|
+
intervene = False
|
|
331
|
+
else:
|
|
332
|
+
prompt_column_name = self.pipeline.get_input_column(GuardStage.PROMPT)
|
|
333
|
+
response_column_name = self.pipeline.get_input_column(GuardStage.RESPONSE)
|
|
334
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
copy_df[metric_column_name] = copy_df.apply(
|
|
338
|
+
lambda x: calculate_faithfulness(
|
|
339
|
+
evaluator=guard.faithfulness_evaluator,
|
|
340
|
+
llm_query=x[prompt_column_name],
|
|
341
|
+
llm_context=[x[col] for col in citation_columns],
|
|
342
|
+
llm_response=x[response_column_name],
|
|
343
|
+
),
|
|
344
|
+
axis=1,
|
|
345
|
+
)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
title = "Faithfulness calculation failed"
|
|
348
|
+
message = f"Exception: {e}"
|
|
349
|
+
self._logger.error(title + " " + message)
|
|
350
|
+
self._logger.error(traceback.format_exc())
|
|
351
|
+
await self.pipeline.send_event_async(
|
|
352
|
+
title,
|
|
353
|
+
message,
|
|
354
|
+
ModerationEventTypes.MODERATION_MODEL_RUNTIME_ERROR,
|
|
355
|
+
guard_name=guard.name,
|
|
356
|
+
)
|
|
357
|
+
intervene = False
|
|
358
|
+
|
|
359
|
+
return copy_df, intervene
|
|
360
|
+
|
|
361
|
+
async def _handle_agent_goal_accuracy(self, guard, copy_df, stage):
|
|
362
|
+
if not isinstance(guard, AgentGoalAccuracyGuard):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"Guard object should be of type AgentGoalAccuracyGuard, got: {type(guard)}"
|
|
365
|
+
)
|
|
366
|
+
if stage == GuardStage.PROMPT:
|
|
367
|
+
raise ValueError("Agent Goal Accuracy only supports evaluating the response")
|
|
368
|
+
|
|
369
|
+
if AGENTIC_PIPELINE_INTERACTIONS_ATTR not in copy_df.columns:
|
|
370
|
+
title = "Agent goal accuracy cannot be calculated without pipeline interactions"
|
|
371
|
+
message = f"Input Column Names: {copy_df.columns}"
|
|
372
|
+
self._logger.error(title + " " + message)
|
|
373
|
+
await self.pipeline.send_event_async(
|
|
374
|
+
title,
|
|
375
|
+
message,
|
|
376
|
+
ModerationEventTypes.MODERATION_MODEL_CONFIG_ERROR,
|
|
377
|
+
guard_name=guard.name,
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
prompt_column_name = self.pipeline.get_input_column(GuardStage.PROMPT)
|
|
381
|
+
response_column_name = self.pipeline.get_input_column(GuardStage.RESPONSE)
|
|
382
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
copy_df[metric_column_name] = copy_df.apply(
|
|
386
|
+
lambda x: calculate_agent_goal_accuracy(
|
|
387
|
+
scorer=guard.accuracy_scorer,
|
|
388
|
+
prompt=x[prompt_column_name],
|
|
389
|
+
interactions=x[AGENTIC_PIPELINE_INTERACTIONS_ATTR],
|
|
390
|
+
response=x[response_column_name],
|
|
391
|
+
),
|
|
392
|
+
axis=1,
|
|
393
|
+
)
|
|
394
|
+
except Exception as e:
|
|
395
|
+
title = "Agent Goal accuracy calculation failed"
|
|
396
|
+
message = f"Exception: {e}"
|
|
397
|
+
self._logger.error(title + " " + message)
|
|
398
|
+
self._logger.error(traceback.format_exc())
|
|
399
|
+
await self.pipeline.send_event_async(
|
|
400
|
+
title,
|
|
401
|
+
message,
|
|
402
|
+
ModerationEventTypes.MODERATION_MODEL_RUNTIME_ERROR,
|
|
403
|
+
guard_name=guard.name,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return copy_df
|
|
407
|
+
|
|
408
|
+
async def _handle_task_adherence(self, guard, copy_df, stage):
|
|
409
|
+
if not isinstance(guard, TaskAdherenceGuard):
|
|
410
|
+
raise ValueError(
|
|
411
|
+
f"Guard object should be of type TaskAdherenceGuard, got: {type(guard)}"
|
|
412
|
+
)
|
|
413
|
+
if stage == GuardStage.PROMPT:
|
|
414
|
+
raise ValueError("Task Adherence only supports evaluating the response")
|
|
415
|
+
|
|
416
|
+
if AGENTIC_PIPELINE_INTERACTIONS_ATTR not in copy_df.columns:
|
|
417
|
+
title = "Task adherence cannot be calculated without pipeline interactions"
|
|
418
|
+
message = f"Input Column Names: {copy_df.columns}"
|
|
419
|
+
self._logger.error(title + " " + message)
|
|
420
|
+
await self.pipeline.send_event_async(
|
|
421
|
+
title,
|
|
422
|
+
message,
|
|
423
|
+
ModerationEventTypes.MODERATION_MODEL_CONFIG_ERROR,
|
|
424
|
+
guard_name=guard.name,
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
prompt_column_name = self.pipeline.get_input_column(GuardStage.PROMPT)
|
|
428
|
+
response_column_name = self.pipeline.get_input_column(GuardStage.RESPONSE)
|
|
429
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
copy_df[metric_column_name] = copy_df.apply(
|
|
433
|
+
lambda x: calculate_task_adherence(
|
|
434
|
+
scorer=guard.task_adherence_scorer,
|
|
435
|
+
prompt=x[prompt_column_name],
|
|
436
|
+
interactions=x[AGENTIC_PIPELINE_INTERACTIONS_ATTR],
|
|
437
|
+
response=x[response_column_name],
|
|
438
|
+
),
|
|
439
|
+
axis=1,
|
|
440
|
+
)
|
|
441
|
+
except Exception as e:
|
|
442
|
+
title = "Task Adherence calculation failed"
|
|
443
|
+
message = f"Exception: {e}"
|
|
444
|
+
self._logger.error(title + " " + message)
|
|
445
|
+
self._logger.error(traceback.format_exc())
|
|
446
|
+
await self.pipeline.send_event_async(
|
|
447
|
+
title,
|
|
448
|
+
message,
|
|
449
|
+
ModerationEventTypes.MODERATION_MODEL_RUNTIME_ERROR,
|
|
450
|
+
guard_name=guard.name,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return copy_df
|
|
454
|
+
|
|
455
|
+
async def _handle_rouge_1(self, guard, copy_df, stage, intervene):
|
|
456
|
+
if not isinstance(guard, OOTBGuard):
|
|
457
|
+
raise ValueError(f"Guard object should be of type OOTBGuard, got: {type(guard)}")
|
|
458
|
+
|
|
459
|
+
citation_columns = get_citation_columns(copy_df.columns)
|
|
460
|
+
if len(citation_columns) == 0:
|
|
461
|
+
# For now, let us simply log the error. In future, we can add new error custom
|
|
462
|
+
# metrics to track it
|
|
463
|
+
title = "ROUGE-1 guard configured without citation columns"
|
|
464
|
+
message = f"Input Column Names: {copy_df.columns}"
|
|
465
|
+
await self.pipeline.send_event_async(
|
|
466
|
+
title,
|
|
467
|
+
message,
|
|
468
|
+
ModerationEventTypes.MODERATION_MODEL_CONFIG_ERROR,
|
|
469
|
+
guard_name=guard.name,
|
|
470
|
+
)
|
|
471
|
+
self._logger.error(title + " " + message)
|
|
472
|
+
intervene = False
|
|
473
|
+
else:
|
|
474
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
475
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
476
|
+
copy_df[metric_column_name] = copy_df.apply(
|
|
477
|
+
lambda x: get_rouge_1_score(
|
|
478
|
+
scorer=self.pipeline.rouge_scorer,
|
|
479
|
+
llm_context=[x[col] for col in citation_columns],
|
|
480
|
+
llm_response=[x[input_column]],
|
|
481
|
+
),
|
|
482
|
+
axis=1,
|
|
483
|
+
)
|
|
484
|
+
return copy_df, intervene
|
|
485
|
+
|
|
486
|
+
async def _handle_cost(self, guard, copy_df, stage):
|
|
487
|
+
if not isinstance(guard, OOTBCostMetric):
|
|
488
|
+
raise ValueError(f"Guard object should be of type OOTBCostMetric, got: {type(guard)}")
|
|
489
|
+
|
|
490
|
+
prompt_column_name = self.pipeline.get_input_column(GuardStage.PROMPT)
|
|
491
|
+
response_column_name = self.pipeline.get_input_column(GuardStage.RESPONSE)
|
|
492
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
493
|
+
if (
|
|
494
|
+
PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE not in copy_df.columns
|
|
495
|
+
or RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE not in copy_df.columns
|
|
496
|
+
):
|
|
497
|
+
# In case cost is configured with the score interface, we can try our best
|
|
498
|
+
# to get the token count numbers
|
|
499
|
+
copy_df = calculate_token_counts_for_cost_calculations(
|
|
500
|
+
prompt_column_name, response_column_name, copy_df
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
copy_df[metric_column_name] = copy_df.apply(
|
|
504
|
+
lambda x: x[PROMPT_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE] * guard.input_multiplier
|
|
505
|
+
+ x[RESPONSE_TOKEN_COUNT_COLUMN_NAME_FROM_USAGE] * guard.output_multiplier,
|
|
506
|
+
axis=1,
|
|
507
|
+
)
|
|
508
|
+
return copy_df
|
|
509
|
+
|
|
510
|
+
async def run_ootb_guard(self, guard, copy_df, stage):
|
|
511
|
+
if not isinstance(guard, OOTBGuard):
|
|
512
|
+
raise ValueError(f"Guard object should be of type OOTBGuard, got: {type(guard)}")
|
|
513
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
514
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
515
|
+
intervene = self._should_intervene(guard)
|
|
516
|
+
|
|
517
|
+
if guard.ootb_type == OOTBType.TOKEN_COUNT:
|
|
518
|
+
copy_df[metric_column_name] = copy_df[input_column].apply(lambda x: get_token_count(x))
|
|
519
|
+
elif guard.ootb_type == OOTBType.ROUGE_1:
|
|
520
|
+
copy_df, intervene = await self._handle_rouge_1(guard, copy_df, stage, intervene)
|
|
521
|
+
elif guard.ootb_type == OOTBType.FAITHFULNESS:
|
|
522
|
+
copy_df, intervene = await self._handle_faithfulness(guard, copy_df, stage, intervene)
|
|
523
|
+
elif guard.ootb_type == OOTBType.COST:
|
|
524
|
+
copy_df = await self._handle_cost(guard, copy_df, stage)
|
|
525
|
+
# No intervention for cost metric
|
|
526
|
+
intervene = False
|
|
527
|
+
elif guard.ootb_type == OOTBType.AGENT_GOAL_ACCURACY:
|
|
528
|
+
copy_df = await self._handle_agent_goal_accuracy(guard, copy_df, stage)
|
|
529
|
+
# No intervention for agent goal accuracy
|
|
530
|
+
intervene = False
|
|
531
|
+
elif guard.ootb_type == OOTBType.TASK_ADHERENCE:
|
|
532
|
+
copy_df = await self._handle_task_adherence(guard, copy_df, stage)
|
|
533
|
+
# No intervention for task adherence
|
|
534
|
+
intervene = False
|
|
535
|
+
elif guard.ootb_type == OOTBType.CUSTOM_METRIC:
|
|
536
|
+
body = {
|
|
537
|
+
"df": copy_df.to_dict(),
|
|
538
|
+
"stage": stage,
|
|
539
|
+
"metric_column_name": metric_column_name,
|
|
540
|
+
"input_column": input_column,
|
|
541
|
+
}
|
|
542
|
+
response = requests.post(
|
|
543
|
+
guard.faas_url,
|
|
544
|
+
data=json.dumps(body),
|
|
545
|
+
headers={"Content-Type": "application/json"},
|
|
546
|
+
)
|
|
547
|
+
if response.status_code == 200:
|
|
548
|
+
copy_df = pd.DataFrame.from_dict(response.json()["df"])
|
|
549
|
+
else:
|
|
550
|
+
status_code = response.status_code
|
|
551
|
+
err_message = response.json().get("err_message")
|
|
552
|
+
self._logger.error(
|
|
553
|
+
"Custom metric guard calculation failed with"
|
|
554
|
+
f" status code {status_code}: {err_message}"
|
|
555
|
+
)
|
|
556
|
+
intervene = False
|
|
557
|
+
|
|
558
|
+
if intervene:
|
|
559
|
+
copy_df, _ = self._intervene(guard, copy_df, stage, metric_column_name)
|
|
560
|
+
else:
|
|
561
|
+
copy_df = self._dont_intervene(guard, copy_df, stage)
|
|
562
|
+
return copy_df
|
|
563
|
+
|
|
564
|
+
async def run_nemo_guard(self, guard, copy_df, stage):
|
|
565
|
+
if not isinstance(guard, NeMoGuard):
|
|
566
|
+
raise ValueError(f"Guard object should be of type NeMoGuard, got: {type(guard)}")
|
|
567
|
+
|
|
568
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
569
|
+
metric_column_name = guard.get_metric_column_name(stage)
|
|
570
|
+
intervene = self._should_intervene(guard)
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
if stage == GuardStage.PROMPT:
|
|
574
|
+
result_series = await asyncio.gather(
|
|
575
|
+
*(guard.nemo_llm_rails.generate_async(x) for x in copy_df[input_column])
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
nemo_assistant_output = await asyncio.gather(
|
|
579
|
+
*(
|
|
580
|
+
guard.nemo_llm_rails.generate_async(
|
|
581
|
+
messages=nemo_response_stage_input_formatter(x)
|
|
582
|
+
)
|
|
583
|
+
for x in copy_df[input_column]
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
result_series = [
|
|
587
|
+
nemo_response_stage_output_formatter(x) for x in nemo_assistant_output
|
|
588
|
+
]
|
|
589
|
+
copy_df[metric_column_name] = result_series
|
|
590
|
+
except Exception as e:
|
|
591
|
+
title = "NeMo guard calculation failed"
|
|
592
|
+
message = f"Exception: {e}"
|
|
593
|
+
self._logger.error(title + " " + message)
|
|
594
|
+
self._logger.error(traceback.format_exc())
|
|
595
|
+
await self.pipeline.send_event_async(
|
|
596
|
+
title,
|
|
597
|
+
message,
|
|
598
|
+
ModerationEventTypes.MODERATION_MODEL_RUNTIME_ERROR,
|
|
599
|
+
guard_name=guard.name,
|
|
600
|
+
)
|
|
601
|
+
intervene = False
|
|
602
|
+
|
|
603
|
+
if intervene:
|
|
604
|
+
copy_df, _ = self._intervene(guard, copy_df, stage, metric_column_name)
|
|
605
|
+
else:
|
|
606
|
+
copy_df = self._dont_intervene(guard, copy_df, stage)
|
|
607
|
+
return copy_df
|
|
608
|
+
|
|
609
|
+
def run_guards(self, input_df, guards, stage):
|
|
610
|
+
start_time = time.time()
|
|
611
|
+
df = self.loop.run_until_complete(self.async_guard_executor(input_df, guards, stage))
|
|
612
|
+
end_time = time.time()
|
|
613
|
+
latency = end_time - start_time
|
|
614
|
+
self.pipeline.report_stage_latency(latency, stage)
|
|
615
|
+
self.pipeline.report_stage_total_inputs(stage, input_df.shape[0])
|
|
616
|
+
return df, latency
|
|
617
|
+
|
|
618
|
+
def _merge_moderation_columns(self, final_df, result_df, join_columns, guard, stage):
|
|
619
|
+
final_df = final_df.merge(result_df, on=list(join_columns))
|
|
620
|
+
# Ensure that the index of result matches final, because merge resets
|
|
621
|
+
# index
|
|
622
|
+
final_df.index = result_df.index
|
|
623
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
624
|
+
(
|
|
625
|
+
enforced_column_name,
|
|
626
|
+
enforced_message_column_name,
|
|
627
|
+
action_column_name,
|
|
628
|
+
) = self._get_enforced_and_action_column_names(
|
|
629
|
+
guard.get_intervention_action(), input_column
|
|
630
|
+
)
|
|
631
|
+
# This is logical OR on 'enforced' column
|
|
632
|
+
final_df[enforced_column_name] = (
|
|
633
|
+
final_df[enforced_column_name + "_x"] + final_df[enforced_column_name + "_y"]
|
|
634
|
+
)
|
|
635
|
+
final_df[action_column_name] = final_df[
|
|
636
|
+
[action_column_name + "_x", action_column_name + "_y"]
|
|
637
|
+
].apply(lambda x: ",".join(filter(None, x.dropna())), axis=1)
|
|
638
|
+
if enforced_message_column_name:
|
|
639
|
+
final_df[enforced_message_column_name] = final_df[
|
|
640
|
+
[
|
|
641
|
+
enforced_message_column_name + "_x",
|
|
642
|
+
enforced_message_column_name + "_y",
|
|
643
|
+
]
|
|
644
|
+
].apply(lambda x: ",".join(filter(None, x.dropna())), axis=1)
|
|
645
|
+
column_list_to_drop = [
|
|
646
|
+
enforced_column_name + "_x",
|
|
647
|
+
enforced_column_name + "_y",
|
|
648
|
+
action_column_name + "_x",
|
|
649
|
+
action_column_name + "_y",
|
|
650
|
+
]
|
|
651
|
+
if enforced_message_column_name:
|
|
652
|
+
column_list_to_drop.extend(
|
|
653
|
+
[
|
|
654
|
+
enforced_message_column_name + "_x",
|
|
655
|
+
enforced_message_column_name + "_y",
|
|
656
|
+
]
|
|
657
|
+
)
|
|
658
|
+
final_df.drop(columns=column_list_to_drop, inplace=True)
|
|
659
|
+
# We need to capture the information of which prompts were blocked specifically
|
|
660
|
+
# by this guard.
|
|
661
|
+
if guard.get_intervention_action() != GuardAction.NONE:
|
|
662
|
+
final_df[self.pipeline.get_enforced_column_name(guard, stage)] = result_df[
|
|
663
|
+
enforced_column_name
|
|
664
|
+
]
|
|
665
|
+
return final_df
|
|
666
|
+
|
|
667
|
+
def _get_input_df_for_the_guard(self, _input_df, join_columns, guard, stage):
|
|
668
|
+
if stage == GuardStage.RESPONSE and isinstance(guard, OOTBGuard):
|
|
669
|
+
if guard.ootb_type in [OOTBType.ROUGE_1, OOTBType.FAITHFULNESS] or guard.copy_citations:
|
|
670
|
+
join_columns = join_columns.union(set(get_citation_columns(_input_df.columns)))
|
|
671
|
+
if guard.ootb_type in [
|
|
672
|
+
OOTBType.FAITHFULNESS,
|
|
673
|
+
OOTBType.COST,
|
|
674
|
+
OOTBType.AGENT_GOAL_ACCURACY,
|
|
675
|
+
OOTBType.TASK_ADHERENCE,
|
|
676
|
+
]:
|
|
677
|
+
join_columns.add(self.pipeline.get_input_column(GuardStage.PROMPT))
|
|
678
|
+
if guard.ootb_type in [OOTBType.AGENT_GOAL_ACCURACY, OOTBType.TASK_ADHERENCE]:
|
|
679
|
+
join_columns.add(AGENTIC_PIPELINE_INTERACTIONS_ATTR)
|
|
680
|
+
copy_df = _input_df[list(join_columns)].copy(deep=True)
|
|
681
|
+
return copy_df, join_columns
|
|
682
|
+
|
|
683
|
+
async def async_guard_executor(self, input_df, guards, stage):
|
|
684
|
+
tasks = list()
|
|
685
|
+
|
|
686
|
+
_input_df = input_df.copy(deep=True)
|
|
687
|
+
|
|
688
|
+
final_df = _input_df.copy(deep=True)
|
|
689
|
+
input_column = self.pipeline.get_input_column(stage)
|
|
690
|
+
for intervention_action in GuardAction.ALL:
|
|
691
|
+
(
|
|
692
|
+
enforced_column_name,
|
|
693
|
+
enforced_message_column_name,
|
|
694
|
+
action_column_name,
|
|
695
|
+
) = self._get_enforced_and_action_column_names(intervention_action, input_column)
|
|
696
|
+
self._initialize_enforced_and_action_columns(
|
|
697
|
+
final_df,
|
|
698
|
+
enforced_column_name,
|
|
699
|
+
enforced_message_column_name,
|
|
700
|
+
action_column_name,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
for guard in guards:
|
|
704
|
+
join_columns = {input_column}
|
|
705
|
+
association_id_column_name = self.pipeline.get_association_id_column_name()
|
|
706
|
+
if association_id_column_name:
|
|
707
|
+
if association_id_column_name not in _input_df.columns:
|
|
708
|
+
self._logger.warning(
|
|
709
|
+
f"Association ID Column {association_id_column_name} is missing in the "
|
|
710
|
+
"input dataframe, custom metrics won't be available"
|
|
711
|
+
)
|
|
712
|
+
else:
|
|
713
|
+
join_columns.add(association_id_column_name)
|
|
714
|
+
|
|
715
|
+
copy_df, join_columns = self._get_input_df_for_the_guard(
|
|
716
|
+
_input_df, join_columns, guard, stage
|
|
717
|
+
)
|
|
718
|
+
task_name = f"{guard.name}_{guard.stage}"
|
|
719
|
+
task = asyncio.create_task(self.run_guard(guard, copy_df, stage), name=task_name)
|
|
720
|
+
task.context = {
|
|
721
|
+
"join_columns": join_columns,
|
|
722
|
+
"guard": guard,
|
|
723
|
+
"df": copy_df,
|
|
724
|
+
"stage": stage,
|
|
725
|
+
}
|
|
726
|
+
tasks.append(task)
|
|
727
|
+
|
|
728
|
+
while len(tasks) > 0:
|
|
729
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
730
|
+
for task in done:
|
|
731
|
+
guard = task.context["guard"]
|
|
732
|
+
try:
|
|
733
|
+
await task
|
|
734
|
+
except Exception as e:
|
|
735
|
+
# Task Cancellation is also handled here - CancelledError exception is raised
|
|
736
|
+
self._logger.error(f"Exception in the task {task}: {e}")
|
|
737
|
+
self._logger.error(traceback.format_exc())
|
|
738
|
+
result_df = self._dont_intervene(
|
|
739
|
+
guard, task.context["df"], task.context["stage"]
|
|
740
|
+
)
|
|
741
|
+
latency = 0
|
|
742
|
+
else:
|
|
743
|
+
result_df, latency = task.result()
|
|
744
|
+
final_df = self._merge_moderation_columns(
|
|
745
|
+
final_df, result_df, task.context["join_columns"], guard, stage
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# If there are multiple prompts, we don't have way to detect guard latency for
|
|
749
|
+
# each prompt (DataRobot `predict` does not return that). So, we use the same
|
|
750
|
+
# guard latency for each prompt. However, our typical use case is one prompt /
|
|
751
|
+
# response
|
|
752
|
+
final_df[f"{guard.name}_latency"] = latency / final_df.shape[0]
|
|
753
|
+
tasks = pending
|
|
754
|
+
|
|
755
|
+
return final_df
|