- Unified alert types (AlertSource, AlertCategory, CorrelationStatus, EntityType) - NormalizedAlert and CorrelationGroup Prisma models - AlertNormalizer for all 4 services (DarkWatch, SpamShield, VoicePrint, CallAnalysis) - CorrelationEngine with temporal + entity-based correlation detection - CorrelationService orchestrator with dashboard API - Correlation API routes (/api/v1/correlation/*) - Service emitters wired to DarkWatch, SpamShield, VoicePrint - pnpm workspace config for monorepo
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
import prisma from "@shieldai/db";
|
|
import { AudioPreprocessor, AudioFeatures } from "../preprocessor/AudioPreprocessor";
|
|
import { EmbeddingService, EmbeddingOutput } from "../embedding/EmbeddingService";
|
|
import { VoiceEnrollmentService } from "../enrollment/VoiceEnrollmentService";
|
|
import { emitVoicePrintAlert } from "@shieldai/correlation";
|
|
import {
|
|
AnalyzeAudioInput,
|
|
AnalysisJobStatus,
|
|
AnalysisType,
|
|
DetectionVerdict,
|
|
AnalysisResultOutput,
|
|
} from "@shieldai/types";
|
|
|
|
export class AnalysisService {
|
|
private preprocessor: AudioPreprocessor;
|
|
private embeddingService: EmbeddingService;
|
|
private enrollmentService: VoiceEnrollmentService;
|
|
private readonly syntheticThreshold = 0.7;
|
|
private readonly uncertainThreshold = 0.4;
|
|
|
|
constructor() {
|
|
this.preprocessor = new AudioPreprocessor();
|
|
this.embeddingService = new EmbeddingService();
|
|
this.enrollmentService = new VoiceEnrollmentService();
|
|
}
|
|
|
|
async analyze(input: AnalyzeAudioInput, userId: string): Promise<AnalysisResultOutput> {
|
|
const startTime = Date.now();
|
|
|
|
const job = await prisma.analysisJob.create({
|
|
data: {
|
|
userId,
|
|
analysisType: input.analysisType || AnalysisType.SYNTHETIC_DETECTION,
|
|
audioFilePath: `voiceprint/${userId}/${Date.now()}.wav`,
|
|
status: AnalysisJobStatus.RUNNING,
|
|
},
|
|
});
|
|
|
|
try {
|
|
const preprocessed = await this.preprocessor.preprocess(input.audioBuffer, input.sampleRate);
|
|
const features = await this.preprocessor.extractFeatures(preprocessed.audio);
|
|
const embedding = await this.embeddingService.extract(preprocessed.audio);
|
|
|
|
const syntheticScore = await this.classifySynthetic(features, embedding);
|
|
const verdict = this.determineVerdict(syntheticScore);
|
|
const confidence = this.computeConfidence(syntheticScore, verdict);
|
|
|
|
let matchedEnrollmentId: string | undefined;
|
|
let matchedSimilarity: number | undefined;
|
|
|
|
if (input.analysisType === AnalysisType.VOICE_MATCH) {
|
|
const match = await this.enrollmentService.matchVoice(input.audioBuffer, userId);
|
|
if (match) {
|
|
matchedEnrollmentId = match.enrollmentId;
|
|
matchedSimilarity = match.similarity;
|
|
}
|
|
}
|
|
|
|
const processingTimeMs = Date.now() - startTime;
|
|
|
|
const result = await prisma.analysisResult.create({
|
|
data: {
|
|
analysisJobId: job.id,
|
|
syntheticScore,
|
|
verdict,
|
|
confidence,
|
|
processingTimeMs,
|
|
matchedEnrollmentId,
|
|
matchedSimilarity,
|
|
modelVersion: this.embeddingService.getModelVersion(),
|
|
},
|
|
});
|
|
|
|
await prisma.analysisJob.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: AnalysisJobStatus.COMPLETED,
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
if (result.verdict === DetectionVerdict.SYNTHETIC || result.verdict === DetectionVerdict.UNCERTAIN) {
|
|
emitVoicePrintAlert(
|
|
userId,
|
|
job.id,
|
|
result.verdict,
|
|
result.syntheticScore,
|
|
result.confidence,
|
|
result.matchedEnrollmentId || undefined,
|
|
result.matchedSimilarity || undefined,
|
|
input.analysisType || undefined
|
|
).catch((err) => console.error(`[Correlation] VoicePrint emit failed:`, err));
|
|
}
|
|
|
|
return {
|
|
jobId: job.id,
|
|
syntheticScore: result.syntheticScore,
|
|
verdict: result.verdict,
|
|
confidence: result.confidence,
|
|
matchedEnrollmentId: result.matchedEnrollmentId || undefined,
|
|
matchedSimilarity: result.matchedSimilarity || undefined,
|
|
processingTimeMs: result.processingTimeMs,
|
|
modelVersion: result.modelVersion || undefined,
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Analysis failed";
|
|
await prisma.analysisJob.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: AnalysisJobStatus.FAILED,
|
|
errorMessage: message,
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async getResult(jobId: string): Promise<AnalysisResultOutput | null> {
|
|
const job = await prisma.analysisJob.findUnique({
|
|
where: { id: jobId },
|
|
include: { result: true },
|
|
});
|
|
|
|
if (!job || !job.result) return null;
|
|
|
|
const r = job.result;
|
|
return {
|
|
jobId,
|
|
syntheticScore: r.syntheticScore,
|
|
verdict: r.verdict,
|
|
confidence: r.confidence,
|
|
matchedEnrollmentId: r.matchedEnrollmentId || undefined,
|
|
matchedSimilarity: r.matchedSimilarity || undefined,
|
|
processingTimeMs: r.processingTimeMs,
|
|
modelVersion: r.modelVersion || undefined,
|
|
};
|
|
}
|
|
|
|
async getUserResults(userId: string, limit: number = 20): Promise<AnalysisResultOutput[]> {
|
|
const jobs = await prisma.analysisJob.findMany({
|
|
where: { userId, status: AnalysisJobStatus.COMPLETED },
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
include: { result: true },
|
|
});
|
|
|
|
return jobs
|
|
.filter((j) => j.result)
|
|
.map((j) => {
|
|
const r = j.result!;
|
|
return {
|
|
jobId: j.id,
|
|
syntheticScore: r.syntheticScore,
|
|
verdict: r.verdict,
|
|
confidence: r.confidence,
|
|
matchedEnrollmentId: r.matchedEnrollmentId || undefined,
|
|
matchedSimilarity: r.matchedSimilarity || undefined,
|
|
processingTimeMs: r.processingTimeMs,
|
|
modelVersion: r.modelVersion || undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
private async classifySynthetic(
|
|
features: AudioFeatures,
|
|
embedding: EmbeddingOutput
|
|
): Promise<number> {
|
|
const modelScore = await this.embeddingService.classify(embedding.vector);
|
|
|
|
const zcrAnomaly = Math.abs(features.zeroCrossingRate - 0.05) / 0.05;
|
|
const spectralAnomaly = Math.abs(features.spectralCentroid - 500) / 500;
|
|
|
|
const artifactScore = Math.min(1, (zcrAnomaly + spectralAnomaly) / 4);
|
|
|
|
return 0.7 * modelScore + 0.3 * artifactScore;
|
|
}
|
|
|
|
private determineVerdict(score: number): DetectionVerdict {
|
|
if (score >= this.syntheticThreshold) return DetectionVerdict.SYNTHETIC;
|
|
if (score <= this.uncertainThreshold) return DetectionVerdict.NATURAL;
|
|
return DetectionVerdict.UNCERTAIN;
|
|
}
|
|
|
|
private computeConfidence(score: number, verdict: DetectionVerdict): number {
|
|
if (verdict === DetectionVerdict.SYNTHETIC) {
|
|
return Math.min(1, (score - this.syntheticThreshold) / (1 - this.syntheticThreshold));
|
|
}
|
|
if (verdict === DetectionVerdict.NATURAL) {
|
|
return Math.min(1, (this.uncertainThreshold - score) / this.uncertainThreshold);
|
|
}
|
|
return 1 - Math.min(
|
|
Math.abs(score - this.uncertainThreshold) / (this.syntheticThreshold - this.uncertainThreshold),
|
|
1
|
|
);
|
|
}
|
|
}
|