Skier8402 commited on
Commit
cdb228e
·
verified ·
1 Parent(s): 2574df9

Upload 3 files

Browse files
Files changed (3) hide show
  1. eval.py +206 -0
  2. safety.py +650 -0
  3. telemetry.py +95 -0
eval.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation module for the RAG system using Ragas.
2
+ This script provides tools to measure faithfulness, relevancy, and retrieval precision.
3
+
4
+ How to run:
5
+ python eval.py <testset_csv_path>
6
+ """
7
+
8
+ # pylint: disable=import-error,no-name-in-module,invalid-name,broad-except,missing-function-docstring,missing-class-docstring,wrong-import-order,ungrouped-imports,line-too-long,logging-fstring-interpolation,import-outside-toplevel
9
+
10
+ import os
11
+ import logging
12
+ import pandas as pd
13
+ from typing import List, Optional, Any
14
+ from datasets import Dataset
15
+ from ragas import evaluate
16
+ from ragas.metrics.collections import (
17
+ faithfulness,
18
+ answer_relevancy,
19
+ context_precision,
20
+ context_recall,
21
+ )
22
+
23
+ try:
24
+ from langchain.chat_models import ChatOpenAI
25
+ except Exception:
26
+ from langchain_openai import ChatOpenAI
27
+
28
+ try:
29
+ from langchain_huggingface import HuggingFaceEmbeddings
30
+ except Exception:
31
+ from langchain_community.embeddings import HuggingFaceEmbeddings
32
+
33
+
34
+ def run_evaluation(
35
+ questions: List[str],
36
+ answers: List[str],
37
+ contexts: List[List[str]],
38
+ ground_truths: Optional[List[str]] = None,
39
+ ) -> Any:
40
+ """
41
+ Run Ragas evaluation on a set of QA results.
42
+
43
+ Parameters
44
+ ----------
45
+ questions : List[str]
46
+ List of user questions.
47
+ answers : List[str]
48
+ List of generated answers.
49
+ contexts : List[List[str]]
50
+ List of context strings retrieved for each question.
51
+ ground_truths : List[str], optional
52
+ Optional list of ground truth answers for recall metrics.
53
+
54
+ Returns
55
+ -------
56
+ Any
57
+ Ragas evaluation results containing metric scores.
58
+ """
59
+ data = {
60
+ "question": questions,
61
+ "answer": answers,
62
+ "contexts": contexts,
63
+ }
64
+ if ground_truths:
65
+ data["ground_truth"] = ground_truths
66
+
67
+ # Ragas evaluate works best with dataset objects
68
+ dataset = Dataset.from_dict(data)
69
+
70
+ # Use OpenRouter if key is available, else default to OpenAI
71
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
72
+ if openrouter_key:
73
+ # Use OpenRouter-compatible base and forward the key as the OpenAI key
74
+ os.environ["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1"
75
+ os.environ["OPENAI_API_KEY"] = openrouter_key
76
+
77
+ # Allow overriding the eval/model via env var; default to a compatible model
78
+ eval_model = os.getenv(
79
+ "OPENAI_MODEL", os.getenv("EVAL_MODEL", "openai/gpt-oss-120b")
80
+ )
81
+ logging.info("Using evaluation LLM model=%s", eval_model)
82
+ # Allow overriding how many generations ragas requests from the LLM.
83
+ # Some providers (or models) ignore multi-generation requests; default to 1 to avoid warnings.
84
+ try:
85
+ num_gens = int(os.getenv("RAGAS_NUM_GENERATIONS", "1"))
86
+ except Exception:
87
+ num_gens = 1
88
+ logging.info("Requesting %s generation(s) per prompt", num_gens)
89
+ try:
90
+ llm = ChatOpenAI(model=eval_model, n=num_gens)
91
+ except TypeError:
92
+ # Some ChatOpenAI wrappers do not accept `n` at construction; fall back to default.
93
+ llm = ChatOpenAI(model=eval_model)
94
+
95
+ # Use the same embeddings as the main app for consistency
96
+ embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-en-v1.5")
97
+
98
+ logging.info("Starting Ragas evaluation...")
99
+ result = evaluate(
100
+ dataset=dataset,
101
+ metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
102
+ llm=llm,
103
+ embeddings=embeddings,
104
+ )
105
+ logging.info("Evaluation complete.")
106
+
107
+ return result
108
+
109
+
110
+ def extract_scalar_metrics(result: Any) -> dict:
111
+ """Extract common scalar metrics (faithfulness, relevancy, precision, recall)
112
+ from a ragas evaluation result. Returns a dict of metric->float or empty dict.
113
+ """
114
+ keys_of_interest = {
115
+ "faithfulness",
116
+ "answer_relevancy",
117
+ "context_precision",
118
+ "context_recall",
119
+ "relevancy",
120
+ "precision",
121
+ "recall",
122
+ }
123
+
124
+ found: dict = {}
125
+
126
+ def is_number(x):
127
+ return isinstance(x, (int, float)) and not isinstance(x, bool)
128
+
129
+ def traverse(obj):
130
+ if isinstance(obj, dict):
131
+ for k, v in obj.items():
132
+ if (
133
+ isinstance(k, str)
134
+ and k.lower() in keys_of_interest
135
+ and is_number(v)
136
+ ):
137
+ found[k.lower()] = float(v)
138
+ traverse(v)
139
+ elif isinstance(obj, (list, tuple)):
140
+ for v in obj:
141
+ traverse(v)
142
+ else:
143
+ try:
144
+ if hasattr(obj, "__dict__"):
145
+ traverse(vars(obj))
146
+ except Exception:
147
+ pass
148
+
149
+ try:
150
+ traverse(result)
151
+ # check common attrs
152
+ for attr in ("metrics", "results", "scores", "score"):
153
+ try:
154
+ val = getattr(result, attr, None)
155
+ if val is not None:
156
+ traverse(val)
157
+ except Exception:
158
+ pass
159
+ except Exception:
160
+ pass
161
+
162
+ return found
163
+
164
+
165
+ def evaluate_from_csv(csv_path: str) -> Any:
166
+ """
167
+ Load a testset from CSV and run evaluation.
168
+
169
+ Parameters
170
+ ----------
171
+ csv_path : str
172
+ Path to the testset CSV.
173
+
174
+ Returns
175
+ -------
176
+ Any
177
+ Evaluation results.
178
+ """
179
+ df = pd.read_csv(csv_path)
180
+ # Ragas testset generation typically provides 'question', 'answer', 'contexts', 'ground_truth'
181
+ # 'contexts' is often stored as a string representation of a list in CSV
182
+ import ast
183
+
184
+ df["contexts"] = df["contexts"].apply(
185
+ lambda x: ast.literal_eval(x) if isinstance(x, str) else x
186
+ )
187
+
188
+ return run_evaluation(
189
+ questions=df["question"].tolist(),
190
+ answers=df["answer"].tolist(),
191
+ contexts=df["contexts"].tolist(),
192
+ ground_truths=(
193
+ df["ground_truth"].tolist() if "ground_truth" in df.columns else None
194
+ ),
195
+ )
196
+
197
+
198
+ if __name__ == "__main__":
199
+ import sys
200
+
201
+ logging.basicConfig(level=logging.INFO)
202
+ if len(sys.argv) > 1:
203
+ res = evaluate_from_csv(sys.argv[1])
204
+ print(res)
205
+ else:
206
+ logging.info("Eval module ready. Pass a CSV file to evaluate.")
safety.py ADDED
@@ -0,0 +1,650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Safety and Guardrails module for the LLM application.
3
+ This script provides runtime safety checks for toxicity and output length using Guardrails AI.
4
+
5
+ How to run:
6
+ This module is intended to be imported and used within other scripts.
7
+ """
8
+
9
+ # pylint: disable=import-error,no-name-in-module,invalid-name,broad-except,missing-function-docstring,missing-class-docstring,logging-fstring-interpolation,line-too-long
10
+ # The safety module is intentionally procedural and contains many heuristics
11
+ # and early-return branches; relax complexity checks for linting clarity.
12
+ # pylint: disable=too-many-branches,too-many-statements,too-many-locals,too-many-return-statements
13
+
14
+ import os
15
+ import re
16
+ import json
17
+ import logging
18
+ import unicodedata
19
+ from typing import Tuple, Optional, List
20
+ import requests
21
+
22
+ try:
23
+ import openai
24
+
25
+ OPENAI_AVAILABLE = True
26
+ except Exception:
27
+ openai = None
28
+ OPENAI_AVAILABLE = False
29
+
30
+ try:
31
+ from guardrails import Guard
32
+ from guardrails.hub import ValidLength, ToxicLanguage
33
+
34
+ GUARDRAILS_AVAILABLE = True
35
+ except Exception:
36
+ Guard = None
37
+ ValidLength = None
38
+ ToxicLanguage = None
39
+ GUARDRAILS_AVAILABLE = False
40
+
41
+ try:
42
+ pass
43
+ except Exception:
44
+ pass
45
+
46
+ # Optional telemetry helper: prefer to import at module load so inner functions
47
+ # can call `notify_low_score(...)` without importing repeatedly.
48
+ try:
49
+ from telemetry import notify_low_score # type: ignore
50
+ except Exception: # pragma: no cover - telemetry optional
51
+
52
+ def notify_low_score(*_args, **_kwargs):
53
+ return None
54
+
55
+
56
+ def get_safety_guard() -> Optional[Guard]:
57
+ """
58
+ Initialize a safety guard with toxicity and length checks.
59
+
60
+ Returns
61
+ -------
62
+ Optional[Guard]
63
+ A Guardrails Guard object if initialization succeeds, else None.
64
+ """
65
+ if not GUARDRAILS_AVAILABLE or Guard is None:
66
+ logging.debug("Guardrails not available; using local safety fallbacks.")
67
+ return None
68
+
69
+ try:
70
+ # Use OpenRouter if key is available; forward key as OPENAI_API_KEY
71
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
72
+ if openrouter_key:
73
+ os.environ["OPENAI_API_BASE"] = "https://api.openrouter.ai/v1"
74
+ os.environ["OPENAI_API_KEY"] = openrouter_key
75
+
76
+ # Toxicity "fix" requires an LLM to rephrase.
77
+ # Length "refuse" returns an empty string or refusal if it fails.
78
+ guard = Guard().use_many(
79
+ ToxicLanguage(threshold=0.5, on_fail="fix"),
80
+ ValidLength(min=10, max=2000, on_fail="refuse"),
81
+ )
82
+ return guard
83
+ except Exception as e:
84
+ logging.debug(f"Failed to initialize Guardrails: {e}")
85
+ return None
86
+
87
+
88
+ def _call_safeguard_model(text: str) -> Optional[dict]:
89
+ """
90
+ Optionally call an external safeguard model (e.g. OpenRouter's gpt-oss-safeguard-20b)
91
+ using the OpenAI-compatible chat completions endpoint.
92
+
93
+ Returns a dict with keys: {'passed': bool, 'score': float, 'reason': str}
94
+ or None if the call couldn't be made or parsed.
95
+ """
96
+ model = os.getenv(
97
+ "SAFEGUARD_MODEL",
98
+ os.getenv("OPENROUTER_SAFEGUARD_MODEL", "openai/gpt-oss-safeguard-20b"),
99
+ )
100
+ if not model:
101
+ return None
102
+ api_key = os.getenv("OPENAI_API_KEY")
103
+ api_base = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
104
+ if not api_key:
105
+ logging.debug(
106
+ "SAFEGUARD_MODEL configured but OPENAI_API_KEY missing; skipping safeguard call"
107
+ )
108
+ return None
109
+
110
+ system_prompt = "You are a safety classifier. Given the user input, determine whether it is safe to send to a general-purpose assistant. Respond ONLY with a JSON object with the keys: 'passed' (true|false), 'score' (0-100), 'reason' (short string)."
111
+ user_prompt = f"Classify the following input for safety and prompt-injection risk:\n\n'''{text}'''"
112
+
113
+ url = api_base.rstrip("/") + "/chat/completions"
114
+ payload = {
115
+ "model": model,
116
+ "messages": [
117
+ {"role": "system", "content": system_prompt},
118
+ {"role": "user", "content": user_prompt},
119
+ ],
120
+ "temperature": 0.0,
121
+ "max_tokens": 256,
122
+ }
123
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
124
+ try:
125
+ resp = requests.post(url, headers=headers, json=payload, timeout=8)
126
+ if resp.status_code != 200:
127
+ logging.debug(
128
+ "Safeguard model call failed status=%s text=%s",
129
+ resp.status_code,
130
+ resp.text,
131
+ )
132
+ return None
133
+ j = resp.json()
134
+ # openai-compatible response: choices[0].message.content
135
+ choices = j.get("choices") or []
136
+ if not choices:
137
+ return None
138
+ content = choices[0].get("message", {}).get("content") or choices[0].get("text")
139
+ if not content:
140
+ return None
141
+ # Attempt to parse JSON from content
142
+ try:
143
+ parsed = json.loads(content.strip())
144
+ # normalize keys
145
+ return {
146
+ "passed": bool(parsed.get("passed")),
147
+ "score": float(parsed.get("score", 0.0)),
148
+ "reason": str(parsed.get("reason", "")),
149
+ }
150
+ except Exception:
151
+ # try to extract JSON blob from text
152
+ m = re.search(r"\{.*\}", content, flags=re.S)
153
+ if not m:
154
+ return None
155
+ try:
156
+ parsed = json.loads(m.group(0))
157
+ return {
158
+ "passed": bool(parsed.get("passed")),
159
+ "score": float(parsed.get("score", 0.0)),
160
+ "reason": str(parsed.get("reason", "")),
161
+ }
162
+ except Exception:
163
+ return None
164
+ except Exception as e: # pragma: no cover - network call
165
+ logging.debug("Safeguard model call exception: %s", e)
166
+ return None
167
+
168
+
169
+ def check_safety(
170
+ text: str, include_meta: bool = False
171
+ ) -> Tuple[str, Optional[bool], float, Optional[dict]]:
172
+ """
173
+ Check a piece of text against safety rails.
174
+
175
+ Parameters
176
+ ----------
177
+ text : str
178
+ The text to be validated.
179
+
180
+ Returns
181
+ -------
182
+ Tuple[str, Optional[bool], float]
183
+ A tuple containing the validated (and potentially fixed) output,
184
+ a boolean indicating if the validation passed, and a numeric
185
+ safety score in range 0.0-100.0 (higher is safer).
186
+ """
187
+ # Normalize input: Unicode NFKC and strip zero-width/control chars
188
+ if isinstance(text, str):
189
+ normalized = unicodedata.normalize("NFKC", text)
190
+ # remove zero-width and control characters
191
+ normalized = re.sub(r"[\u200B-\u200F\uFEFF\x00-\x1F\x7F]", "", normalized)
192
+ # collapse whitespace
193
+ normalized = re.sub(r"\s+", " ", normalized).strip()
194
+ else:
195
+ normalized = ""
196
+
197
+ # Quick whitelist for statistical analysis queries (reduce false positives)
198
+ try:
199
+ stats_patterns = [
200
+ r"\b(f[\s-]?test|f-test|analysis of variance|anova)\b",
201
+ r"\b(t[\s-]?test|t-test|paired t-test|two[-\s]sample t-test)\b",
202
+ r"\bchi[-\s]?square\b",
203
+ r"\bp[-\s]?value\b",
204
+ r"\bdegrees of freedom\b",
205
+ r"\bpower analysis\b",
206
+ r"\bsample size\b",
207
+ r"\b(cohen'?s d)\b",
208
+ r"\b(regression|linear regression|logistic regression)\b",
209
+ ]
210
+ # clinical / device domain keywords often used in safe, non-procedural queries
211
+ clinical_patterns = [
212
+ r"\b(intrauterine device|iud|copper iud|copper intrauterine)\b",
213
+ r"\b(device|implant|biocompatibility|adverse event|adverse-event|safety data|clinical trial|pilot study)\b",
214
+ r"\b(manufactur|steriliz|quality control|q[mc]|risk assessment)\b",
215
+ # general lab/facility terms and simple read-only queries about labs
216
+ r"\b(lab|labs|laborator(?:y|ies)|facility|good laboratory practice|good lab practice)\b",
217
+ r"\b(what|which|list)\s+(labs|laborator(?:y|ies)|facilities?)\b",
218
+ ]
219
+ # common read-only query patterns (methods, devices, findings, results)
220
+ readonly_patterns = [
221
+ r"\b(what|which|list|describe|mention(?:ed)?)\b.*\b(methods?|methodology|procedures?)\b",
222
+ r"\b(what|which|list|describe|mention(?:ed)?)\b.*\b(devices?|implants?|instruments?)\b",
223
+ r"\b(what|which|list|describe|mention(?:ed)?)\b.*\b(results?|findings?|conclusions?)\b",
224
+ r"\b(what|which|list|describe)\b.*\b(software|packages|tools|libraries)\b",
225
+ ]
226
+ readonly_hits = [
227
+ p for p in readonly_patterns if re.search(p, normalized, flags=re.I)
228
+ ]
229
+ # quick-sensitive keywords that should prevent whitelisting
230
+ sensitive_quick = re.search(
231
+ r"(rm\s+-rf|exfiltrate|api[_-]?key|password|secret|ssh\b|curl\b|open a shell|execute command|run shell|sudo)",
232
+ normalized,
233
+ flags=re.I,
234
+ )
235
+ stats_hits = [p for p in stats_patterns if re.search(p, normalized, flags=re.I)]
236
+ clinical_hits = [
237
+ p for p in clinical_patterns if re.search(p, normalized, flags=re.I)
238
+ ]
239
+
240
+ # If the prompt is clearly statistical or clinical (and contains no obvious sensitive ops),
241
+ # treat as safe (high score). This avoids false positives for legitimate research queries
242
+ # such as statistical analysis or device safety discussions.
243
+ if (stats_hits or clinical_hits) and not sensitive_quick:
244
+ meta = {"whitelist": []}
245
+ if stats_hits:
246
+ meta["whitelist"].append("statistics")
247
+ meta["matches_stats"] = stats_hits
248
+ if clinical_hits:
249
+ meta["whitelist"].append("clinical")
250
+ meta["matches_clinical"] = clinical_hits
251
+ if readonly_hits:
252
+ if "whitelist" not in meta:
253
+ meta["whitelist"] = []
254
+ meta["whitelist"].append("readonly")
255
+ meta["matches_readonly"] = readonly_hits
256
+ # If a safeguard model is configured, consult it and honor its score
257
+ try:
258
+ sg = _call_safeguard_model(normalized)
259
+ except Exception:
260
+ sg = None
261
+
262
+ # If safeguard model explicitly refuses, honor that decision
263
+ if sg and not sg.get("passed", True):
264
+ try:
265
+ notify_low_score(
266
+ {
267
+ "kind": "prompt",
268
+ "reason": "safeguard_model_whitelist_refuse",
269
+ "score": float(sg.get("score", 0.0)),
270
+ "meta": meta,
271
+ }
272
+ )
273
+ except Exception:
274
+ pass
275
+ if include_meta:
276
+ return (
277
+ "refuse",
278
+ False,
279
+ float(sg.get("score", 0.0) or 0.0),
280
+ {"safeguard": sg, "whitelist": meta.get("whitelist")},
281
+ )
282
+ return "refuse", False, float(sg.get("score", 0.0) or 0.0)
283
+
284
+ # baseline score: prefer safeguard score if present, otherwise 95.0
285
+ baseline = float(sg.get("score", 95.0)) if sg else 95.0
286
+ score = min(95.0, baseline)
287
+ try:
288
+ notify_low_score(
289
+ {
290
+ "kind": "prompt",
291
+ "reason": "whitelist_applied",
292
+ "score": float(score),
293
+ "meta": meta,
294
+ }
295
+ )
296
+ except Exception:
297
+ pass
298
+ if include_meta:
299
+ return normalized, True, float(score), meta
300
+ return normalized, True, float(score)
301
+ except Exception:
302
+ # non-fatal; fall back to normal heuristics
303
+ pass
304
+
305
+ guard = get_safety_guard()
306
+ if guard:
307
+ try:
308
+ # Pass the text to the guard. If "fix" is triggered, it uses internal logic.
309
+ validation_result = guard.validate(normalized)
310
+ # When guardrails is available, map pass/fail to a simple score
311
+ score = 95.0 if validation_result.validation_passed else 20.0
312
+ if include_meta:
313
+ return (
314
+ validation_result.validated_output,
315
+ validation_result.validation_passed,
316
+ score,
317
+ {"guardrails": True},
318
+ )
319
+ return (
320
+ validation_result.validated_output,
321
+ validation_result.validation_passed,
322
+ score,
323
+ )
324
+ except Exception as e:
325
+ logging.error(f"Safety check failed during validation: {e}")
326
+ if include_meta:
327
+ return text, False, 0.0, None
328
+ return text, False, 0.0
329
+
330
+ # Local fallback checks when Guardrails isn't available
331
+ # Optional: consult a dedicated safeguard model if configured
332
+ try:
333
+ safeguard_enabled = os.getenv("SAFEGUARD_ENABLED", "true").lower() in (
334
+ "1",
335
+ "true",
336
+ "yes",
337
+ )
338
+ except Exception:
339
+ safeguard_enabled = True
340
+ if safeguard_enabled:
341
+ try:
342
+ sg = _call_safeguard_model(normalized)
343
+ if sg:
344
+ # If the safeguard model explicitly refuses, treat as unsafe
345
+ try:
346
+ if not sg.get("passed", True) or float(
347
+ sg.get("score", 0.0)
348
+ ) < float(os.getenv("SAFETY_SCORE_THRESHOLD", "40.0")):
349
+ notify_low_score(
350
+ {
351
+ "kind": "prompt",
352
+ "reason": "safeguard_model",
353
+ "score": float(sg.get("score", 0.0)),
354
+ }
355
+ )
356
+ except Exception:
357
+ pass
358
+ if not sg.get("passed", True):
359
+ if include_meta:
360
+ return (
361
+ "refuse",
362
+ False,
363
+ float(sg.get("score", 0.0) or 0.0),
364
+ {"safeguard": sg},
365
+ )
366
+ return "refuse", False, float(sg.get("score", 0.0) or 0.0)
367
+ # If passed, we can use the model's score as a baseline
368
+ base_score = float(sg.get("score", 100.0))
369
+ # continue to heuristics but start with model baseline
370
+ else:
371
+ base_score = None
372
+ except Exception:
373
+ base_score = None
374
+ else:
375
+ base_score = None
376
+ # Short benign message whitelist (greetings, thanks, simple queries)
377
+ try:
378
+ greeting_pattern = r"^(hi|hello|hey|hi there|hello there|thanks|thank you|bye|goodbye|good morning|good afternoon|good evening|how are you)[\.!?]?$"
379
+ except Exception:
380
+ greeting_pattern = r"^(hi|hello|hey|thanks|thank you|bye|goodbye)[\.!?]?$"
381
+
382
+ if isinstance(normalized, str) and re.search(
383
+ greeting_pattern, normalized, flags=re.I
384
+ ):
385
+ if include_meta:
386
+ return normalized, True, 100.0, {"whitelist": "greeting"}
387
+ return normalized, True, 100.0
388
+
389
+ # Length checks (configurable via env vars SAFETY_MIN_LEN and SAFETY_MAX_LEN)
390
+ try:
391
+ min_len = int(os.getenv("SAFETY_MIN_LEN", "10"))
392
+ except Exception:
393
+ min_len = 10
394
+ try:
395
+ max_len = int(os.getenv("SAFETY_MAX_LEN", "2000"))
396
+ except Exception:
397
+ max_len = 2000
398
+ if not text or len(text) < min_len:
399
+ if include_meta:
400
+ return text, False, 10.0, None
401
+ return text, False, 10.0
402
+ if len(text) > max_len:
403
+ # truncated output, penalize score
404
+ if include_meta:
405
+ return text[:max_len], False, 30.0, None
406
+ return text[:max_len], False, 30.0
407
+
408
+ # Simple profanity filter (best-effort)
409
+ bad_words = {"fuck", "shit", "bitch", "asshole", "damn"}
410
+ lowered = normalized.lower()
411
+ found = False
412
+ for w in bad_words:
413
+ if w in lowered:
414
+ found = True
415
+ normalized = normalized.replace(w, "*" * len(w))
416
+
417
+ # Obfuscation detection heuristics
418
+ def looks_like_base64(s: str) -> bool:
419
+ # long base64-like strings (no spaces, length > 100, valid charset)
420
+ b64_re = re.compile(r"^[A-Za-z0-9+/=\\n\\r]{100,}$")
421
+ return bool(b64_re.search(s))
422
+
423
+ def looks_like_hex(s: str) -> bool:
424
+ # long hex strings
425
+ hex_re = re.compile(r"^(0x)?[0-9a-fA-F]{80,}$")
426
+ return bool(hex_re.search(s))
427
+
428
+ def looks_like_percent_encoding(s: str) -> bool:
429
+ return bool(re.search(r"%[0-9A-Fa-f]{2}", s)) and len(s) > 80
430
+
431
+ def long_punct_seq(s: str) -> bool:
432
+ return bool(re.search(r"[\W_]{20,}", s))
433
+
434
+ obf_hits = []
435
+ if looks_like_base64(text) or looks_like_base64(normalized):
436
+ obf_hits.append("base64")
437
+ if looks_like_hex(text) or looks_like_hex(normalized):
438
+ obf_hits.append("hex")
439
+ if looks_like_percent_encoding(text) or looks_like_percent_encoding(normalized):
440
+ obf_hits.append("percent-encoding")
441
+ if long_punct_seq(text) or long_punct_seq(normalized):
442
+ obf_hits.append("long-punct")
443
+ # Additional heuristic detectors
444
+
445
+ def detect_prompt_injection(s: str) -> List[str]:
446
+ patterns = [
447
+ r"ignore (all )?previous (instructions|prompts|messages)",
448
+ r"disregard (the )?above",
449
+ r"forget (your )?instructions",
450
+ r"ignore (this )?and do",
451
+ r"now do the following",
452
+ r"please break the rules",
453
+ r"ignore previous",
454
+ r"ignore all previous",
455
+ r"disobey",
456
+ ]
457
+ return [p for p in patterns if re.search(p, s, flags=re.I)]
458
+
459
+ def detect_jailbreak(s: str) -> List[str]:
460
+ patterns = [
461
+ r"jailbreak",
462
+ r"bypass (the )?policy",
463
+ r"break out",
464
+ r"override (the )?system",
465
+ r"sudo",
466
+ r"escape the sandbox",
467
+ r"remove restrictions",
468
+ r"disable safety",
469
+ ]
470
+ return [p for p in patterns if re.search(p, s, flags=re.I)]
471
+
472
+ def detect_prefix_attack(s: str) -> List[str]:
473
+ # look for system-like prefix instructions commonly used in prompt-injection
474
+ patterns = [
475
+ r"^system[:\-]",
476
+ r"^assistant[:\-]",
477
+ r"^user[:\-]",
478
+ r"^###",
479
+ r"^---",
480
+ r"^\[system\]",
481
+ r"^\[assistant\]",
482
+ ]
483
+ return [p for p in patterns if re.search(p, s, flags=re.I | re.M)]
484
+
485
+ def detect_sensitive_operations(s: str) -> List[str]:
486
+ patterns = [
487
+ r"rm\s+-rf",
488
+ r"delete (all|files|data)",
489
+ r"shutdown",
490
+ r"format (disk|drive)",
491
+ r"open a shell",
492
+ r"run shell",
493
+ r"execute command",
494
+ r"curl http",
495
+ r"ssh ",
496
+ r"api[_-]?key",
497
+ r"access token",
498
+ r"password",
499
+ r"secret",
500
+ r"exfiltrate",
501
+ r"send email",
502
+ r"transfer file",
503
+ r"write to /etc",
504
+ r"drop table",
505
+ ]
506
+ return [p for p in patterns if re.search(p, s, flags=re.I)]
507
+
508
+ injections = detect_prompt_injection(normalized)
509
+ jailbreaks = detect_jailbreak(normalized)
510
+ prefixes = detect_prefix_attack(normalized)
511
+ sensitive = detect_sensitive_operations(normalized)
512
+
513
+ # Moderation hook (optional): combine OpenAI moderation results when available
514
+ moderation_flag = None
515
+ if os.getenv("OPENAI_API_KEY") and OPENAI_AVAILABLE:
516
+ try:
517
+ # Use the moderation endpoint via openai if available. Access via
518
+ # getattr to avoid static attribute checks that pylint may flag.
519
+ mod_client = getattr(openai, "Moderation", None)
520
+ if mod_client is not None and hasattr(mod_client, "create"):
521
+ resp = mod_client.create(input=normalized)
522
+ # The response structure includes categories and a 'flagged' boolean
523
+ results = resp.get("results") or []
524
+ if results:
525
+ mod = results[0]
526
+ moderation_flag = mod.get("flagged", False)
527
+ logging.debug("Moderation result: %s", json.dumps(mod))
528
+ except Exception as e: # pragma: no cover - external call
529
+ logging.debug("Moderation call failed: %s", e)
530
+ elif os.getenv("OPENAI_API_KEY") and not OPENAI_AVAILABLE:
531
+ # If openai library not installed, attempt lightweight HTTP call
532
+ try:
533
+ url = "https://api.openai.com/v1/moderations"
534
+ headers = {
535
+ "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
536
+ "Content-Type": "application/json",
537
+ }
538
+ payload = {"input": normalized}
539
+ r = requests.post(url, headers=headers, json=payload, timeout=5)
540
+ if r.status_code == 200:
541
+ j = r.json()
542
+ results = j.get("results") or []
543
+ if results:
544
+ mod = results[0]
545
+ moderation_flag = mod.get("flagged", False)
546
+ logging.debug("Moderation http result: %s", json.dumps(mod))
547
+ except Exception: # pragma: no cover - external call
548
+ pass
549
+
550
+ logging.debug(
551
+ "Safety detectors: injections=%s jailbreaks=%s prefixes=%s sensitive=%s obf=%s moderation=%s",
552
+ injections,
553
+ jailbreaks,
554
+ prefixes,
555
+ sensitive,
556
+ obf_hits,
557
+ moderation_flag,
558
+ )
559
+
560
+ # If sensitive operations are requested, refuse immediately
561
+ if sensitive:
562
+ try:
563
+ notify_low_score(
564
+ {
565
+ "kind": "prompt",
566
+ "reason": "sensitive_operation",
567
+ "detectors": sensitive,
568
+ "score": 0.0,
569
+ }
570
+ )
571
+ except Exception:
572
+ pass
573
+ if include_meta:
574
+ return "refuse", False, 0.0, {"moderation_flag": True}
575
+ return "refuse", False, 0.0
576
+
577
+ # If moderation flagged the content, refuse
578
+ if moderation_flag:
579
+ try:
580
+ notify_low_score(
581
+ {
582
+ "kind": "prompt",
583
+ "reason": "moderation_flagged",
584
+ "score": 0.0,
585
+ }
586
+ )
587
+ except Exception:
588
+ pass
589
+ if include_meta:
590
+ return "refuse", False, 0.0, {"sensitive": True}
591
+ return "refuse", False, 0.0
592
+
593
+ # Compute a simple heuristic score
594
+ score = 100.0
595
+ if found:
596
+ score -= 40.0
597
+ if obf_hits:
598
+ score -= 30.0
599
+ if jailbreaks:
600
+ score -= 45.0
601
+ if injections:
602
+ score -= 35.0
603
+ if prefixes:
604
+ score -= 20.0
605
+ if len(normalized) < 50:
606
+ score -= 10.0
607
+
608
+ # clamp
609
+ score = max(score, 0.0)
610
+
611
+ # If a safeguard model provided a baseline score, be conservative and apply it
612
+ if "base_score" in locals() and base_score is not None:
613
+ try:
614
+ score = min(float(base_score), float(score))
615
+ except Exception:
616
+ pass
617
+
618
+ passed_flag = (
619
+ (not found) and (not jailbreaks) and (not injections) and (not obf_hits)
620
+ )
621
+
622
+ # Telemetry on low scores
623
+ try:
624
+ threshold = float(os.getenv("SAFETY_SCORE_THRESHOLD", "40.0"))
625
+ if score < threshold:
626
+ notify_low_score(
627
+ {
628
+ "kind": "prompt",
629
+ "reason": "low_score",
630
+ "score": float(score),
631
+ "detectors": {
632
+ "found": found,
633
+ "obf_hits": obf_hits,
634
+ "jailbreaks": jailbreaks,
635
+ "injections": injections,
636
+ "prefixes": prefixes,
637
+ },
638
+ }
639
+ )
640
+ except Exception:
641
+ # do not fail safety on telemetry errors
642
+ pass
643
+
644
+ # Return metadata including any safeguard baseline if requested
645
+ meta: dict = {}
646
+ if "base_score" in locals() and base_score is not None:
647
+ meta["safeguard_base_score"] = float(base_score)
648
+ if include_meta:
649
+ return normalized, passed_flag, float(score), (meta if meta else None)
650
+ return normalized, passed_flag, float(score)
telemetry.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telemetry helper for anonymized safety events.
2
+
3
+ This module provides a minimal notifier that logs low-score / refused
4
+ events and optionally POSTs anonymized metadata to a configured webhook.
5
+ No secrets are sent.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import re
12
+ from typing import Mapping, Any
13
+
14
+
15
+ def notify_low_score(event: Mapping[str, Any]) -> None:
16
+ """Notify about a low-score or refused safety event.
17
+
18
+ event: mapping with non-sensitive metadata, e.g. {
19
+ "kind": "prompt" | "answer",
20
+ "score": 12.3,
21
+ "detectors": ["jailbreak", "base64"],
22
+ "session_id_present": False,
23
+ }
24
+ """
25
+ logger = logging.getLogger("telemetry")
26
+ try:
27
+ logger.info("Telemetry event: %s", json.dumps(event))
28
+ except Exception:
29
+ logger.info("Telemetry event: %s", str(event))
30
+
31
+ webhook = os.getenv("TELEMETRY_WEBHOOK")
32
+ if not webhook:
33
+ return
34
+
35
+ # Only POST refused events to the external telemetry webhook.
36
+ # Allowed refused reasons:
37
+ refused_reasons = {
38
+ "sensitive_operation",
39
+ "moderation_flagged",
40
+ "safeguard_model",
41
+ "response_refused",
42
+ "refused",
43
+ }
44
+
45
+ reason = str(event.get("reason", "")).lower() if event else ""
46
+ score = None
47
+ try:
48
+ score = float(event.get("score")) if event and "score" in event else None
49
+ except Exception:
50
+ score = None
51
+
52
+ # Post only when explicitly refused (reason in refused_reasons) or score==0.0
53
+ should_post = False
54
+ if reason and any(r in reason for r in refused_reasons):
55
+ should_post = True
56
+ if score is not None and score <= 0.0:
57
+ should_post = True
58
+
59
+ if not should_post:
60
+ logger.info(
61
+ "Telemetry event ignored (not a refused event): %s", json.dumps(event)
62
+ )
63
+ return
64
+
65
+ payload = {"event": event}
66
+ try:
67
+ import requests
68
+
69
+ headers = {"Content-Type": "application/json"}
70
+ # POST anonymized metadata only
71
+ requests.post(webhook, json=payload, headers=headers, timeout=2)
72
+ except Exception as e: # pragma: no cover - network
73
+ logger.debug("Telemetry post failed: %s", e)
74
+
75
+
76
+ def make_snippet(text: str, max_chars: int = 120) -> str:
77
+ """Create a short, single-line snippet suitable for logs and UI.
78
+
79
+ - strips control characters and collapses whitespace
80
+ - truncates with an ellipsis if longer than `max_chars`
81
+ """
82
+ if not text:
83
+ return ""
84
+ try:
85
+ s = str(text)
86
+ except Exception:
87
+ return ""
88
+ # replace control characters with a single space to avoid word concatenation
89
+ s = re.sub(r"[\u0000-\u001F\u007F]+", " ", s)
90
+ # collapse whitespace/newlines
91
+ s = re.sub(r"\s+", " ", s).strip()
92
+ if len(s) <= max_chars:
93
+ return s
94
+ # truncate safely
95
+ return s[: max_chars - 1].rstrip() + "…"