Mattral's picture
Add Multimodal Financial RAG app
2fe3a9e
Raw
History Blame Contribute Delete
5.96 kB
"""utils/guardrails.py — Numeric grounding, PII detection, and injection protection.
Mirrors src/rag_system/components/guardrails/ logic in standalone form.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Optional, Tuple
@dataclass
class GuardrailResult:
overall_passed: bool
numeric_grounding_passed: bool
pii_detected: bool
injection_detected: bool
ungrounded_numbers: List[str]
pii_entities: List[str]
redacted_query: Optional[str]
details: List[str]
warnings: List[str]
_NUMBER_RE = re.compile(
r"""
(?:
\$\s*[\d,]+(?:\.\d+)?(?:\s*(?:billion|million|trillion|B|M|T|K))?
| [\d,]+(?:\.\d+)?\s*(?:billion|million|trillion|B|M|T|K)\b
| [\d,]+(?:\.\d+)?%
| \d+\.\d+(?:\s*x\b)?
| (?<!\w)\d{4}(?!\w)
)
""",
re.VERBOSE | re.IGNORECASE,
)
def _normalise_number(raw: str) -> str:
return re.sub(r"\s+", "", raw.lower().replace(",", ""))
def check_numeric_grounding(answer: str, context_texts: List[str]) -> Tuple[bool, List[str], List[str]]:
details = []
answer_numbers = _NUMBER_RE.findall(answer)
if not answer_numbers:
details.append("No numeric claims found in answer - grounding check not applicable")
return True, [], details
context_combined = " ".join(context_texts)
context_numbers_raw = _NUMBER_RE.findall(context_combined)
context_numbers_norm = {_normalise_number(n) for n in context_numbers_raw}
ungrounded = []
for num in answer_numbers:
norm = _normalise_number(num)
found = norm in context_numbers_norm or any(
norm in cn or cn in norm for cn in context_numbers_norm
)
if not found:
ungrounded.append(num)
if not ungrounded:
details.append(f"All {len(answer_numbers)} numeric values grounded in source context")
else:
details.append(
f"{len(ungrounded)} of {len(answer_numbers)} numbers not found in context: "
+ ", ".join(f"`{n}`" for n in ungrounded[:5])
)
return len(ungrounded) == 0, ungrounded, details
_PII_PATTERNS = [
(r"\b\d{3}-\d{2}-\d{4}\b", "SSN"),
(r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{1,3}\b", "IBAN"),
(r"\b[A-Z]\d{9}\b", "CUSIP"),
(r"\b[A-Z]{2}[A-Z0-9]{10}\b", "ISIN"),
(r"\b\d{4}[\s-]\d{4}[\s-]\d{4}[\s-]\d{4}\b", "Card number"),
(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "Email", re.IGNORECASE),
(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "Phone"),
(r"\bACC\d{8,12}\b", "Account number"),
]
def check_pii(text: str) -> Tuple[bool, List[str], str]:
found_entities = []
redacted = text
for pattern_def in _PII_PATTERNS:
pattern, label = pattern_def[0], pattern_def[1]
flags = pattern_def[2] if len(pattern_def) > 2 else 0
matches = re.findall(pattern, text, flags)
if matches:
found_entities.extend([f"{label}: {m}" for m in matches[:3]])
redacted = re.sub(pattern, f"[{label.upper()}_REDACTED]", redacted, flags=flags)
return bool(found_entities), found_entities, redacted
_INJECTION_PATTERNS = re.compile(
r"(ignore\s+(previous|all|prior)\s+instructions?|"
r"disregard\s+(your|all|the)\s+(instructions?|guidelines?|system\s+prompt)|"
r"jailbreak|act\s+as\s+(an?\s+)?unrestricted|"
r"you\s+are\s+now\s+(?:a\s+)?(?:dan|evil|uncensored)|"
r"bypass\s+(safety|guardrails?|filters?)|"
r"pretend\s+you\s+(have\s+no|don't\s+have)\s+(restrictions?|limits?)|"
r"developer\s+mode\s+enabled|"
r"new\s+system\s+prompt|override\s+instructions?)",
re.IGNORECASE,
)
def check_injection(query: str) -> Tuple[bool, Optional[str]]:
match = _INJECTION_PATTERNS.search(query)
if match:
return True, match.group(0)
return False, None
def run_guardrails(query: str, answer: str, context_texts: List[str]) -> GuardrailResult:
details = ["**Guardrail Check Results**", ""]
warnings = []
is_injection, injection_match = check_injection(query)
if is_injection:
details.append(f"Injection blocked: Pattern `{injection_match}` detected in query")
return GuardrailResult(
overall_passed=False, numeric_grounding_passed=False,
pii_detected=False, injection_detected=True,
ungrounded_numbers=[], pii_entities=[],
redacted_query=None, details=details, warnings=warnings,
)
details.append("Injection check: No adversarial patterns detected")
pii_found, pii_entities, redacted_query = check_pii(query)
if pii_found:
details.append(
f"PII detected & redacted: {', '.join(pii_entities[:3])}"
+ (" (+more)" if len(pii_entities) > 3 else "")
)
warnings.append("Query contained PII - redacted before processing")
else:
details.append("PII check: No sensitive identifiers detected in query")
numeric_passed, ungrounded, numeric_details = check_numeric_grounding(answer, context_texts)
details.extend(numeric_details)
if not numeric_passed:
warnings.append(
f"{len(ungrounded)} numeric value(s) in the answer could not be verified "
f"against the retrieved source context. This may indicate the model "
f"extrapolated beyond the document."
)
overall = numeric_passed and not is_injection
if overall:
details.append("\nOverall: All guardrails passed")
else:
details.append("\nOverall: One or more guardrails flagged - review warnings above")
return GuardrailResult(
overall_passed=overall,
numeric_grounding_passed=numeric_passed,
pii_detected=pii_found,
injection_detected=False,
ungrounded_numbers=ungrounded,
pii_entities=pii_entities,
redacted_query=redacted_query if pii_found else None,
details=details,
warnings=warnings,
)