Files
Kordant/services/spamshield/src/engine/decision-engine.ts
Michael Freno 3663e5b80a FRE-4517, FRE-4499: Complete SpamShield implementation and billing updates
- 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>
2026-05-01 19:53:19 -04:00

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;
}
}