- SpamFeedback table migration with timestamp index - Real-time interception engine completion - Billing service enhancements - Classifier and rule engine updates Co-Authored-By: Paperclip <noreply@paperclip.ing>
309 lines
8.8 KiB
TypeScript
309 lines
8.8 KiB
TypeScript
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<string, any>;
|
|
}
|
|
|
|
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<DecisionEngineConfig>;
|
|
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<DecisionResult> {
|
|
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<number> {
|
|
return reputation.score;
|
|
}
|
|
|
|
private async calculateRuleScore(ruleMatches: RuleMatch[]): Promise<number> {
|
|
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<number> {
|
|
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<number> {
|
|
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<DecisionEngineConfig> {
|
|
return { ...this.config };
|
|
}
|
|
|
|
updateConfig(config: Partial<DecisionEngineConfig>): 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;
|
|
}
|
|
}
|