feat(reports): align bot metrics builder counts

This commit is contained in:
2026-06-07 01:23:10 +08:00
parent 108aae44b2
commit 1232e974ac
3 changed files with 76 additions and 13 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 reports conversation agent-metric parity checkpoint, prepared as `feat(reports): align conversation agent metrics`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align conversation agent metrics`.
- Latest documentation/tooling checkpoint: this tracker update records P3.49 reports conversation agent-metric parity plus the committed P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-07 bot metrics builder parity checkpoint, prepared as `feat(reports): align bot metrics builder counts`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align bot metrics builder counts`.
- Latest documentation/tooling checkpoint: this tracker update records P3.50 bot metrics builder count parity plus the committed P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: v2 reports conversation metrics now match inspected Chatwoot `Api::V2::Accounts::ReportsController#conversations` and `V2::ReportBuilder#conversation_metrics/#agent_metrics` behavior: blank `type` returns empty `422`, `type=account` keeps raw live account metrics, and non-account requests now return paginated account-user agent objects sorted by open conversation count with `id/name/email/thumbnail/availability/metric` instead of grouped assignee buckets. P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: v2 reports bot metrics now match inspected Chatwoot `V2::Reports::BotMetricsBuilder` count semantics: bot messages are counted only for conversations created in the active-bot inbox range, bot resolutions are distinct conversations after excluding handoff conversations in the same range, and handoffs are distinct non-null conversations. P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.50 bot metrics builder count parity | Implemented for reused v2 reports bot metric clients: GoChat now follows inspected Chatwoot `V2::Reports::BotMetricsBuilder`. `message_count` is scoped to outgoing messages whose `conversation_id` belongs to bot conversations created in the requested range, `resolution_rate` uses distinct resolved conversations after excluding any conversation with a handoff in range, and `handoff_rate` uses distinct non-null handoff conversations. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for `active_bot?` inbox activation drift, message sender-type filtering drift, or provider/bot-inbox status semantics beyond the inspected builder. | Focused Analytics bot metrics service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.49 reports conversation agent-metric parity | Implemented for legacy/reused v2 reports conversation clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#conversations`, `V2::ReportBuilder#conversation_metrics`, and `V2::ReportBuilder#agent_metrics`. Blank `type` returns empty `422`; `type=account` preserves raw account live metrics; non-account requests return paginated account-user objects with `id`, `name`, `email`, `thumbnail`, `availability`, and a `metric` object containing only `open` and `unattended`, sorted by open count descending. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for Kaminari pagination metadata assumptions, tie ordering, non-agent `type` scoping with `id`, or exact nil thumbnail/avatar behavior. | Focused Analytics handler and service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.48 live reports grouped team-scope parity | Implemented for reused live-report grouped metrics clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::LiveReportsController#load_conversations`, which applies optional `team_id` before both `conversation_metrics` and `grouped_conversation_metrics`. The grouped path now parses `team_id` and filters persisted conversations before grouping by `team_id` or `assignee_id`, preserving raw open/unattended/unassigned counts and nil-assignee buckets. | Keep in Review; reopen from B12 live-report smoke or fresh reference evidence for missing-team `404` body shape, authorization status drift, grouped ordering assumptions, or open/unattended scope changes. | Focused `LiveReportHandler` and Analytics live metrics tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.47 live reports grouped error-shape parity | Implemented for reused live-report grouped metrics clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::LiveReportsController#set_group_scope` and `spec/controllers/api/v2/accounts/live_reports_controller_spec.rb` by returning raw `422 { error: "invalid group_by" }` for invalid grouped live-report requests. Focused tests also cover assignee grouping payloads, including the nil-assignee bucket, open/unattended/unassigned counts, and absence of the local success envelope. | Keep in Review; reopen from B12 live-report smoke or fresh reference evidence for authorization status drift, team lookup `404` behavior, grouped ordering assumptions, or pending/open scope drift. | Focused `LiveReportHandler` tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -288,6 +289,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): align bot metrics builder counts` | Advances P3.50 bot metrics builder parity by matching Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat now derives `message_count` from outgoing messages attached to in-range bot conversations, not every outgoing message in an active-bot inbox; `resolution_rate` uses distinct resolved conversations after excluding handoff conversations; and `handoff_rate` uses distinct non-null handoff conversations. | `go test ./internal/service -run 'AnalyticsBotMetrics\|AnalyticsReportsUsePersisted' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.50 moves to Review for current bot metrics builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align conversation agent metrics` | Advances P3.49 reports conversation agent-metric parity by matching Chatwoot `Api::V2::Accounts::ReportsController#conversations` and `V2::ReportBuilder#conversation_metrics/#agent_metrics`. GoChat now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns paginated account-user agent objects for non-account requests instead of grouped assignee/team buckets; each agent row includes Chatwoot user fields plus `metric.open` and `metric.unattended`, sorted by open count descending. | `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; `go test ./internal/service -run AnalyticsReportsConversationsAgentMetrics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.49 moves to Review for current reports conversation evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): filter grouped live metrics by team` | Advances P3.48 live reports grouped team-scope parity by matching Chatwoot `Api::V2::Accounts::LiveReportsController#load_conversations`. GoChat grouped live-report metrics now parse optional `team_id` and apply that scope before grouping by `team_id` or `assignee_id`, so grouped assignee payloads exclude other-team conversations while retaining Chatwoot open/unattended/unassigned count fields and nil-assignee buckets. | `go test ./internal/handler/api/v1 -run LiveReportHandler -count=1`; `go test ./internal/service -run AnalyticsLiveConversationMetrics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.48 moves to Review for current live-report team-scope evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): align live grouped errors` | Advances P3.47 live reports grouped error-shape parity by matching Chatwoot `Api::V2::Accounts::LiveReportsController#set_group_scope` and live-report controller specs. GoChat now returns raw `422 { error: "invalid group_by" }` for invalid `/live_reports/grouped_conversation_metrics` requests instead of the local error envelope, and focused tests cover grouped assignee payloads with nil-assignee buckets and open/unattended/unassigned counts. | `go test ./internal/handler/api/v1 -run LiveReportHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.47 moves to Review for current live-report grouped error evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2557,3 +2559,4 @@ Verification milestone gates:
- 2026-06-07: P3.47 live reports grouped error checkpoint prepared as `feat(reports): align live grouped errors`; audited Chatwoot v2 live reports controller `set_group_scope` and request specs. GoChat grouped live reports now return raw `422 { error: "invalid group_by" }` for invalid `group_by` instead of the local error envelope, and focused handler tests cover assignee grouping with nil-assignee buckets and Chatwoot open/unattended/unassigned counts. Focused `LiveReportHandler` tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.48 live reports grouped team-scope checkpoint prepared as `feat(reports): filter grouped live metrics by team`; audited Chatwoot v2 live reports controller `load_conversations` and grouped metrics behavior. GoChat grouped live reports now parse optional `team_id` and apply that scope before grouping by assignee/team, so other-team conversations no longer leak into `grouped_conversation_metrics` when the frontend supplies a team filter. Focused `LiveReportHandler` and Analytics live metrics tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.49 reports conversation agent-metric checkpoint prepared as `feat(reports): align conversation agent metrics`; audited Chatwoot v2 reports controller `conversations`, `V2::ReportBuilder#conversation_metrics/#agent_metrics`, `ReportHelper#scope`, and reused dashboard `reports.js` conversation metric caller. GoChat `/reports/conversations` now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns Chatwoot agent metric rows for non-account requests with paginated account users, availability, thumbnail, and `metric.open/unattended` sorted by open count instead of grouped assignee buckets. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.50 bot metrics builder count checkpoint prepared as `feat(reports): align bot metrics builder counts`; audited Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat `/reports/bot_metrics` now counts outgoing bot messages only through in-range bot conversation IDs, computes bot resolutions as distinct non-null conversations after excluding handoff conversations, and computes handoffs as distinct non-null conversations, matching the builder rate denominators and avoiding active-bot inbox leakage from old conversations. Focused Analytics bot metrics tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change.
+38
View File
@@ -306,3 +306,41 @@ func TestAnalyticsBotSummaryUsesAggregateRawCountStrategies(t *testing.T) {
assert.NotNil(t, summary.Previous)
assert.Equal(t, int64(0), summary.Previous.BotResolutionsCount)
}
func TestAnalyticsBotMetricsUseBotConversationScopeAndDistinctCounts(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"}
require.NoError(t, db.Create(bot).Error)
require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
inRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
oldConversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
handoffConversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(inRange).Error)
require.NoError(t, db.Create(oldConversation).Error)
require.NoError(t, db.Create(handoffConversation).Error)
require.NoError(t, db.Model(inRange).Update("created_at", since.Add(time.Hour)).Error)
require.NoError(t, db.Model(oldConversation).Update("created_at", since.Add(-time.Hour)).Error)
require.NoError(t, db.Model(handoffConversation).Update("created_at", since.Add(2*time.Hour)).Error)
senderID := user.ID
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: inRange.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "bot reply"}).Error)
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: oldConversation.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "old conv reply"}).Error)
seed := func(name string, conversationID *uint, minute int) {
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)}, AccountID: account.ID, Name: name, ConversationID: conversationID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(time.Duration(minute) * time.Minute)}).Error)
}
seed("conversation_bot_resolved", &inRange.ID, 10)
seed("conversation_bot_resolved", &inRange.ID, 11)
seed("conversation_bot_resolved", &handoffConversation.ID, 20)
seed("conversation_bot_handoff", &handoffConversation.ID, 30)
seed("conversation_bot_handoff", &handoffConversation.ID, 31)
seed("conversation_bot_handoff", nil, 32)
metrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until)
require.NoError(t, err)
payload := metrics.(map[string]interface{})
assert.Equal(t, int64(2), payload["conversation_count"])
assert.Equal(t, int64(1), payload["message_count"])
assert.Equal(t, 50, payload["resolution_rate"])
assert.Equal(t, 50, payload["handoff_rate"])
}
+31 -9
View File
@@ -719,29 +719,32 @@ func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since
return nil, err
}
}
conversationCount := int64(0)
conversationIDs := []uint{}
messageCount := int64(0)
if len(inboxIDs) > 0 {
if err := db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id IN ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, since, until).
Count(&conversationCount).Error; err != nil {
Pluck("id", &conversationIDs).Error; err != nil {
return nil, err
}
if err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND inbox_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, string(model.MessageTypeOutgoing), since, until).
Count(&messageCount).Error; err != nil {
return nil, err
if len(conversationIDs) > 0 {
if err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND conversation_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, conversationIDs, string(model.MessageTypeOutgoing), since, until).
Count(&messageCount).Error; err != nil {
return nil, err
}
}
}
summary, err := s.botSummaryCounts(ctx, accountID, since, until, "account", 0)
conversationCount := int64(len(conversationIDs))
resolutions, handoffs, err := s.botMetricDistinctCounts(ctx, db, accountID, since, until)
if err != nil {
return nil, err
}
resolutionRate := 0
handoffRate := 0
if conversationCount > 0 {
resolutionRate = int(float64(summary.BotResolutionsCount) / float64(conversationCount) * 100)
handoffRate = int(float64(summary.BotHandoffsCount) / float64(conversationCount) * 100)
resolutionRate = int(float64(resolutions) / float64(conversationCount) * 100)
handoffRate = int(float64(handoffs) / float64(conversationCount) * 100)
}
return map[string]interface{}{
"conversation_count": conversationCount,
@@ -751,6 +754,25 @@ func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since
}, nil
}
func (s *AnalyticsService) botMetricDistinctCounts(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) (int64, int64, error) {
handoffIDs := []uint{}
if err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_handoff", since, until).
Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return 0, 0, err
}
resolutionQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_resolved", since, until)
if len(handoffIDs) > 0 {
resolutionQ = resolutionQ.Where("conversation_id NOT IN ?", handoffIDs)
}
var resolutions int64
if err := resolutionQ.Distinct("conversation_id").Count(&resolutions).Error; err != nil {
return 0, 0, err
}
return resolutions, int64(len(handoffIDs)), nil
}
func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {