5 Commits

Author SHA1 Message Date
3bb4315925 fix: persist LOGIN events for session rollup — fixes zero dashboard metrics
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
History event persistence was gated behind if(mapped), but login returns
null for state (UI waits for release). LOGIN events were never written
to AgentEvent table → rollup computed 0 for loginDuration → idle/pause/
wrap all zero. Moved history persistence outside the state mapping gate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:30:46 +05:30
7402590969 fix: always include Health Packages section in KB — empty state says "not configured"
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
When zero active packages exist, the KB silently omitted the section.
AI then responded "I couldn't find that" for package queries. Now always
includes the section header with explicit "not configured" guidance.

Also removed contradicting rule 8 (bullet points) — conflicts with the
structured output format that requires plain text sentences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:36 +05:30
3f22166ac0 fix: dispose creates inbound Call record, webhook enriches — eliminates UCID mismatch + timing race
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Root cause: CDR webhook fires 5s after dispose, stores monitorUCID.
Frontend has agent-side UCID from SIP. They never matched → disposition
not persisted, agent call history empty.

Fix:
- Dispose endpoint now creates Call records for ALL answered calls
  (inbound + outbound), not just outbound. Record gets agent-side UCID
  + correct disposition immediately.
- Webhook checks if Call record already exists (by agent UCID via
  monitorUCID→agentUCID mapping). If found, enriches with recording
  URL, agent chain name, CDR timing. If not found, creates as fallback.
- SupervisorService stores UCID mapping from real-time events (which
  carry both UCIDs). Auto-expires after 10 minutes.

Verified: UCID-MAP logged, dispose creates record, webhook enriches
without duplicating. DB shows correct agent UCID + disposition + recording.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 08:31:07 +05:30
8c8b1e78b0 feat: caller context cache invalidation endpoint
- CallerContextService: added invalidateCache(leadId) method
- CallerResolutionController: POST /api/caller/invalidate-context
  endpoint — frontend calls after appointment mutations to bust
  stale AI context cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:29:56 +05:30
77b3e917db fix: fetch Lead first to resolve patientId before appointments query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The build() method previously fetched Lead and Appointments in parallel.
When the input patientId was empty (outbound dial, first-time linkage),
the appointments query was skipped even though the Lead record in the DB
had a valid patientId. Now fetches Lead first, reads its patientId, then
fetches appointments/calls/activities in parallel with the correct ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:23:48 +05:30
7 changed files with 152 additions and 93 deletions

View File

@@ -773,8 +773,8 @@ export class AiChatController {
undefined, auth, undefined, auth,
); );
const packages = pkgData.healthPackages.edges.map((e: any) => e.node); const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
sections.push('\n## Health Packages');
if (packages.length) { if (packages.length) {
sections.push('\n## Health Packages');
for (const p of packages) { for (const p of packages) {
const price = p.price ? `${p.price.amountMicros / 1_000_000}` : ''; const price = p.price ? `${p.price.amountMicros / 1_000_000}` : '';
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : ''; const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
@@ -791,6 +791,8 @@ export class AiChatController {
sections.push(` Includes: ${p.inclusions}`); sections.push(` Includes: ${p.inclusions}`);
} }
} }
} else {
sections.push('No packages available.');
} }
} catch (err) { } catch (err) {
this.logger.warn(`Failed to fetch health packages: ${err}`); this.logger.warn(`Failed to fetch health packages: ${err}`);

View File

@@ -78,6 +78,13 @@ export class CallerContextService {
return ctx; 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 // Fire-and-forget pre-warm — called from caller resolution
// so the cache is hot when the AI stream fires seconds later. // so the cache is hot when the AI stream fires seconds later.
prewarm(leadId: string, patientId: string, auth: string): void { prewarm(leadId: string, patientId: string, auth: string): void {
@@ -89,19 +96,34 @@ export class CallerContextService {
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> { private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
try { try {
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([ // Step 1: Fetch lead first to get the authoritative patientId
this.platform.queryWithAuth<any>( const leadData = await this.platform.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${leadId}" } }) { `{ lead(filter: { id: { eq: "${leadId}" } }) {
id contactName { firstName lastName } id contactName { firstName lastName }
contactPhone { primaryPhoneNumber } contactPhone { primaryPhoneNumber }
source status interestedService source status interestedService
aiSummary contactAttempts lastContacted aiSummary contactAttempts lastContacted
utmCampaign patientId utmCampaign patientId
} }`, } }`,
undefined, auth, undefined, auth,
), );
patientId ? this.platform.queryWithAuth<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { 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 {
scheduledAt status doctorName department reasonForVisit scheduledAt status doctorName department reasonForVisit
} } } }`, } } } }`,
undefined, auth, undefined, auth,
@@ -120,12 +142,6 @@ 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 appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({ const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
startedAt: e.node.startedAt, startedAt: e.node.startedAt,
@@ -148,7 +164,7 @@ export class CallerContextService {
return { return {
leadId, leadId,
patientId: patientId || lead.patientId || '', patientId: resolvedPatientId,
name: `${firstName} ${lastName}`.trim() || 'Unknown', name: `${firstName} ${lastName}`.trim() || 'Unknown',
phone: lead.contactPhone?.primaryPhoneNumber ?? '', phone: lead.contactPhone?.primaryPhoneNumber ?? '',
isNew: false, isNew: false,

View File

@@ -33,4 +33,13 @@ export class CallerResolutionController {
return result; 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

@@ -123,7 +123,6 @@ RULES:
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that". 5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
6. Be concise — agents are on live calls. Under 100 words unless asked for detail. 6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
7. NEVER give medical advice, diagnosis, or treatment recommendations. 7. NEVER give medical advice, diagnosis, or treatment recommendations.
8. Format with bullet points for easy scanning.
RESPONSE FORMAT (STRICT): RESPONSE FORMAT (STRICT):
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON: You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:

View File

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

View File

@@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit {
private readonly activeCalls = new Map<string, ActiveCall>(); private readonly activeCalls = new Map<string, ActiveCall>();
private readonly agentStates = new Map<string, AgentStateEntry>(); private readonly agentStates = new Map<string, AgentStateEntry>();
private readonly acwTimers = new Map<string, NodeJS.Timeout>(); 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 agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>(); readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
// Worklist update stream — emitted when a missed call is created or // Worklist update stream — emitted when a missed call is created or
@@ -78,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
} }
} }
resolveAgentUcid(monitorUcid: string): string | null {
return this.ucidMap.get(monitorUcid) ?? null;
}
handleCallEvent(event: any) { handleCallEvent(event: any) {
const action = event.action; const action = event.action;
const ucid = event.ucid ?? event.monitorUCID; const ucid = event.ucid ?? event.monitorUCID;
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
const agentId = event.agent_id ?? event.agentID; const agentId = event.agent_id ?? event.agentID;
const callerNumber = event.caller_id ?? event.callerID; const callerNumber = event.caller_id ?? event.callerID;
const callType = event.call_type ?? event.Type; const callType = event.call_type ?? event.Type;
@@ -89,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
if (!ucid) return; 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') { if (action === 'Answered' || action === 'Calling') {
// Don't show calls for offline agents (ghost calls) // Don't show calls for offline agents (ghost calls)
const agentState = this.agentStates.get(agentId); const agentState = this.agentStates.get(agentId);
@@ -163,26 +176,30 @@ export class SupervisorService implements OnModuleInit {
const priorState = this.agentStates.get(agentId)?.state; const priorState = this.agentStates.get(agentId)?.state;
const mapped = this.mapOzonetelAction(action, eventData, pauseReason); const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
// Persist to AgentEvent table regardless of state mapping.
// login returns null for state (UI waits for release/ready) but
// the history pipeline needs LOGIN to compute loginDuration.
const historyEventType = this.mapToHistoryEventType(action, priorState);
if (historyEventType) {
const resolvedPauseReason = (pauseReason || eventData || '') || null;
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
this.history.persistAgentEvent({
ozonetelAgentId: agentId,
eventType: historyEventType,
eventAt: this.parseOzonetelTime(eventTime),
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
}).catch((err) => {
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
});
} else {
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
}
if (mapped) { if (mapped) {
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime }); this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime }); this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} ${mapped}`); this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'}${mapped} (action=${action})`);
// Persist to AgentEvent table. CALL_START/CALL_END are
// handled in handleCallEvent (they arrive via a separate
// Ozonetel webhook). Everything else is captured here.
// Pass priorState so 'release' → RESUME / ACW_END / READY can
// be disambiguated for the session rollup.
const historyEventType = this.mapToHistoryEventType(action, priorState);
if (historyEventType) {
const resolvedPauseReason = (pauseReason || eventData || '') || null;
this.history.persistAgentEvent({
ozonetelAgentId: agentId,
eventType: historyEventType,
eventAt: this.parseOzonetelTime(eventTime),
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
}).catch(() => {});
}
// Layer 3: ACW auto-dispose safety net // Layer 3: ACW auto-dispose safety net
if (mapped === 'acw') { if (mapped === 'acw') {

View File

@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00'); const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
const agentName = payload.AgentName ?? null; const agentName = payload.AgentName ?? null;
const recordingUrl = payload.AudioFile ?? null; const recordingUrl = payload.AudioFile ?? null;
const ucid = payload.monitorUCID ?? 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 disposition = payload.Disposition ?? null; const disposition = payload.Disposition ?? null;
const hangupBy = payload.HangupBy ?? null; const hangupBy = payload.HangupBy ?? null;
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`); this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
} }
// Step 2: Create call record with leadId + leadName baked in so // Step 2: For answered calls, the dispose endpoint creates the
// the worklist row renders the patient name immediately. // Call record ~5s before this webhook fires. Check if it already
const callId = await this.createCall({ // exists and enrich it instead of creating a duplicate.
callerPhone, let callId: string;
direction, if (callStatus === 'COMPLETED' && ucid) {
callStatus, const existing = await this.platform.queryWithAuth<any>(
agentName, `{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
startTime, undefined, authHeader,
endTime, ).catch(() => null);
duration, const existingId = existing?.calls?.edges?.[0]?.node?.id;
recordingUrl, if (existingId) {
disposition, // Enrich existing record with webhook data (recording, chain name, timing)
ucid, const enrichData: Record<string, any> = {};
leadId: resolved.leadId || null, if (agentName) enrichData.agentName = agentName;
leadName: resolved.leadName, if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
}, authHeader); if (resolved.leadId) enrichData.leadId = resolved.leadId;
if (resolved.leadName) enrichData.leadName = resolved.leadName;
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${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}` : ''}`);
}
// Push worklist SSE so agents see new calls instantly // Push worklist SSE so agents see new calls instantly
// instead of waiting for the 30s frontend poll. // instead of waiting for the 30s frontend poll.