5 Commits

Author SHA1 Message Date
34eae1c19a merge: hardening/apr-week2 → master (v0.13-ai-coaching)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
AI coaching pipeline + sidecar hardening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:47:37 +05:30
Kartik Datrika
1dd8413297 Revert "AI Summary not showing appointments fix."
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This reverts commit 973614749b.
2026-04-16 14:54:20 +05:30
Kartik Datrika
7d8424b446 Revert "AI Summary not showing appointments fix."
This reverts commit 55b8680923.
2026-04-16 14:54:17 +05:30
Kartik Datrika
55b8680923 AI Summary not showing appointments fix. 2026-04-16 12:50:33 +05:30
Kartik Datrika
973614749b AI Summary not showing appointments fix. 2026-04-16 11:36:10 +05:30
5 changed files with 74 additions and 128 deletions

View File

@@ -78,13 +78,6 @@ export class CallerContextService {
return ctx;
}
async invalidateCache(leadId: string): Promise<void> {
if (!leadId) return;
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
await this.session.deleteCache(cacheKey).catch(() => {});
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
}
// Fire-and-forget pre-warm — called from caller resolution
// so the cache is hot when the AI stream fires seconds later.
prewarm(leadId: string, patientId: string, auth: string): void {
@@ -96,34 +89,19 @@ export class CallerContextService {
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
try {
// Step 1: Fetch lead first to get the authoritative patientId
const leadData = await this.platform.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${leadId}" } }) {
id contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
source status interestedService
aiSummary contactAttempts lastContacted
utmCampaign patientId
} }`,
undefined, auth,
);
const lead = leadData?.lead;
if (!lead) return null;
// Use Lead's patientId as authoritative source — the input
// param may be empty if caller resolution just linked them.
const resolvedPatientId = patientId || lead.patientId || '';
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
// Step 2: Fetch appointments, calls, activities in parallel
// using the resolved patientId from the Lead record.
const [appointmentsData, callsData, activitiesData] = await Promise.all([
resolvedPatientId ? this.platform.queryWithAuth<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
this.platform.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${leadId}" } }) {
id contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
source status interestedService
aiSummary contactAttempts lastContacted
utmCampaign patientId
} }`,
undefined, auth,
),
patientId ? this.platform.queryWithAuth<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
scheduledAt status doctorName department reasonForVisit
} } } }`,
undefined, auth,
@@ -142,6 +120,12 @@ export class CallerContextService {
),
]);
const lead = leadData?.lead;
if (!lead) return null;
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
startedAt: e.node.startedAt,
@@ -164,7 +148,7 @@ export class CallerContextService {
return {
leadId,
patientId: resolvedPatientId,
patientId: patientId || lead.patientId || '',
name: `${firstName} ${lastName}`.trim() || 'Unknown',
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
isNew: false,

View File

@@ -33,13 +33,4 @@ export class CallerResolutionController {
return result;
}
@Post('invalidate-context')
async invalidateContext(@Body('leadId') leadId: string) {
if (!leadId) {
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
}
await this.callerContext.invalidateCache(leadId);
return { status: 'ok' };
}
}

View File

@@ -156,13 +156,11 @@ export class OzonetelAgentController {
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
}
// Create call record at dispose time for ALL answered calls
// (inbound + outbound). The dispose endpoint fires BEFORE the
// CDR webhook, so creating here gives us the correct agent-side
// UCID and the agent's chosen disposition immediately. The webhook
// arrives ~5s later and enriches with recording URL + chain name.
if (body.callerPhone) {
const isInbound = body.direction !== 'OUTBOUND';
// Create call record for outbound calls. Inbound calls are
// created by the webhook — but we skip outbound in the webhook
// (they're not "missed calls"). So the dispose endpoint is the
// only place that creates the call record for outbound dials.
if (body.direction === 'OUTBOUND' && body.callerPhone) {
try {
const durationSec = body.durationSec ?? 0;
const endedAt = new Date().toISOString();
@@ -170,8 +168,8 @@ export class OzonetelAgentController {
? new Date(Date.now() - durationSec * 1000).toISOString()
: endedAt;
const callData: Record<string, any> = {
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
name: `Outbound — ${body.callerPhone}`,
direction: 'OUTBOUND',
callStatus: 'COMPLETED',
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
agentName: agentId,
@@ -198,7 +196,7 @@ export class OzonetelAgentController {
{ data: callData },
`Bearer ${apiKey}`,
);
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
const callId = result.createCall.id;
@@ -280,9 +278,33 @@ export class OzonetelAgentController {
}
}
// Inbound disposition is now handled by the call record creation
// above — the dispose endpoint creates the record with the correct
// disposition. No separate update-by-UCID needed.
// Update disposition on answered inbound calls. The webhook creates
// the Call record with the Ozonetel default disposition ("General
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
// agent has submitted their actual disposition, write it back to the
// platform Call record by matching on UCID.
//
// Skipped for outbound (already created with correct disposition
// above) and for missed-call callbacks (handled in the block above).
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
try {
const callData = await this.platform.query<any>(
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
);
const callId = callData?.calls?.edges?.[0]?.node?.id;
if (callId) {
await this.platform.query<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: callId, data: { disposition: body.disposition } },
);
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
} else {
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
}
} catch (err: any) {
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
}
}
// Auto-assign next missed call to this agent
try {

View File

@@ -35,8 +35,6 @@ export class SupervisorService implements OnModuleInit {
private readonly activeCalls = new Map<string, ActiveCall>();
private readonly agentStates = new Map<string, AgentStateEntry>();
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
private readonly ucidMap = new Map<string, string>();
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
// Worklist update stream — emitted when a missed call is created or
@@ -80,14 +78,9 @@ export class SupervisorService implements OnModuleInit {
}
}
resolveAgentUcid(monitorUcid: string): string | null {
return this.ucidMap.get(monitorUcid) ?? null;
}
handleCallEvent(event: any) {
const action = event.action;
const ucid = event.ucid ?? event.monitorUCID;
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
const agentId = event.agent_id ?? event.agentID;
const callerNumber = event.caller_id ?? event.callerID;
const callType = event.call_type ?? event.Type;
@@ -96,12 +89,6 @@ export class SupervisorService implements OnModuleInit {
if (!ucid) return;
if (monitorUcid && ucid !== monitorUcid) {
this.ucidMap.set(monitorUcid, ucid);
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
}
if (action === 'Answered' || action === 'Calling') {
// Don't show calls for offline agents (ghost calls)
const agentState = this.agentStates.get(agentId);

View File

@@ -50,15 +50,7 @@ export class MissedCallWebhookController {
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
const agentName = payload.AgentName ?? null;
const recordingUrl = payload.AudioFile ?? null;
const monitorUcid = payload.monitorUCID ?? null;
// Resolve agent-side UCID from real-time event mapping.
// The dispose endpoint creates Call records with the agent UCID;
// this lets us find and enrich that record instead of duplicating.
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
const ucid = agentUcid ?? monitorUcid;
if (agentUcid) {
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
}
const ucid = payload.monitorUCID ?? null;
const disposition = payload.Disposition ?? null;
const hangupBy = payload.HangupBy ?? null;
@@ -117,54 +109,24 @@ export class MissedCallWebhookController {
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
}
// Step 2: For answered calls, the dispose endpoint creates the
// Call record ~5s before this webhook fires. Check if it already
// exists and enrich it instead of creating a duplicate.
let callId: string;
if (callStatus === 'COMPLETED' && ucid) {
const existing = await this.platform.queryWithAuth<any>(
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
undefined, authHeader,
).catch(() => null);
const existingId = existing?.calls?.edges?.[0]?.node?.id;
if (existingId) {
// Enrich existing record with webhook data (recording, chain name, timing)
const enrichData: Record<string, any> = {};
if (agentName) enrichData.agentName = agentName;
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
if (resolved.leadId) enrichData.leadId = resolved.leadId;
if (resolved.leadName) enrichData.leadName = resolved.leadName;
if (startTime) enrichData.startedAt = istToUtc(startTime);
if (endTime) enrichData.endedAt = istToUtc(endTime);
if (duration) enrichData.durationSec = duration;
if (Object.keys(enrichData).length > 0) {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
{ id: existingId, data: enrichData },
authHeader,
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
}
callId = existingId;
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
} else {
// Fallback: dispose didn't create it (edge case) — create normally
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
callId = await this.createCall({
callerPhone, direction, callStatus, agentName,
startTime, endTime, duration, recordingUrl, disposition, ucid,
leadId: resolved.leadId || null, leadName: resolved.leadName,
}, authHeader);
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
}
} else {
// Missed calls — always create (no dispose fires for unanswered)
callId = await this.createCall({
callerPhone, direction, callStatus, agentName,
startTime, endTime, duration, recordingUrl, disposition, ucid,
leadId: resolved.leadId || null, leadName: resolved.leadName,
}, authHeader);
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
}
// Step 2: Create call record with leadId + leadName baked in so
// the worklist row renders the patient name immediately.
const callId = await this.createCall({
callerPhone,
direction,
callStatus,
agentName,
startTime,
endTime,
duration,
recordingUrl,
disposition,
ucid,
leadId: resolved.leadId || null,
leadName: resolved.leadName,
}, authHeader);
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
// Push worklist SSE so agents see new calls instantly
// instead of waiting for the 30s frontend poll.