- Implement WebSocket endpoint (/ws/fraud-results) in API for real-time fraud detection updates - Add Redis pub/sub listener in API to broadcast consumer results to connected WebSocket clients - Update consumer to publish fraud detection results to Redis 'fraud_results' channel with transaction details - Add WebSocket connection logic in frontend with auto-reconnect (exponential backoff) - Display real-time consumer status panel with fraud score color coding and decision status - Implement client-side transaction results pagination (10/25/50 items per page) - Add pagination controls: Previous/Next buttons, page indicator, page size selector - Newest-first transaction sorting (DESC by timestamp) - Add comprehensive styling for consumer panel and pagination controls - All frontend components tested and builds successfully Co-authored-by: Copilot <[email protected]>
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
"""
|
|
Decision Engine & Circuit Breaker
|
|
|
|
Responsibilities:
|
|
- Combine model scores into final decision
|
|
- Apply circuit breaker pattern for graceful degradation
|
|
- Store decision results in Redis
|
|
|
|
Academic Point:
|
|
The circuit breaker is a critical resilience pattern. If the deep analysis (Layer 2)
|
|
becomes slow or unavailable, the system can gracefully degrade to using only Layer 1
|
|
scores, rather than failing entirely. This ensures uptime even during partial outages.
|
|
"""
|
|
|
|
import redis
|
|
import logging
|
|
import os
|
|
from pybreaker import CircuitBreaker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class DecisionEngine:
|
|
"""Makes final approve/block decisions with resilience patterns"""
|
|
|
|
def __init__(self, redis_host: str = None, redis_port: int = None):
|
|
# Support environment variables for Docker deployments
|
|
if redis_host is None:
|
|
redis_host = os.getenv("REDIS_HOST", "localhost")
|
|
if redis_port is None:
|
|
redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
|
|
|
self.redis_client = redis.Redis(
|
|
host=redis_host,
|
|
port=redis_port,
|
|
decode_responses=True
|
|
)
|
|
|
|
# Circuit breaker for Layer 2 calls
|
|
# Fails after 3 consecutive errors, resets after 30 seconds
|
|
self.layer2_breaker = CircuitBreaker(
|
|
fail_max=3,
|
|
reset_timeout=30,
|
|
listeners=[] # Could add monitoring here
|
|
)
|
|
|
|
logger.info(
|
|
"[DECISION ENGINE] Initialized with circuit breaker: "
|
|
"fail_max=3, reset_timeout=30s"
|
|
)
|
|
|
|
def make_decision(self,
|
|
transaction_id: str,
|
|
layer1_score: float,
|
|
layer2_scorer=None,
|
|
features=None) -> str:
|
|
"""
|
|
Make final decision: "approved" or "blocked"
|
|
|
|
Logic:
|
|
1. If Layer 1 score < 0.4: Approve immediately (fast path)
|
|
2. If Layer 1 score >= 0.4: Try Layer 2 with circuit breaker
|
|
- If Layer 2 available: Use Layer 2 score (threshold: 0.7)
|
|
- If Layer 2 fails: Fall back to conservative Layer 1 estimate
|
|
"""
|
|
|
|
logger.info(f"\n[DECISION ENGINE] Processing transaction {transaction_id}")
|
|
|
|
# Fast path: low risk
|
|
if layer1_score < 0.4:
|
|
decision = "approved"
|
|
logger.info(
|
|
f"[DECISION] ✓ APPROVED (Layer 1 score {layer1_score:.4f} < 0.4 threshold)"
|
|
)
|
|
return decision
|
|
|
|
# Suspicious: try Layer 2
|
|
logger.info(f"[DECISION] Suspicious score from Layer 1 ({layer1_score:.4f})")
|
|
logger.info("[DECISION] Attempting Layer 2 deep analysis...")
|
|
|
|
if layer2_scorer is None or features is None:
|
|
logger.warning("[DECISION] Layer 2 scorer not provided, using Layer 1 only")
|
|
decision = "approved" if layer1_score < 0.5 else "blocked"
|
|
return decision
|
|
|
|
# Try Layer 2 with circuit breaker
|
|
try:
|
|
@self.layer2_breaker
|
|
def run_layer2():
|
|
return layer2_scorer.score_layer2(features)
|
|
|
|
layer2_score = run_layer2()
|
|
|
|
# Layer 2 threshold
|
|
if layer2_score > 0.7:
|
|
decision = "blocked"
|
|
logger.info(
|
|
f"[DECISION] ✗ BLOCKED (Layer 2 score {layer2_score:.4f} > 0.7 threshold)"
|
|
)
|
|
else:
|
|
decision = "approved"
|
|
logger.info(
|
|
f"[DECISION] ✓ APPROVED (Layer 2 score {layer2_score:.4f} <= 0.7 threshold)"
|
|
)
|
|
|
|
except Exception as e:
|
|
# Circuit breaker is open or Layer 2 failed
|
|
logger.warning(f"[CIRCUIT BREAKER] ⚠ Layer 2 unavailable: {str(e)}")
|
|
logger.warning("[CIRCUIT BREAKER] Falling back to conservative Layer 1 estimate")
|
|
|
|
# Conservative fallback: if Layer 1 is uncertain, block
|
|
if layer1_score > 0.6:
|
|
decision = "blocked"
|
|
logger.info(
|
|
f"[DECISION] ✗ BLOCKED (Layer 2 failed, Layer 1 score {layer1_score:.4f} too high)"
|
|
)
|
|
else:
|
|
decision = "approved"
|
|
logger.info(
|
|
f"[DECISION] ✓ APPROVED (Layer 2 failed, Layer 1 score {layer1_score:.4f} acceptable)"
|
|
)
|
|
|
|
return decision
|
|
|
|
def store_result(self, transaction_id: str, decision: str,
|
|
layer1_score: float, layer2_score: float = None,
|
|
transaction_data: dict = None) -> None:
|
|
"""Store decision result in Redis and publish to pub/sub channel"""
|
|
import json
|
|
from datetime import datetime
|
|
|
|
key = f"result:{transaction_id}"
|
|
value = {
|
|
"decision": decision,
|
|
"layer1_score": round(layer1_score, 4),
|
|
}
|
|
if layer2_score is not None:
|
|
value["layer2_score"] = round(layer2_score, 4)
|
|
|
|
# Store with 1-hour TTL
|
|
self.redis_client.setex(key, 3600, str(value))
|
|
|
|
logger.info(f"[RESULT STORAGE] Stored decision in Redis: {key} = {decision}")
|
|
|
|
# Publish to pub/sub channel for real-time WebSocket updates
|
|
try:
|
|
# Build message with transaction details for frontend
|
|
message = {
|
|
"transaction_id": transaction_id,
|
|
"decision": decision,
|
|
"fraud_score": round(layer2_score, 4) if layer2_score is not None else round(layer1_score, 4),
|
|
"layer1_score": round(layer1_score, 4),
|
|
"timestamp": datetime.utcnow().isoformat() + "Z"
|
|
}
|
|
|
|
# Add transaction data if provided (amount, merchant)
|
|
if transaction_data:
|
|
message["amount"] = transaction_data.get("amount")
|
|
message["merchant"] = transaction_data.get("merchant")
|
|
|
|
# Publish to Redis pub/sub channel
|
|
self.redis_client.publish("fraud_results", json.dumps(message))
|
|
logger.info(f"[RESULT STORAGE] Published result to pub/sub channel: {transaction_id[:8]}... → {decision}")
|
|
except Exception as e:
|
|
logger.error(f"[RESULT STORAGE] Failed to publish to pub/sub: {str(e)}")
|