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>
Endpoints:
- GET /api/supervisor/barge/sip-credentials — fetch SIP number from pool
- POST /api/supervisor/barge — initiate barge via Ozonetel apiId 63
- POST /api/supervisor/barge/mode — update mode (listen/whisper/barge)
- POST /api/supervisor/barge/end — cleanup session + Redis
SupervisorService extended with barge session tracking (in-memory Map).
Mode changes emit SSE events to agent: supervisor-whisper, supervisor-barge,
supervisor-left. Listen mode is silent (no event to agent).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- adminUsername + adminPassword in ozonetel section
- Masked in GET response, sentinel-stripped on update
- Env seeds: OZONETEL_ADMIN_USERNAME, OZONETEL_ADMIN_PASSWORD
- Used by supervisor barge/whisper/listen endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
agent-state, dispose, dial, performance, force-ready, unlock-agent
all required agentId from the request body now. No silent fallback
to OZONETEL_AGENT_ID env var which caused cross-tenant operations
in multi-agent setups (Ramaiah operations hitting Global's agent).
OZONETEL_AGENT_ID removed from telephony env seed list. Hardcoded
fallbacks (agent3, Test123$, 521814) deleted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#536: Performance endpoint now accepts agentId query param and filters
CDR to that agent only. Previously returned all agents' calls as one
agent's total. Fixed 'Unanswered' → 'NotAnswered' status filter.
#538: Team performance now includes per-agent call metrics (total,
inbound, outbound, answered, missed) from CDR data + teamTotals
aggregate. Previously only returned Ozonetel time breakdown without
any call counts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redis-cached (5min TTL) lookups via /api/masterdata/departments,
/api/masterdata/doctors, /api/masterdata/clinics. Warms cache on
startup. Frontend dropdowns use these instead of hardcoded lists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dispose endpoint creates Call entity for outbound calls (direction=OUTBOUND).
The webhook now skips outbound, so dispose is the only path for outbound records.
- MissedQueueService filters abandonCalls by own campaign (read from TelephonyConfigService).
Prevents cross-tenant ingestion from shared Ozonetel account.
- WorklistModule provides TelephonyConfigService directly (avoids circular dep with ConfigThemeModule).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Webhook controller now skips outbound calls (type=Manual/OutBound).
An unanswered outbound dial is NOT a missed inbound call — it was
being incorrectly created as MISSED with PENDING_CALLBACK status.
MissedQueueService now filters the Ozonetel abandonCalls API response
by campaign name (read from TelephonyConfigService). Prevents
cross-tenant ingestion when multiple sidecars share the same
Ozonetel account.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dispose endpoint now accepts agentId from body (same pattern as dial
fix). Fixes "Invalid Agent ID" when disposing as non-default agent.
Also fixed JS operator precedence bug in campaign name fallback
that produced "Inbound_" instead of "Inbound_918041763400".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds TelephonyRegistrationService that:
1. On startup: queries platform for agent list, registers with the
telephony dispatcher at TELEPHONY_DISPATCHER_URL
2. Every 30s: sends heartbeat to keep registration alive (90s TTL)
3. On shutdown: deregisters (best-effort, TTL cleans up anyway)
4. On heartbeat failure: auto re-registers
Env vars:
TELEPHONY_DISPATCHER_URL — where to register (outbound to dispatcher)
TELEPHONY_CALLBACK_URL — where events come back (inbound to sidecar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defect 5: Worklist, missed-call-webhook, missed-queue, ai-chat, and
rules-engine all used legacy lowercase field names (callbackstatus,
callsourcenumber, missedcallcount, callbackattemptedat) from the old
VPS schema. Fixed to camelCase (callbackStatus, callSourceNumber,
missedCallCount, callbackAttemptedAt) matching the current SDK sync.
Defect 6: Dial endpoint used global defaults (OZONETEL_AGENT_ID env
var) instead of the logged-in agent's config. Now accepts agentId
and campaignName from the frontend request body. Falls back to
telephony config → DID-derived campaign name → explicit error.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Ozonetel sends an ACW event, starts a 30-second timer. If no
/api/ozonetel/dispose call arrives within that window (frontend
crashed, tab closed, page refreshed), auto-disposes with "General
Enquiry" + autoRelease:true. Agent exits ACW automatically.
Timer is cancelled when:
- Frontend submits disposition normally (cancelAcwTimer in controller)
- Agent transitions to Ready or Offline
- Agent logs out
Wiring: OzonetelAgentModule now imports SupervisorModule (forwardRef
for circular dep), controller injects SupervisorService to cancel
the timer on successful dispose.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Team module: POST /api/team/members (in-place employee creation with
temp password + Redis cache), PUT /api/team/members/:id, GET temp
password endpoint. Uses signUpInWorkspace — no email invites.
- Dockerfile: rewritten as multi-stage build (builder + runtime) so
native modules compile for target arch. Fixes darwin→linux crash.
- .dockerignore: exclude dist, node_modules, .env, .git, data/
- package-lock.json: regenerated against public npmjs.org (was
pointing at localhost:4873 Verdaccio — broke docker builds)
- Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors
helper for visit-slot-aware queries across 6 consumers
- AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped
setup-state with workspace ID isolation, AI prompt defaults overhaul
- Agent config: camelCase field fix for SDK-synced workspaces
- Session service: workspace-scoped Redis key prefixing for setup state
- Recordings/supervisor/widget services: updated to use doctor-utils
shared fragments instead of inline visitingHours queries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>