lexsi-sdk 0.1.16__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.
- lexsi_sdk/__init__.py +5 -0
- lexsi_sdk/client/__init__.py +0 -0
- lexsi_sdk/client/client.py +176 -0
- lexsi_sdk/common/__init__.py +0 -0
- lexsi_sdk/common/config/.env.prod +3 -0
- lexsi_sdk/common/constants.py +143 -0
- lexsi_sdk/common/enums.py +8 -0
- lexsi_sdk/common/environment.py +49 -0
- lexsi_sdk/common/monitoring.py +81 -0
- lexsi_sdk/common/trigger.py +75 -0
- lexsi_sdk/common/types.py +122 -0
- lexsi_sdk/common/utils.py +93 -0
- lexsi_sdk/common/validation.py +110 -0
- lexsi_sdk/common/xai_uris.py +197 -0
- lexsi_sdk/core/__init__.py +0 -0
- lexsi_sdk/core/agent.py +62 -0
- lexsi_sdk/core/alert.py +56 -0
- lexsi_sdk/core/case.py +618 -0
- lexsi_sdk/core/dashboard.py +131 -0
- lexsi_sdk/core/guardrails/__init__.py +0 -0
- lexsi_sdk/core/guardrails/guard_template.py +299 -0
- lexsi_sdk/core/guardrails/guardrail_autogen.py +554 -0
- lexsi_sdk/core/guardrails/guardrails_langgraph.py +525 -0
- lexsi_sdk/core/guardrails/guardrails_openai.py +541 -0
- lexsi_sdk/core/guardrails/openai_runner.py +1328 -0
- lexsi_sdk/core/model_summary.py +110 -0
- lexsi_sdk/core/organization.py +549 -0
- lexsi_sdk/core/project.py +5131 -0
- lexsi_sdk/core/synthetic.py +387 -0
- lexsi_sdk/core/text.py +595 -0
- lexsi_sdk/core/tracer.py +208 -0
- lexsi_sdk/core/utils.py +36 -0
- lexsi_sdk/core/workspace.py +325 -0
- lexsi_sdk/core/wrapper.py +766 -0
- lexsi_sdk/core/xai.py +306 -0
- lexsi_sdk/version.py +34 -0
- lexsi_sdk-0.1.16.dist-info/METADATA +100 -0
- lexsi_sdk-0.1.16.dist-info/RECORD +40 -0
- lexsi_sdk-0.1.16.dist-info/WHEEL +5 -0
- lexsi_sdk-0.1.16.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict, Union
|
|
6
|
+
from lexsi_sdk.common.xai_uris import (
|
|
7
|
+
RUN_GUARDRAILS_URI,
|
|
8
|
+
RUN_GUARDRAILS_PARALLEL_URI
|
|
9
|
+
)
|
|
10
|
+
from lexsi_sdk.core.project import Project
|
|
11
|
+
from openinference.instrumentation.langchain import get_current_span
|
|
12
|
+
from opentelemetry import trace
|
|
13
|
+
import time
|
|
14
|
+
from .guard_template import Guard
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GuardrailRunResult(TypedDict, total=False):
|
|
18
|
+
"""Structured response returned from guardrail API execution."""
|
|
19
|
+
details: Dict[str, Any]
|
|
20
|
+
validated_output: Any
|
|
21
|
+
validation_passed: bool
|
|
22
|
+
sanitized_output: Any
|
|
23
|
+
duration: float
|
|
24
|
+
latency: str
|
|
25
|
+
on_fail_action: str
|
|
26
|
+
retry_count: int
|
|
27
|
+
max_retries: int
|
|
28
|
+
start_time: str
|
|
29
|
+
end_time: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LangGraphGuardrail:
|
|
33
|
+
"""
|
|
34
|
+
Decorator utility for applying Guardrails checks to LangGraph node inputs and outputs
|
|
35
|
+
by calling the Guardrails HTTP APIs.
|
|
36
|
+
|
|
37
|
+
Supports two modes:
|
|
38
|
+
- "adhoc": calls /guardrails/run_guardrail per guard passed in the decorator
|
|
39
|
+
- "configured": calls /guardrails/run to use project/model configured guardrails
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
project: Optional[Project],
|
|
45
|
+
default_apply_on: str = "input",
|
|
46
|
+
llm: Optional[Any] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize guardrail helper with project context and defaults."""
|
|
49
|
+
if project is not None:
|
|
50
|
+
self.client = project.api_client
|
|
51
|
+
self.project_name = project.project_name
|
|
52
|
+
|
|
53
|
+
self.default_apply_on = default_apply_on
|
|
54
|
+
self.logs: List[Dict[str, Any]] = []
|
|
55
|
+
self.max_retries = 1
|
|
56
|
+
self.retry_delay = 1.0
|
|
57
|
+
self.tracer = trace.get_tracer(__name__)
|
|
58
|
+
self.llm = llm
|
|
59
|
+
|
|
60
|
+
def guardrail(
|
|
61
|
+
self,
|
|
62
|
+
guards: Union[List[str], List[Dict[str, Any]], str, Dict[str, Any], None] = None,
|
|
63
|
+
action: str = "block",
|
|
64
|
+
apply_to: str = "both",
|
|
65
|
+
input_key: Optional[str] = None,
|
|
66
|
+
output_key: Optional[str] = None,
|
|
67
|
+
# process_entire_state: bool = False,
|
|
68
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
69
|
+
"""
|
|
70
|
+
Decorator factory.
|
|
71
|
+
|
|
72
|
+
- action: 'block' | 'retry' | 'warn'. If validation fails:
|
|
73
|
+
- block: raise ValueError
|
|
74
|
+
- retry: replace with sanitized output when available
|
|
75
|
+
- warn: keep content, log only
|
|
76
|
+
- apply_to: 'input' | 'output' | 'both'
|
|
77
|
+
- input_key: Optional key in state to apply guardrail to for input (defaults to checking 'messages' or 'input')
|
|
78
|
+
- output_key: Optional key in result to apply guardrail to for output (defaults to checking 'messages' or treating as str)
|
|
79
|
+
- process_entire_state: If True, applies guardrails to all strings in the state/result recursively (overrides input_key/output_key)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
if isinstance(guards, (str, dict)):
|
|
83
|
+
guards = [guards] # type: ignore[assignment]
|
|
84
|
+
|
|
85
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
86
|
+
"""Decorator that wraps a LangGraph node with guardrail checks."""
|
|
87
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
88
|
+
"""Execute guardrails around the wrapped node call."""
|
|
89
|
+
# In LangGraph, the first argument is the state dict
|
|
90
|
+
state = args[0] if args else kwargs.get("state")
|
|
91
|
+
node_name = getattr(func, "__name__", "unknown_node")
|
|
92
|
+
|
|
93
|
+
# Pre-process input
|
|
94
|
+
if state and apply_to in ("input", "both"):
|
|
95
|
+
input_content = self._get_content(state, input_key, is_input=True)
|
|
96
|
+
if input_content is not None:
|
|
97
|
+
processed = self._process_content(
|
|
98
|
+
content=input_content,
|
|
99
|
+
node_name=node_name,
|
|
100
|
+
content_type="input",
|
|
101
|
+
action=action,
|
|
102
|
+
guards=guards,
|
|
103
|
+
)
|
|
104
|
+
self._set_content(state, processed, input_key, is_input=True)
|
|
105
|
+
|
|
106
|
+
result = func(*args, **kwargs)
|
|
107
|
+
|
|
108
|
+
# Post-process output
|
|
109
|
+
if apply_to in ("output", "both"):
|
|
110
|
+
|
|
111
|
+
output_content = self._get_content(result, output_key, is_input=False)
|
|
112
|
+
if output_content is not None:
|
|
113
|
+
processed = self._process_content(
|
|
114
|
+
content=output_content,
|
|
115
|
+
node_name=node_name,
|
|
116
|
+
content_type="output",
|
|
117
|
+
action=action,
|
|
118
|
+
guards=guards,
|
|
119
|
+
|
|
120
|
+
)
|
|
121
|
+
if output_key is not None:
|
|
122
|
+
if isinstance(result, dict):
|
|
123
|
+
result[output_key] = processed
|
|
124
|
+
else:
|
|
125
|
+
if isinstance(result, dict):
|
|
126
|
+
if "messages" in result:
|
|
127
|
+
result["messages"] = processed
|
|
128
|
+
elif isinstance(result, str):
|
|
129
|
+
result = processed
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
return wrapper
|
|
134
|
+
|
|
135
|
+
return decorator
|
|
136
|
+
|
|
137
|
+
def _get_content(self, data: Any, key: Optional[str], is_input: bool) -> Any:
|
|
138
|
+
"""Extract guardrail-relevant content from the input or output structure."""
|
|
139
|
+
if key is not None:
|
|
140
|
+
if isinstance(data, dict) and key in data:
|
|
141
|
+
return data[key]
|
|
142
|
+
return None
|
|
143
|
+
else:
|
|
144
|
+
# Default behavior
|
|
145
|
+
if isinstance(data, dict):
|
|
146
|
+
if "messages" in data:
|
|
147
|
+
return data["messages"]
|
|
148
|
+
elif is_input and "input" in data:
|
|
149
|
+
return data["input"]
|
|
150
|
+
if not is_input and isinstance(data, str):
|
|
151
|
+
return data
|
|
152
|
+
return None # No content to process
|
|
153
|
+
|
|
154
|
+
def _set_content(self, data: Any, processed: Any, key: Optional[str], is_input: bool) -> None:
|
|
155
|
+
"""Write processed content back into the state or result container."""
|
|
156
|
+
if key is not None:
|
|
157
|
+
if isinstance(data, dict):
|
|
158
|
+
data[key] = processed
|
|
159
|
+
else:
|
|
160
|
+
# Default set
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
if "messages" in data:
|
|
163
|
+
data["messages"] = processed
|
|
164
|
+
elif is_input and "input" in data:
|
|
165
|
+
data["input"] = processed
|
|
166
|
+
|
|
167
|
+
def _process_content(
|
|
168
|
+
self,
|
|
169
|
+
content: Any,
|
|
170
|
+
node_name: str,
|
|
171
|
+
content_type: str,
|
|
172
|
+
action: str,
|
|
173
|
+
guards: Optional[List[Union[str, Dict[str, Any]]]],
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Run guardrails over provided content and return sanitized result when needed."""
|
|
176
|
+
if not guards:
|
|
177
|
+
return content
|
|
178
|
+
|
|
179
|
+
is_list = isinstance(content, list)
|
|
180
|
+
if is_list and content:
|
|
181
|
+
content_to_process = content[-1].content
|
|
182
|
+
elif isinstance(content, str):
|
|
183
|
+
content_to_process = content
|
|
184
|
+
else:
|
|
185
|
+
return content
|
|
186
|
+
|
|
187
|
+
current_content = content_to_process
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
parent_span = get_current_span()
|
|
191
|
+
if parent_span is not None:
|
|
192
|
+
ctx = trace.set_span_in_context(parent_span)
|
|
193
|
+
# Create parent span for input/output guardrails with node name for better tracing
|
|
194
|
+
with self.tracer.start_as_current_span(f"guardrails:{content_type}", context=ctx) as parent_gr_span:
|
|
195
|
+
# Set parent span attributes
|
|
196
|
+
parent_gr_span.set_attribute("node", str(node_name)) # Add node attribute
|
|
197
|
+
parent_gr_span.set_attribute("component", str(node_name))
|
|
198
|
+
parent_gr_span.set_attribute("content_type", str(content_type))
|
|
199
|
+
|
|
200
|
+
# Run all guardrails in parallel
|
|
201
|
+
parallel_result = self._apply_guardrail_parallel(current_content, guards)
|
|
202
|
+
|
|
203
|
+
if not parallel_result.get("success", False):
|
|
204
|
+
return current_content
|
|
205
|
+
|
|
206
|
+
# Set timing attributes only on parent span
|
|
207
|
+
parent_gr_span.set_attribute("start_time", str(parallel_result.get("start_time", "")))
|
|
208
|
+
parent_gr_span.set_attribute("end_time", str(parallel_result.get("end_time", "")))
|
|
209
|
+
parent_gr_span.set_attribute("duration", float(parallel_result.get("duration", 0.0)))
|
|
210
|
+
|
|
211
|
+
for guard_result in parallel_result.get("details", []):
|
|
212
|
+
guard_name = guard_result.get("name", "unknown")
|
|
213
|
+
|
|
214
|
+
run_result: GuardrailRunResult = {
|
|
215
|
+
"details": guard_result,
|
|
216
|
+
"validated_output": guard_result.get("validated_output"),
|
|
217
|
+
"validation_passed": guard_result.get("validation_passed", False),
|
|
218
|
+
"sanitized_output": guard_result.get("sanitized_output", current_content),
|
|
219
|
+
"duration": guard_result.get("duration", 0.0),
|
|
220
|
+
"latency": guard_result.get("latency", "0 ms"),
|
|
221
|
+
"start_time": parallel_result.get("start_time", ""),
|
|
222
|
+
"end_time": parallel_result.get("end_time", ""),
|
|
223
|
+
"retry_count": 0,
|
|
224
|
+
"max_retries": self.max_retries
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
run_result["response"] = guard_result
|
|
228
|
+
run_result["input"] = current_content
|
|
229
|
+
|
|
230
|
+
# Process each guard result with child spans
|
|
231
|
+
current_content = self._handle_action(
|
|
232
|
+
original=current_content,
|
|
233
|
+
run_result=run_result,
|
|
234
|
+
action=action,
|
|
235
|
+
node_name=node_name,
|
|
236
|
+
content_type=content_type,
|
|
237
|
+
guard_name=guard_name,
|
|
238
|
+
parent_span=parent_gr_span # Pass parent span
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if is_list:
|
|
242
|
+
content[-1].content = current_content
|
|
243
|
+
return content
|
|
244
|
+
else:
|
|
245
|
+
return current_content
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return content
|
|
249
|
+
|
|
250
|
+
def _apply_guardrail_parallel(
|
|
251
|
+
self,
|
|
252
|
+
content: Any,
|
|
253
|
+
guards: List[Union[str, Dict[str, Any]]],
|
|
254
|
+
) -> Any:
|
|
255
|
+
"""
|
|
256
|
+
Run multiple guardrails in parallel using batch endpoint
|
|
257
|
+
|
|
258
|
+
:param content: Content to validate against multiple guardrails.
|
|
259
|
+
:param guards: List of guardrails to run in parallel.
|
|
260
|
+
:return: Response dict containing success status and validation details.
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
# Convert string guards to proper dict format with name and class
|
|
264
|
+
guard_specs = []
|
|
265
|
+
for guard in guards:
|
|
266
|
+
guard_specs.append(guard)
|
|
267
|
+
|
|
268
|
+
# Prepare payload according to RunGuardParallelPayload schema
|
|
269
|
+
payload = {
|
|
270
|
+
"input_data": content,
|
|
271
|
+
"guards": guard_specs # List of properly formatted guard specs
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
import requests
|
|
275
|
+
start_time = datetime.now()
|
|
276
|
+
response = self.client.post(
|
|
277
|
+
RUN_GUARDRAILS_PARALLEL_URI,
|
|
278
|
+
payload=payload,
|
|
279
|
+
)
|
|
280
|
+
end_time = datetime.now()
|
|
281
|
+
result = response
|
|
282
|
+
result.update({
|
|
283
|
+
"start_time": start_time.isoformat(),
|
|
284
|
+
"end_time": end_time.isoformat(),
|
|
285
|
+
"duration": (end_time - start_time).total_seconds()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return {
|
|
292
|
+
"success": False,
|
|
293
|
+
"details": f"Failed to run guardrails in parallel: {str(e)}",
|
|
294
|
+
"start_time": datetime.now().isoformat(),
|
|
295
|
+
"end_time": datetime.now().isoformat()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
def _apply_guardrail_with_retry(
|
|
299
|
+
self,
|
|
300
|
+
content: Any,
|
|
301
|
+
guard_spec: Dict[str, Any],
|
|
302
|
+
node_name: str,
|
|
303
|
+
content_type: str,
|
|
304
|
+
action: str,
|
|
305
|
+
) -> Any:
|
|
306
|
+
"""Apply a guardrail, optionally retrying with sanitized output."""
|
|
307
|
+
current_content = content
|
|
308
|
+
retry_count = 0
|
|
309
|
+
|
|
310
|
+
if action == "retry":
|
|
311
|
+
while retry_count <= self.max_retries:
|
|
312
|
+
run_result = self._call_run_guardrail(current_content, guard_spec, content_type)
|
|
313
|
+
validation_passed = bool(run_result.get("validation_passed", True))
|
|
314
|
+
detected_issue = not validation_passed or not run_result.get("success", True)
|
|
315
|
+
|
|
316
|
+
if detected_issue and self.llm is not None and retry_count < self.max_retries:
|
|
317
|
+
prompt = self._build_sanitize_prompt(guard_spec.get("name", "unknown"), current_content, content_type)
|
|
318
|
+
try:
|
|
319
|
+
sanitized = self.llm.invoke(prompt)
|
|
320
|
+
if hasattr(sanitized, "content"):
|
|
321
|
+
sanitized = sanitized.content
|
|
322
|
+
except Exception:
|
|
323
|
+
sanitized = current_content
|
|
324
|
+
retry_action = f"retry_{retry_count+1}"
|
|
325
|
+
self._handle_action(
|
|
326
|
+
original=current_content,
|
|
327
|
+
run_result=run_result,
|
|
328
|
+
action=retry_action,
|
|
329
|
+
node_name=node_name,
|
|
330
|
+
content_type=content_type,
|
|
331
|
+
guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
|
|
332
|
+
)
|
|
333
|
+
current_content = sanitized
|
|
334
|
+
retry_count += 1
|
|
335
|
+
time.sleep(self.retry_delay)
|
|
336
|
+
continue
|
|
337
|
+
else:
|
|
338
|
+
return self._handle_action(
|
|
339
|
+
original=current_content,
|
|
340
|
+
run_result=run_result,
|
|
341
|
+
action=f"retry_{retry_count}" if retry_count > 0 else action,
|
|
342
|
+
node_name=node_name,
|
|
343
|
+
content_type=content_type,
|
|
344
|
+
guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
|
|
345
|
+
)
|
|
346
|
+
return current_content
|
|
347
|
+
else:
|
|
348
|
+
# Only one check for non-retry actions
|
|
349
|
+
run_result = self._call_run_guardrail(current_content, guard_spec, content_type)
|
|
350
|
+
return self._handle_action(
|
|
351
|
+
original=current_content,
|
|
352
|
+
run_result=run_result,
|
|
353
|
+
action=action,
|
|
354
|
+
node_name=node_name,
|
|
355
|
+
content_type=content_type,
|
|
356
|
+
guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# --------- HTTP calls ---------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _call_run_guardrail(self, input_data: Any, guard: Dict[str, Any], content_type: Any) -> GuardrailRunResult:
|
|
363
|
+
"""Invoke the guardrail service for a single guard."""
|
|
364
|
+
uri = RUN_GUARDRAILS_URI
|
|
365
|
+
input = input_data
|
|
366
|
+
|
|
367
|
+
start_time = datetime.now()
|
|
368
|
+
try:
|
|
369
|
+
body = {"input_data": input, "guard": guard}
|
|
370
|
+
data = self.client.post(uri, body)
|
|
371
|
+
end_time = datetime.now()
|
|
372
|
+
|
|
373
|
+
details = data.get("details", {}) if isinstance(data, dict) else {}
|
|
374
|
+
result: GuardrailRunResult = {
|
|
375
|
+
"success": bool(data.get("success", False)) if isinstance(data, dict) else False,
|
|
376
|
+
"details": details if isinstance(details, dict) else {},
|
|
377
|
+
"start_time": start_time.isoformat(),
|
|
378
|
+
"end_time": end_time.isoformat(),
|
|
379
|
+
}
|
|
380
|
+
if "duration" not in details:
|
|
381
|
+
result["duration"] = (end_time - start_time).total_seconds()
|
|
382
|
+
if isinstance(details, dict):
|
|
383
|
+
if "validated_output" in details:
|
|
384
|
+
result["validated_output"] = details["validated_output"]
|
|
385
|
+
if "validation_passed" in details:
|
|
386
|
+
result["validation_passed"] = details["validation_passed"]
|
|
387
|
+
if "sanitized_output" in details:
|
|
388
|
+
result["sanitized_output"] = details["sanitized_output"]
|
|
389
|
+
if "duration" in details:
|
|
390
|
+
result["duration"] = details["duration"]
|
|
391
|
+
if "latency" in details:
|
|
392
|
+
result["latency"] = details["latency"]
|
|
393
|
+
|
|
394
|
+
result["retry_count"] = 0
|
|
395
|
+
result["max_retries"] = self.max_retries
|
|
396
|
+
|
|
397
|
+
result["response"] = data
|
|
398
|
+
result["input"] = input
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
end_time = datetime.now() # Still capture end time on exception
|
|
404
|
+
raise exc
|
|
405
|
+
|
|
406
|
+
# --------- Action handling ---------
|
|
407
|
+
def _handle_action(
|
|
408
|
+
self,
|
|
409
|
+
original: Any,
|
|
410
|
+
run_result: GuardrailRunResult,
|
|
411
|
+
action: str,
|
|
412
|
+
node_name: str,
|
|
413
|
+
content_type: str,
|
|
414
|
+
guard_name: str,
|
|
415
|
+
parent_span: Optional[Any] = None,
|
|
416
|
+
) -> Any:
|
|
417
|
+
"""Handle guardrail results according to configured action."""
|
|
418
|
+
validation_passed = bool(run_result.get("validation_passed", True))
|
|
419
|
+
detected_issue = not validation_passed or not run_result.get("success", True)
|
|
420
|
+
|
|
421
|
+
def create_child_span(is_issue: bool):
|
|
422
|
+
"""Record a child tracing span for a single guard result."""
|
|
423
|
+
|
|
424
|
+
with self.tracer.start_as_current_span(
|
|
425
|
+
f"guard: {guard_name}",
|
|
426
|
+
context=trace.set_span_in_context(parent_span)
|
|
427
|
+
) as gr_span:
|
|
428
|
+
gr_span.set_attribute("component", str(node_name))
|
|
429
|
+
gr_span.set_attribute("guard", str(guard_name))
|
|
430
|
+
gr_span.set_attribute("content_type", str(content_type))
|
|
431
|
+
gr_span.set_attribute("detected", is_issue)
|
|
432
|
+
gr_span.set_attribute("action", action)
|
|
433
|
+
|
|
434
|
+
# Don't set timing attributes on child spans
|
|
435
|
+
gr_span.set_attribute("input.value", self._safe_str(run_result.get("input")))
|
|
436
|
+
gr_span.set_attribute("output.value", json.dumps(run_result.get("response")))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if detected_issue:
|
|
440
|
+
if parent_span is not None:
|
|
441
|
+
create_child_span(True)
|
|
442
|
+
|
|
443
|
+
if action == "block":
|
|
444
|
+
raise ValueError(f"Guardrail '{guard_name}' detected an issue in {content_type}. Operation blocked.")
|
|
445
|
+
elif "retry" in action:
|
|
446
|
+
return run_result.get("sanitized_output", original)
|
|
447
|
+
else: # warn
|
|
448
|
+
return original
|
|
449
|
+
|
|
450
|
+
# Handle successful validation
|
|
451
|
+
if parent_span is not None:
|
|
452
|
+
create_child_span(False)
|
|
453
|
+
|
|
454
|
+
return original
|
|
455
|
+
|
|
456
|
+
def _build_sanitize_prompt(self, guard_name: str, content: Any, content_type: str) -> str:
|
|
457
|
+
"""Construct a prompt asking the LLM to sanitize problematic content."""
|
|
458
|
+
instructions = {
|
|
459
|
+
"Detect PII": "Sanitize the following text by removing or masking any personally identifiable information (PII). Do not change anything else.",
|
|
460
|
+
"NSFW Text": "Sanitize the following text by removing or masking any not safe for work (NSFW) content. Do not change anything else.",
|
|
461
|
+
"Ban List": "Sanitize the following text by removing or masking any banned words. Do not change anything else.",
|
|
462
|
+
"Bias Check": "Sanitize the following text by removing or masking any biased language. Do not change anything else.",
|
|
463
|
+
"Competitor Check": "Sanitize the following text by removing or masking any competitor names. Do not change anything else.",
|
|
464
|
+
"Correct Language": "Sanitize the following text by correcting the language to the expected language. Do not change anything else.",
|
|
465
|
+
"Gibberish Text": "Sanitize the following text by removing or correcting any gibberish. Do not change anything else.",
|
|
466
|
+
"Profanity Free": "Sanitize the following text by removing or masking any profanity. Do not change anything else.",
|
|
467
|
+
"Secrets Present": "Sanitize the following text by removing or masking any secrets. Do not change anything else.",
|
|
468
|
+
"Toxic Language": "Sanitize the following text by removing or masking any toxic language. Do not change anything else.",
|
|
469
|
+
"Contains String": "Sanitize the following text by removing or masking the specified substring. Do not change anything else.",
|
|
470
|
+
"Detect Jailbreak": "Sanitize the following text by removing or masking any jailbreak attempts. Do not change anything else.",
|
|
471
|
+
"Endpoint Is Reachable": "Sanitize the following text by ensuring any mentioned endpoints are reachable. Do not change anything else.",
|
|
472
|
+
"Ends With": "Sanitize the following text by ensuring it ends with the specified string. Do not change anything else.",
|
|
473
|
+
"Has Url": "Sanitize the following text by removing or masking any URLs. Do not change anything else.",
|
|
474
|
+
"Lower Case": "Sanitize the following text by converting it to lower case. Do not change anything else.",
|
|
475
|
+
"Mentions Drugs": "Sanitize the following text by removing or masking any mentions of drugs. Do not change anything else.",
|
|
476
|
+
"One Line": "Sanitize the following text by ensuring it is a single line. Do not change anything else.",
|
|
477
|
+
"Reading Time": "Sanitize the following text by ensuring its reading time matches the specified value. Do not change anything else.",
|
|
478
|
+
"Redundant Sentences": "Sanitize the following text by removing redundant sentences. Do not change anything else.",
|
|
479
|
+
"Regex Match": "Sanitize the following text by ensuring it matches the specified regex. Do not change anything else.",
|
|
480
|
+
"Sql Column Presence": "Sanitize the following text by ensuring specified SQL columns are present. Do not change anything else.",
|
|
481
|
+
"Two Words": "Sanitize the following text by ensuring it contains only two words. Do not change anything else.",
|
|
482
|
+
"Upper Case": "Sanitize the following text by converting it to upper case. Do not change anything else.",
|
|
483
|
+
"Valid Choices": "Sanitize the following text by ensuring it matches one of the valid choices. Do not change anything else.",
|
|
484
|
+
"Valid Json": "Sanitize the following text by ensuring it is valid JSON. Do not change anything else.",
|
|
485
|
+
"Valid Length": "Sanitize the following text by ensuring its length is valid. Do not change anything else.",
|
|
486
|
+
"Valid Range": "Sanitize the following text by ensuring its value is within the valid range. Do not change anything else.",
|
|
487
|
+
"Valid URL": "Sanitize the following text by ensuring it is a valid URL. Do not change anything else.",
|
|
488
|
+
"Web Sanitization": "Sanitize the following text by removing any unsafe web content. Do not change anything else.",
|
|
489
|
+
}
|
|
490
|
+
instruction = instructions.get(guard_name, "Sanitize the following text according to the guardrail requirements. Do not change anything else.")
|
|
491
|
+
prompt = f"{instruction}\n\nContent:\n{content}"
|
|
492
|
+
return prompt
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _safe_str(value: Any) -> str:
|
|
496
|
+
"""Safely stringify potentially complex values for logging."""
|
|
497
|
+
try:
|
|
498
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
499
|
+
return str(value)
|
|
500
|
+
if hasattr(value, "content"):
|
|
501
|
+
return str(getattr(value, "content", ""))
|
|
502
|
+
if isinstance(value, (list, tuple)):
|
|
503
|
+
parts = []
|
|
504
|
+
for item in value:
|
|
505
|
+
parts.append(Guard._safe_str(item) if hasattr(Guard, "_safe_str") else str(item))
|
|
506
|
+
return ", ".join(parts)
|
|
507
|
+
if isinstance(value, dict):
|
|
508
|
+
safe_dict: Dict[str, Any] = {}
|
|
509
|
+
for k, v in value.items():
|
|
510
|
+
key = str(k)
|
|
511
|
+
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
512
|
+
safe_dict[key] = v
|
|
513
|
+
elif hasattr(v, "content"):
|
|
514
|
+
safe_dict[key] = str(getattr(v, "content", ""))
|
|
515
|
+
else:
|
|
516
|
+
safe_dict[key] = str(v)
|
|
517
|
+
return json.dumps(safe_dict, ensure_ascii=False)
|
|
518
|
+
return str(value)
|
|
519
|
+
except Exception:
|
|
520
|
+
return "<unserializable>"
|
|
521
|
+
|
|
522
|
+
# Convenience function for quick guardrail setup
|
|
523
|
+
def create_guardrail(project: Project) -> LangGraphGuardrail:
|
|
524
|
+
"""Quick factory function to create a guardrail instance with a project"""
|
|
525
|
+
return LangGraphGuardrail(project=project)
|