import { SpamShieldService, ReputationResult } from '../services/spamshield.service'; import { RuleEngine, RuleMatch } from './rule-engine'; import { DEFAULT_REPUTATION_WEIGHT, DEFAULT_RULE_WEIGHT, DEFAULT_BEHAVIORAL_WEIGHT, DEFAULT_USER_HISTORY_WEIGHT, DEFAULT_BLOCK_THRESHOLD, DEFAULT_FLAG_THRESHOLD, DEFAULT_EVALUATION_TIMEOUT, DEFAULT_FALLBACK_DECISION, DEFAULT_FALLBACK_ON_TIMEOUT, SHORT_CALL_SCORE, SHORT_SMS_SCORE, SHORT_CONTENT_SCORE, URGENT_KEYWORD_SCORE, } from '../constants/decision-engine.constants'; export interface CallMetadata { callId: string; startTime: Date; duration?: number; direction: 'inbound' | 'outbound'; callType?: 'voice' | 'video' | 'sms'; carrierInfo?: Record; } export interface SmsContent { messageId: string; body: string; timestamp: Date; direction: 'inbound' | 'outbound'; } export interface UserSpamHistory { phoneNumberHash: string; spamCount: number; hamCount: number; lastSpamReportedAt?: Date; userPreference?: 'block' | 'flag' | 'allow'; } export interface DecisionContext { phoneNumber: string; phoneNumberHash?: string; callMetadata?: CallMetadata; smsContent?: SmsContent; cachedReputation: ReputationResult; ruleMatches: RuleMatch[]; userHistory?: UserSpamHistory; requestId?: string; } export interface DecisionResult { decision: 'BLOCK' | 'FLAG' | 'ALLOW'; confidence: number; reasons: string[]; fallbackDecision: 'BLOCK' | 'FLAG' | 'ALLOW'; scoring: { reputationScore: number; ruleScore: number; behavioralScore: number; userHistoryScore: number; totalScore: number; }; executedAt: Date; requestId?: string; } export interface DecisionEngineConfig { // Scoring weights reputationWeight?: number; ruleWeight?: number; behavioralWeight?: number; userHistoryWeight?: number; // Thresholds blockThreshold?: number; flagThreshold?: number; // Timeouts evaluationTimeout?: number; // Fallback behavior fallbackOnTimeout?: boolean; fallbackDecision?: 'BLOCK' | 'FLAG' | 'ALLOW'; } // Configuration defaults exported from constants module export class DecisionEngine { private readonly config: Required; private readonly reputationService: SpamShieldService; private readonly ruleEngine: RuleEngine; constructor( reputationService: SpamShieldService, ruleEngine: RuleEngine, config?: DecisionEngineConfig ) { this.config = { reputationWeight: config?.reputationWeight ?? DEFAULT_REPUTATION_WEIGHT, ruleWeight: config?.ruleWeight ?? DEFAULT_RULE_WEIGHT, behavioralWeight: config?.behavioralWeight ?? DEFAULT_BEHAVIORAL_WEIGHT, userHistoryWeight: config?.userHistoryWeight ?? DEFAULT_USER_HISTORY_WEIGHT, blockThreshold: config?.blockThreshold ?? DEFAULT_BLOCK_THRESHOLD, flagThreshold: config?.flagThreshold ?? DEFAULT_FLAG_THRESHOLD, evaluationTimeout: config?.evaluationTimeout ?? DEFAULT_EVALUATION_TIMEOUT, fallbackOnTimeout: config?.fallbackOnTimeout ?? DEFAULT_FALLBACK_ON_TIMEOUT, fallbackDecision: config?.fallbackDecision ?? DEFAULT_FALLBACK_DECISION, }; this.reputationService = reputationService; this.ruleEngine = ruleEngine; } async evaluate(context: DecisionContext): Promise { const startTime = Date.now(); const reqId = context.requestId ?? 'unknown'; try { const [reputationScore, ruleScore, behavioralScore, userHistoryScore] = await Promise.all([ this.calculateReputationScore(context.cachedReputation), this.calculateRuleScore(context.ruleMatches), this.calculateBehavioralScore(context), this.calculateUserHistoryScore(context.userHistory), ]); const totalScore = reputationScore * this.config.reputationWeight + ruleScore * this.config.ruleWeight + behavioralScore * this.config.behavioralWeight + userHistoryScore * this.config.userHistoryWeight; const decision = this.applyThresholds(totalScore); const reasons = this.collectReasons( reputationScore, ruleScore, behavioralScore, userHistoryScore, context.ruleMatches ); return { decision, confidence: totalScore, reasons, fallbackDecision: this.config.fallbackDecision, scoring: { reputationScore, ruleScore, behavioralScore, userHistoryScore, totalScore, }, executedAt: new Date(), requestId: reqId, }; } catch (error) { console.error(`[DecisionEngine] [${reqId}] Evaluation error:`, error); if (this.config.fallbackOnTimeout) { return { decision: this.config.fallbackDecision, confidence: 0.5, reasons: ['Fallback decision due to evaluation error'], fallbackDecision: this.config.fallbackDecision, scoring: { reputationScore: 0.5, ruleScore: 0.5, behavioralScore: 0.5, userHistoryScore: 0.5, totalScore: 0.5, }, executedAt: new Date(), requestId: reqId, }; } throw error; } } private async calculateReputationScore(reputation: ReputationResult): Promise { return reputation.score; } private async calculateRuleScore(ruleMatches: RuleMatch[]): Promise { if (ruleMatches.length === 0) { return 0; } const totalScore = ruleMatches.reduce((sum, match) => sum + match.score, 0); return Math.min(totalScore, 1.0); } private async calculateBehavioralScore(context: DecisionContext): Promise { let score = 0; if (context.callMetadata) { const { callMetadata } = context; if (callMetadata.duration && callMetadata.duration < 5) { score += SHORT_CALL_SCORE; } if (callMetadata.callType === 'sms') { score += SHORT_SMS_SCORE; } } if (context.smsContent) { const { smsContent } = context; if (smsContent.body.length < 10) { score += SHORT_CONTENT_SCORE; } if (/\b(URGENT|ACT NOW|LIMITED)\b/i.test(smsContent.body)) { score += URGENT_KEYWORD_SCORE; } } return Math.min(score, 1.0); } private async calculateUserHistoryScore(userHistory?: UserSpamHistory): Promise { if (!userHistory) { return 0.5; } const totalReports = userHistory.spamCount + userHistory.hamCount; if (totalReports === 0) { return 0.5; } const spamRatio = userHistory.spamCount / totalReports; if (userHistory.userPreference) { switch (userHistory.userPreference) { case 'block': return 1.0; case 'flag': return 0.6; case 'allow': return 0.2; } } return spamRatio; } private applyThresholds(score: number): 'BLOCK' | 'FLAG' | 'ALLOW' { if (score >= this.config.blockThreshold) { return 'BLOCK'; } if (score >= this.config.flagThreshold) { return 'FLAG'; } return 'ALLOW'; } private collectReasons( reputationScore: number, ruleScore: number, behavioralScore: number, userHistoryScore: number, ruleMatches: RuleMatch[] ): string[] { const reasons: string[] = []; if (reputationScore > 0.8) { reasons.push(`High reputation spam score: ${reputationScore.toFixed(2)}`); } if (ruleMatches.length > 0) { reasons.push(`Matched ${ruleMatches.length} spam rule(s)`); ruleMatches.forEach(match => { reasons.push(` - ${match.ruleName} (${match.score.toFixed(2)})`); }); } if (behavioralScore > 0.5) { reasons.push(`Suspicious behavioral pattern detected`); } if (userHistoryScore > 0.7) { reasons.push(`User history indicates high spam probability`); } if (reasons.length === 0) { reasons.push('No spam indicators detected'); } return reasons; } getConfig(): Required { return { ...this.config }; } updateConfig(config: Partial): void { this.config.reputationWeight = config.reputationWeight ?? this.config.reputationWeight; this.config.ruleWeight = config.ruleWeight ?? this.config.ruleWeight; this.config.behavioralWeight = config.behavioralWeight ?? this.config.behavioralWeight; this.config.userHistoryWeight = config.userHistoryWeight ?? this.config.userHistoryWeight; this.config.blockThreshold = config.blockThreshold ?? this.config.blockThreshold; this.config.flagThreshold = config.flagThreshold ?? this.config.flagThreshold; this.config.evaluationTimeout = config.evaluationTimeout ?? this.config.evaluationTimeout; this.config.fallbackOnTimeout = config.fallbackOnTimeout ?? this.config.fallbackOnTimeout; this.config.fallbackDecision = config.fallbackDecision ?? this.config.fallbackDecision; } }