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,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