""" 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)}")