Files
chatwoot/queries.md
T

6.4 KiB

Captain Overview — effective SQL per stat

These are the queries Captain::AssistantStatsBuilder effectively runs for account_id = 1, assistant_id = 1, over a 30-day current window (created_at >= NOW() - INTERVAL '30 days'). Each metric runs once for the current window and once for the previous equal-length window (NOW() - INTERVAL '60 days' to NOW() - INTERVAL '30 days'); only the current window is shown below. Swap the interval bounds for the previous window.

Knowledge stats are point-in-time (no window, no previous).


1. Conversations handled

Distinct conversations the assistant authored any message in.

SELECT COUNT(DISTINCT conversation_id)
FROM messages
WHERE account_id = 1
  AND sender_type = 'Captain::Assistant'
  AND sender_id = 1
  AND created_at >= NOW() - INTERVAL '30 days';

Indexes that matter: messages (account_id, ...), plus the sender_type/sender_id filter. This is the "handled set" reused as a subquery by stats 2 and 3.


2 & 3. Auto-resolution rate + handoff rate (combined)

auto_resolution = resolved / handled * 100 and handoff = handoff / handled * 100 share the same handled-set subquery, so they fold into a single pass with conditional aggregation. This runs the (costly) messages handled-set subquery once and scans reporting_events once, instead of twice each.

SELECT
  COUNT(DISTINCT conversation_id)
    FILTER (WHERE name IN ('conversation_captain_inference_resolved',
                           'conversation_bot_resolved'))                AS resolved,
  COUNT(DISTINCT conversation_id)
    FILTER (WHERE name IN ('conversation_captain_inference_handoff',
                           'conversation_bot_handoff'))                 AS handoff
FROM reporting_events
WHERE account_id = 1
  AND name IN ('conversation_captain_inference_resolved', 'conversation_bot_resolved',
               'conversation_captain_inference_handoff', 'conversation_bot_handoff')
  AND conversation_id IN (
    SELECT conversation_id
    FROM messages
    WHERE account_id = 1
      AND sender_type = 'Captain::Assistant'
      AND sender_id = 1
      AND created_at >= NOW() - INTERVAL '30 days'
  );

The rates are still computed in Ruby (resolved / handled, handoff / handled), so divide-by-zero guarding stays in the builder. The two rates do not sum to 100%: a handled conversation may carry neither event (still open) or both (handed off then later resolved).

Note: the event rows themselves are not date-filtered; the window is applied via the handled-set subquery (the conversations the assistant touched in the window).

Optional: fold in "handled" too (one query for all three)

Materialize the handled set once in a CTE and derive all three numbers from it. Useful because the page also displays handled, and every metric runs for both the current and previous window.

WITH handled AS (
  SELECT DISTINCT conversation_id
  FROM messages
  WHERE account_id = 1
    AND sender_type = 'Captain::Assistant'
    AND sender_id = 1
    AND created_at >= NOW() - INTERVAL '30 days'
)
SELECT
  (SELECT COUNT(*) FROM handled) AS handled,
  COUNT(DISTINCT re.conversation_id)
    FILTER (WHERE re.name IN ('conversation_captain_inference_resolved',
                              'conversation_bot_resolved'))             AS resolved,
  COUNT(DISTINCT re.conversation_id)
    FILTER (WHERE re.name IN ('conversation_captain_inference_handoff',
                              'conversation_bot_handoff'))              AS handoff
FROM reporting_events re
JOIN handled h ON h.conversation_id = re.conversation_id
WHERE re.account_id = 1
  AND re.name IN ('conversation_captain_inference_resolved', 'conversation_bot_resolved',
                  'conversation_captain_inference_handoff', 'conversation_bot_handoff');

Keep handled sourced from the CTE count (not COUNT(*) over the join), so conversations with no event row still count toward handled.


4. Hours saved

public_outgoing_count * avg_reply_time_seconds / 3600, rounded. Two queries.

Public outgoing replies the assistant sent:

SELECT COUNT(*)
FROM messages
WHERE account_id = 1
  AND sender_type = 'Captain::Assistant'
  AND sender_id = 1
  AND message_type = 1   -- outgoing
  AND private = false
  AND created_at >= NOW() - INTERVAL '30 days';

Average reply time across the account in the window (seconds):

SELECT AVG(value)
FROM reporting_events
WHERE account_id = 1
  AND name = 'reply_time'
  AND created_at >= NOW() - INTERVAL '30 days';

5. Conversation depth (messages / conversation)

public_outgoing_count / distinct_conversations. Reuses query 4's count plus:

SELECT COUNT(DISTINCT conversation_id)
FROM messages
WHERE account_id = 1
  AND sender_type = 'Captain::Assistant'
  AND sender_id = 1
  AND message_type = 1   -- outgoing
  AND private = false
  AND created_at >= NOW() - INTERVAL '30 days';

6. Reopen-after-resolve rate

reopened / resolved * 100, where "resolved" is the inbox-based Captain-resolved set. Two queries.

Resolved (inbox-scoped) in the window:

SELECT COUNT(DISTINCT conversation_id)
FROM reporting_events
WHERE account_id = 1
  AND name = 'conversation_captain_inference_resolved'
  AND inbox_id IN (
    SELECT inbox_id FROM captain_inboxes WHERE captain_assistant_id = 1
  )
  AND created_at >= NOW() - INTERVAL '30 days';

Of those, the ones later reopened (conversation_opened with value > 0):

SELECT COUNT(DISTINCT conversation_id)
FROM reporting_events
WHERE account_id = 1
  AND name = 'conversation_opened'
  AND value > 0
  AND conversation_id IN (
    SELECT conversation_id
    FROM reporting_events
    WHERE account_id = 1
      AND name = 'conversation_captain_inference_resolved'
      AND inbox_id IN (
        SELECT inbox_id FROM captain_inboxes WHERE captain_assistant_id = 1
      )
      AND created_at >= NOW() - INTERVAL '30 days'
  );

Note: the conversation_opened events are not date-filtered; a reopen counts whenever it happened, as long as the resolve fell in the window.


7. Knowledge (point-in-time, no window)

Approved / pending FAQ counts:

SELECT status, COUNT(*)
FROM captain_assistant_responses
WHERE assistant_id = 1
GROUP BY status;
-- status: 0 = pending, 1 = approved

Document count:

SELECT COUNT(*)
FROM captain_documents
WHERE assistant_id = 1;

coverage = approved / (approved + pending) * 100, computed in Ruby.