fix: reconcile caller aggregates and strengthen evidence handling

This commit is contained in:
hectorzhao
2026-08-31 17:52:11 +08:00
parent d78616561e
commit 2a3dcb0c68
8 changed files with 85 additions and 17 deletions
@@ -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});
}
});
});