Skip to content

Add human handoff, CSAT capture, and analytics dashboard - #64

Merged
nedasvi merged 9 commits into
mainfrom
feature/p0-handoff-csat-analytics
Jul 27, 2026
Merged

Add human handoff, CSAT capture, and analytics dashboard#64
nedasvi merged 9 commits into
mainfrom
feature/p0-handoff-csat-analytics

Conversation

@nedasvi

@nedasvi nedasvi commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the P0 competitive gaps identified vs. Intercom Fin / Zendesk AI Agents:

  • Human handoff: AI can escalate to a human (tool-triggered or max-tool-rounds fallback), dashboard operators can also manually take over any active session, reply as a human agent, and mark sessions resolved.
  • CSAT capture: 1-5 star rating request after an AI-resolved turn (debounced 15s, cancels if the user keeps chatting) or immediately after a human marks a session resolved. Suppressed automatically during an active handoff.
  • Analytics dashboard: resolution rate, escalation rate, CSAT distribution, daily session volume.
  • Reliability fixes found while testing the above end-to-end: orphaned tool calls / duplicate concurrent turns caused by transient Redis errors, a dashboard client bug that misrouted every non-auth API call to production regardless of deploy target, and missing internal-service env var wiring in all three docker-compose files.

Test plan

  • uv run python -m pytest tests/ — 230 passed, 2 pre-existing unrelated failures (confirmed via git stash against main)
  • uv run python scripts/check_openapi_sync.py
  • Manually verified end-to-end on staging: session creation, escalation (AI + manual takeover), human reply, resolve, CSAT prompt (both triggers), analytics page — via iOS and Android sample apps

nedasvi and others added 9 commits July 21, 2026 09:47
Closes the P0 competitive gaps vs. Intercom Fin/Zendesk AI Agents: sessions
can now escalate to a human agent (server-side tool + max-tool-rounds
fallback), operators can reply/resolve escalated sessions from the
dashboard, users can submit a 1-5 CSAT rating after a resolved turn, and
the dashboard exposes a resolution-rate/escalation-rate/CSAT analytics page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The reused-session-history endpoint filtered messages to user/assistant
only, so a human_agent reply sent while a client was offline would never
appear on reconnect (only via the live human_message SSE event).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sessions previously only had reply/resolve as raw API endpoints with no
way to use them from the dashboard. Adds an escalation banner with
escalation reason, a "Mark resolved" button, and a reply composer on
escalated sessions in the Sessions page. Also surfaces escalated_at/
escalation_reason/resolved_by from sessionOut, which the serializer was
previously dropping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The CI pipeline builds one dashboard image and bakes NEXT_PUBLIC_API_BASE_URL
to production's domain for every deploy target. Since `dashboard` and `api`
are the same Next.js app/image in every topology this repo supports, route
all client requests same-origin (matching how the cookie-bound auth routes
already behaved) instead of trusting that build-time constant. Fixes staging
(and any other non-prod deploy) silently calling production's API and
getting 401s / "failed to load" for data that only exists in that
environment's own database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two gaps found while testing the human-handoff flow end to end:

- There was no way for an operator to join a conversation the AI hadn't
  already escalated. New POST .../escalate route + "Take over" button on
  active sessions in the dashboard manually flips a session to escalated,
  which unlocks the existing reply/resolve UI.
- CSAT was only ever requested after an AI-resolved turn, never after a
  human manually resolves an escalated session. New internal endpoint
  (POST /internal/sessions/{id}/feedback-requested) + dashboard resolve
  route now fires feedback_requested when an operator marks a session
  resolved, on top of the existing AI-turn trigger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feedback_requested now carries an "immediate" flag: false for the
existing post-AI-turn trigger (SDKs debounce it), true for the
human-resolve trigger (SDKs should show it right away, since the
conversation is definitively over).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root-caused a real bug hit while testing the human-handoff flow: a brief
Redis stall (e.g. a scheduled BGSAVE) during a tool-call round caused a
cascade of failures that left a session in a broken, duplicated-turn state.

Three contributing bugs, all in Redis-touching code paths:

1. resolve_pending_tool_result (called when the SDK submits a tool
   result) let a transient Redis publish/lock-check error propagate,
   which skipped the same-process local future resolve entirely --
   silently dropping an already-successful tool result.
2. run_agent_loop's tool-wait only caught asyncio.TimeoutError. Any other
   failure (e.g. that same Redis error, or its knock-on effects) crashed
   the whole turn unhandled. _run_turn's `finally: clear_turn()` then
   released the per-session turn lock anyway, letting a second message
   start a *concurrent* second turn for the same session while the first
   call sat forever unresolved -- exactly the "duplicate tool request,
   stuck approval card" state observed.
3. The SSE /events endpoint's `except TimeoutError` doesn't catch
   redis.exceptions.TimeoutError (it isn't a subclass of the builtin),
   so a Redis I/O hiccup crashed the whole stream instead of degrading
   to a keep-alive-and-retry, producing the misleading "something went
   wrong" bubble on the client.

All three now degrade gracefully: a transient error resolves locally
where possible, is recorded as a normal (recoverable) tool_result
error and the loop continues, or falls back to a keep-alive -- instead
of corrupting turn-lock exclusivity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The internal service-to-service JWT settings (added earlier for the
dashboard->agent human-reply endpoint) were only ever added to
agent/config.py's Python defaults and .env.example -- never actually
passed through to the dashboard/api or backend containers in any of
the three docker-compose files. The dashboard's token signer throws at
runtime if this key is unset/left as the insecure placeholder, so every
reply attempt failed silently server-side ("cannot send the message to
the user"). Wires it into all three compose files the same way
RK_KNOWLEDGE_BASES_SIGNING_KEY already is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The manual "Take over" route updated the session's status in the DB
but never told the connected SDK client -- so isEscalated never became
true on-device, the escalated banner never showed, and critically the
CSAT-suppression-on-escalation logic never fired. A CSAT prompt from
the AI's last turn could keep showing (or later reappear) mid human
handoff, on an unresolved session.

New internal endpoint (POST /internal/sessions/{id}/session-escalated)
mirrors the existing feedback-requested one: the dashboard route now
pushes a real session_escalated SSE event after taking over, exactly
like the AI-triggered escalation path already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nedasvi
nedasvi merged commit 9e748e3 into main Jul 27, 2026
12 checks passed
@nedasvi
nedasvi deleted the feature/p0-handoff-csat-analytics branch July 27, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant