18 Commits

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:47:37 +05:30
68ba3e135d fix: remove example from schema description — AI was copying it verbatim
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:58:59 +05:30
e1babb30e5 fix: AI message formatting — plain text sentences, no markdown/data dump
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) <noreply@anthropic.com>
2026-04-17 11:46:44 +05:30
ae360a183d 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) <noreply@anthropic.com>
2026-04-17 11:40:25 +05:30
e03b1e6235 feat: structured JSON output + suggestion rules in AI system prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:11:13 +05:30
2d18110786 feat: suggestion rules engine + caller context evaluation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:09:47 +05:30
a576552f8a feat: pre-fetched caller context replaces tool-based patient lookups
- 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) <noreply@anthropic.com>
2026-04-17 09:56:18 +05:30
b11f4ea336 feat: log backfill endpoint for desktop log panel
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 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) <noreply@anthropic.com>
2026-04-17 08:51:55 +05:30
96ae867288 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) <noreply@anthropic.com>
2026-04-17 08:22:11 +05:30
9a016a2ed0 feat: real-time active call SSE — hold/unhold status for supervisor live monitor
- 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) <noreply@anthropic.com>
2026-04-17 05:45:14 +05:30
9cf0f69dde feat: SSE push for worklist updates — instant missed-call notifications
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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) <noreply@anthropic.com>
2026-04-16 18:32:57 +05:30
a6f4c51ca9 fix: disposition for answered inbound calls + SLA timing wiring + backfill
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) <noreply@anthropic.com>
2026-04-16 18:02:49 +05:30
2d8308bed8 fix: remove hardcoded Inbound_918041763265 campaign fallback
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
2026-04-16 17:33:35 +05:30
2666a10f48 fix: await Ozonetel logout + per-agent sipPassword + campaign name on missed calls
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) <noreply@anthropic.com>
2026-04-16 16:54:08 +05:30
Kartik Datrika
1dd8413297 Revert "AI Summary not showing appointments fix."
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This reverts commit 973614749b.
2026-04-16 14:54:20 +05:30
Kartik Datrika
7d8424b446 Revert "AI Summary not showing appointments fix."
This reverts commit 55b8680923.
2026-04-16 14:54:17 +05:30
Kartik Datrika
55b8680923 AI Summary not showing appointments fix. 2026-04-16 12:50:33 +05:30
Kartik Datrika
973614749b AI Summary not showing appointments fix. 2026-04-16 11:36:10 +05:30
18 changed files with 822 additions and 30 deletions

View File

@@ -1,11 +1,13 @@
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common'; import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express'; 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 type { LanguageModel } from 'ai';
import { aiResponseSchema } from './ai-response-schema';
import { z } from 'zod'; import { z } from 'zod';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { CallerResolutionService } from '../caller/caller-resolution.service'; import { CallerResolutionService } from '../caller/caller-resolution.service';
import { CallerContextService } from '../caller/caller-context.service';
import { createAiModel, isAiConfigured } from './ai-provider'; import { createAiModel, isAiConfigured } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service'; import { AiConfigService } from '../config/ai-config.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
@@ -28,6 +30,7 @@ export class AiChatController {
private platform: PlatformGraphqlService, private platform: PlatformGraphqlService,
private aiConfig: AiConfigService, private aiConfig: AiConfigService,
private caller: CallerResolutionService, private caller: CallerResolutionService,
private callerContext: CallerContextService,
) { ) {
const cfg = aiConfig.getConfig(); const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({ this.aiModel = createAiModel({
@@ -96,15 +99,19 @@ export class AiChatController {
const kb = await this.buildKnowledgeBase(auth); const kb = await this.buildKnowledgeBase(auth);
systemPrompt = this.buildSystemPrompt(kb); systemPrompt = this.buildSystemPrompt(kb);
// Inject caller context so the AI knows who is selected // Inject pre-fetched caller context (appointments, call history,
if (ctx) { // activities, AI summary) so the LLM can answer from the KB
const parts: string[] = []; // without tool calls. No UUIDs exposed — only human-readable data.
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`); if (ctx?.leadId) {
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`); const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth);
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`); if (callerCtx) {
if (parts.length) { systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`;
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.`; 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.`;
} }
} }
@@ -623,6 +630,7 @@ export class AiChatController {
messages, messages,
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
tools: isSupervisor ? supervisorTools : agentTools, tools: isSupervisor ? supervisorTools : agentTools,
...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }),
}); });
const response = result.toTextStreamResponse(); const response = result.toTextStreamResponse();

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const aiResponseSchema = z.object({
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']),
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<typeof aiResponseSchema>;

View File

@@ -29,7 +29,11 @@ export class AgentConfigService {
return this.telephony.getConfig().sip.wsPort || '444'; return this.telephony.getConfig().sip.wsPort || '444';
} }
private get defaultCampaignName(): string { 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<AgentConfig | null> { async getByMemberId(memberId: string): Promise<AgentConfig | null> {

View File

@@ -138,10 +138,9 @@ export class AuthController {
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`); this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
}); });
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
this.ozonetelAgent.loginAgent({ this.ozonetelAgent.loginAgent({
agentId: agentConfig.ozonetelAgentId, agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword, password: agentConfig.sipPassword,
phoneNumber: agentConfig.sipExtension, phoneNumber: agentConfig.sipExtension,
mode: 'blended', mode: 'blended',
}).catch(err => { }).catch(err => {
@@ -250,9 +249,14 @@ export class AuthController {
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId); await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
this.logger.log(`Session unlocked for ${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, agentId: agentConfig.ozonetelAgentId,
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$', password: agentConfig.sipPassword,
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`)); }).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
this.agentConfigService.clearCache(memberId); this.agentConfigService.clearCache(memberId);

View File

@@ -0,0 +1,233 @@
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;
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;
}>;
// Rule-driven suggestion triggers
suggestionTriggers: SuggestionTrigger[];
};
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<CallerContext | null> {
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<CallerContext | null> {
try {
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
this.platform.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${leadId}" } }) {
id contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
source status interestedService
aiSummary contactAttempts lastContacted
utmCampaign patientId
} }`,
undefined, auth,
),
patientId ? this.platform.queryWithAuth<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
scheduledAt status doctorName department reasonForVisit
} } } }`,
undefined, auth,
) : Promise.resolve(null),
this.platform.queryWithAuth<any>(
`{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
startedAt direction durationSec disposition agentName
} } } }`,
undefined, auth,
),
this.platform.queryWithAuth<any>(
`{ 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 ?? '';
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 || '',
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,
calls,
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
suggestionTriggers,
};
} catch (err: any) {
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
return null;
}
}
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}`);
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');
}
}

View File

@@ -1,11 +1,15 @@
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { CallerResolutionService } from './caller-resolution.service'; import { CallerResolutionService } from './caller-resolution.service';
import { CallerContextService } from './caller-context.service';
@Controller('api/caller') @Controller('api/caller')
export class CallerResolutionController { export class CallerResolutionController {
private readonly logger = new Logger(CallerResolutionController.name); private readonly logger = new Logger(CallerResolutionController.name);
constructor(private readonly resolution: CallerResolutionService) {} constructor(
private readonly resolution: CallerResolutionService,
private readonly callerContext: CallerContextService,
) {}
@Post('resolve') @Post('resolve')
async resolve( async resolve(
@@ -21,6 +25,12 @@ export class CallerResolutionController {
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`); this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
const result = await this.resolution.resolve(phone, auth); 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; return result;
} }
} }

View File

@@ -3,11 +3,12 @@ import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { CallerResolutionController } from './caller-resolution.controller'; import { CallerResolutionController } from './caller-resolution.controller';
import { CallerResolutionService } from './caller-resolution.service'; import { CallerResolutionService } from './caller-resolution.service';
import { CallerContextService } from './caller-context.service';
@Module({ @Module({
imports: [PlatformModule, forwardRef(() => AuthModule)], imports: [PlatformModule, forwardRef(() => AuthModule)],
controllers: [CallerResolutionController], controllers: [CallerResolutionController],
providers: [CallerResolutionService], providers: [CallerResolutionService, CallerContextService],
exports: [CallerResolutionService], exports: [CallerResolutionService, CallerContextService],
}) })
export class CallerResolutionModule {} export class CallerResolutionModule {}

View File

@@ -125,6 +125,21 @@ RULES:
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. 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" 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
- 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.
- 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): KNOWLEDGE BASE (this is real data from our system):
{{knowledgeBase}}`; {{knowledgeBase}}`;

View File

@@ -0,0 +1,61 @@
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<LogEntry>();
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) {
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) {
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);
}
}

View File

@@ -3,9 +3,11 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path'; import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { LogStreamService } from './logging/log-stream.service';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const logger = LogStreamService.instance;
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
const config = app.get(ConfigService); const config = app.get(ConfigService);
app.enableCors({ app.enableCors({

View File

@@ -31,13 +31,26 @@ export class MaintController {
async forceReady(@Body() body: { agentId: string }) { async forceReady(@Body() body: { agentId: string }) {
if (!body?.agentId) throw new HttpException('agentId required', 400); if (!body?.agentId) throw new HttpException('agentId required', 400);
const agentId = body.agentId; 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<any>(
`{ 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 { try {
await this.ozonetel.logoutAgent({ agentId, password }); await this.ozonetel.logoutAgent({ agentId, password });
@@ -959,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)}`); 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 }; 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<string, any>();
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<any>(
`{ 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<string, string> = {
'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<string, any> = {};
// 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<any>(
`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 };
}
} }

View File

@@ -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<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 {
await this.missedQueue.assignNext(agentId); await this.missedQueue.assignNext(agentId);
@@ -354,6 +382,13 @@ export class OzonetelAgentController {
try { try {
const result = await this.ozonetelAgent.callControl(body); 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; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Call control failed'; const message = error.response?.data?.message ?? error.message ?? 'Call control failed';

View File

@@ -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<string, { package: string; description: string }> = {
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<string, { department: string; reason: string }> = {
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,
},
];

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common'; import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
import { Observable, filter, map } from 'rxjs'; import { Observable, filter, map } from 'rxjs';
import { SupervisorService } from './supervisor.service'; import { SupervisorService } from './supervisor.service';
import { LogStreamService } from '../logging/log-stream.service';
@Controller('api/supervisor') @Controller('api/supervisor')
export class SupervisorController { export class SupervisorController {
@@ -13,6 +14,16 @@ export class SupervisorController {
return this.supervisor.getActiveCalls(); return this.supervisor.getActiveCalls();
} }
@Sse('active-calls/stream')
streamActiveCalls(): Observable<MessageEvent> {
this.logger.log('[SSE] Active calls stream opened');
return this.supervisor.activeCallSubject.pipe(
map(event => ({
data: JSON.stringify(event),
} as MessageEvent)),
);
}
@Get('team-performance') @Get('team-performance')
async getTeamPerformance(@Query('date') date?: string) { async getTeamPerformance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0]; const targetDate = date ?? new Date().toISOString().split('T')[0];
@@ -52,4 +63,33 @@ export class SupervisorController {
} as MessageEvent)), } 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<MessageEvent> {
this.logger.log('[SSE] Worklist stream opened');
return this.supervisor.worklistSubject.pipe(
map(event => ({
data: JSON.stringify(event),
} as MessageEvent)),
);
}
@Get('logs/recent')
getRecentLogs(@Query('limit') limit?: string) {
return LogStreamService.instance.getRecentLogs(limit ? parseInt(limit, 10) : 200);
}
@Sse('logs/stream')
streamLogs(): Observable<MessageEvent> {
this.logger.log('[SSE] Log stream opened');
return LogStreamService.instance.logSubject.pipe(
map(entry => ({
data: JSON.stringify(entry),
} as MessageEvent)),
);
}
} }

View File

@@ -36,6 +36,16 @@ export class SupervisorService implements OnModuleInit {
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>();
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 }>();
// 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 // Barge session tracking — key is agentId
private readonly bargeSessions = new Map<string, { private readonly bargeSessions = new Map<string, {
@@ -86,10 +96,9 @@ export class SupervisorService implements OnModuleInit {
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`); this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
return; return;
} }
this.activeCalls.set(ucid, { const call: ActiveCall = { ucid, agentId, callerNumber, callType, startTime: eventTime, status: 'active' };
ucid, agentId, callerNumber, this.activeCalls.set(ucid, call);
callType, startTime: eventTime, status: 'active', this.activeCallSubject.next({ type: 'update', call, ucid });
});
this.logger.log(`Active call: ${agentId}${callerNumber} (${ucid})`); this.logger.log(`Active call: ${agentId}${callerNumber} (${ucid})`);
// Persist CALL_START as AgentEvent on the "Answered" moment // Persist CALL_START as AgentEvent on the "Answered" moment
@@ -101,10 +110,27 @@ export class SupervisorService implements OnModuleInit {
eventType: 'CALL_START', eventType: 'CALL_START',
eventAt: iso, eventAt: iso,
}).catch(() => {}); }).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') { } else if (action === 'Disconnect') {
const wasActive = this.activeCalls.get(ucid); const wasActive = this.activeCalls.get(ucid);
this.activeCalls.delete(ucid); this.activeCalls.delete(ucid);
this.activeCallSubject.next({ type: 'remove', ucid });
this.logger.log(`Call ended: ${ucid}`); this.logger.log(`Call ended: ${ucid}`);
// Persist CALL_END — pair against the start for duration. // Persist CALL_END — pair against the start for duration.
@@ -269,6 +295,17 @@ export class SupervisorService implements OnModuleInit {
// definitely stale (e.g. Disconnect webhook was dropped). // definitely stale (e.g. Disconnect webhook was dropped).
private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']); 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[] { getActiveCalls(): ActiveCall[] {
// Sweep stale entries before returning. The activeCalls Map is a // Sweep stale entries before returning. The activeCalls Map is a
// best-effort in-memory projection of Ozonetel call events — if // best-effort in-memory projection of Ozonetel call events — if
@@ -306,6 +343,50 @@ export class SupervisorService implements OnModuleInit {
return Array.from(this.activeCalls.values()); 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<void> {
try {
const data = await this.platform.query<any>(
`{ 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<string, any> = {};
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<any>(
`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<any> { async getTeamPerformance(date: string): Promise<any> {
// Get all agents from platform. Field names are label-derived // Get all agents from platform. Field names are label-derived
// camelCase on the current platform schema — see // camelCase on the current platform schema — see

View File

@@ -1,7 +1,8 @@
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common'; import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AgentLookupService } from '../platform/agent-lookup.service'; import { AgentLookupService } from '../platform/agent-lookup.service';
import { CallerResolutionService } from '../caller/caller-resolution.service'; import { CallerResolutionService } from '../caller/caller-resolution.service';
import { SupervisorService } from '../supervisor/supervisor.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
// Ozonetel sends all timestamps in IST — convert to UTC for storage // Ozonetel sends all timestamps in IST — convert to UTC for storage
@@ -24,6 +25,7 @@ export class MissedCallWebhookController {
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly caller: CallerResolutionService, private readonly caller: CallerResolutionService,
private readonly agentLookup: AgentLookupService, private readonly agentLookup: AgentLookupService,
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
) { ) {
this.apiKey = config.get<string>('platform.apiKey') ?? ''; this.apiKey = config.get<string>('platform.apiKey') ?? '';
} }
@@ -126,6 +128,15 @@ export class MissedCallWebhookController {
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`); 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) // Step 3: Lead-side side-effects (activity log + contact stats)
if (resolved.leadId) { if (resolved.leadId) {
const summary = callStatus === 'MISSED' const summary = callStatus === 'MISSED'

View File

@@ -4,6 +4,7 @@ import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { RulesEngineModule } from '../rules-engine/rules-engine.module'; import { RulesEngineModule } from '../rules-engine/rules-engine.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module'; import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { SupervisorModule } from '../supervisor/supervisor.module';
import { TelephonyConfigService } from '../config/telephony-config.service'; import { TelephonyConfigService } from '../config/telephony-config.service';
import { WorklistController } from './worklist.controller'; import { WorklistController } from './worklist.controller';
import { WorklistService } from './worklist.service'; import { WorklistService } from './worklist.service';
@@ -12,7 +13,7 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
import { KookooCallbackController } from './kookoo-callback.controller'; import { KookooCallbackController } from './kookoo-callback.controller';
@Module({ @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], controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
providers: [WorklistService, MissedQueueService, TelephonyConfigService], providers: [WorklistService, MissedQueueService, TelephonyConfigService],
exports: [MissedQueueService], exports: [MissedQueueService],

View File

@@ -177,6 +177,7 @@ export class WorklistService {
startedAt endedAt durationSec startedAt endedAt durationSec
disposition leadId leadName disposition leadId leadName
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
campaign { id campaignName }
} } pageInfo { hasNextPage endCursor } } }`, } } pageInfo { hasNextPage endCursor } } }`,
'calls', 'calls',
authHeader, authHeader,