Files
Kordant/services/voiceprint/src/analysis/AnalysisService.ts
Senior Engineer 03276dde2d Add cross-service alert correlation system FRE-4500
- 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
2026-05-02 01:10:44 -04:00

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