From a1413aae40e9d4c6cf129521420c5a0fd0d6ca10 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 05:38:52 +0530 Subject: [PATCH 01/15] fix(supervisor): sweep stale activeCalls before returning to Live Monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 560: Live Call Monitor showed ghost calls with runaway timers when the agent wasn't on a call. Cause — activeCalls Map only added on 'Answered' and deleted on 'Disconnect'; a missed Disconnect (sidecar restart, Ozonetel subscription hiccup, network blip) left the entry lingering forever. getActiveCalls() now sweeps stale entries before returning: - drop if startTime is older than 30 minutes - drop if the mapped agent is currently ready / offline / paused (agent can't be on a call in any of those states) Each sweep logs the reason so we can track how often this fires. --- src/supervisor/supervisor.service.ts | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 1f85527..0a88f2e 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -257,7 +257,52 @@ export class SupervisorService implements OnModuleInit { this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() }); } + // Max plausible call length before the entry is treated as orphaned. + // Real Ozonetel calls cap out far short of this — 30 minutes is a safe + // ceiling for a hospital call-center context. If a genuinely longer + // call existed, losing it from Live Monitor is preferable to the ghost + // state (supervisors lose trust in the dashboard otherwise). + private static readonly MAX_ACTIVE_CALL_AGE_MS = 30 * 60 * 1000; + + // Agent states that are incompatible with having an active call. If the + // mapped agent is currently in one of these, the activeCalls entry is + // definitely stale (e.g. Disconnect webhook was dropped). + private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']); + getActiveCalls(): ActiveCall[] { + // Sweep stale entries before returning. The activeCalls Map is a + // best-effort in-memory projection of Ozonetel call events — if + // Ozonetel drops a Disconnect (network blip, subscription hiccup, + // sidecar restart mid-call), the entry lingers forever and the + // Live Call Monitor shows a ghost call with a runaway timer. + // + // Two signals identify staleness: + // 1. The associated agent is not in a busy state (ready, offline, + // paused — they can't be on a call). + // 2. startTime is older than MAX_ACTIVE_CALL_AGE_MS (hard ceiling + // regardless of agent-state signal). + const now = Date.now(); + const toDelete: string[] = []; + + for (const [ucid, call] of this.activeCalls.entries()) { + const ageMs = now - new Date(call.startTime).getTime(); + if (isNaN(ageMs)) continue; + + if (ageMs > SupervisorService.MAX_ACTIVE_CALL_AGE_MS) { + toDelete.push(ucid); + this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} (age ${Math.round(ageMs / 60000)}m, exceeds ${SupervisorService.MAX_ACTIVE_CALL_AGE_MS / 60000}m cap)`); + continue; + } + + const agentState = this.agentStates.get(call.agentId)?.state; + if (agentState && SupervisorService.NON_CALL_AGENT_STATES.has(agentState)) { + toDelete.push(ucid); + this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} — agent ${call.agentId} is ${agentState}`); + } + } + + for (const ucid of toDelete) this.activeCalls.delete(ucid); + return Array.from(this.activeCalls.values()); } From a00668c517a23802156b263924d24743a39005f8 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 05:39:08 +0530 Subject: [PATCH 02/15] feat(ai): UUID-safe agent tools + lookup_lead_activities + tool logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 553 (partial) — AI Panel 'Patient History' returned 'not in system' even though the caller had 7 calls + an appointment. The model was hallucinating instead of chaining lookup tools. UUID safety: LLMs drop hyphens / swap chars on 36-char ids once the context wears thin. To keep the model off the UUID path for 'this caller' questions: - lookup_appointments, lookup_call_history, lookup_lead_activities now accept their id arguments OPTIONALLY - when omitted, the sidecar resolves leadId from ctx and patientId from the lead record (cached per-request) - new lookup_lead_activities tool rounds out the patient-history trio (call history + activity log + appointments) System prompt (ccAgentHelper) tightened: - chain call history + activities + appointments for history questions - call lookup tools with NO arguments when using the current caller - don't re-type UUIDs seen in CURRENT CONTEXT - say 'feature not set up yet' when KB section is empty (packages, etc.) instead of 'I couldn't find that' All agent tools now emit structured [AI-TOOL] trace lines with full UUIDs printed — tail sidecar logs to see which tool the model chose, whether the model passed an id or used the context fallback, and how many records came back. If the model ever hallucinates a UUID, the resolved= field on the log line will echo it and count=0 will flag the miss immediately. --- src/ai/ai-chat.controller.ts | 119 ++++++++++++++++++++++++++++++----- src/config/ai.defaults.ts | 19 +++--- 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 84c3f9c..2e5572c 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -295,6 +295,54 @@ export class AiChatController { }; // Agent tools — patient lookup, appointments, doctors + // + // UUID safety: LLMs hallucinate 36-char identifiers once the context + // starts wearing thin (dropped hyphens, swapped chars). To keep the + // model off the UUID path for "this caller" questions, the tools + // below accept their id arguments OPTIONALLY — when omitted we fall + // back to the leadId carried on the call context, and resolve + // patientId from it server-side. The model is instructed (see + // ccAgentHelper prompt) to omit the id entirely when asking about + // the current caller, so it never has to echo the UUID back. + // + // Every tool below logs a one-line structured trace via `toolLog`: + // [AI-TOOL] args=<...> resolved=<...> result=<...> + // This lets us see which tool the model chose, whether it passed + // the UUID through or used the context fallback, and what came + // back. Tail sidecar logs while testing and you'll see the full + // orchestration trail for each chat turn. + const logger = this.logger; + const toolLog = (name: string, args: Record, outcome: Record) => { + // Print full values — UUIDs in particular are kept intact so we + // can diff the model's argument against the platform record when + // hunting hallucinated ids. Grep with `AI-TOOL` to pull the + // orchestration trail for a given chat turn. + const argStr = Object.entries(args).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' '); + const outStr = Object.entries(outcome).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' '); + logger.log(`[AI-TOOL] ${name} ${argStr} → ${outStr}`); + }; + + let cachedPatientId: string | undefined; + const resolveLeadId = (arg?: string): string | undefined => arg || ctx?.leadId || undefined; + const resolvePatientId = async (arg?: string): Promise => { + if (arg) return arg; + if (cachedPatientId) return cachedPatientId; + const lid = ctx?.leadId; + if (!lid) return undefined; + try { + const data = await platformService.queryWithAuth( + `{ lead(filter: { id: { eq: "${lid}" } }) { id patientId } }`, + undefined, auth, + ); + cachedPatientId = data?.lead?.patientId ?? undefined; + logger.log(`[AI-TOOL] resolvePatientId lead=${lid} patientId=${cachedPatientId ?? '∅'}`); + return cachedPatientId; + } catch (err: any) { + logger.warn(`[AI-TOOL] resolvePatientId failed: ${err?.message ?? err}`); + return undefined; + } + }; + const agentTools = { lookup_patient: tool({ description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.', @@ -329,24 +377,32 @@ export class AiChatController { return false; }); + toolLog('lookup_patient', { phone, name }, { scanned: leads.length, matched: matched.length }); if (!matched.length) return { found: false, message: 'No patient/lead found.' }; return { found: true, count: matched.length, leads: matched }; }, }), lookup_appointments: tool({ - description: 'Get appointments for a patient. Returns doctor, department, date, status.', + description: 'Get appointments for a patient. Omit patientId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.', inputSchema: z.object({ - patientId: z.string().describe('Patient ID'), + patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'), }), execute: async ({ patientId }) => { + const resolved = await resolvePatientId(patientId); + if (!resolved) { + toolLog('lookup_appointments', { patientId }, { resolved: null, result: 'no-context' }); + return { appointments: [], message: 'No patient context — ask the agent which patient.' }; + } const data = await platformService.queryWithAuth( - `{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + `{ appointments(first: 20, filter: { patientId: { eq: "${resolved}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { id scheduledAt status doctorName department reasonForVisit } } } }`, undefined, auth, ); - return { appointments: data.appointments.edges.map((e: any) => e.node) }; + const appointments = data.appointments.edges.map((e: any) => e.node); + toolLog('lookup_appointments', { patientId }, { resolved, count: appointments.length }); + return { appointments }; }, }), @@ -375,7 +431,7 @@ export class AiChatController { const full = `${fn} ${ln}`; return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w))); }); - this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`); + toolLog('lookup_doctor', { doctorName }, { scanned: doctors.length, matched: matched.length }); if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` }; return { found: true, doctors: matched }; }, @@ -393,7 +449,7 @@ export class AiChatController { reason: z.string().describe('Reason for visit'), }), execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => { - this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | clinic=${clinicId ?? 'none'} | ${scheduledAt}`); + toolLog('book_appointment', { patientName, phoneNumber, department, doctorName, clinicId, scheduledAt }, { reason: reason?.slice(0, 40) }); try { const result = await platformService.queryWithAuth( `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, @@ -412,11 +468,13 @@ export class AiChatController { ); const id = result?.createAppointment?.id; if (id) { + toolLog('book_appointment', { doctorName }, { booked: true, appointmentId: id }); return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` }; } + toolLog('book_appointment', { doctorName }, { booked: false }); return { booked: false, message: 'Appointment creation failed.' }; } catch (err: any) { - this.logger.error(`[TOOL] book_appointment failed: ${err.message}`); + logger.error(`[AI-TOOL] book_appointment failed: ${err.message}`); return { booked: false, message: `Failed to book: ${err.message}` }; } }, @@ -430,7 +488,7 @@ export class AiChatController { interest: z.string().describe('What they are enquiring about'), }), execute: async ({ name, phoneNumber, interest }) => { - this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`); + toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {}); try { const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10); const resolved = await this.caller.resolve(cleanPhone, auth); @@ -455,7 +513,7 @@ export class AiChatController { ); patientId = p?.createPatient?.id; } catch (err: any) { - this.logger.warn(`[TOOL] create_lead patient create failed: ${err.message}`); + logger.warn(`[AI-TOOL] create_lead patient create failed: ${err.message}`); } const created = await platformService.queryWithAuth( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, @@ -474,8 +532,10 @@ export class AiChatController { ); const id = created?.createLead?.id; if (id) { + toolLog('create_lead', { name }, { created: true, isNew: true, leadId: id }); return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` }; } + toolLog('create_lead', { name }, { created: false }); return { created: false, message: 'Lead creation failed.' }; } @@ -501,27 +561,58 @@ export class AiChatController { auth, ).catch(() => {}); } + toolLog('create_lead', { name }, { created: true, isNew: false, leadId: resolved.leadId }); return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` }; } catch (err: any) { - this.logger.error(`[TOOL] create_lead failed: ${err.message}`); + logger.error(`[AI-TOOL] create_lead failed: ${err.message}`); return { created: false, message: `Failed: ${err.message}` }; } }, }), lookup_call_history: tool({ - description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.', + description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.', inputSchema: z.object({ - leadId: z.string().describe('Lead ID'), + leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'), }), execute: async ({ leadId }) => { + const resolved = resolveLeadId(leadId); + if (!resolved) { + toolLog('lookup_call_history', { leadId }, { resolved: null, result: 'no-context' }); + return { calls: [], message: 'No lead context — ask the agent which caller.' }; + } const data = await platformService.queryWithAuth( - `{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + `{ calls(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`, undefined, auth, ); - return { calls: data.calls.edges.map((e: any) => e.node) }; + const calls = data.calls.edges.map((e: any) => e.node); + toolLog('lookup_call_history', { leadId }, { resolved, count: calls.length }); + return { calls }; + }, + }), + + lookup_lead_activities: tool({ + description: 'Get activity log entries for a lead — notes, status changes, enquiries. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context.', + inputSchema: z.object({ + leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'), + }), + execute: async ({ leadId }) => { + const resolved = resolveLeadId(leadId); + if (!resolved) { + toolLog('lookup_lead_activities', { leadId }, { resolved: null, result: 'no-context' }); + return { activities: [], message: 'No lead context — ask the agent which caller.' }; + } + const data = await platformService.queryWithAuth( + `{ leadActivities(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { + id activityType summary occurredAt performedBy channel outcome + } } } }`, + undefined, auth, + ); + const activities = data.leadActivities.edges.map((e: any) => e.node); + toolLog('lookup_lead_activities', { leadId }, { resolved, count: activities.length }); + return { activities }; }, }), }; diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index 2837615..69f5f36 100644 --- a/src/config/ai.defaults.ts +++ b/src/config/ai.defaults.ts @@ -112,13 +112,18 @@ The knowledge base below contains REAL clinic locations, timings, doctor details When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know. RULES: -1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. -2. For doctor details beyond what's in the KB, use the lookup_doctor tool. -3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. -4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system." -5. Be concise — agents are on live calls. Under 100 words unless asked for detail. -6. NEVER give medical advice, diagnosis, or treatment recommendations. -7. Format with bullet points for easy scanning. +1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. NEVER say a patient doesn't exist without calling a tool first. +2. When CURRENT CONTEXT lists a Lead ID, the lookup tools already know which caller to pull. Call them with NO arguments — do not re-type the Lead ID or Patient ID as a tool argument: + - lookup_call_history() → calls for this caller + - lookup_lead_activities() → activity log for this caller + - lookup_appointments() → appointments for this caller + Pass IDs explicitly only when the agent is asking about a different, specific patient — and even then, prefer name/phone via lookup_patient. +3. For "summarize this patient's history" or similar, chain multiple lookups (call history + lead activities + appointments) and stitch the answer from what came back. If all three return empty, say so honestly — otherwise report what you found. +4. For doctor details beyond what's in the KB, use the lookup_doctor tool. +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. +7. NEVER give medical advice, diagnosis, or treatment recommendations. +8. Format with bullet points for easy scanning. KNOWLEDGE BASE (this is real data from our system): {{knowledgeBase}}`; From 2666a10f48efebf655384a024b0209a57cc47057 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 16:54:08 +0530 Subject: [PATCH 03/15] fix: await Ozonetel logout + per-agent sipPassword + campaign name on missed calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. Await Ozonetel logout in /auth/logout — prevents race condition when agent re-logs in quickly via "Remember me". The fire-and-forget logoutAgent() left a window where the next loginAgent() arrived while Ozonetel was still processing the previous logout, leaving the agent stuck in "Telephony Unavailable". (#559) 2. Use agentConfig.sipPassword (from Agent entity) instead of OZONETEL_AGENT_PASSWORD env var for login/logout/force-ready. The env var was a single shared credential that ignored per-agent passwords. Removed hardcoded "Test123$" fallback. Force-ready now looks up the Agent entity by ozonetelAgentId to get the correct sipPassword + sipExtension. 3. Missed-calls worklist query now fetches campaign { id campaignName } so the frontend Branch column can show the campaign name instead of the raw DID phone number. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/auth/auth.controller.ts | 12 ++++++++---- src/maint/maint.controller.ts | 25 +++++++++++++++++++------ src/worklist/worklist.service.ts | 1 + 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 11408b6..b33c316 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -138,10 +138,9 @@ export class AuthController { this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`); }); - const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$'; this.ozonetelAgent.loginAgent({ agentId: agentConfig.ozonetelAgentId, - password: ozAgentPassword, + password: agentConfig.sipPassword, phoneNumber: agentConfig.sipExtension, mode: 'blended', }).catch(err => { @@ -250,9 +249,14 @@ export class AuthController { await this.sessionService.unlockSession(agentConfig.ozonetelAgentId); this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`); - this.ozonetelAgent.logoutAgent({ + // Await the Ozonetel logout so it completes before the + // HTTP response returns. Without this, a fast re-login + // (e.g. "remember me" auto-fill) races the logout and + // the agent lands in "Telephony Unavailable" because + // Ozonetel receives login while still processing logout. + await this.ozonetelAgent.logoutAgent({ agentId: agentConfig.ozonetelAgentId, - password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$', + password: agentConfig.sipPassword, }).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`)); this.agentConfigService.clearCache(memberId); diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 4c9c90e..1553d45 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -31,13 +31,26 @@ export class MaintController { async forceReady(@Body() body: { agentId: string }) { if (!body?.agentId) throw new HttpException('agentId required', 400); const agentId = body.agentId; - const oz = this.telephony.getConfig().ozonetel; - const password = oz.agentPassword; - if (!password) throw new HttpException('agent password not configured', 400); - const sipId = oz.sipId; - if (!sipId) throw new HttpException('SIP ID not configured', 400); - this.logger.log(`[MAINT] Force ready: agent=${agentId}`); + // Look up the Agent entity to get sipPassword + sipExtension. + // Password comes from the Agent record, not an env var — each + // agent owns their own Ozonetel credential. + const agentData = await this.platform.query( + `{ agents(first: 1, filter: { ozonetelAgentId: { eq: "${agentId}" } }) { edges { node { + id sipExtension sipPassword + } } } }`, + ).catch(() => null); + + const agent = agentData?.agents?.edges?.[0]?.node; + if (!agent) throw new HttpException(`Agent ${agentId} not found in platform`, 404); + + const password = agent.sipPassword ?? agent.sipExtension; + if (!password) throw new HttpException(`Agent ${agentId} has no sipPassword configured`, 400); + + const sipId = agent.sipExtension; + if (!sipId) throw new HttpException(`Agent ${agentId} has no sipExtension configured`, 400); + + this.logger.log(`[MAINT] Force ready: agent=${agentId} ext=${sipId}`); try { await this.ozonetel.logoutAgent({ agentId, password }); diff --git a/src/worklist/worklist.service.ts b/src/worklist/worklist.service.ts index e5a1663..fc57c59 100644 --- a/src/worklist/worklist.service.ts +++ b/src/worklist/worklist.service.ts @@ -177,6 +177,7 @@ export class WorklistService { startedAt endedAt durationSec disposition leadId leadName callbackStatus callSourceNumber missedCallCount callbackAttemptedAt + campaign { id campaignName } } } pageInfo { hasNextPage endCursor } } }`, 'calls', authHeader, From 2d8308bed8e530ce0171aeb1e76b2feed053b1e9 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 17:33:35 +0530 Subject: [PATCH 04/15] fix: remove hardcoded Inbound_918041763265 campaign fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default campaign name was hardcoded to 'Inbound_918041763265'. After the Ozonetel campaigns were renamed (Inbound_918041763265 → Global, Inbound_918041763400 → Ramaiah), agent login/dial would break because the old name doesn't exist on Ozonetel anymore. Campaign name now comes exclusively from the Agent entity's campaignName field (per-agent) or the OZONETEL_CAMPAIGN_NAME env var (per-workspace). No hardcoded fallback. --- src/auth/agent-config.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/auth/agent-config.service.ts b/src/auth/agent-config.service.ts index 965d84a..4a22f88 100644 --- a/src/auth/agent-config.service.ts +++ b/src/auth/agent-config.service.ts @@ -29,7 +29,11 @@ export class AgentConfigService { return this.telephony.getConfig().sip.wsPort || '444'; } private get defaultCampaignName(): string { - return this.telephony.getConfig().ozonetel.campaignName || 'Inbound_918041763265'; + // No hardcoded fallback — each Agent entity's own campaignName + // field is the source of truth. Env var is the per-workspace + // default; if neither is set, the Ozonetel login will use + // whatever the agent's entity specifies. + return this.telephony.getConfig().ozonetel.campaignName || ''; } async getByMemberId(memberId: string): Promise { From a6f4c51ca928402b5769d031b2d7661e2c9a28f2 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 18:02:49 +0530 Subject: [PATCH 05/15] fix: disposition for answered inbound calls + SLA timing wiring + backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes: 1. Disposition for answered inbound calls Previously the dispose endpoint sent the agent's choice to Ozonetel but never wrote it back to the platform Call record. The webhook's pre-disposition value ("General Enquiry" → INFO_PROVIDED) persisted. Now: dispose endpoint finds the Call by UCID and updates disposition to the agent's actual selection. 2. SLA timing wiring (assignedAt / answeredAt / responseTimeS) patchCallTiming() existed but was never called. Now wired into handleCallEvent: - "Calling" event → writes assignedAt (ring start) - "Answered" event → writes answeredAt + computes responseTimeS (answeredAt - startedAt = caller wait time) Uses patchCallTimingByUcid helper that looks up Call by UCID. 3. Backfill maint endpoint: POST /api/maint/backfill-call-disposition-timing Walks calls for a given date, joins to CDR by UCID (both legs), patches disposition (from CDR's mapped value, always overwrites), timing fields (answeredAt, assignedAt, responseTimeS from CDR), and CDR-specific durations (handlingTimeS, acwDurationS, holdDurationS). Idempotent — safe to run multiple times. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/maint/maint.controller.ts | 106 ++++++++++++++++++++++ src/ozonetel/ozonetel-agent.controller.ts | 28 ++++++ src/supervisor/supervisor.service.ts | 60 ++++++++++++ 3 files changed, 194 insertions(+) diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 1553d45..7a436e6 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -972,4 +972,110 @@ export class MaintController { this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`); return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons }; } + + // Backfill disposition + SLA timing on historical calls using CDR data. + // Walks calls from a given date (IST), joins to CDR by UCID, and patches + // disposition (from CDR's mapped value) + timing fields. Idempotent — + // only overwrites null fields (disposition is always overwritten since + // the webhook default is unreliable). + @Post('backfill-call-disposition-timing') + async backfillCallDispositionTiming(@Body() body: { date?: string }) { + const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10); + this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`); + + // Fetch CDR for the date + const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []); + if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 }; + + // Build UCID + monitorUCID map + const byUcid = new Map(); + for (const row of cdrRows) { + const ucid = String(row.UCID ?? '').trim(); + const monUcid = String(row.monitorUCID ?? '').trim(); + if (ucid) byUcid.set(ucid, row); + if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row); + } + + // Fetch calls for the date that have a UCID + const gte = `${date}T00:00:00+05:30`; + const lte = `${date}T23:59:59+05:30`; + const callsData = await this.platform.query( + `{ calls(first: 500, filter: { + startedAt: { gte: "${gte}", lte: "${lte}" }, + ucid: { is: NOT_NULL } + }) { edges { node { + id ucid disposition assignedAt answeredAt responseTimeS startedAt + } } } }`, + ).catch(() => ({ calls: { edges: [] } })); + + const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? []; + let patched = 0; + let skipped = 0; + + const dispositionMap: Record = { + 'General Enquiry': 'INFO_PROVIDED', + 'Appointment Booked': 'APPOINTMENT_BOOKED', + 'Follow Up': 'FOLLOW_UP_SCHEDULED', + 'Not Interested': 'NOT_INTERESTED', + 'Wrong Number': 'WRONG_NUMBER', + 'No Answer': 'NO_ANSWER', + }; + + const parseHms = (hms: string | null | undefined): number | null => { + if (!hms) return null; + const parts = String(hms).split(':').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) return null; + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + }; + + for (const call of calls) { + const cdrRow = byUcid.get(String(call.ucid).trim()); + if (!cdrRow) { skipped++; continue; } + + const patch: Record = {}; + + // Disposition — always overwrite (webhook default is unreliable) + const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null; + if (cdrDisp) patch.disposition = cdrDisp; + + // Timing — only fill if null + if (!call.answeredAt && cdrRow.AnswerTime) { + patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString(); + } + if (!call.assignedAt && cdrRow.StartTime) { + patch.assignedAt = new Date(cdrRow.StartTime).toISOString(); + } + if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) { + const start = new Date(call.startedAt).getTime(); + const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime(); + if (!isNaN(start) && !isNaN(answered)) { + patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000)); + } + } + + // CDR timing fields + const handlingSec = parseHms(cdrRow.HandlingTime); + const wrapupSec = parseHms(cdrRow.WrapupDuration); + const holdSec = parseHms(cdrRow.HoldDuration); + if (handlingSec !== null) patch.handlingTimeS = handlingSec; + if (wrapupSec !== null) patch.acwDurationS = wrapupSec; + if (holdSec !== null) patch.holdDurationS = holdSec; + + if (Object.keys(patch).length === 0) { skipped++; continue; } + + try { + await this.platform.query( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: call.id, data: patch }, + ); + patched++; + } catch (err: any) { + this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`); + skipped++; + } + } + + this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`); + return { status: 'ok', date, scanned: calls.length, patched, skipped }; + } } diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index cdac0fe..0bc8411 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -278,6 +278,34 @@ export class OzonetelAgentController { } } + // 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( + `{ 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( + `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 { await this.missedQueue.assignNext(agentId); diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index 0a88f2e..c5a64f6 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -101,6 +101,22 @@ export class SupervisorService implements OnModuleInit { eventType: 'CALL_START', eventAt: iso, }).catch(() => {}); + + // Write answeredAt + responseTimeS to the Call record. + // Look up the Call by UCID, then patch. The "Calling" event + // sets assignedAt (ring start); "Answered" computes response + // time as answered - assigned (queue wait time). + this.patchCallTimingByUcid(ucid, { + answeredAt: iso, + }).catch(() => {}); + } + + // "Calling" = agent's phone is ringing → write assignedAt + // (the moment the call was routed to this agent). + if (action === 'Calling') { + this.patchCallTimingByUcid(ucid, { + assignedAt: iso, + }).catch(() => {}); } } else if (action === 'Disconnect') { const wasActive = this.activeCalls.get(ucid); @@ -306,6 +322,50 @@ export class SupervisorService implements OnModuleInit { return Array.from(this.activeCalls.values()); } + // Look up a Call by UCID and patch its timing fields. Used by + // handleCallEvent to write assignedAt/answeredAt in real-time. + // Also computes responseTimeS when answeredAt is written and + // the Call already has a startedAt. + private async patchCallTimingByUcid(ucid: string, fields: { + assignedAt?: string; + answeredAt?: string; + }): Promise { + try { + const data = await this.platform.query( + `{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`, + ); + const call = data?.calls?.edges?.[0]?.node; + if (!call) { + this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`); + return; + } + + const patch: Record = {}; + if (fields.assignedAt) patch.assignedAt = fields.assignedAt; + if (fields.answeredAt) { + patch.answeredAt = fields.answeredAt; + // Compute response time: answered - started (how long the + // caller waited from call creation to agent pickup). + const start = call.startedAt ? new Date(call.startedAt).getTime() : null; + const answered = new Date(fields.answeredAt).getTime(); + if (start && !isNaN(start) && !isNaN(answered)) { + const responseS = Math.max(0, Math.round((answered - start) / 1000)); + patch.responseTimeS = responseS; + } + } + + if (Object.keys(patch).length > 0) { + await this.platform.query( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: call.id, data: patch }, + ); + this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`); + } + } catch (err: any) { + this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`); + } + } + async getTeamPerformance(date: string): Promise { // Get all agents from platform. Field names are label-derived // camelCase on the current platform schema — see From 9cf0f69dde50e7837d954d7ca6b19158c695d852 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 18:32:57 +0530 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20SSE=20push=20for=20worklist=20upd?= =?UTF-8?q?ates=20=E2=80=94=20instant=20missed-call=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New worklist SSE stream replaces the 30s frontend poll. When the missed-call webhook creates a Call record, it emits a worklist-updated event via the supervisor's worklistSubject. All connected agents receive the event immediately. - supervisor.service.ts: worklistSubject + emitWorklistUpdate() - supervisor.controller.ts: @Sse('worklist/stream') broadcast endpoint - missed-call-webhook.controller.ts: emits after createCall() with callerPhone + callerName for toast notification - worklist.module.ts: imports SupervisorModule (forwardRef) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/supervisor/supervisor.controller.ts | 14 ++++++++++++++ src/supervisor/supervisor.service.ts | 9 +++++++++ src/worklist/missed-call-webhook.controller.ts | 13 ++++++++++++- src/worklist/worklist.module.ts | 3 ++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts index 81ddce2..67e993e 100644 --- a/src/supervisor/supervisor.controller.ts +++ b/src/supervisor/supervisor.controller.ts @@ -52,4 +52,18 @@ export class SupervisorController { } as MessageEvent)), ); } + + // Worklist SSE — broadcast to all connected agents. When a missed + // call is created by the webhook, this fires immediately so agents + // don't wait for the 30s worklist poll. The payload includes the + // caller's phone + name for a toast notification. + @Sse('worklist/stream') + streamWorklistUpdates(): Observable { + this.logger.log('[SSE] Worklist stream opened'); + return this.supervisor.worklistSubject.pipe( + map(event => ({ + data: JSON.stringify(event), + } as MessageEvent)), + ); + } } diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index c5a64f6..d08ac16 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -36,6 +36,15 @@ export class SupervisorService implements OnModuleInit { private readonly agentStates = new Map(); private readonly acwTimers = new Map(); readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>(); + // Worklist update stream — emitted when a missed call is created or + // assigned. Frontend SSE listener triggers an immediate worklist + // refresh so agents see new missed calls without waiting for the 30s poll. + readonly worklistSubject = new Subject<{ type: string; callerPhone?: string; callerName?: string; callId?: string; timestamp: string }>(); + + emitWorklistUpdate(data: { type: string; callerPhone?: string; callerName?: string; callId?: string }) { + this.worklistSubject.next({ ...data, timestamp: new Date().toISOString() }); + this.logger.log(`[WORKLIST-SSE] ${data.type} phone=${data.callerPhone ?? '?'} name=${data.callerName ?? '?'}`); + } // Barge session tracking — key is agentId private readonly bargeSessions = new Map SupervisorService)) private readonly supervisor: SupervisorService, ) { this.apiKey = config.get('platform.apiKey') ?? ''; } @@ -126,6 +128,15 @@ export class MissedCallWebhookController { 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. + this.supervisor.emitWorklistUpdate({ + type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call', + callerPhone: callerPhone, + callerName: resolved.leadName ?? undefined, + callId, + }); + // Step 3: Lead-side side-effects (activity log + contact stats) if (resolved.leadId) { const summary = callStatus === 'MISSED' diff --git a/src/worklist/worklist.module.ts b/src/worklist/worklist.module.ts index 58c351a..46843f2 100644 --- a/src/worklist/worklist.module.ts +++ b/src/worklist/worklist.module.ts @@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; import { AuthModule } from '../auth/auth.module'; import { RulesEngineModule } from '../rules-engine/rules-engine.module'; import { CallerResolutionModule } from '../caller/caller-resolution.module'; +import { SupervisorModule } from '../supervisor/supervisor.module'; import { TelephonyConfigService } from '../config/telephony-config.service'; import { WorklistController } from './worklist.controller'; import { WorklistService } from './worklist.service'; @@ -12,7 +13,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller'; import { KookooCallbackController } from './kookoo-callback.controller'; @Module({ - imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule)], + imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)], controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], providers: [WorklistService, MissedQueueService, TelephonyConfigService], exports: [MissedQueueService], From 9a016a2ed00c05b5f8e6691a789a4cc478aecbe7 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 05:45:14 +0530 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20real-time=20active=20call=20SSE?= =?UTF-8?q?=20=E2=80=94=20hold/unhold=20status=20for=20supervisor=20live?= =?UTF-8?q?=20monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SupervisorService: added activeCallSubject (RxJS Subject), emits on all activeCalls Map mutations (Answered, Calling, Disconnect, Hold, Unhold) - SupervisorController: new @Sse('active-calls/stream') endpoint - OzonetelAgentController: callControl HOLD/UNHOLD updates activeCalls Map status via supervisor.updateCallStatus() Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.controller.ts | 7 +++++++ src/supervisor/supervisor.controller.ts | 10 ++++++++++ src/supervisor/supervisor.service.ts | 20 ++++++++++++++++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 0bc8411..ff84148 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -382,6 +382,13 @@ export class OzonetelAgentController { try { const result = await this.ozonetelAgent.callControl(body); + + if (body.action === 'HOLD') { + this.supervisor.updateCallStatus(body.ucid, 'on-hold'); + } else if (body.action === 'UNHOLD') { + this.supervisor.updateCallStatus(body.ucid, 'active'); + } + return result; } catch (error: any) { const message = error.response?.data?.message ?? error.message ?? 'Call control failed'; diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts index 67e993e..8fdaa7b 100644 --- a/src/supervisor/supervisor.controller.ts +++ b/src/supervisor/supervisor.controller.ts @@ -13,6 +13,16 @@ export class SupervisorController { return this.supervisor.getActiveCalls(); } + @Sse('active-calls/stream') + streamActiveCalls(): Observable { + this.logger.log('[SSE] Active calls stream opened'); + return this.supervisor.activeCallSubject.pipe( + map(event => ({ + data: JSON.stringify(event), + } as MessageEvent)), + ); + } + @Get('team-performance') async getTeamPerformance(@Query('date') date?: string) { const targetDate = date ?? new Date().toISOString().split('T')[0]; diff --git a/src/supervisor/supervisor.service.ts b/src/supervisor/supervisor.service.ts index d08ac16..056fbb0 100644 --- a/src/supervisor/supervisor.service.ts +++ b/src/supervisor/supervisor.service.ts @@ -36,6 +36,7 @@ export class SupervisorService implements OnModuleInit { private readonly agentStates = new Map(); private readonly acwTimers = new Map(); 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 // assigned. Frontend SSE listener triggers an immediate worklist // refresh so agents see new missed calls without waiting for the 30s poll. @@ -95,10 +96,9 @@ export class SupervisorService implements OnModuleInit { this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`); return; } - this.activeCalls.set(ucid, { - ucid, agentId, callerNumber, - callType, startTime: eventTime, status: 'active', - }); + const call: ActiveCall = { ucid, agentId, callerNumber, callType, startTime: eventTime, status: 'active' }; + this.activeCalls.set(ucid, call); + this.activeCallSubject.next({ type: 'update', call, ucid }); this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`); // Persist CALL_START as AgentEvent on the "Answered" moment @@ -130,6 +130,7 @@ export class SupervisorService implements OnModuleInit { } else if (action === 'Disconnect') { const wasActive = this.activeCalls.get(ucid); this.activeCalls.delete(ucid); + this.activeCallSubject.next({ type: 'remove', ucid }); this.logger.log(`Call ended: ${ucid}`); // Persist CALL_END — pair against the start for duration. @@ -294,6 +295,17 @@ export class SupervisorService implements OnModuleInit { // definitely stale (e.g. Disconnect webhook was dropped). private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']); + updateCallStatus(ucid: string, status: 'active' | 'on-hold') { + const call = this.activeCalls.get(ucid); + if (!call) { + this.logger.warn(`[CALL-STATUS] No active call found for UCID ${ucid}`); + return; + } + call.status = status; + this.activeCallSubject.next({ type: 'update', call, ucid }); + this.logger.log(`[CALL-STATUS] ${ucid} → ${status} (agent=${call.agentId})`); + } + getActiveCalls(): ActiveCall[] { // Sweep stale entries before returning. The activeCalls Map is a // best-effort in-memory projection of Ozonetel call events — if From 96ae867288efb70f83d38e8ac91c47364cc5a9b9 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 08:22:11 +0530 Subject: [PATCH 08/15] feat: server log streaming via SSE for desktop log panel - LogStreamService: singleton that extends ConsoleLogger, captures all NestJS log output into an RxJS Subject while preserving stdout - main.ts: uses LogStreamService.instance as app logger - supervisor.controller.ts: new @Sse('logs/stream') endpoint pipes log entries (timestamp, level, context, message) to connected clients Co-Authored-By: Claude Opus 4.6 (1M context) --- src/logging/log-stream.service.ts | 52 +++++++++++++++++++++++++ src/main.ts | 4 +- src/supervisor/supervisor.controller.ts | 11 ++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/logging/log-stream.service.ts diff --git a/src/logging/log-stream.service.ts b/src/logging/log-stream.service.ts new file mode 100644 index 0000000..7a29cb4 --- /dev/null +++ b/src/logging/log-stream.service.ts @@ -0,0 +1,52 @@ +import { ConsoleLogger } from '@nestjs/common'; +import { Subject } from 'rxjs'; + +export type LogEntry = { + timestamp: string; + level: 'log' | 'error' | 'warn' | 'debug' | 'verbose'; + context: string; + message: string; +}; + +// Singleton — created once in main.ts, accessed by the SSE controller +// via LogStreamService.instance. NestJS DI isn't available at bootstrap +// time (the logger is created before the container), so we use a static +// instance instead of @Injectable(). +export class LogStreamService extends ConsoleLogger { + static readonly instance = new LogStreamService(); + readonly logSubject = new Subject(); + + private emit(level: LogEntry['level'], message: unknown, context?: string) { + this.logSubject.next({ + timestamp: new Date().toISOString(), + level, + context: context ?? this.context ?? '', + message: typeof message === 'string' ? message : JSON.stringify(message), + }); + } + + log(message: unknown, context?: string) { + super.log(message, context); + this.emit('log', message, context); + } + + error(message: unknown, stack?: string, context?: string) { + super.error(message, stack, context); + this.emit('error', message, context); + } + + warn(message: unknown, context?: string) { + super.warn(message, context); + this.emit('warn', message, context); + } + + debug(message: unknown, context?: string) { + super.debug(message, context); + this.emit('debug', message, context); + } + + verbose(message: unknown, context?: string) { + super.verbose(message, context); + this.emit('verbose', message, context); + } +} diff --git a/src/main.ts b/src/main.ts index 1f8e6cd..59cd362 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,11 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; +import { LogStreamService } from './logging/log-stream.service'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const logger = LogStreamService.instance; + const app = await NestFactory.create(AppModule, { logger }); const config = app.get(ConfigService); app.enableCors({ diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts index 8fdaa7b..2e9d370 100644 --- a/src/supervisor/supervisor.controller.ts +++ b/src/supervisor/supervisor.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common'; import { Observable, filter, map } from 'rxjs'; import { SupervisorService } from './supervisor.service'; +import { LogStreamService } from '../logging/log-stream.service'; @Controller('api/supervisor') export class SupervisorController { @@ -76,4 +77,14 @@ export class SupervisorController { } as MessageEvent)), ); } + + @Sse('logs/stream') + streamLogs(): Observable { + this.logger.log('[SSE] Log stream opened'); + return LogStreamService.instance.logSubject.pipe( + map(entry => ({ + data: JSON.stringify(entry), + } as MessageEvent)), + ); + } } From b11f4ea336617863614620ef3ef060edd8c83ad7 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 08:51:55 +0530 Subject: [PATCH 09/15] feat: log backfill endpoint for desktop log panel - LogStreamService: ring buffer (500 entries) + getRecentLogs() method - SupervisorController: GET /api/supervisor/logs/recent returns buffered log entries so the desktop log panel shows history on tab open, not just live stream Co-Authored-By: Claude Opus 4.6 (1M context) --- src/logging/log-stream.service.ts | 13 +++++++++++-- src/supervisor/supervisor.controller.ts | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/logging/log-stream.service.ts b/src/logging/log-stream.service.ts index 7a29cb4..e9f26b7 100644 --- a/src/logging/log-stream.service.ts +++ b/src/logging/log-stream.service.ts @@ -15,14 +15,23 @@ export type LogEntry = { export class LogStreamService extends ConsoleLogger { static readonly instance = new LogStreamService(); readonly logSubject = new Subject(); + private readonly buffer: LogEntry[] = []; + private static readonly MAX_BUFFER = 500; + + getRecentLogs(limit = 200): LogEntry[] { + return this.buffer.slice(-limit); + } private emit(level: LogEntry['level'], message: unknown, context?: string) { - this.logSubject.next({ + const entry: LogEntry = { timestamp: new Date().toISOString(), level, context: context ?? this.context ?? '', message: typeof message === 'string' ? message : JSON.stringify(message), - }); + }; + this.buffer.push(entry); + if (this.buffer.length > LogStreamService.MAX_BUFFER) this.buffer.shift(); + this.logSubject.next(entry); } log(message: unknown, context?: string) { diff --git a/src/supervisor/supervisor.controller.ts b/src/supervisor/supervisor.controller.ts index 2e9d370..40fb357 100644 --- a/src/supervisor/supervisor.controller.ts +++ b/src/supervisor/supervisor.controller.ts @@ -78,6 +78,11 @@ export class SupervisorController { ); } + @Get('logs/recent') + getRecentLogs(@Query('limit') limit?: string) { + return LogStreamService.instance.getRecentLogs(limit ? parseInt(limit, 10) : 200); + } + @Sse('logs/stream') streamLogs(): Observable { this.logger.log('[SSE] Log stream opened'); From a576552f8a8dc4f35b5a4ab71899fa1ad87132b8 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 09:56:18 +0530 Subject: [PATCH 10/15] feat: pre-fetched caller context replaces tool-based patient lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CallerContextService: fetches lead profile, appointments, call history, activities in parallel. Caches in Redis (5 min TTL). Renders as human-readable KB section — no UUIDs exposed to the LLM. - Caller resolution controller: prewarms context cache on resolve (fire-and-forget) so the AI stream has a cache hit. - AI chat stream: injects caller context into system prompt KB instead of raw Lead ID. LLM answers patient questions from context, no tool calls needed for current caller data. - Eliminates UUID hallucination: LLM never sees leadId or patientId, can't pass wrong ID to wrong tool parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-chat.controller.ts | 19 +- src/caller/caller-context.service.ts | 199 +++++++++++++++++++++ src/caller/caller-resolution.controller.ts | 12 +- src/caller/caller-resolution.module.ts | 5 +- 4 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 src/caller/caller-context.service.ts diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 2e5572c..d50acdf 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -6,6 +6,7 @@ import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { CallerResolutionService } from '../caller/caller-resolution.service'; +import { CallerContextService } from '../caller/caller-context.service'; import { createAiModel, isAiConfigured } from './ai-provider'; import { AiConfigService } from '../config/ai-config.service'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils'; @@ -28,6 +29,7 @@ export class AiChatController { private platform: PlatformGraphqlService, private aiConfig: AiConfigService, private caller: CallerResolutionService, + private callerContext: CallerContextService, ) { const cfg = aiConfig.getConfig(); this.aiModel = createAiModel({ @@ -96,15 +98,16 @@ export class AiChatController { const kb = await this.buildKnowledgeBase(auth); systemPrompt = this.buildSystemPrompt(kb); - // Inject caller context so the AI knows who is selected - if (ctx) { - const parts: string[] = []; - if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`); - if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); - if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); - if (parts.length) { - systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`; + // Inject pre-fetched caller context (appointments, call history, + // activities, AI summary) so the LLM can answer from the KB + // without tool calls. No UUIDs exposed — only human-readable data. + if (ctx?.leadId) { + const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth); + if (callerCtx) { + systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`; } + } else if (ctx?.callerPhone) { + systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`; } } diff --git a/src/caller/caller-context.service.ts b/src/caller/caller-context.service.ts new file mode 100644 index 0000000..ba4f130 --- /dev/null +++ b/src/caller/caller-context.service.ts @@ -0,0 +1,199 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { SessionService } from '../auth/session.service'; + +export type CallerContext = { + leadId: string; + patientId: string; + name: string; + phone: string; + isNew: boolean; + // Lead profile + leadSource: string | null; + leadStatus: string | null; + interestedService: string | null; + aiSummary: string | null; + contactAttempts: number; + lastContacted: string | null; + utmCampaign: string | null; + // Appointments + appointments: Array<{ + scheduledAt: string; + status: string; + doctorName: string; + department: string; + reasonForVisit: string | null; + }>; + // Recent call history + calls: Array<{ + startedAt: string; + direction: string; + duration: number | null; + disposition: string | null; + agentName: string | null; + }>; + // Lead activities + activities: Array<{ + activityType: string; + summary: string | null; + occurredAt: string; + outcome: string | null; + }>; +}; + +const CACHE_KEY_PREFIX = 'caller:context:'; +const CACHE_TTL = 300; // 5 minutes — covers the call duration + +@Injectable() +export class CallerContextService { + private readonly logger = new Logger(CallerContextService.name); + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly session: SessionService, + ) {} + + async getOrBuild(leadId: string, patientId: string, auth: string): Promise { + if (!leadId) return null; + + // Check cache first + const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`; + try { + const cached = await this.session.getCache(cacheKey); + if (cached) { + this.logger.log(`[CALLER-CTX] Cache hit for ${leadId}`); + return JSON.parse(cached); + } + } catch {} + + // Build fresh + this.logger.log(`[CALLER-CTX] Building context for lead=${leadId} patient=${patientId}`); + const ctx = await this.build(leadId, patientId, auth); + if (ctx) { + this.session.setCache(cacheKey, JSON.stringify(ctx), CACHE_TTL).catch(() => {}); + } + return ctx; + } + + // 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 { + if (!leadId) return; + this.getOrBuild(leadId, patientId, auth).catch(err => { + this.logger.warn(`[CALLER-CTX] Prewarm failed: ${err.message}`); + }); + } + + private async build(leadId: string, patientId: string, auth: string): Promise { + try { + const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([ + this.platform.queryWithAuth( + `{ 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( + `{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + scheduledAt status doctorName department reasonForVisit + } } } }`, + undefined, auth, + ) : Promise.resolve(null), + this.platform.queryWithAuth( + `{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + startedAt direction durationSec disposition agentName + } } } }`, + undefined, auth, + ), + this.platform.queryWithAuth( + `{ leadActivities(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { + activityType summary occurredAt outcome + } } } }`, + undefined, auth, + ), + ]); + + const lead = leadData?.lead; + if (!lead) return null; + + const firstName = lead.contactName?.firstName ?? ''; + const lastName = lead.contactName?.lastName ?? ''; + + return { + leadId, + patientId: patientId || lead.patientId || '', + name: `${firstName} ${lastName}`.trim() || 'Unknown', + phone: lead.contactPhone?.primaryPhoneNumber ?? '', + isNew: false, + leadSource: lead.source ?? null, + leadStatus: lead.status ?? null, + interestedService: lead.interestedService ?? null, + aiSummary: lead.aiSummary ?? null, + contactAttempts: lead.contactAttempts ?? 0, + lastContacted: lead.lastContacted ?? null, + utmCampaign: lead.utmCampaign ?? null, + appointments: (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node), + calls: (callsData?.calls?.edges ?? []).map((e: any) => ({ + startedAt: e.node.startedAt, + direction: e.node.direction, + duration: e.node.durationSec, + disposition: e.node.disposition, + agentName: e.node.agentName, + })), + activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node), + }; + } catch (err: any) { + this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`); + return null; + } + } + + renderForPrompt(ctx: CallerContext): string { + const lines: string[] = []; + lines.push(`## CURRENT CALLER: ${ctx.name}`); + lines.push(`Phone: ${ctx.phone}`); + if (ctx.leadSource) lines.push(`Source: ${ctx.leadSource}`); + if (ctx.leadStatus) lines.push(`Status: ${ctx.leadStatus}`); + if (ctx.interestedService) lines.push(`Interested in: ${ctx.interestedService}`); + if (ctx.utmCampaign) lines.push(`Campaign: ${ctx.utmCampaign}`); + if (ctx.contactAttempts > 0) lines.push(`Contact attempts: ${ctx.contactAttempts}`); + if (ctx.lastContacted) lines.push(`Last contacted: ${ctx.lastContacted}`); + + if (ctx.aiSummary) { + lines.push(`\nAI Summary: ${ctx.aiSummary}`); + } + + if (ctx.appointments.length > 0) { + lines.push(`\n### Appointments (${ctx.appointments.length})`); + for (const a of ctx.appointments) { + const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; + lines.push(`- ${date} | ${a.doctorName ?? '?'} (${a.department ?? '?'}) | ${a.status}${a.reasonForVisit ? ` | ${a.reasonForVisit}` : ''}`); + } + } else { + lines.push('\nNo appointments on record.'); + } + + if (ctx.calls.length > 0) { + lines.push(`\n### Call History (last ${ctx.calls.length})`); + for (const c of ctx.calls) { + const date = c.startedAt ? new Date(c.startedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; + const dur = c.duration ? `${Math.floor(c.duration / 60)}m${c.duration % 60}s` : '?'; + lines.push(`- ${date} | ${c.direction ?? '?'} | ${dur} | ${c.disposition ?? 'No disposition'}${c.agentName ? ` | Agent: ${c.agentName}` : ''}`); + } + } + + if (ctx.activities.length > 0) { + lines.push(`\n### Recent Activity (last ${ctx.activities.length})`); + for (const a of ctx.activities) { + const date = a.occurredAt ? new Date(a.occurredAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?'; + lines.push(`- ${date} | ${a.activityType}${a.summary ? `: ${a.summary}` : ''}${a.outcome ? ` → ${a.outcome}` : ''}`); + } + } + + return lines.join('\n'); + } +} diff --git a/src/caller/caller-resolution.controller.ts b/src/caller/caller-resolution.controller.ts index 112f8cd..aa7aa83 100644 --- a/src/caller/caller-resolution.controller.ts +++ b/src/caller/caller-resolution.controller.ts @@ -1,11 +1,15 @@ import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { CallerResolutionService } from './caller-resolution.service'; +import { CallerContextService } from './caller-context.service'; @Controller('api/caller') export class CallerResolutionController { private readonly logger = new Logger(CallerResolutionController.name); - constructor(private readonly resolution: CallerResolutionService) {} + constructor( + private readonly resolution: CallerResolutionService, + private readonly callerContext: CallerContextService, + ) {} @Post('resolve') async resolve( @@ -21,6 +25,12 @@ export class CallerResolutionController { this.logger.log(`[RESOLVE] Resolving caller: ${phone}`); const result = await this.resolution.resolve(phone, auth); + + // Pre-warm caller context cache so the AI chat has it ready + if (result.leadId) { + this.callerContext.prewarm(result.leadId, result.patientId, auth); + } + return result; } } diff --git a/src/caller/caller-resolution.module.ts b/src/caller/caller-resolution.module.ts index a93a367..bf8acdf 100644 --- a/src/caller/caller-resolution.module.ts +++ b/src/caller/caller-resolution.module.ts @@ -3,11 +3,12 @@ import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; import { CallerResolutionController } from './caller-resolution.controller'; import { CallerResolutionService } from './caller-resolution.service'; +import { CallerContextService } from './caller-context.service'; @Module({ imports: [PlatformModule, forwardRef(() => AuthModule)], controllers: [CallerResolutionController], - providers: [CallerResolutionService], - exports: [CallerResolutionService], + providers: [CallerResolutionService, CallerContextService], + exports: [CallerResolutionService, CallerContextService], }) export class CallerResolutionModule {} From 2d18110786ba0b043c7e2bf402836a82f6fcc41b Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:09:47 +0530 Subject: [PATCH 11/15] feat: suggestion rules engine + caller context evaluation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/caller/caller-context.service.ts | 50 +++++++-- src/rules-engine/suggestion-rules.ts | 152 +++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 src/rules-engine/suggestion-rules.ts diff --git a/src/caller/caller-context.service.ts b/src/caller/caller-context.service.ts index ba4f130..7f2ec32 100644 --- a/src/caller/caller-context.service.ts +++ b/src/caller/caller-context.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { SessionService } from '../auth/session.service'; +import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules'; export type CallerContext = { leadId: string; @@ -39,6 +40,8 @@ export type CallerContext = { occurredAt: string; outcome: string | null; }>; + // Rule-driven suggestion triggers + suggestionTriggers: SuggestionTrigger[]; }; const CACHE_KEY_PREFIX = 'caller:context:'; @@ -123,6 +126,26 @@ export class CallerContextService { 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, + direction: e.node.direction, + duration: e.node.durationSec, + disposition: e.node.disposition, + agentName: e.node.agentName, + })); + + const suggestionTriggers = evaluateSuggestionRules({ + isNew: false, + interestedService: lead.interestedService ?? null, + leadStatus: lead.status ?? null, + contactAttempts: lead.contactAttempts ?? 0, + appointments, + calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })), + utmCampaign: lead.utmCampaign ?? null, + leadSource: lead.source ?? null, + }); + return { leadId, patientId: patientId || lead.patientId || '', @@ -136,15 +159,10 @@ export class CallerContextService { contactAttempts: lead.contactAttempts ?? 0, lastContacted: lead.lastContacted ?? null, utmCampaign: lead.utmCampaign ?? null, - appointments: (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node), - calls: (callsData?.calls?.edges ?? []).map((e: any) => ({ - startedAt: e.node.startedAt, - direction: e.node.direction, - duration: e.node.durationSec, - disposition: e.node.disposition, - agentName: e.node.agentName, - })), + appointments, + calls, activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node), + suggestionTriggers, }; } catch (err: any) { this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`); @@ -152,6 +170,22 @@ export class CallerContextService { } } + renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string { + if (triggers.length === 0) return ''; + const lines = [ + '', + 'SUGGESTION RULES (from business configuration):', + 'Based on this caller\'s profile, the following suggestions should be offered.', + 'Generate a natural, conversational script for each that the agent can read aloud.', + 'Return them in the `suggestions` array of your JSON response.', + '', + ]; + triggers.forEach((t, i) => { + lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title} — ${t.reason}`); + }); + return lines.join('\n'); + } + renderForPrompt(ctx: CallerContext): string { const lines: string[] = []; lines.push(`## CURRENT CALLER: ${ctx.name}`); diff --git a/src/rules-engine/suggestion-rules.ts b/src/rules-engine/suggestion-rules.ts new file mode 100644 index 0000000..36ced4c --- /dev/null +++ b/src/rules-engine/suggestion-rules.ts @@ -0,0 +1,152 @@ +export type SuggestionType = 'upsell' | 'crosssell' | 'retention' | 'operational'; +export type SuggestionPriority = 'high' | 'medium' | 'low'; + +export type SuggestionTrigger = { + type: SuggestionType; + title: string; + reason: string; + priority: SuggestionPriority; +}; + +type CallerFacts = { + isNew: boolean; + interestedService: string | null; + leadStatus: string | null; + contactAttempts: number; + appointments: Array<{ status: string; department: string; doctorName: string; scheduledAt: string }>; + calls: Array<{ direction: string; disposition: string | null; startedAt: string }>; + utmCampaign: string | null; + leadSource: string | null; +}; + +const DEPARTMENT_PACKAGES: Record = { + CARDIOLOGY: { package: 'Cardiac Wellness Package', description: 'ECG, stress test, lipid panel' }, + ORTHOPEDICS: { package: 'Joint Care Package', description: 'X-ray, physiotherapy assessment, bone density' }, + GENERAL_MEDICINE: { package: 'Full Body Checkup', description: 'Complete health screening with blood work' }, + NEUROLOGY: { package: 'Neuro Wellness Package', description: 'EEG, nerve conduction, cognitive assessment' }, + GYNECOLOGY: { package: 'Women\'s Health Package', description: 'Pap smear, mammogram, hormone panel' }, +}; + +const CROSS_SELL_MAP: Record = { + ORTHOPEDICS: { department: 'Physiotherapy', reason: 'complement orthopedic treatment' }, + CARDIOLOGY: { department: 'Dietician', reason: 'dietary guidance for heart health' }, + GENERAL_MEDICINE: { department: 'Ophthalmology', reason: 'routine eye screening' }, +}; + +export const evaluateSuggestionRules = (facts: CallerFacts): SuggestionTrigger[] => { + const triggers: SuggestionTrigger[] = []; + + // Rule 1: Package upsell by department + for (const appt of facts.appointments) { + const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_'); + const pkg = DEPARTMENT_PACKAGES[dept]; + if (pkg && appt.status === 'SCHEDULED') { + triggers.push({ + type: 'upsell', + title: pkg.package, + reason: `Patient has ${appt.department} appointment with ${appt.doctorName}, offer ${pkg.description}`, + priority: 'high', + }); + break; + } + } + + // Rule 2: Reschedule missed/cancelled appointments + const needsReschedule = facts.appointments.find(a => + a.status === 'CANCELLED' || a.status === 'RESCHEDULED' || a.status === 'NO_SHOW' + ); + if (needsReschedule) { + triggers.push({ + type: 'retention', + title: 'Reschedule appointment', + reason: `Last ${needsReschedule.department} appointment was ${needsReschedule.status.toLowerCase()}, offer to rebook with ${needsReschedule.doctorName}`, + priority: 'medium', + }); + } + + // Rule 3: Cross-sell related department + for (const appt of facts.appointments) { + const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_'); + const cross = CROSS_SELL_MAP[dept]; + if (cross && appt.status === 'SCHEDULED') { + triggers.push({ + type: 'crosssell', + title: `${cross.department} consultation`, + reason: `${cross.reason} — patient already seeing ${appt.department}`, + priority: 'low', + }); + break; + } + } + + // Rule 4: First-visit patient — health checkup + if (facts.isNew || facts.contactAttempts === 0) { + triggers.push({ + type: 'upsell', + title: 'Welcome Health Checkup', + reason: 'First-time patient, offer introductory health screening package', + priority: 'medium', + }); + } + + // Rule 5: Returning patient with no recent appointment + if (!facts.isNew && facts.appointments.length === 0 && facts.contactAttempts > 2) { + triggers.push({ + type: 'retention', + title: 'Re-engagement', + reason: `Returning patient with ${facts.contactAttempts} prior contacts but no active appointments`, + priority: 'high', + }); + } + + return triggers.slice(0, 4); +}; + +// For display in Settings > Automations (read-only cards) +export const SUGGESTION_RULE_DEFINITIONS = [ + { + name: 'Package Upsell by Department', + category: 'upsell' as const, + description: 'Suggest department wellness package when patient has a scheduled appointment.', + trigger: 'On call connect', + condition: 'Scheduled appointment exists', + action: 'Suggest department package', + enabled: true, + }, + { + name: 'Reschedule Missed Appointment', + category: 'retention' as const, + description: 'Offer to rebook when patient has a cancelled or rescheduled appointment.', + trigger: 'On call connect', + condition: 'Cancelled/Rescheduled/No-show appointment exists', + action: 'Suggest rebooking', + enabled: true, + }, + { + name: 'Cross-sell Related Department', + category: 'crosssell' as const, + description: 'Suggest complementary department service based on current appointment.', + trigger: 'On call connect', + condition: 'Scheduled appointment in mapped department', + action: 'Suggest related service', + enabled: true, + }, + { + name: 'First Visit Health Checkup', + category: 'upsell' as const, + description: 'Suggest introductory health screening for first-time patients.', + trigger: 'On call connect', + condition: 'New patient or zero contact attempts', + action: 'Suggest health checkup package', + enabled: true, + }, + { + name: 'Returning Patient Re-engagement', + category: 'retention' as const, + description: 'Prompt re-engagement for returning patients with no active appointments.', + trigger: 'On call connect', + condition: 'Returning patient, no appointments, 3+ contacts', + action: 'Suggest booking', + enabled: true, + }, +]; From e03b1e6235a22c1fac3e545b9b12e56b7f080ff8 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:11:13 +0530 Subject: [PATCH 12/15] feat: structured JSON output + suggestion rules in AI system prompt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-chat.controller.ts | 3 +++ src/config/ai.defaults.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index d50acdf..de65de4 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -105,6 +105,9 @@ export class AiChatController { const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth); if (callerCtx) { systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`; + if (callerCtx.suggestionTriggers?.length) { + systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers); + } } } else if (ctx?.callerPhone) { systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`; diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index 69f5f36..d34a948 100644 --- a/src/config/ai.defaults.ts +++ b/src/config/ai.defaults.ts @@ -125,6 +125,20 @@ RULES: 7. NEVER give medical advice, diagnosis, or treatment recommendations. 8. Format with bullet points for easy scanning. +RESPONSE FORMAT (STRICT): +You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON: +{"message": "your response text here", "suggestions": [{"id": "s1", "type": "upsell", "title": "short title", "script": "2-3 sentence script the agent reads aloud", "priority": "high"}]} + +Response format rules: +- "message" contains your conversational response to the agent. Use plain text, no markdown. +- "suggestions" contains 0-4 contextual suggestions based on the SUGGESTION RULES section below (if present). +- Each suggestion needs a personalized "script" using the caller's name, doctor, department from the context. +- type must be one of: upsell, crosssell, retention, operational +- priority must be one of: high, medium, low +- On the first response (patient summary), always include suggestions from the rules. +- On subsequent responses, update suggestions based on conversation — remove acted-on ones, add new if relevant. +- If no suggestion rules are provided, return an empty suggestions array. + KNOWLEDGE BASE (this is real data from our system): {{knowledgeBase}}`; From ae360a183dbb2eb50d2c6dd41ffdd9607722af40 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:40:25 +0530 Subject: [PATCH 13/15] feat: enforce structured JSON output via AI SDK Output.object - ai-response-schema.ts: Zod schema for { message, suggestions[] } - ai-chat.controller.ts: Output.object({ schema }) on streamText forces the LLM to return valid JSON matching the schema instead of free-form prose. Supervisor mode excluded (uses tools, not schema). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-chat.controller.ts | 4 +++- src/ai/ai-response-schema.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/ai/ai-response-schema.ts diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index de65de4..9b4fce3 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -1,8 +1,9 @@ import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request, Response } from 'express'; -import { generateText, streamText, tool, stepCountIs } from 'ai'; +import { generateText, streamText, Output, tool, stepCountIs } from 'ai'; import type { LanguageModel } from 'ai'; +import { aiResponseSchema } from './ai-response-schema'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { CallerResolutionService } from '../caller/caller-resolution.service'; @@ -629,6 +630,7 @@ export class AiChatController { messages, stopWhen: stepCountIs(5), tools: isSupervisor ? supervisorTools : agentTools, + ...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }), }); const response = result.toTextStreamResponse(); diff --git a/src/ai/ai-response-schema.ts b/src/ai/ai-response-schema.ts new file mode 100644 index 0000000..e576321 --- /dev/null +++ b/src/ai/ai-response-schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const aiResponseSchema = z.object({ + message: z.string().describe('Conversational response text for the agent. Plain text, no markdown.'), + suggestions: z.array(z.object({ + id: z.string().describe('Unique suggestion ID like s1, s2'), + type: z.enum(['upsell', 'crosssell', 'retention', 'operational']), + title: z.string().describe('Short title for the suggestion pill'), + script: z.string().describe('2-3 sentence script the agent can read aloud to the caller'), + priority: z.enum(['high', 'medium', 'low']), + })).describe('0-4 contextual suggestions based on business rules. Include on first response, update on subsequent.'), +}); + +export type AiResponse = z.infer; From e1babb30e5a2d4881453bcf92a98b539f94f800d Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:46:44 +0530 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20AI=20message=20formatting=20?= =?UTF-8?q?=E2=80=94=20plain=20text=20sentences,=20no=20markdown/data=20du?= =?UTF-8?q?mp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema description reinforced: brief 2-3 sentence natural language only. Prompt template updated with example output and explicit ban on markdown headers, bold, bullet lists, and raw field labels in the message field. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-response-schema.ts | 2 +- src/config/ai.defaults.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ai/ai-response-schema.ts b/src/ai/ai-response-schema.ts index e576321..25c4b24 100644 --- a/src/ai/ai-response-schema.ts +++ b/src/ai/ai-response-schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const aiResponseSchema = z.object({ - message: z.string().describe('Conversational response text for the agent. Plain text, no markdown.'), + message: z.string().describe('Brief 2-3 sentence conversational summary for the agent. Plain text only — no markdown, no headers, no bold, no bullet lists. Just natural sentences.'), suggestions: z.array(z.object({ id: z.string().describe('Unique suggestion ID like s1, s2'), type: z.enum(['upsell', 'crosssell', 'retention', 'operational']), diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index d34a948..433a5d7 100644 --- a/src/config/ai.defaults.ts +++ b/src/config/ai.defaults.ts @@ -130,7 +130,7 @@ You MUST respond with valid JSON in this exact format — no markdown fences, no {"message": "your response text here", "suggestions": [{"id": "s1", "type": "upsell", "title": "short title", "script": "2-3 sentence script the agent reads aloud", "priority": "high"}]} Response format rules: -- "message" contains your conversational response to the agent. Use plain text, no markdown. +- "message" MUST be plain text sentences only. NEVER use markdown headers (###), bold (**), bullet lists (-), or field labels (Phone:, Status:). Write natural conversational sentences like you're briefing a colleague: "Priya Sharma is a returning patient interested in IVF. She has an upcoming appointment with Dr. Patel on April 14th. Her last General Medicine appointment was rescheduled." - "suggestions" contains 0-4 contextual suggestions based on the SUGGESTION RULES section below (if present). - Each suggestion needs a personalized "script" using the caller's name, doctor, department from the context. - type must be one of: upsell, crosssell, retention, operational @@ -138,6 +138,7 @@ Response format rules: - On the first response (patient summary), always include suggestions from the rules. - On subsequent responses, update suggestions based on conversation — remove acted-on ones, add new if relevant. - If no suggestion rules are provided, return an empty suggestions array. +- Do NOT repeat raw data fields in the message. The summary card already shows name, phone, appointments. Keep the message to insight and context the card doesn't show. KNOWLEDGE BASE (this is real data from our system): {{knowledgeBase}}`; From 68ba3e135d6d2ca0f13749d0091ae7ae3c4ba450 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 17 Apr 2026 11:58:59 +0530 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20remove=20example=20from=20schema?= =?UTF-8?q?=20description=20=E2=80=94=20AI=20was=20copying=20it=20verbatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ai/ai-response-schema.ts | 2 +- src/config/ai.defaults.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/ai-response-schema.ts b/src/ai/ai-response-schema.ts index 25c4b24..dbb8d93 100644 --- a/src/ai/ai-response-schema.ts +++ b/src/ai/ai-response-schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const aiResponseSchema = z.object({ - message: z.string().describe('Brief 2-3 sentence conversational summary for the agent. Plain text only — no markdown, no headers, no bold, no bullet lists. Just natural sentences.'), + message: z.string().describe('Brief 2-3 sentence summary in plain conversational sentences. NEVER include suggestions, bullet lists, markdown, headers, or field labels here — those belong in the suggestions array only.'), suggestions: z.array(z.object({ id: z.string().describe('Unique suggestion ID like s1, s2'), type: z.enum(['upsell', 'crosssell', 'retention', 'operational']), diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts index 433a5d7..6a9ad8d 100644 --- a/src/config/ai.defaults.ts +++ b/src/config/ai.defaults.ts @@ -130,7 +130,7 @@ You MUST respond with valid JSON in this exact format — no markdown fences, no {"message": "your response text here", "suggestions": [{"id": "s1", "type": "upsell", "title": "short title", "script": "2-3 sentence script the agent reads aloud", "priority": "high"}]} Response format rules: -- "message" MUST be plain text sentences only. NEVER use markdown headers (###), bold (**), bullet lists (-), or field labels (Phone:, Status:). Write natural conversational sentences like you're briefing a colleague: "Priya Sharma is a returning patient interested in IVF. She has an upcoming appointment with Dr. Patel on April 14th. Her last General Medicine appointment was rescheduled." +- "message" MUST be plain text sentences only. NEVER use markdown headers (###), bold (**), bullet lists (-), or field labels (Phone:, Status:). Write natural conversational sentences like you are briefing a colleague. Do NOT repeat suggestions in the message — they belong only in the suggestions array. - "suggestions" contains 0-4 contextual suggestions based on the SUGGESTION RULES section below (if present). - Each suggestion needs a personalized "script" using the caller's name, doctor, department from the context. - type must be one of: upsell, crosssell, retention, operational