fix: reconcile caller aggregates and strengthen evidence handling
This commit is contained in:
@@ -15,6 +15,7 @@ export interface AnalyticsLeg {
|
||||
event: AnalyticsEvent; first180At: number | null; first183At: number | null;
|
||||
acceptedAt: number | null; endedAt: number | null; finalCode: number;
|
||||
talkMs: number; durationAt: number; durationFinal: boolean; unknown: boolean;
|
||||
coverageGap?: boolean; durationAuthority?: number;
|
||||
}
|
||||
export interface AnalyticsCall { start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>; }
|
||||
export interface AnalyticsCounts {
|
||||
@@ -67,26 +68,28 @@ export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent)
|
||||
event, first180At: null, first183At: null, acceptedAt: null, endedAt: null,
|
||||
finalCode: 0, talkMs: 0, durationAt: 0, durationFinal: false, unknown: event.kind !== 'ATTEMPT'
|
||||
};
|
||||
if (event.kind === 'ATTEMPT') { leg.event = event; leg.unknown = false; }
|
||||
if (event.kind === 'ATTEMPT') { leg.event = event; if (!leg.coverageGap) leg.unknown = false; }
|
||||
if (event.kind === 'PROGRESS' && event.code === 180) leg.first180At = earliest(leg.first180At, event.at);
|
||||
if (event.kind === 'PROGRESS' && event.code === 183) leg.first183At = earliest(leg.first183At, event.at);
|
||||
if (event.kind === 'ACCEPTED' && event.code >= 200 && event.code < 300) leg.acceptedAt = earliest(leg.acceptedAt, event.at);
|
||||
if (event.kind === 'UNKNOWN') leg.unknown = true;
|
||||
if (event.kind === 'UNKNOWN') { leg.unknown = true; leg.coverageGap = true; }
|
||||
if (event.kind === 'END' || event.kind === 'RECONCILE') {
|
||||
if (leg.endedAt === null || event.at >= leg.endedAt) { leg.endedAt = event.at; leg.finalCode = event.code; }
|
||||
// END carries the producer's accumulated phase flags through separate replayable events.
|
||||
if (event.source === 'verified-dialog-final' || event.source === 'platform-final') leg.unknown = false;
|
||||
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null) {
|
||||
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true;
|
||||
if (event.source === 'platform-final' && !event.attemptId && !leg.coverageGap) leg.unknown = false;
|
||||
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null && !leg.durationFinal) {
|
||||
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1;
|
||||
}
|
||||
}
|
||||
if (event.kind === 'ACCEPTED' && leg.endedAt !== null && !leg.durationFinal) {
|
||||
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true;
|
||||
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1;
|
||||
}
|
||||
if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) {
|
||||
const final = event.kind !== 'DURATION';
|
||||
if ((!leg.durationFinal || final) && event.at >= leg.durationAt) {
|
||||
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final;
|
||||
const authority = event.kind === 'RECONCILE' ? 3 : final ? 2 : 0;
|
||||
const priorAuthority = leg.durationAuthority ?? (leg.durationFinal ? 1 : 0);
|
||||
if (authority > priorAuthority || (authority === priorAuthority && event.at >= leg.durationAt)) {
|
||||
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final; leg.durationAuthority = authority;
|
||||
}
|
||||
}
|
||||
result.legs[key] = leg;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CallerAnalyticsStore {
|
||||
const minute = new Date(Math.floor(row.startedAt / 60000) * 60000);
|
||||
const id = analyticsHash([minute.toISOString(), row.view, row.customerId, row.customerGatewayId, row.vendorId, row.vendorGatewayId, row.caller, row.city, row.carrier]);
|
||||
const delta = JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, sign * row[k]])));
|
||||
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${`$.${k}`}`, Prisma.sql`CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED) + ${sign * row[k]}`]));
|
||||
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED) + ${sign * row[k]}`]));
|
||||
await tx.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_minute_buckets
|
||||
(id, started_at, view, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts)
|
||||
VALUES (${id}, ${minute}, ${row.view}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.city}, ${row.carrier}, ${delta})
|
||||
|
||||
@@ -46,4 +46,17 @@ describe('caller analytics business state', () => {
|
||||
it('calculates the three different business rates', () => {
|
||||
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
|
||||
});
|
||||
it('does not erase missing progress evidence with an end event', () => {
|
||||
expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
|
||||
for (const sequence of [[event('UNKNOWN'),event('END')],[event('END'),event('UNKNOWN')]]) {
|
||||
expect(run([event('ATTEMPT'),...sequence,event('ATTEMPT')])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
|
||||
}
|
||||
});
|
||||
it('gives verified final zero priority over a newer provisional observation and delayed END', () => {
|
||||
const provisional=event('DURATION',{at:start+4000,talkMs:3000});
|
||||
const final=event('RECONCILE',{at:start+3000,talkMs:0,source:'verified-dialog-final'});
|
||||
for (const sequence of [[provisional,final],[final,provisional]]) {
|
||||
expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user