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.
- genai_otel/__init__.py +132 -0
- genai_otel/__version__.py +34 -0
- genai_otel/auto_instrument.py +602 -0
- genai_otel/cli.py +92 -0
- genai_otel/config.py +333 -0
- genai_otel/cost_calculator.py +467 -0
- genai_otel/cost_enriching_exporter.py +207 -0
- genai_otel/cost_enrichment_processor.py +174 -0
- genai_otel/evaluation/__init__.py +76 -0
- genai_otel/evaluation/bias_detector.py +364 -0
- genai_otel/evaluation/config.py +261 -0
- genai_otel/evaluation/hallucination_detector.py +525 -0
- genai_otel/evaluation/pii_detector.py +356 -0
- genai_otel/evaluation/prompt_injection_detector.py +262 -0
- genai_otel/evaluation/restricted_topics_detector.py +316 -0
- genai_otel/evaluation/span_processor.py +962 -0
- genai_otel/evaluation/toxicity_detector.py +406 -0
- genai_otel/exceptions.py +17 -0
- genai_otel/gpu_metrics.py +516 -0
- genai_otel/instrumentors/__init__.py +71 -0
- genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
- genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
- genai_otel/instrumentors/autogen_instrumentor.py +394 -0
- genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
- genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
- genai_otel/instrumentors/base.py +919 -0
- genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
- genai_otel/instrumentors/cohere_instrumentor.py +140 -0
- genai_otel/instrumentors/crewai_instrumentor.py +311 -0
- genai_otel/instrumentors/dspy_instrumentor.py +661 -0
- genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
- genai_otel/instrumentors/groq_instrumentor.py +106 -0
- genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
- genai_otel/instrumentors/haystack_instrumentor.py +503 -0
- genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
- genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
- genai_otel/instrumentors/instructor_instrumentor.py +425 -0
- genai_otel/instrumentors/langchain_instrumentor.py +340 -0
- genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
- genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
- genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
- genai_otel/instrumentors/ollama_instrumentor.py +197 -0
- genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
- genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
- genai_otel/instrumentors/openai_instrumentor.py +260 -0
- genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
- genai_otel/instrumentors/replicate_instrumentor.py +87 -0
- genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
- genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
- genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
- genai_otel/llm_pricing.json +1676 -0
- genai_otel/logging_config.py +45 -0
- genai_otel/mcp_instrumentors/__init__.py +14 -0
- genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
- genai_otel/mcp_instrumentors/base.py +105 -0
- genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
- genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/manager.py +139 -0
- genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
- genai_otel/metrics.py +148 -0
- genai_otel/py.typed +2 -0
- genai_otel/server_metrics.py +197 -0
- genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
- genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
- genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
- genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
- genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
- 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
|
+
}
|