Integrates spamAuditLogger into SMSClassifierService.classify() and CallAnalysisService.analyzeCall(). Each decision logs: - Classification type (sms/call), phone hash, decision, confidence - Feature flags active at time of classification - Decision rationale (feature list for SMS, reason codes for calls) Audit entries are queryable via spamAuditLogger.getEntries() with filters for type, decision, date range, and limit. Summary stats available via getSummary(). Co-Authored-By: Paperclip <noreply@paperclip.ing>
119 lines
3.2 KiB
TypeScript
119 lines
3.2 KiB
TypeScript
import { createHash } from 'crypto';
|
|
|
|
export type AuditClassificationType = 'sms' | 'call';
|
|
|
|
export interface AuditClassificationEntry {
|
|
id: string;
|
|
timestamp: string;
|
|
type: AuditClassificationType;
|
|
phoneNumberHash: string;
|
|
decision: 'spam' | 'ham' | 'block' | 'flag' | 'allow';
|
|
confidence: number;
|
|
reasons: string[];
|
|
featureFlags: Record<string, boolean>;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
const MAX_AUDIT_LOG_SIZE = 10_000;
|
|
|
|
class AuditLogger {
|
|
private entries: AuditClassificationEntry[] = [];
|
|
|
|
logClassification(entry: Omit<AuditClassificationEntry, 'id' | 'timestamp'>): AuditClassificationEntry {
|
|
const record: AuditClassificationEntry = {
|
|
id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
timestamp: new Date().toISOString(),
|
|
...entry,
|
|
};
|
|
|
|
this.entries.push(record);
|
|
|
|
if (this.entries.length > MAX_AUDIT_LOG_SIZE) {
|
|
this.entries.shift();
|
|
}
|
|
|
|
console.log(
|
|
`[SpamShield:Audit] type=${record.type} decision=${record.decision} ` +
|
|
`confidence=${record.confidence.toFixed(3)} reasons=${record.reasons.join(',') || 'none'} ` +
|
|
`phoneHash=${record.phoneNumberHash}`
|
|
);
|
|
|
|
return record;
|
|
}
|
|
|
|
getEntries(
|
|
filters?: {
|
|
type?: AuditClassificationType;
|
|
decision?: string;
|
|
startDate?: Date;
|
|
endDate?: Date;
|
|
limit?: number;
|
|
}
|
|
): AuditClassificationEntry[] {
|
|
let results = this.entries;
|
|
|
|
if (filters?.type) {
|
|
results = results.filter(e => e.type === filters.type);
|
|
}
|
|
|
|
if (filters?.decision) {
|
|
results = results.filter(e => e.decision === filters.decision);
|
|
}
|
|
|
|
if (filters?.startDate) {
|
|
results = results.filter(e => new Date(e.timestamp) >= filters.startDate!);
|
|
}
|
|
|
|
if (filters?.endDate) {
|
|
results = results.filter(e => new Date(e.timestamp) <= filters.endDate!);
|
|
}
|
|
|
|
if (filters?.limit) {
|
|
results = results.slice(-filters.limit);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
getSummary(): {
|
|
totalEntries: number;
|
|
spamCount: number;
|
|
hamCount: number;
|
|
blockCount: number;
|
|
flagCount: number;
|
|
allowCount: number;
|
|
avgConfidence: number;
|
|
} {
|
|
const spamCount = this.entries.filter(e => e.decision === 'spam' || e.decision === 'block').length;
|
|
const hamCount = this.entries.filter(e => e.decision === 'ham' || e.decision === 'allow').length;
|
|
const blockCount = this.entries.filter(e => e.decision === 'block').length;
|
|
const flagCount = this.entries.filter(e => e.decision === 'flag').length;
|
|
const allowCount = this.entries.filter(e => e.decision === 'allow').length;
|
|
const avgConfidence =
|
|
this.entries.length > 0
|
|
? this.entries.reduce((s, e) => s + e.confidence, 0) / this.entries.length
|
|
: 0;
|
|
|
|
return {
|
|
totalEntries: this.entries.length,
|
|
spamCount,
|
|
hamCount,
|
|
blockCount,
|
|
flagCount,
|
|
allowCount,
|
|
avgConfidence: Math.round(avgConfidence * 1000) / 1000,
|
|
};
|
|
}
|
|
|
|
clear(): void {
|
|
this.entries = [];
|
|
}
|
|
}
|
|
|
|
export const spamAuditLogger = new AuditLogger();
|
|
|
|
export function hashPhoneNumber(phoneNumber: string): string {
|
|
const hash = createHash('sha256').update(phoneNumber.trim()).digest('hex');
|
|
return `sha256_${hash}`;
|
|
}
|