genai-otel-instrument 0.1.24__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.
Files changed (69) hide show
  1. genai_otel/__init__.py +132 -0
  2. genai_otel/__version__.py +34 -0
  3. genai_otel/auto_instrument.py +602 -0
  4. genai_otel/cli.py +92 -0
  5. genai_otel/config.py +333 -0
  6. genai_otel/cost_calculator.py +467 -0
  7. genai_otel/cost_enriching_exporter.py +207 -0
  8. genai_otel/cost_enrichment_processor.py +174 -0
  9. genai_otel/evaluation/__init__.py +76 -0
  10. genai_otel/evaluation/bias_detector.py +364 -0
  11. genai_otel/evaluation/config.py +261 -0
  12. genai_otel/evaluation/hallucination_detector.py +525 -0
  13. genai_otel/evaluation/pii_detector.py +356 -0
  14. genai_otel/evaluation/prompt_injection_detector.py +262 -0
  15. genai_otel/evaluation/restricted_topics_detector.py +316 -0
  16. genai_otel/evaluation/span_processor.py +962 -0
  17. genai_otel/evaluation/toxicity_detector.py +406 -0
  18. genai_otel/exceptions.py +17 -0
  19. genai_otel/gpu_metrics.py +516 -0
  20. genai_otel/instrumentors/__init__.py +71 -0
  21. genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
  22. genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
  23. genai_otel/instrumentors/autogen_instrumentor.py +394 -0
  24. genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
  25. genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
  26. genai_otel/instrumentors/base.py +919 -0
  27. genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
  28. genai_otel/instrumentors/cohere_instrumentor.py +140 -0
  29. genai_otel/instrumentors/crewai_instrumentor.py +311 -0
  30. genai_otel/instrumentors/dspy_instrumentor.py +661 -0
  31. genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
  32. genai_otel/instrumentors/groq_instrumentor.py +106 -0
  33. genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
  34. genai_otel/instrumentors/haystack_instrumentor.py +503 -0
  35. genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
  36. genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
  37. genai_otel/instrumentors/instructor_instrumentor.py +425 -0
  38. genai_otel/instrumentors/langchain_instrumentor.py +340 -0
  39. genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
  40. genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
  41. genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
  42. genai_otel/instrumentors/ollama_instrumentor.py +197 -0
  43. genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
  44. genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
  45. genai_otel/instrumentors/openai_instrumentor.py +260 -0
  46. genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
  47. genai_otel/instrumentors/replicate_instrumentor.py +87 -0
  48. genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
  49. genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
  50. genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
  51. genai_otel/llm_pricing.json +1676 -0
  52. genai_otel/logging_config.py +45 -0
  53. genai_otel/mcp_instrumentors/__init__.py +14 -0
  54. genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
  55. genai_otel/mcp_instrumentors/base.py +105 -0
  56. genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
  57. genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
  58. genai_otel/mcp_instrumentors/manager.py +139 -0
  59. genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
  60. genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
  61. genai_otel/metrics.py +148 -0
  62. genai_otel/py.typed +2 -0
  63. genai_otel/server_metrics.py +197 -0
  64. genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
  65. genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
  66. genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
  67. genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
  68. genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
  69. genai_otel_instrument-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,174 @@
1
+ """Custom SpanProcessor to enrich OpenInference spans with cost tracking.
2
+
3
+ This processor adds cost attributes to spans created by OpenInference instrumentors
4
+ (smolagents, litellm, mcp) by extracting token usage and model information from
5
+ existing span attributes and calculating costs using our CostCalculator.
6
+
7
+ Supports both OpenTelemetry GenAI and OpenInference semantic conventions:
8
+ - GenAI: gen_ai.request.model, gen_ai.usage.{prompt_tokens,completion_tokens}
9
+ - OpenInference: llm.model_name, llm.token_count.{prompt,completion}
10
+ """
11
+
12
+ import logging
13
+ from typing import Optional
14
+
15
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
16
+ from opentelemetry.trace import SpanContext
17
+
18
+ from .cost_calculator import CostCalculator
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class CostEnrichmentSpanProcessor(SpanProcessor):
24
+ """Enriches spans with cost tracking attributes.
25
+
26
+ This processor:
27
+ 1. Identifies spans from OpenInference instrumentors (smolagents, litellm, mcp)
28
+ 2. Extracts model name and token usage from span attributes
29
+ 3. Calculates cost using CostCalculator
30
+ 4. Adds cost attributes (gen_ai.usage.cost.total, etc.) to the span
31
+ """
32
+
33
+ def __init__(self, cost_calculator: Optional[CostCalculator] = None):
34
+ """Initialize the cost enrichment processor.
35
+
36
+ Args:
37
+ cost_calculator: CostCalculator instance to use for cost calculations.
38
+ If None, creates a new instance.
39
+ """
40
+ self.cost_calculator = cost_calculator or CostCalculator()
41
+ logger.info("CostEnrichmentSpanProcessor initialized")
42
+
43
+ def on_start(self, span: Span, parent_context: Optional[SpanContext] = None) -> None:
44
+ """Called when a span starts. No action needed."""
45
+ pass
46
+
47
+ def on_end(self, span: ReadableSpan) -> None:
48
+ """Called when a span ends. Enriches with cost attributes if applicable.
49
+
50
+ Args:
51
+ span: The span that just ended.
52
+ """
53
+ try:
54
+ # Only process spans that have LLM-related attributes
55
+ if not span.attributes:
56
+ return
57
+
58
+ attributes = span.attributes
59
+
60
+ # Check for model name - support both GenAI and OpenInference conventions
61
+ model = (
62
+ attributes.get("gen_ai.request.model")
63
+ or attributes.get("llm.model_name")
64
+ or attributes.get("embedding.model_name")
65
+ )
66
+ if not model:
67
+ return
68
+
69
+ # Skip if cost attributes are already present (added by instrumentor)
70
+ if "gen_ai.usage.cost.total" in attributes:
71
+ logger.debug(f"Span '{span.name}' already has cost attributes, skipping enrichment")
72
+ return
73
+
74
+ # Extract token usage - support GenAI, OpenInference, and legacy conventions
75
+ prompt_tokens = (
76
+ attributes.get("gen_ai.usage.prompt_tokens")
77
+ or attributes.get("gen_ai.usage.input_tokens")
78
+ or attributes.get("llm.token_count.prompt") # OpenInference
79
+ or 0
80
+ )
81
+ completion_tokens = (
82
+ attributes.get("gen_ai.usage.completion_tokens")
83
+ or attributes.get("gen_ai.usage.output_tokens")
84
+ or attributes.get("llm.token_count.completion") # OpenInference
85
+ or 0
86
+ )
87
+
88
+ # Skip if no tokens recorded
89
+ if prompt_tokens == 0 and completion_tokens == 0:
90
+ return
91
+
92
+ # Get call type - support both GenAI and OpenInference conventions
93
+ # OpenInference uses openinference.span.kind (values: LLM, EMBEDDING, etc.)
94
+ span_kind = attributes.get("openinference.span.kind", "").upper()
95
+ call_type = attributes.get("gen_ai.operation.name") or span_kind.lower() or "chat"
96
+
97
+ # Map operation names to call types for cost calculator
98
+ # Supports both GenAI and OpenInference conventions
99
+ call_type_mapping = {
100
+ # GenAI conventions
101
+ "chat": "chat",
102
+ "completion": "chat",
103
+ "embedding": "embedding",
104
+ "embeddings": "embedding",
105
+ "text_generation": "chat",
106
+ "image_generation": "image",
107
+ "audio": "audio",
108
+ # OpenInference conventions (span.kind values)
109
+ "llm": "chat",
110
+ "embedding": "embedding",
111
+ "chain": "chat",
112
+ "retriever": "embedding",
113
+ "reranker": "embedding",
114
+ "tool": "chat",
115
+ "agent": "chat",
116
+ }
117
+ normalized_call_type = call_type_mapping.get(str(call_type).lower(), "chat")
118
+
119
+ # Calculate cost
120
+ usage = {
121
+ "prompt_tokens": int(prompt_tokens),
122
+ "completion_tokens": int(completion_tokens),
123
+ "total_tokens": int(prompt_tokens) + int(completion_tokens),
124
+ }
125
+
126
+ # Use calculate_granular_cost to get detailed breakdown
127
+ cost_info = self.cost_calculator.calculate_granular_cost(
128
+ model=str(model),
129
+ usage=usage,
130
+ call_type=normalized_call_type,
131
+ )
132
+
133
+ if cost_info and cost_info.get("total", 0.0) > 0:
134
+ # Add cost attributes to the span
135
+ # Use duck typing to check if span supports set_attribute
136
+ if hasattr(span, "set_attribute") and callable(getattr(span, "set_attribute")):
137
+ span.set_attribute("gen_ai.usage.cost.total", cost_info["total"])
138
+
139
+ if cost_info.get("prompt", 0.0) > 0:
140
+ span.set_attribute("gen_ai.usage.cost.prompt", cost_info["prompt"])
141
+ if cost_info.get("completion", 0.0) > 0:
142
+ span.set_attribute("gen_ai.usage.cost.completion", cost_info["completion"])
143
+
144
+ logger.info(
145
+ f"Enriched span '{span.name}' with cost: {cost_info['total']:.6f} USD "
146
+ f"for model {model} ({usage['total_tokens']} tokens)"
147
+ )
148
+ else:
149
+ logger.warning(
150
+ f"Span '{span.name}' is not mutable (type: {type(span).__name__}), "
151
+ "cannot add cost attributes"
152
+ )
153
+
154
+ except Exception as e:
155
+ # Don't fail span processing due to cost enrichment errors
156
+ logger.warning(
157
+ f"Failed to enrich span '{getattr(span, 'name', 'unknown')}' with cost: {e}",
158
+ exc_info=True,
159
+ )
160
+
161
+ def shutdown(self) -> None:
162
+ """Called when the processor is shutdown."""
163
+ logger.info("CostEnrichmentSpanProcessor shutdown")
164
+
165
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
166
+ """Force flush any pending spans.
167
+
168
+ Args:
169
+ timeout_millis: Timeout in milliseconds.
170
+
171
+ Returns:
172
+ True if flush succeeded.
173
+ """
174
+ return True
@@ -0,0 +1,76 @@
1
+ """Evaluation and safety features for GenAI observability.
2
+
3
+ This module provides opt-in evaluation metrics and safety guardrails:
4
+
5
+ - **PII Detection**: Detect and handle personally identifiable information
6
+ - **Toxicity Detection**: Monitor toxic or harmful content
7
+ - **Bias Detection**: Detect demographic and other biases
8
+ - **Prompt Injection Detection**: Protect against prompt injection attacks
9
+ - **Restricted Topics**: Block sensitive or inappropriate topics
10
+ - **Hallucination Detection**: Track factual accuracy and groundedness
11
+
12
+ All features are:
13
+ - Opt-in via configuration
14
+ - Zero-code for basic usage
15
+ - Extensible for custom implementations
16
+ - Compatible with existing instrumentation
17
+
18
+ Example:
19
+ ```python
20
+ from genai_otel import instrument
21
+
22
+ # Enable PII detection
23
+ instrument(
24
+ enable_pii_detection=True,
25
+ pii_mode="redact",
26
+ pii_gdpr_mode=True
27
+ )
28
+ ```
29
+
30
+ Requirements:
31
+ Install optional dependencies:
32
+ ```bash
33
+ pip install genai-otel-instrument[evaluation]
34
+ ```
35
+ """
36
+
37
+ from .bias_detector import BiasDetectionResult, BiasDetector
38
+ from .config import (
39
+ BiasConfig,
40
+ HallucinationConfig,
41
+ PIIConfig,
42
+ PromptInjectionConfig,
43
+ RestrictedTopicsConfig,
44
+ ToxicityConfig,
45
+ )
46
+ from .hallucination_detector import HallucinationDetector, HallucinationResult
47
+ from .pii_detector import PIIDetectionResult, PIIDetector
48
+ from .prompt_injection_detector import PromptInjectionDetector, PromptInjectionResult
49
+ from .restricted_topics_detector import RestrictedTopicsDetector, RestrictedTopicsResult
50
+ from .span_processor import EvaluationSpanProcessor
51
+ from .toxicity_detector import ToxicityDetectionResult, ToxicityDetector
52
+
53
+ __all__ = [
54
+ # Config classes
55
+ "BiasConfig",
56
+ "HallucinationConfig",
57
+ "PIIConfig",
58
+ "PromptInjectionConfig",
59
+ "RestrictedTopicsConfig",
60
+ "ToxicityConfig",
61
+ # Detectors
62
+ "BiasDetector",
63
+ "BiasDetectionResult",
64
+ "HallucinationDetector",
65
+ "HallucinationResult",
66
+ "PIIDetector",
67
+ "PIIDetectionResult",
68
+ "PromptInjectionDetector",
69
+ "PromptInjectionResult",
70
+ "RestrictedTopicsDetector",
71
+ "RestrictedTopicsResult",
72
+ "ToxicityDetector",
73
+ "ToxicityDetectionResult",
74
+ # Span processor
75
+ "EvaluationSpanProcessor",
76
+ ]
@@ -0,0 +1,364 @@
1
+ """Bias Detection for GenAI applications.
2
+
3
+ This module provides bias detection capabilities using pattern-based and
4
+ optional ML-based approaches to identify demographic and other biases
5
+ in prompts and responses.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Set
12
+
13
+ from .config import BiasConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class BiasDetectionResult:
20
+ """Result of bias detection.
21
+
22
+ Attributes:
23
+ has_bias: Whether bias was detected above threshold
24
+ bias_scores: Bias scores by type
25
+ max_score: Maximum bias score across all types
26
+ detected_biases: List of bias types detected
27
+ patterns_matched: Specific patterns that triggered detection
28
+ original_text: Original input text
29
+ """
30
+
31
+ has_bias: bool
32
+ bias_scores: Dict[str, float] = field(default_factory=dict)
33
+ max_score: float = 0.0
34
+ detected_biases: List[str] = field(default_factory=list)
35
+ patterns_matched: Dict[str, List[str]] = field(default_factory=dict)
36
+ original_text: Optional[str] = None
37
+
38
+
39
+ class BiasDetector:
40
+ """Bias detector using pattern-based and optional ML approaches.
41
+
42
+ This detector identifies various types of bias including:
43
+ - Gender bias
44
+ - Racial/ethnic bias
45
+ - Religious bias
46
+ - Age bias
47
+ - Disability bias
48
+ - Sexual orientation bias
49
+ - Political bias
50
+
51
+ Requirements:
52
+ Optional: pip install fairlearn scikit-learn
53
+ """
54
+
55
+ # Pattern definitions for different bias types
56
+ BIAS_PATTERNS = {
57
+ "gender": {
58
+ "patterns": [
59
+ r"\b(women|woman|female|girls?|she|her)\s+(?:are|is|always|never|can't|cannot|shouldn't)\b",
60
+ r"\b(men|man|male|boys?|he|him)\s+(?:are|is|always|never|can't|cannot|shouldn't)\b",
61
+ r"\b(women|woman|female|girls?),?\s+(?:they're?|she's)\s+(?:always|never|too|so)\b", # "women, they're always"
62
+ r"\b(men|man|male|boys?),?\s+(?:they're?|he's)\s+(?:always|never|too|so)\b",
63
+ r"\bfor\s+(his|her)\s+gender\b",
64
+ r"\b(manly|womanly|girly|boyish)\s+(?:behavior|traits?|characteristic)",
65
+ r"(?:cry|act|behave|think)\s+like\s+(?:a\s+)?(?:man|woman|boys?|girls?)",
66
+ r"\b(?:real|proper|typical)\s+(?:man|woman|boy|girl)",
67
+ ],
68
+ "keywords": ["sexist", "misogyny", "misandry", "gender stereotype"],
69
+ },
70
+ "race": {
71
+ "patterns": [
72
+ r"\b(?:all|most|typical)?\s*(?:black|white|asian|hispanic|latino|arab)\s+people\s+(?:are|tend\s+to|always|never)\b",
73
+ r"\brace\s+(?:card|baiting)\b",
74
+ r"\b(?:act|sound|look)\s+(?:white|black|asian|hispanic)\b",
75
+ r"\b(?:being|that's|stop\s+being|don't\s+be)\s+racist\b", # Pattern for "racist" usage
76
+ ],
77
+ "keywords": [
78
+ "racial slur"
79
+ ], # Removed "racist" keyword to avoid false political matches
80
+ },
81
+ "ethnicity": {
82
+ "patterns": [
83
+ r"\b(?:all|most|typical)\s+\w+(?:ese|ian|ish)\s+people\b",
84
+ r"\bforeigner",
85
+ r"\b(?:speak|accent|look)\s+like\s+(?:an?\s+)?\w+(?:ese|ian|ish)\b",
86
+ ],
87
+ "keywords": ["xenophobic", "ethnic stereotype", "ethnicity"],
88
+ },
89
+ "religion": {
90
+ "patterns": [
91
+ r"\b(?:muslims?|christians?|jews?|hindus?|buddhists?|atheists?)\s+(?:are|always|never|tend\s+to)\b",
92
+ r"\bsharia\s+law\b",
93
+ r"\breligious\s+extremis",
94
+ ],
95
+ "keywords": ["islamophobic", "anti-semitic", "religious bias", "religious stereotype"],
96
+ },
97
+ "age": {
98
+ "patterns": [
99
+ r"\b(?:old|elderly|senior)\s+people\s+(?:are|can't|cannot|shouldn't)\b",
100
+ r"\b(?:millennials?|gen\s*z|zoomers?|boomers?)\s+(?:are|always|never)\b",
101
+ r"\b(?:young)\s+people\s+(?:are|always|never)\b",
102
+ r"\btoo\s+(?:old|young)\s+(?:to|for)\b",
103
+ r"\b(?:act|look)\s+(?:your|their)\s+age\b",
104
+ ],
105
+ "keywords": ["ageist", "ageism", "age discrimination", "age stereotype"],
106
+ },
107
+ "disability": {
108
+ "patterns": [
109
+ r"\b(?:disabled|handicapped|crippled|retarded)\s+people\s+(?:are|can't|cannot)\b",
110
+ r"\bwheelchair\s+bound\b",
111
+ r"\bsuffer(?:s|ing)\s+from\s+(?:autism|disability)\b",
112
+ r"\b(?:special|differently)\s+(?:needs|abled)\b",
113
+ ],
114
+ "keywords": ["ableist", "ableism", "disability discrimination"],
115
+ },
116
+ "sexual_orientation": {
117
+ "patterns": [
118
+ r"\b(?:gay|lesbian|homosexual|bisexual|transgender|lgbt)\s+people\s+(?:are|always|never)\b",
119
+ r"\b(?:gay|lesbian|homosexual|bisexual|transgender|lgbt)\s+(?:agenda|lifestyle)\b",
120
+ r"\bchoose\s+to\s+be\s+(?:gay|homosexual|transgender)\b",
121
+ r"\b(?:real|normal|natural)\s+(?:man|woman|gender)\b",
122
+ r"\b(?:he-she|it|tranny)\b",
123
+ ],
124
+ "keywords": [
125
+ "homophobic",
126
+ "transphobic",
127
+ "lgbtq discrimination",
128
+ "sexual orientation bias",
129
+ ],
130
+ },
131
+ "political": {
132
+ "patterns": [
133
+ r"\b(?:liberals?|conservatives?|democrats?|republicans?)\s+(?:are|were)\s+(?:all\s+)?(?:\w+)", # "Conservatives are all racists"
134
+ r"\b(?:all|most|typical)\s+(?:liberals?|conservatives?|democrats?|republicans?)\s+(?:are|always|never)\b",
135
+ r"\b(?:libtard|conservatard|trumptard)\b",
136
+ r"\b(?:left|right)\s+wing\s+(?:nut|extremist|radical)\b",
137
+ r"\b(?:fake|biased|lying)\s+(?:media|news)\b",
138
+ ],
139
+ "keywords": [], # Removed broad keywords
140
+ },
141
+ }
142
+
143
+ def __init__(self, config: BiasConfig):
144
+ """Initialize bias detector.
145
+
146
+ Args:
147
+ config: Bias detection configuration
148
+ """
149
+ self.config = config
150
+ self._fairlearn_available = False
151
+ self._check_availability()
152
+
153
+ def _check_availability(self):
154
+ """Check if optional ML libraries are available."""
155
+ if self.config.use_fairlearn:
156
+ try:
157
+ import fairlearn # noqa: F401
158
+ import sklearn # noqa: F401
159
+
160
+ self._fairlearn_available = True
161
+ logger.info("Fairlearn ML-based bias detection available")
162
+ except ImportError as e:
163
+ logger.warning(
164
+ "Fairlearn not available: %s. Install with: pip install fairlearn scikit-learn",
165
+ e,
166
+ )
167
+ self._fairlearn_available = False
168
+
169
+ def is_available(self) -> bool:
170
+ """Check if bias detector is available.
171
+
172
+ Returns:
173
+ bool: Always True (pattern-based detection always available)
174
+ """
175
+ return True # Pattern-based detection is always available
176
+
177
+ def detect(self, text: str) -> BiasDetectionResult:
178
+ """Detect bias in text.
179
+
180
+ Args:
181
+ text: Text to analyze
182
+
183
+ Returns:
184
+ BiasDetectionResult: Detection results
185
+ """
186
+ if not self.config.enabled:
187
+ return BiasDetectionResult(has_bias=False, original_text=text)
188
+
189
+ try:
190
+ # Perform pattern-based detection
191
+ bias_scores = {}
192
+ patterns_matched = {}
193
+
194
+ for bias_type in self.config.bias_types:
195
+ if bias_type not in self.BIAS_PATTERNS:
196
+ continue
197
+
198
+ score, matched = self._check_bias_type(text, bias_type)
199
+ bias_scores[bias_type] = score
200
+ if matched:
201
+ patterns_matched[bias_type] = matched
202
+
203
+ # Determine which biases exceed threshold
204
+ detected_biases = [
205
+ bias_type
206
+ for bias_type, score in bias_scores.items()
207
+ if score >= self.config.threshold
208
+ ]
209
+
210
+ has_bias = len(detected_biases) > 0
211
+ max_score = max(bias_scores.values(), default=0.0)
212
+
213
+ return BiasDetectionResult(
214
+ has_bias=has_bias,
215
+ bias_scores=bias_scores,
216
+ max_score=max_score,
217
+ detected_biases=detected_biases,
218
+ patterns_matched=patterns_matched,
219
+ original_text=text,
220
+ )
221
+
222
+ except Exception as e:
223
+ logger.error("Error detecting bias: %s", e, exc_info=True)
224
+ return BiasDetectionResult(has_bias=False, original_text=text)
225
+
226
+ def _check_bias_type(self, text: str, bias_type: str) -> tuple[float, List[str]]:
227
+ """Check for a specific type of bias.
228
+
229
+ Args:
230
+ text: Text to analyze
231
+ bias_type: Type of bias to check
232
+
233
+ Returns:
234
+ tuple: (score, matched_patterns)
235
+ """
236
+ patterns_config = self.BIAS_PATTERNS.get(bias_type, {})
237
+ patterns = patterns_config.get("patterns", [])
238
+ keywords = patterns_config.get("keywords", [])
239
+
240
+ matched = []
241
+ text_lower = text.lower()
242
+
243
+ # Handle leetspeak/character substitutions to detect obfuscated bias
244
+ # @ -> a, $ -> s, 3 -> e, 1 -> i, 0 -> o
245
+ deobfuscated_text = text
246
+ substitutions = {"@": "a", "$": "s", "3": "e", "1": "i", "0": "o"}
247
+ for char, replacement in substitutions.items():
248
+ deobfuscated_text = deobfuscated_text.replace(char, replacement)
249
+
250
+ # Normalize text by removing remaining special characters but keeping spaces
251
+ # This helps patterns match even with # etc.
252
+ normalized_text = re.sub(r"[^\w\s]", " ", deobfuscated_text)
253
+
254
+ # Check regex patterns on original, deobfuscated, and normalized text
255
+ for pattern in patterns:
256
+ # Try on original text first
257
+ matches = list(re.finditer(pattern, text, re.IGNORECASE))
258
+ if not matches:
259
+ # Try on deobfuscated text
260
+ matches = list(re.finditer(pattern, deobfuscated_text, re.IGNORECASE))
261
+ if not matches:
262
+ # Try on normalized text
263
+ matches = list(re.finditer(pattern, normalized_text, re.IGNORECASE))
264
+ for match in matches:
265
+ matched.append(match.group())
266
+
267
+ # Check keywords - but only as standalone words to avoid false positives
268
+ for keyword in keywords:
269
+ # Use word boundary matching to avoid substring matches
270
+ keyword_pattern = r"\b" + re.escape(keyword) + r"\b"
271
+ if re.search(keyword_pattern, text_lower):
272
+ matched.append(keyword)
273
+
274
+ # Calculate score based on matches
275
+ if not matched:
276
+ return 0.0, []
277
+
278
+ # Score calculation:
279
+ # - Base score of 0.3 for any match
280
+ # - Additional 0.1 per unique match, capped at 0.9
281
+ base_score = 0.3
282
+ match_score = min(len(set(matched)) * 0.1, 0.6)
283
+ total_score = min(base_score + match_score, 0.9)
284
+
285
+ return total_score, matched
286
+
287
+ def analyze_batch(self, texts: List[str]) -> List[BiasDetectionResult]:
288
+ """Analyze multiple texts for bias.
289
+
290
+ Args:
291
+ texts: List of texts to analyze
292
+
293
+ Returns:
294
+ List[BiasDetectionResult]: Detection results for each text
295
+ """
296
+ results = []
297
+ for text in texts:
298
+ results.append(self.detect(text))
299
+ return results
300
+
301
+ def get_statistics(self, results: List[BiasDetectionResult]) -> Dict[str, Any]:
302
+ """Get statistics from multiple detection results.
303
+
304
+ Args:
305
+ results: List of detection results
306
+
307
+ Returns:
308
+ Dict[str, Any]: Statistics including bias rates, type distribution
309
+ """
310
+ total_biased = sum(1 for r in results if r.has_bias)
311
+
312
+ # Aggregate bias type counts
313
+ bias_type_counts: Dict[str, int] = {}
314
+ for result in results:
315
+ for bias_type in result.detected_biases:
316
+ bias_type_counts[bias_type] = bias_type_counts.get(bias_type, 0) + 1
317
+
318
+ # Calculate average scores by type
319
+ avg_scores: Dict[str, float] = {}
320
+ for bias_type in self.config.bias_types:
321
+ scores = [
322
+ r.bias_scores.get(bias_type, 0.0) for r in results if r.bias_scores.get(bias_type)
323
+ ]
324
+ avg_scores[bias_type] = sum(scores) / len(scores) if scores else 0.0
325
+
326
+ # Calculate max scores by type
327
+ max_scores: Dict[str, float] = {}
328
+ for bias_type in self.config.bias_types:
329
+ scores = [
330
+ r.bias_scores.get(bias_type, 0.0) for r in results if r.bias_scores.get(bias_type)
331
+ ]
332
+ max_scores[bias_type] = max(scores, default=0.0)
333
+
334
+ return {
335
+ "total_texts_analyzed": len(results),
336
+ "biased_texts_count": total_biased,
337
+ "bias_rate": total_biased / len(results) if results else 0.0,
338
+ "bias_type_counts": bias_type_counts,
339
+ "average_scores": avg_scores,
340
+ "max_scores": max_scores,
341
+ "most_common_bias": (
342
+ max(bias_type_counts.items(), key=lambda x: x[1])[0] if bias_type_counts else None
343
+ ),
344
+ }
345
+
346
+ def get_sensitive_attributes(self) -> Set[str]:
347
+ """Get configured sensitive attributes for bias checking.
348
+
349
+ Returns:
350
+ Set[str]: Sensitive attributes from config
351
+ """
352
+ if self.config.sensitive_attributes:
353
+ return set(self.config.sensitive_attributes)
354
+
355
+ # Default sensitive attributes
356
+ return {
357
+ "gender",
358
+ "race",
359
+ "ethnicity",
360
+ "age",
361
+ "religion",
362
+ "disability",
363
+ "sexual_orientation",
364
+ }