Was reading non-existent availableSlots field. Now reads raw visitSlots,
matches target date's day-of-week, and generates hourly time slots from
startTime to endTime. Doctors with 08:00-20:00 get 10 hourly options.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AI was asking "which department?" in text instead of sending the
interactive list. Updated system prompt to be prescriptive: IMMEDIATELY
call send_department_list, never ask in text. Also injects hospital name
from theme config.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Provider-agnostic WhatsApp integration for AI-driven appointment booking:
- MessagingProvider interface (sendText, sendButtons, sendList, parseInbound)
- GupshupProvider implementation (Gupshup WhatsApp API)
- MessagingService — AI orchestration with tools (department/doctor/slot
lists via interactive WhatsApp messages, appointment booking, caller
resolution + context injection)
- Redis conversation history (24h TTL, matches WhatsApp session window)
- Webhook controller at POST /api/messaging/webhook
Swappable to Ozonetel or Meta Cloud API by implementing MessagingProvider
and switching MESSAGING_PROVIDER env var.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
History event persistence was gated behind if(mapped), but login returns
null for state (UI waits for release). LOGIN events were never written
to AgentEvent table → rollup computed 0 for loginDuration → idle/pause/
wrap all zero. Moved history persistence outside the state mapping gate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
History event persistence was gated behind if(mapped), but login returns
null for state (UI waits for release). LOGIN events were never written
to AgentEvent table → rollup computed 0 for loginDuration → idle/pause/
wrap all zero. Moved history persistence outside the state mapping gate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When zero active packages exist, the KB silently omitted the section.
AI then responded "I couldn't find that" for package queries. Now always
includes the section header with explicit "not configured" guidance.
Also removed contradicting rule 8 (bullet points) — conflicts with the
structured output format that requires plain text sentences.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: CDR webhook fires 5s after dispose, stores monitorUCID.
Frontend has agent-side UCID from SIP. They never matched → disposition
not persisted, agent call history empty.
Fix:
- Dispose endpoint now creates Call records for ALL answered calls
(inbound + outbound), not just outbound. Record gets agent-side UCID
+ correct disposition immediately.
- Webhook checks if Call record already exists (by agent UCID via
monitorUCID→agentUCID mapping). If found, enriches with recording
URL, agent chain name, CDR timing. If not found, creates as fallback.
- SupervisorService stores UCID mapping from real-time events (which
carry both UCIDs). Auto-expires after 10 minutes.
Verified: UCID-MAP logged, dispose creates record, webhook enriches
without duplicating. DB shows correct agent UCID + disposition + recording.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CallerContextService: added invalidateCache(leadId) method
- CallerResolutionController: POST /api/caller/invalidate-context
endpoint — frontend calls after appointment mutations to bust
stale AI context cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The build() method previously fetched Lead and Appointments in parallel.
When the input patientId was empty (outbound dial, first-time linkage),
the appointments query was skipped even though the Lead record in the DB
had a valid patientId. Now fetches Lead first, reads its patientId, then
fetches appointments/calls/activities in parallel with the correct ID.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
- 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>
- 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>
- 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>
- 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>
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>
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>
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.
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>
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.
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.
Per-tenant flag that hides self-serve setup surfaces when the product team owns onboarding for a workspace. Set HELIX_SETUP_MANAGED=true on the sidecar env for that tenant; the frontend reads this endpoint at boot, hides the Settings nav + Setup banner, and blocks /settings/* routes. Setup-state APIs stay live so ops can still drive the wizard remotely.
Unlock Agent / Force Ready shortcuts used to read the target agentId from localStorage helix_agent_config — supervisors don't have that set and got 400 'agentId required'.
- SessionService.listLockedSessions() — SCAN over agent:session:*
- POST /api/maint/session-status returns { locked, free } by joining
the platform Agent entities against Redis session locks
- orphan locks (Redis key with no matching Agent record) surface in
the Locked bucket so the operator can still clear stale lock state
Scenario: unknown caller books appointment (creates Patient), calls again, caller resolver links Lead↔Patient. On the second call the frontend found the lead in the worklist cache but it lacked patientId — so Book Appt pills couldn't find the prior appointment. The resolver had the right patientId; the worklist didn't.
Adding patientId to the GraphQL selection so the cached row carries it end-to-end.
Inbound transferred calls arrive with AgentName like 'RamaiahAdmin -> GlobalHealthX'. The webhook was persisting the raw chain string and leaving agentId null; the CDR enrichment cron then silently skipped 100% of rows because the bulk CDR keys on caller-leg UCID while the webhook stores monitorUCID — the join never matched.
- missed-call-webhook: split chain on ' -> ', take final handler,
resolve via AgentLookupService (ozonetelAgentId + display name)
- cdr-enrichment: index CDR rows by both UCID and monitorUCID so
the cron actually patches historical rows
- enrichment also parses chain in CDR AgentName as a second fallback
- spec: add CallerResolutionService + AgentLookupService mocks
Ozonetel's summaryReport only tallies CLOSED login→logout pairs — an
agent who's still logged in reports 00:00:00, so the KPI card on My
Performance always showed 0s for the current session.
Our AgentSession rollup already caps open sessions at "now" when it
runs. Endpoint now:
1. Triggers an on-demand rollupSessions(targetDate) to refresh the
AgentSession row (no 15-min wait after login)
2. Reads AgentSession and renders in the HH:MM:SS shape the frontend
expects
3. Falls back to Ozonetel's summaryReport when AgentSession is
empty (brand-new agent, workspace missing AgentEvent entity)
Works transparently — same timeUtilization shape as before, frontend
unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Populates clinicId on historical Appointments that predate clinicId
persistence. Infers from DoctorVisitSlot records:
1. Pull all appointments with clinicId=NULL
2. Pre-load visit slots per unique doctorId (batched)
3. For each appointment: match slots by weekday + time-window
4. Single clinic → use it
5. Multiple candidates → pick lex-order first (deterministic; logged)
6. Last resort → any slot's clinic
No CDR or rate-limited calls — pure platform queries. Safe to re-run;
idempotent (only patches rows still missing clinicId).
POST /api/maint/backfill-appointment-clinics
Header: x-maint-otp: <OTP>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous flow cached the unfiltered slot list AND applied the "hide
past slots" filter — but only on the fresh-fetch path. A cache hit
returned the stored list untouched, so by lunchtime agents saw morning
slots that had already passed.
Refactored into a post-cache filterPastSlotsForToday() helper applied
on both cache-hit and fresh paths. Cache stores the full day's slots
(keyed by doctorId + dayOfWeek), so same-weekday reuse across weeks
stays correct.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the untrusted platform function path (SDK's lead-auto-assign
was written but never deployed to either workspace — all leads created
after seeding are orphan).
Polls every 60s:
1. Fetch up to 100 unassigned leads (assignedAgent empty or null)
2. Fetch platform Agents whose live SupervisorService state is
ready/calling/in-call/acw (skip offline/break/training/unknown)
3. Build open-lead count per agent (single paginated query)
4. Assign each unassigned lead to the least-loaded active agent —
writes agent.name into lead.assignedAgent to match the worklist
filter (assignedAgent: { eq: agentName })
Catches every lead-creation path: CSV import, enquiry form,
missed-call webhook, widget, livekit. No platform changes needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Clinic entity has never had weekdayHours / saturdayHours /
sundayHours. Schema uses 7 booleans (openMonday..openSunday) + a single
opensAt/closesAt pair, and requiredDocuments is a RELATION
(ClinicRequiredDocumentConnection), not a scalar TEXT.
Query was failing silently since 2026-03-18 — AI chat knowledge base
was missing clinic info for a month.
Fix:
- Query the real fields: openMonday..openSunday, opensAt, closesAt
- Render "Open: Mon, Tue, ... HH:MM–HH:MM" + "Closed: Sat, Sun"
- Walk requiredDocuments.edges for documentType list instead of treating
it as a string
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audited all 23 sidecar create-mutation call sites; 7 were missing the
top-level data.name field that the platform uses as record title:
- caller-resolution.service.ts createPatient — full name from first/last
- maint.controller.ts createPatient (backfill-lead-patient-links) — same
- widget.service.ts createPatient (chat path + booking path) — full name
- widget.service.ts createAppointment — "<Patient> — <date>"
- worklist/missed-queue.service.ts createCall — "Missed — <phone>"
- rules-engine/actions/escalate.action.ts createPerformanceAlert —
"<agent>: <message> (<value>)"
- supervisor/agent-history.service.ts createAgentEvent / createAgentSession
Cosmetic only — the app fetches fullName/agentName for display, so
end users never saw "Untitled". Fixes platform-side admin browsing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase A+B of the alerts overhaul:
- New PerformanceFactsProvider exposes agent.idleMinutes (from
AgentSession), agent.busyMinutes, agent.totalCallsToday,
agent.bookedCallsToday, agent.conversionPercent
- Implement EscalateActionHandler (was a stub): persists a
PerformanceAlert row, dedupes per agent+type+IST date so a 5-min
cron can't spam, updates value if it changes
- New PerformanceConsumer: setInterval every 5 min, reads on_schedule
rules referencing agent.* facts, evaluates per agent, dispatches
escalate actions
- Two starter rules in hospital-starter.json: excessive-idle (>60min)
and low-conversion (<15% with >10 calls today). NPS deferred — no
source signal exists yet
- New PerformanceAlertsController: GET /api/supervisor/performance-alerts
(active list), POST /:id/dismiss, POST /dismiss-all
- Rules engine now injects EscalateActionHandler via DI so the action
has access to PlatformGraphqlService for persistence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Inbound webhook rows store agentName as Ozonetel's display string
("Ganesh Bandi", "GlobalHealthX") which doesn't match either
ozonetelAgentId or platform Agent.name. Add ozonetelDisplayName as a
third index — populated on each platform Agent with the Full Name from
the Ozonetel admin UI.
After this + setting display names on Global + Ramaiah agents:
backfill matched 122/136 (~90%) on Global; remaining are calls handled
by agents that don't exist on this workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Historical Calls pre-date UCID persistence, so the CDR-join enrichment
can't reach them. Fallback: parse agentName (may be "A -> B -> C"
transfer chain), take the final hop, resolve to Agent by ozonetelAgentId
(case-insensitive) or by Full Name. Preserve the full chain string in
transferredTo when it was actually chained.
No rate limit — pure platform queries, no CDR.
POST /api/maint/backfill-call-agents-by-name
Header: x-maint-otp: <OTP>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first boot hit 429 because two dates (today + yesterday) were
fetched back-to-back, and the dispose flow's fetchCdrByUCID shares the
same 2-req/min budget. 35s between dates keeps us clear of the cap.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ozonetel's webhook AgentName is a transfer-chain display string — same
display can collide (two agents both named "GlobalHealthX" with distinct
agent IDs), and chained like "RamaiahAdmin -> Ganesh Bandi -> GlobalHealthX".
Team Performance was bucketing every unique raw string as a separate
"agent", producing 7 rows for 3 real agents.
Fix — authoritative agent link via CDR AgentID (unique):
- New AgentLookupService (platform module): case-insensitive
ozonetelAgentId → Agent UUID cache, shared across webhook / dispose /
enrichment / backfill paths
- Webhook + outbound-dispose now persist UCID on Call so CDR can join
- Outbound dispose resolves agent relation at create time and overwrites
from CDR AgentID post-hoc (catches dial transfers)
- New CdrEnrichmentService: every 30 min fetches today + yesterday CDR,
patches Calls missing agentId / transferredTo / transferType by UCID
join. Well under Ozonetel's 2 req/min cap.
- Historical backfill maint endpoint: /api/maint/enrich-call-agents
with configurable day window (default 2, max 15). Rate-limited at 35s
between dates.
Call schema additions (synced on Global + Ramaiah): agent relation,
ucid, transferredTo, transferType. agentName remains for legacy/display.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3 — wire the dashboard to the new metrics path without touching
the frontend. getTeamPerformance now:
1. Fetches AgentSession rows for the given IST date (keyed by agent UUID)
2. For each agent: uses AgentSession data rendered as HH:MM:SS if a row
exists, otherwise falls back to Ozonetel summaryReport
3. Returns timeBreakdownSource so the frontend can optionally show which
source was used
Frontend continues to parse the existing HH:MM:SS shape via parseTime()
— no UI change needed. Historical dates without AgentSession rows still
render via Ozonetel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Recomputes durationS on existing AgentEvent rows using the fixed
per-category pairing logic and re-runs the session rollup for every
affected date. Fixes the 0-second CALL_END durations written before the
slot-split fix.
Idempotent — only patches rows whose stored durationS differs from the
newly computed value. Safe to re-run.
POST /api/maint/backfill-agent-event-durations
Headers: x-maint-otp: <OTP>
Body: { "date": "YYYY-MM-DD" } (optional; defaults to today IST; use "all" to backfill every row)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CALL and ACW overlap: an agent enters ACW before the CALL_END webhook
arrives. With a single shared pending slot, ACW_START would clobber the
pending CALL_START and CALL_END would compute 0-second duration against
the ACW_START timestamp. Verified in production data — 4/4 CALL_END rows
on Global had durationS=0.
Fix: one slot per category (pause/call/acw). Each END reads and clears
its own slot. READY and LOGOUT defensively flush all slots to avoid
leaking state across sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New AgentHistoryService: persistAgentEvent pairs START/END for durationS, patchCallTiming updates Call SLA fields
- Supervisor service wires handleCallEvent (CALL_START on Answered, CALL_END on Disconnect) and handleAgentEvent (LOGIN/LOGOUT/PAUSE/RESUME/ACW_START/ACW_END/READY) via priorState-aware mapping
- setInterval-based nightly-ish rollup: every 15min aggregates AgentEvent into AgentSession per IST day (idempotent upsert by agentId+date)
- Ozonetel dispose flow extracts HandlingTime/WrapupDuration/HoldDuration from CDR, patches Call timing fields
- Field names match platform truncation: durationS, loginDurationS, busyTimeS, idleTimeS, pauseTimeS, wrapupTimeS, avgHandlingTimeS, handlingTimeS, acwDurationS, holdDurationS, responseTimeS, sessionDate → date
- Skips cleanly on workspaces where AgentEvent entity isn't synced
Known issue: pending-pair map has single slot per agent, so ACW_START overwrites pending CALL_START and CALL_END computes 0s duration. Fix in followup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AI chat book_appointment tool: accepts optional clinicId
- Widget booking: passes clinicId from request
- LiveKit agent: passes clinicId from doctor context if available
- Dispose endpoint: sets startedAt/endedAt on outbound call records
(computed from durationSec). Fixes null timestamps in call history.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Return empty IDs for unrecognized numbers instead of creating lead+patient.
Per PRD: 'System will not identify the patient — no summary shown.'
Records are created when agent books appointment or logs enquiry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ozonetel sends action: 'pause' via webhook when agent is paused, but
mapOzonetelAction only handled 'AUX'. The 'pause' action fell through
to default (null), so the break SSE event was never emitted. The agent
UI stayed on 'Ready' while Ozonetel had the agent PAUSED.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Supervisor users were getting 'executive' role because only 'HelixEngage
Manager' was mapped to admin. This broke admin route access after the
RequireAdmin guard was added.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ozonetel returns raw base64 public key without PEM headers. Node's
crypto.publicEncrypt requires PEM format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>