Daily LLM classification of every chat (topics + product tags + deal won/lost/undecided) aggregated into immutable daily metrics, with a filterable admin report dashboard, product-catalog import (text/CSV/XLSX), weekly persona summary, and an approval flow (LINE -> Telegram -> webhook) for applying persona recommendations. - Llm::AnalyticsClassifier: per-account openai -> Captain fallback cascade - AccountDailyProcessor + Conversation/CustomerDailyMetric aggregation - ReportService + DrilldownService (summary + deep filterable drilldown) - AnalyticsReports.vue + productCatalog import UI (admin-only) - WeeklyPersonaEvaluator + PersonaApprovalService (LINE/Telegram/webhook) - Weekly cron (Mon 10:00) + daily cron (02:30)
14 KiB
M2 — Self-Improving Chatbot Analytics (Moreminimore Chat)
Status: part2b ✅ · phase 2 ✅ · phase 3 import-backend ✅ (in review passed) · persona-eval/approval/import-UI next · Updated: 2026-08-19
Repo: /Users/kunthawat/Gitea/Chatwoot · branch develop
Goal
Every chat of every account is analyzed by an LLM daily → auto-tagged (topic + product) + deal outcome → aggregated into immutable daily metrics → admin-only reports + weekly persona improvement recommendation.
Design decisions (approved by user)
- Analyze EVERY chat of EVERY account daily (LLM cost accepted — selling point is self-improving chatbot).
- LLM cascade (
lib/llm/resolver.rb): per-account openai hook → Captain config → nil (feature disabled). LLM effectively always on unless Captain unset. - Original conversation tags: use Chatwoot's
label_list(ActsAsTaggableOn) — do NOT add many tag fields. Product/sale tag goes into the same tag list, prefixed (e.g.group>subgroup>productjoined, orสินค้า:.../กลุ่ม:...).top_tagsaggregates from these. - No
tagged_at(skip time-tracking to avoid DB complexity). - Daily stats are immutable snapshots; live tags get re-tagged later (e.g. session becomes product B → product A removed). Snapshot preserves earlier state.
- Report page admin-role ONLY. Filter agent/team/inbox/channel/tag/sale-tag/time + cross-analysis. Break down per CUSTOMER and per AGENT.
- Weekly (Mon 10:00) LLM summarizes 7 days → persona improvement recommendation (summary only, NOT full prompt). Delivered via Line (quick-replies) / Telegram (inline keyboard) / webhook. Admin approve / view-full-prompt / reject; viewing full prompt → re-approve; approve → webhook with prompt.
- Product list: per-account, admin-only menu. Import copy/paste or CSV/XLSX. Multi-level hierarchy (group>subgroup>product>display). LLM matches from lowest level first, tags ancestors; supports multiple products/groups per chat.
Data foundation (DONE — committed)
conversation_daily_metrics(per account/day: message_count, conversation_count, resolved/unresolved, avg_session_duration, top_tags, sale_tags, deal_outcomes, peak hours, agent/team/channel/inbox breakdown) —9fbb0da9bcustomer_daily_metrics(per customer/day: same core + agent_ids) —0fad9ce6bproduct_catalog_entries(per-account hierarchical: group>subgroup>product+aliases;tag_hierarchyhelper) —161e4210blib/llm/resolver.rb(cascade; https-only api_base) —ccff2dfca- M1 openai custom base_url —
f9628db0e
Remaining work
part2b-ii — LLM classifier service ✅ DONE (2026-08-19)
Read a conversation (latest message + history for context) → classify → tags (topic + product from catalog using > join) + deal (won/lost/undecided). Cascade via Llm::Resolver.
lib/llm/analytics_classifier.rb(new): NOTCaptain::BaseTaskService(no captain feature-flag gate — M2 is always-on via resolver).- Uses
Llm::Resolver.resolve(account)→Llm::Config.with_api_key(key, api_base:)+context.chat(model:).with_schema(...).ask(...)for structured JSON output. - Matches product from
ProductCatalogEntryviatag_hierarchy(index → path mapping withvalues_at+compact, safe against bad indexes). - Writes nothing — pure classifier. Returns
Result(topics/products/deal) ordisabled: truewhen resolver is nil (fail-closed, no LLM call, no content sent). - Also fixed: added
has_many :product_catalog_entriestoAccount(foundation commit161e4210badded the model+migration but never wired the reverse association → would have beenNoMethodError). - Verified:
ruby -cclean (2.6.10 + 3.4.10); isolated smoke test 8/8 under Ruby 3.4.10 (disabled path, catalog join, fence sanitize, present path mapping + deal); static security scan clean. Independent reviewerdeleg_47c1a2cb= complete 5-key PASS (empty security/logic; non-blocking em-dash escape fix applied).
part2b-iii — daily batch job ✅ DONE (2026-08-19)
sidekiq-cron ~02:30 in the SUPER ADMIN timezone (Asia/Bangkok, single TZ, no hourly-check) → auto-write tags + aggregate into conversation_daily_metrics + customer_daily_metrics (immutable counts; re-tag live).
app/services/analytics/account_daily_processor.rb(new): per-account/date aggregation. Computes UTC day window fromaccount.reporting_timezone(default UTC/Time.zone); selects conversations active in window; classifies each viaLlm::AnalyticsClassifier(skips on error/disabled — fail-closed); applies tags (replaces prior M2-managed tags —topic:prefix + current catalog paths — preserves manual labels); accumulates per-conversation + per-customer metrics; upsertsConversationDailyMetric(unique account+date) +CustomerDailyMetric(unique account+contact+date).app/jobs/analytics/daily_metrics_job.rb(new): iterates all accounts with per-account rescue; computes closed day (yesterday) in account TZ.config/schedule.yml: addsanalytics_daily_metrics_jobat30 2 * * *timezoneAsia/Bangkok, queue scheduled_jobs.- Verified:
ruby -cclean (3.4.10); isolated smoke test 15/15 under Ruby 3.4.10 (day_window Bangkok UTC bounds, apply_tags re-tag preserving manual labels, accumulate conv+customer counts, upsert payload shape). Static scan clean; schedule.yml no dup keys. Independent reviewerdeleg_fecf7c61= complete 5-key PASS (empty security/logic; 4 non-blocking suggestions: add specs, N+1 in chat_message_count, deal_outcomes dual structure, apply_tags rescue scope).
phase 2 — rollup + report (BACKEND DONE + REVIEWED-PASS; frontend in review)
Backend (all committed-eligible, reviewer deleg_6800b012 = complete 5-key PASS):
Analytics::ReportService— summary/timeseries/customers/agents from immutable conversation_daily_metrics + customer_daily_metrics (rollup/snapshot surface). Deal_outcomes normalization handles both 'totals'-nested (conv) and flat (customer).Analytics::DrilldownService— deep filterable drilldown over live Conversation records (since/until/agent_id/team_id/inbox_id/channel/tag/deal + pagination). Fail-closed label-join (scope.none when no label exists). Tag/deal match viaacts_as_taggable_on :labels(Deal tag conventiondeal:won/lost/undecided).Api::V2::Accounts::AnalyticsReportsController— admin-only (authorize :report, :view?→ ReportPolicy#view? = administrator?) withsummary+drilldownendpoints; params validation (BadRequest on invalid date/int).config/routes.rb—resources :analytics_reportscollection get :summary / get :drilldown under v2 accounts.AccountDailyProcessor#apply_tags— now also writesdeal:<outcome>tag onto the live conversation (M2-managed, replaced on re-tag) so drilldown can filter by deal.- Verified:
ruby -cclean (3.4.10), smoke 12/12 (report summary/timeseries/deal-norm/top_tags/customers/agents/daterange + drilldown deal/channel/label_list/unknown-tag), routes parse, static scan clean. Reviewerdeleg_6800b012= complete 5-key PASS (4 non-blocking suggestions: N+1 in serialize, no specs, page not clamped, memoize labels).
Frontend (built, reviewer deleg_15f8447c pending):
api/analyticsReports.js— v2 API wrapper (summary + drilldown).routes/dashboard/settings/reports/AnalyticsReports.vue— admin report dashboard: summary cards + filter bar (since/until/agent/team/inbox/channel/tag/deal) + drilldown table.reports.routes.js— newanalytics_reportsroute under the (admin-only, REPORTS feature-flag) reports section.en/report.json—ANALYTICS_REPORTSi18n block.- Verified:
node --checkon .js + JSON.parse on report.json. NOTE: full frontend build (eslint/vite/vitest) canNOT run locally (no node_modules) — flagged as limitation; runpnpm install && pnpm testbefore ship.
phase 3 — import + persona eval + approval
Import backend (DONE, reviewer deleg_7c595252 = complete 5-key PASS):
Analytics::ProductCatalogImportService— importsproduct_catalog_entriesfrom copy/paste / CSV / TSV / keyword (key: value) / named-header forms. Parsing: TSV vs '|' vs comma detection; keyword-mode + named-header column mapping (COLUMN_ALIASES group/product/subgroup/display); ordered 2-col=group+product / 3-col=group+subgroup+product; alias normalization (comma/semicolon split); upsert (same account+group+product → update, else create). Returns{ imported, updated, errors[] }. Non-blocking reviewer fix applied: error line numbers now reference original input (header-shift offset).Api::V2::Accounts::ProductCatalogEntriesController— admin-only (ReportPolicy#view?):index(list) +import(POST content, BadRequest if blank).config/routes.rb—resources :product_catalog_entriesonly [:index] + collection post :import.- Verified:
ruby -cclean (3.4.10), smoke 10/10 (TSV/pipe/keyword/header/ordered/upsert/error), static scan clean. Reviewerdeleg_7c595252= complete 5-key PASS (4 non-blocking: no specs, line-offset [fixed], tab+pipe edge, XLSX unsupported).
XLSX support (DONE, reviewer deleg_e7e0879c = complete 5-key PASS): Added gem 'roo', '~> 2.10' to Gemfile; ProductCatalogImportService.import_file(account:, file_path:, filename:) + parse_spreadsheet (uses Roo::Spreadsheet.open lazily, converts sheet rows to pipe-delimited lines then reuses the shared parse_rows_from_lines parser; graceful rescue LoadError for missing roo + rescue StandardError for parse errors); extension() prefers original filename (Rack tempfiles lack extension). Controller import branches to file-upload (file.tempfile.path + file.original_filename) with a SUPPORTED_SPREADSHEET_EXTS whitelist (xlsx xls ods csv) rejecting other types, vs text content. Verified: ruby -c clean, stub smoke tests text 10/10 + xlsx 5/5 under plain Ruby 3.4.10 with a fake roo module, static scan clean, reviewer deleg_e7e0879c = complete 5-key PASS (3 non-blocking: no committed specs, file-ext whitelist [applied], extension() nil guard). REQUIRED deploy step: bundle install must run in the deploy env to resolve roo + update Gemfile.lock (cannot run here — Ruby 3.4.4 pin vs 3.4.10 installed).
Remaining phase 3 — weekly persona eval (DONE, reviewer deleg_b7d63fd7 = complete 5-key PASS):
Analytics::WeeklyPersonaEvaluator— summarizes 7 days viaAnalytics::ReportService(aggregate counts/tags/deal_outcomes only, NO raw message content / NO customer PII) + LLM viaLlm::Resolvercascade +with_schema→Result(summary, recommendations). Pure evaluator (no persistence). Fail-closed: no credential → disabled, never calls LLM.persona_evaluationadmin-only endpoint (POST /analytics_reports/persona_evaluation) + route.- Verified:
ruby -cclean, smoke 8/8, static scan clean, reviewerdeleg_b7d63fd7= complete 5-key PASS (3 non-blocking: no specs, guard non-string recs, confirm .content — latter matches repo convention per Captain::ChatResponseHelper).
Remaining (NOT built):
- Frontend product-catalog import UI (DONE, reviewer
deleg_0e9f9291= PASS):api/productCatalog.js+productCatalog/Index.vue(paste + .xlsx/.csv upload + result + table) + routeproduct_catalog_index(admin-only) wired intosettings.routes.js. Verified:node --check. NOTE: full frontend build cannot run locally — runpnpm install && pnpm testbefore ship. - Approval flow (DONE, all reviewers PASS) — user chose "LINE primary (quick-replies) + webhook fallback", then asked to add Telegram. Delivery precedence: LINE → Telegram → webhook.
Analytics::PersonaApprovalService(deliver: LINE push topersona_line_user_idw/ quickReply buttons / TelegramsendMessageinline keyboard topersona_telegram_chat_id/ webhook POST topersona_webhook_url+ HMAC sig header;notify_approval: webhook-only sends the approval decision to the webhook) +Analytics::WeeklyPersonaEvaluationJob+ controller actions (persona_evaluation_deliver,persona_approval_settings[line_user_id + telegram_chat_id + webhook_url, conditional],persona_decision) + routes + schedule.ymlanalytics_weekly_persona_evaluation_job(Mon 10:00 Asia/Bangkok). Config inAccount#custom_attributes. Reviewers:deleg_1fe19871FAIL-CLOSED (1 medium logic error:approvepath — fixed) → re-reviewdeleg_3052f7a4PASS; Telegram channeldeleg_34116d1a= complete 5-key PASS (3 non-blocking: i18n/dedup button labels, no specs). POST-MVP design note — prompt webhook handshake (DECIDED, NOT built; awaiting LLM-server details from user): User clarified the self-improvement loop: a SEPARATE LLM server (not this app) will hold/apply the actual prompt, and this app talks to it over webhook. - Connect-first: on first webhook connection, the app should pull the current prompt via webhook (GET) and store it (snapshot).
- Approve → push: when the admin approves the weekly persona recommendation, the app POSTs the new prompt via webhook to the LLM server to apply.
- Analytics separate: this app only surfaces "what customers asked" (topics/products/deal) + recommendations; knowledge-prep/prompt-authoring is done/admin/LLM-server side, NOT here.
- Delivery-prompt-webhook design (REST 2-endpoint vs generic action-body) — DEFERRED, user said "document/design only, wait for details". Not built.
- Docs — NOT built.
Enterprise (EE) note
EE files still read CAPTAIN_OPEN_AI_ENDPOINT directly and won't honor per-account base_url gating — intentional (out of OSS scope), tracked as follow-up.
Verification notes
- Ruby env active is 2.6.10; target 3.4.4. Rails/RSpec/RuboCop cannot be reliably claimed passed — use
ruby -csyntax + YAML validity + isolated smoke tests. - Full Vitest clean under TZ=UTC: 414 files / 4176 tests, 0 failed (frontend untouched here).
- No secrets committed. Temp helpers under /tmp only.